feat(backend): cover extraction for cbz and epub (opf cover-image/meta)

This commit is contained in:
2026-09-05 00:23:02 +08:00
parent 589cb210bb
commit b02ca67d16
2 changed files with 200 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
package bookfile
import (
"archive/zip"
"encoding/xml"
"errors"
"io"
"net/url"
"path"
"strings"
)
func CBZCover(f io.ReaderAt, size int64) ([]byte, string, error) {
idx, err := PageIndex(f, size)
if err != nil {
return nil, "", err
}
if len(idx) == 0 {
return nil, "", errors.New("no page images")
}
img, err := ReadEntry(f, size, idx[0])
return img, strings.ToLower(path.Ext(idx[0])), err
}
func readZipEntry(zr *zip.Reader, name string) ([]byte, error) {
for _, zf := range zr.File {
if zf.Name == name {
rc, err := zf.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(io.LimitReader(rc, 8<<20))
}
}
return nil, errors.New("entry not found: " + name)
}
type opfDoc struct {
XMLName xml.Name `xml:"package"`
Metadata struct {
Meta []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
Manifest struct {
Items []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
Properties string `xml:"properties,attr"`
MediaType string `xml:"media-type,attr"`
} `xml:"item"`
} `xml:"manifest"`
}
func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) {
zr, err := zip.NewReader(f, size)
if err != nil {
return nil, "", ErrNotZip
}
cont, err := readZipEntry(zr, "META-INF/container.xml")
if err != nil {
return nil, "", err
}
var root struct {
XMLName xml.Name `xml:"container"`
RootFiles []struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
if err := xml.Unmarshal(cont, &root); err != nil || len(root.RootFiles) == 0 {
return nil, "", errors.New("bad container.xml")
}
opfPath := root.RootFiles[0].FullPath
opfData, err := readZipEntry(zr, opfPath)
if err != nil {
return nil, "", err
}
var doc opfDoc
if err := xml.Unmarshal(opfData, &doc); err != nil {
return nil, "", errors.New("bad opf")
}
metaCoverID := ""
for _, m := range doc.Metadata.Meta {
if m.Name == "cover" {
metaCoverID = m.Content
}
}
href := ""
for _, it := range doc.Manifest.Items {
if strings.Contains(it.Properties, "cover-image") {
href = it.Href
break
}
if metaCoverID != "" && it.ID == metaCoverID {
href = it.Href
break
}
}
if href == "" {
for _, it := range doc.Manifest.Items {
if strings.HasPrefix(it.MediaType, "image/") && strings.Contains(strings.ToLower(it.ID), "cover") {
href = it.Href
break
}
}
}
if href == "" {
return nil, "", errors.New("no cover found")
}
unescaped, e := url.PathUnescape(href)
if e == nil {
href = unescaped
}
name := path.Join(path.Dir(opfPath), href)
if name == "." {
name = href
}
img, err := readZipEntry(zr, name)
if err != nil {
return nil, "", err
}
return img, strings.ToLower(path.Ext(name)), nil
}
+75
View File
@@ -0,0 +1,75 @@
package bookfile
import (
"archive/zip"
"bytes"
"testing"
)
func TestCBZCoverIsFirstPage(t *testing.T) {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
w, _ := zw.Create("02.jpg")
w.Write([]byte("second"))
w, _ = zw.Create("01.jpg")
w.Write([]byte("first"))
zw.Close()
r := bytes.NewReader(buf.Bytes())
img, ext, err := CBZCover(r, int64(r.Len()))
if err != nil || string(img) != "first" || ext != ".jpg" {
t.Fatalf("%q %q %v", img, ext, err)
}
}
func zipEntries(t *testing.T, kv map[string]string) *bytes.Reader {
t.Helper()
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for name, data := range kv {
w, _ := zw.Create(name)
w.Write([]byte(data))
}
zw.Close()
return bytes.NewReader(buf.Bytes())
}
const containerXML = `<?xml version="1.0"?><container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>`
func opf(manifest, spine string) string {
return `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata>` +
spine + `</metadata><manifest>` + manifest + `</manifest></package>`
}
func TestEPUBCoverProperties(t *testing.T) {
r := zipEntries(t, map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/content.opf": opf(`<item id="c" href="img/cover%20one.png" properties="cover-image" media-type="image/png"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
"OEBPS/img/cover one.png": "PNGDATA",
})
img, ext, err := EPUBCover(r, int64(r.Len()))
if err != nil || string(img) != "PNGDATA" || ext != ".png" {
t.Fatalf("%q %q %v", img, ext, err)
}
}
func TestEPUBCoverMetaID(t *testing.T) {
r := zipEntries(t, map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/content.opf": opf(`<item id="cv" href="art.jpg" media-type="image/jpeg"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, `<meta name="cover" content="cv"/>`),
"OEBPS/art.jpg": "JPGDATA",
})
img, _, err := EPUBCover(r, int64(r.Len()))
if err != nil || string(img) != "JPGDATA" {
t.Fatalf("%q %v", img, err)
}
}
func TestEPUBNoCover(t *testing.T) {
r := zipEntries(t, map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/content.opf": opf(`<item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
})
if _, _, err := EPUBCover(r, int64(r.Len())); err == nil {
t.Fatal("want no-cover error")
}
}