From b02ca67d168159009c369ae9bbf86a88c0305d3f Mon Sep 17 00:00:00 2001 From: Fendy Date: Sat, 5 Sep 2026 00:23:02 +0800 Subject: [PATCH] feat(backend): cover extraction for cbz and epub (opf cover-image/meta) --- backend/internal/bookfile/cover.go | 125 ++++++++++++++++++++++++ backend/internal/bookfile/cover_test.go | 75 ++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 backend/internal/bookfile/cover.go create mode 100644 backend/internal/bookfile/cover_test.go diff --git a/backend/internal/bookfile/cover.go b/backend/internal/bookfile/cover.go new file mode 100644 index 0000000..2fa2f1a --- /dev/null +++ b/backend/internal/bookfile/cover.go @@ -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 +} diff --git a/backend/internal/bookfile/cover_test.go b/backend/internal/bookfile/cover_test.go new file mode 100644 index 0000000..097e60a --- /dev/null +++ b/backend/internal/bookfile/cover_test.go @@ -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 = `` + +func opf(manifest, spine string) string { + return `` + + spine + `` + manifest + `` +} + +func TestEPUBCoverProperties(t *testing.T) { + r := zipEntries(t, map[string]string{ + "META-INF/container.xml": containerXML, + "OEBPS/content.opf": opf(``, ""), + "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(``, ``), + "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(``, ""), + }) + if _, _, err := EPUBCover(r, int64(r.Len())); err == nil { + t.Fatal("want no-cover error") + } +}