feat(backend): cover extraction for cbz and epub (opf cover-image/meta)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user