fix(scanner): resolve EPUB covers via structured OPF parsing, Calibre chain
The cover lookup scraped the OPF with attribute-order-sensitive regexes. Real books serialize attributes in any order - Grand Central's '3 Days to Live' puts href before id on manifest items and content before name on the cover meta - so all three regex paths missed and the book fell through to filename guessing, extracting no cover at all. Attribute order is meaningless in XML; the regexes were never safe. Replace them with a structured parse (encoding/xml, namespace and attribute-order agnostic; see the new media_scanner_opf.go) and follow Calibre's read_raster_cover resolution order: 1. manifest item with properties=cover-image (non-(X)HTML media only) 2. <meta name=cover> resolved through the manifest, same media guard 3. first spine item that is itself a raster image (store manga) 4. NEW cover-page fallback: books declaring no raster cover at all - the classic EPUB2/Adobe cover.xhtml wrapper - are mined for <img src> / SVG <image xlink:href> references (Calibre renders the page with Qt; extracting the referenced image covers the practical cases without a rendering engine) 5. existing zip filename guessing stays as the last resort, and the old regex chain survives as findCoverInOPFLegacy for OPFs too malformed for a real XML parse. Hrefs are now URL-decoded and posix-normalized against the OPF's own path (path.Join semantics), so '../art/cover.jpg' from a nested cover page and %20-encoded names resolve correctly. Tests: attribute-order chaos modeled on the failing Patterson book, SVG-wrapped cover pages via guide references, image-first spines, and path resolution edge cases. Verified live against the real '3 Days to Live' EPUB, which previously produced no cover.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to build an EPUB zip from a file map for cover tests
|
||||
func writeEPUB(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
for name, content := range files {
|
||||
fw, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
|
||||
|
||||
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
||||
|
||||
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
|
||||
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
|
||||
// Live" serialization (href before id, content before name on the meta tag).
|
||||
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>3 Days to Live</dc:title>
|
||||
<meta content="cover-image" name="cover"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
|
||||
</manifest>
|
||||
<spine/>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/images/9781538752760.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "book.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover not extracted - attribute order still defeats resolution")
|
||||
}
|
||||
if _, err := os.Stat(coverPath); err != nil {
|
||||
t.Fatalf("cover file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
|
||||
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
|
||||
// (here via SVG), reachable through the guide reference or first spine item.
|
||||
func TestFindCoverInOPFCoverPage(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Old Adobe Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="coverpage"/></spine>
|
||||
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
|
||||
</package>`
|
||||
coverPage := `<?xml version="1.0"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<body>
|
||||
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
|
||||
</body>
|
||||
</html>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/text/cover.xhtml": coverPage,
|
||||
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
|
||||
// item is a raster image itself (Calibre's third resolution step).
|
||||
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="p1"/></spine>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/pages/0001.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "manga.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("image-first spine cover not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOPFPath checks URL decoding and posix normalization of
|
||||
// OPF-relative hrefs.
|
||||
func TestResolveOPFPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
opfPath, href, want string
|
||||
}{
|
||||
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
|
||||
{"package.opf", "cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
|
||||
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user