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:
@@ -23,8 +23,10 @@ import (
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -1949,7 +1951,47 @@ func findCoverImageInZip(files []*zip.File) string {
|
||||
}
|
||||
|
||||
// findCoverInOPF parses OPF content to find cover image reference
|
||||
func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string {
|
||||
// findCoverInOPF locates the cover image for an EPUB, following Calibre's
|
||||
// read_raster_cover resolution order (see media_scanner_opf.go):
|
||||
// 1. manifest item with properties="cover-image"
|
||||
// 2. <meta name="cover"> resolved through the manifest
|
||||
// 3. the first spine item being a raster image itself (store manga)
|
||||
// 4. NEW: the cover page (guide type="cover" or first spine item) mined for
|
||||
// <img src> / SVG <image xlink:href> - covers books that declare no
|
||||
// raster cover at all, e.g. classic EPUB2/Adobe cover.xhtml wrappers
|
||||
// 5. filename guessing in the zip (pre-existing fallback)
|
||||
//
|
||||
// XML parsing is attribute-order agnostic; if the OPF is too malformed for
|
||||
// encoding/xml, the legacy regex chain runs as a compatibility fallback.
|
||||
func findCoverInOPF(opfContent []byte, files []*zip.File, opfPath string) string {
|
||||
opf, err := parseOPFXML(opfContent)
|
||||
if err != nil {
|
||||
return findCoverInOPFLegacy(opfContent, files, opfPath)
|
||||
}
|
||||
|
||||
if href := opf.findRasterCoverInOPF(); href != "" {
|
||||
return resolveOPFPath(opfPath, href)
|
||||
}
|
||||
|
||||
// Cover-page fallback: Calibre renders the page; we extract the image it
|
||||
// references (the practical case - the page wraps a raster in img/SVG).
|
||||
if pageHref := opf.coverPageHref(); pageHref != "" {
|
||||
if pageContent, err := readFileFromZip(files, resolveOPFPath(opfPath, pageHref)); err == nil {
|
||||
if imgRef := findImageReferenceInPage(pageContent); imgRef != "" {
|
||||
imgPath := resolveOPFPath(resolveOPFPath(opfPath, pageHref), imgRef)
|
||||
if zipHasFile(files, imgPath) {
|
||||
return imgPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findCoverImageInZip(files)
|
||||
}
|
||||
|
||||
// findCoverInOPFLegacy is the pre-XML cover lookup, kept solely as a
|
||||
// fallback for OPFs too malformed for a real XML parse.
|
||||
func findCoverInOPFLegacy(opfContent []byte, files []*zip.File, opfPath string) string {
|
||||
contentStr := string(opfContent)
|
||||
|
||||
// Look for item with properties="cover-image"
|
||||
@@ -1961,7 +2003,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
||||
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
||||
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
||||
if len(hrefMatches) > 1 {
|
||||
return resolveOPFPath(opfDir, hrefMatches[1])
|
||||
return resolveOPFPath(opfPath, hrefMatches[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1975,7 +2017,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
||||
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
||||
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
||||
if len(hrefMatches) > 1 {
|
||||
return resolveOPFPath(opfDir, hrefMatches[1])
|
||||
return resolveOPFPath(opfPath, hrefMatches[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1984,18 +2026,34 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
||||
return findCoverImageInZip(files)
|
||||
}
|
||||
|
||||
// resolveOPFPath resolves a relative path against the OPF directory
|
||||
func resolveOPFPath(opfDir, href string) string {
|
||||
if opfDir == "" {
|
||||
return href
|
||||
// zipHasFile reports whether the zip contains an entry with exactly this name.
|
||||
func zipHasFile(files []*zip.File, name string) bool {
|
||||
name = filepath.ToSlash(name)
|
||||
for _, f := range files {
|
||||
if filepath.ToSlash(f.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Handle ../ in href
|
||||
if strings.HasPrefix(href, "../") {
|
||||
// Simple case: just use the href as-is for now
|
||||
return href
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveOPFPath resolves an OPF-relative href against the OPF document's own
|
||||
// path inside the zip. Hrefs are URL-decoded and normalized with posix path
|
||||
// semantics ("../" walks up), matching Calibre's
|
||||
// posixpath.normpath(posixpath.join(base, href)).
|
||||
func resolveOPFPath(opfPath, href string) string {
|
||||
if unescaped, err := url.PathUnescape(href); err == nil {
|
||||
href = unescaped
|
||||
}
|
||||
// Join the directory with the href
|
||||
return filepath.Join(filepath.Dir(opfDir), href)
|
||||
href = strings.TrimPrefix(filepath.ToSlash(href), "/")
|
||||
base := ""
|
||||
if dir := path.Dir(filepath.ToSlash(opfPath)); dir != "." {
|
||||
base = dir
|
||||
}
|
||||
if base == "" {
|
||||
return path.Clean(href)
|
||||
}
|
||||
return path.Clean(path.Join(base, href))
|
||||
}
|
||||
|
||||
// readFileFromZip reads a file from the zip by name
|
||||
|
||||
Reference in New Issue
Block a user