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.
165 lines
5.4 KiB
Go
165 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|