Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa8ee3a59 | ||
|
|
72d167005f |
@@ -23,8 +23,10 @@ import (
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -1022,9 +1024,10 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
}
|
||||
|
||||
var sidecar struct {
|
||||
Title string `json:"title"`
|
||||
Authors []string `json:"authors"`
|
||||
Series []struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Series []struct {
|
||||
Series string `json:"series"`
|
||||
Sequence string `json:"sequence"`
|
||||
} `json:"series"`
|
||||
@@ -1046,6 +1049,11 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
metadata := &MediaMetadata{
|
||||
Title: strings.TrimSpace(sidecar.Title),
|
||||
}
|
||||
// Subtitle joins the title Calibre-style ("Main: Subtitle") - there is no
|
||||
// separate subtitle column, and Calibre-sidecar books arrive pre-joined.
|
||||
if subtitle := strings.TrimSpace(sidecar.Subtitle); subtitle != "" && metadata.Title != "" {
|
||||
metadata.Title = metadata.Title + ": " + subtitle
|
||||
}
|
||||
if len(sidecar.Authors) > 0 {
|
||||
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
|
||||
}
|
||||
@@ -1719,80 +1727,75 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
|
||||
|
||||
// parseOPFContent parses an OPF document (Dublin Core metadata) into
|
||||
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
|
||||
// embedded inside an EPUB - the dc:* vocabulary is identical. Namespace-aware
|
||||
// parsing means it tolerates wherever the xmlns:dc declaration lives.
|
||||
// embedded inside an EPUB - the dc:* vocabulary is identical. Parsing is
|
||||
// attribute-order agnostic and namespace aware (see media_scanner_opf.go);
|
||||
// title selection and series detection follow Calibre's behavior.
|
||||
func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
||||
// Define XML structure for parsing with full Dublin Core namespace URLs
|
||||
var opf struct {
|
||||
XMLName xml.Name `xml:"package"`
|
||||
Metadata struct {
|
||||
XMLName xml.Name `xml:"metadata"`
|
||||
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
||||
Desc []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
||||
Publisher []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
||||
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
Language []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Identifiers []struct {
|
||||
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
// Calibre-specific meta tags - capture all, filter later
|
||||
MetaTags []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"content,attr"`
|
||||
} `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
}
|
||||
// Parse XML
|
||||
if err := xml.NewDecoder(bytes.NewReader(content)).Decode(&opf); err != nil {
|
||||
opf, err := parseOPFXML(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
|
||||
}
|
||||
md := opf.Metadata
|
||||
|
||||
// Map to MediaMetadata struct
|
||||
metadata := &MediaMetadata{}
|
||||
// Title (required)
|
||||
if len(opf.Metadata.Titles) > 0 {
|
||||
metadata.Title = opf.Metadata.Titles[0]
|
||||
// Title: EPUB3 title-type main selection with Calibre-style subtitle join
|
||||
if title := opf.selectTitle(); title != "" {
|
||||
metadata.Title = title
|
||||
}
|
||||
// Author (first creator)
|
||||
if len(opf.Metadata.Creators) > 0 {
|
||||
metadata.Author = opf.Metadata.Creators[0]
|
||||
if len(md.Creators) > 0 {
|
||||
metadata.Author = md.Creators[0]
|
||||
}
|
||||
// Tags (all subjects)
|
||||
if len(opf.Metadata.Subjects) > 0 {
|
||||
metadata.Tags = utils.NormalizeTags(opf.Metadata.Subjects)
|
||||
// Tags (all subjects; one element = one tag, commas are legal inside
|
||||
// subject headings like "Holmes, Sherlock (Fictitious character)").
|
||||
// Genre mirrors the sidecar path's processGenresAndTags: first subject.
|
||||
if len(md.Subjects) > 0 {
|
||||
metadata.Tags = utils.NormalizeTags(md.Subjects)
|
||||
if len(metadata.Tags) > 0 {
|
||||
metadata.Genre = metadata.Tags[0]
|
||||
}
|
||||
}
|
||||
// Description
|
||||
if len(opf.Metadata.Desc) > 0 {
|
||||
metadata.Description = opf.Metadata.Desc[0]
|
||||
if len(md.Descriptions) > 0 {
|
||||
metadata.Description = md.Descriptions[0]
|
||||
}
|
||||
// Publisher
|
||||
if len(opf.Metadata.Publisher) > 0 {
|
||||
metadata.Publisher = opf.Metadata.Publisher[0]
|
||||
if len(md.Publishers) > 0 {
|
||||
metadata.Publisher = md.Publishers[0]
|
||||
}
|
||||
// Language
|
||||
if len(opf.Metadata.Language) > 0 && opf.Metadata.Language[0] != "" {
|
||||
metadata.Language = opf.Metadata.Language[0]
|
||||
if len(md.Languages) > 0 && md.Languages[0] != "" {
|
||||
metadata.Language = md.Languages[0]
|
||||
}
|
||||
// Publish date
|
||||
if len(opf.Metadata.Dates) > 0 {
|
||||
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", opf.Metadata.Dates[0]); err == nil {
|
||||
if len(md.Dates) > 0 {
|
||||
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", md.Dates[0]); err == nil {
|
||||
metadata.PublishDate = date
|
||||
} else if date, err := time.Parse("2006-01-02", opf.Metadata.Dates[0]); err == nil {
|
||||
} else if date, err := time.Parse("2006-01-02", md.Dates[0]); err == nil {
|
||||
metadata.PublishDate = date
|
||||
} else {
|
||||
// Try alternative date formats
|
||||
if date, err := time.Parse("2006", opf.Metadata.Dates[0]); err == nil {
|
||||
if date, err := time.Parse("2006", md.Dates[0]); err == nil {
|
||||
metadata.PublishDate = date
|
||||
}
|
||||
}
|
||||
}
|
||||
// Identifiers (ISBN, ASIN)
|
||||
for _, id := range opf.Metadata.Identifiers {
|
||||
// Identifiers: opf:scheme attribute first, then URN-prefixed values
|
||||
// (urn:isbn:...), then bare values that normalize to a valid ISBN.
|
||||
for _, id := range md.Identifiers {
|
||||
value := strings.TrimSpace(id.Value)
|
||||
switch strings.ToUpper(id.Scheme) {
|
||||
scheme := strings.ToUpper(strings.TrimSpace(id.Scheme))
|
||||
if scheme == "" {
|
||||
if lower := strings.ToLower(value); strings.HasPrefix(lower, "urn:") {
|
||||
rest := value[4:]
|
||||
if prefix, val, ok := strings.Cut(rest, ":"); ok {
|
||||
scheme = strings.ToUpper(prefix)
|
||||
value = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch scheme {
|
||||
case "ISBN":
|
||||
metadata.ISBN = utils.NormalizeISBNSafe(value)
|
||||
case "ASIN":
|
||||
@@ -1801,28 +1804,30 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
||||
// Store UUID in hash info, not metadata
|
||||
// Will be extracted by extractHashInfo()
|
||||
default:
|
||||
// EPUB3 identifiers often carry no opf:scheme attribute;
|
||||
// accept a bare value that normalizes to a valid ISBN.
|
||||
if metadata.ISBN == "" && id.Scheme == "" {
|
||||
if normalized := utils.NormalizeISBNSafe(value); normalized != "" {
|
||||
// EPUB3 identifiers often carry no opf:scheme attribute; accept a
|
||||
// bare value only when it is ISBN-shaped (rejects the URIs and
|
||||
// UUIDs that commonly share the identifier list).
|
||||
if metadata.ISBN == "" && scheme == "" && isISBNLike(value) {
|
||||
if normalized, err := utils.NormalizeISBN(value); err == nil {
|
||||
metadata.ISBN = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contributors
|
||||
if len(opf.Metadata.Contributors) > 0 {
|
||||
metadata.Contributors = utils.NormalizeContributors(opf.Metadata.Contributors)
|
||||
if len(md.Contributors) > 0 {
|
||||
metadata.Contributors = utils.NormalizeContributors(md.Contributors)
|
||||
}
|
||||
// Series: EPUB3 belongs-to-collection, then calibre:series metas
|
||||
if series, index := opf.readSeries(); series != "" {
|
||||
metadata.Series = series
|
||||
if index > 0 {
|
||||
metadata.SeriesNumber = int32(index)
|
||||
}
|
||||
}
|
||||
// Calibre-specific meta tags (filter by name attribute)
|
||||
for _, meta := range opf.Metadata.MetaTags {
|
||||
for _, meta := range md.Metas {
|
||||
switch meta.Name {
|
||||
case "calibre:series":
|
||||
metadata.Series = meta.Value
|
||||
case "calibre:series_index":
|
||||
if index, err := strconv.ParseFloat(meta.Value, 32); err == nil {
|
||||
metadata.SeriesNumber = int32(index)
|
||||
}
|
||||
case "calibre:rating":
|
||||
// Not imported (ratings are per-user in Bookhoard)
|
||||
case "calibre:title_sort":
|
||||
@@ -1949,7 +1954,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 +2006,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 +2020,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 +2029,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
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
|
||||
// with attribute-order-sensitive regexes; real books serialize attributes in
|
||||
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
|
||||
// content before name), which silently defeated cover detection. Everything
|
||||
// here is parsed with encoding/xml so attribute order and namespace prefix
|
||||
// choices are irrelevant.
|
||||
|
||||
type opfDCValue struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfIdentifier struct {
|
||||
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfMeta struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
Property string `xml:"property,attr"`
|
||||
Refines string `xml:"refines,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
Properties string `xml:"properties,attr"`
|
||||
}
|
||||
|
||||
// opfDocument is a structured view of an OPF package document.
|
||||
type opfDocument struct {
|
||||
Metadata struct {
|
||||
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
||||
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
||||
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
||||
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
Metas []opfMeta `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
Manifest struct {
|
||||
Items []opfItem `xml:"item"`
|
||||
} `xml:"manifest"`
|
||||
Spine struct {
|
||||
Itemrefs []struct {
|
||||
IDRef string `xml:"idref,attr"`
|
||||
} `xml:"itemref"`
|
||||
} `xml:"spine"`
|
||||
Guide struct {
|
||||
References []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"reference"`
|
||||
} `xml:"guide"`
|
||||
}
|
||||
|
||||
func parseOPFXML(content []byte) (*opfDocument, error) {
|
||||
var doc opfDocument
|
||||
if err := xml.Unmarshal(content, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// refinesFor maps an element id to its EPUB3 refining metas
|
||||
// (those whose refines attribute starts with '#').
|
||||
func (d *opfDocument) refinesFor(id string) []opfMeta {
|
||||
var out []opfMeta
|
||||
if id == "" {
|
||||
return out
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refinesProperty returns the value of the first refining meta carrying the
|
||||
// given property (e.g. "title-type", "collection-type", "group-position").
|
||||
func refinesProperty(metas []opfMeta, property string) (string, bool) {
|
||||
for _, m := range metas {
|
||||
if strings.EqualFold(m.Property, property) {
|
||||
if v := strings.TrimSpace(m.Value); v != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
|
||||
// title-type "main"; fall back to the first non-empty title. A distinct
|
||||
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
|
||||
// main title with ": ", exactly as Calibre stores it.
|
||||
func (d *opfDocument) selectTitle() string {
|
||||
var first, main, subtitle string
|
||||
for _, t := range d.Metadata.Titles {
|
||||
v := strings.TrimSpace(t.Value)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = v
|
||||
}
|
||||
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(tt) {
|
||||
case "main":
|
||||
if main == "" {
|
||||
main = v
|
||||
}
|
||||
default:
|
||||
l := strings.ToLower(tt)
|
||||
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
|
||||
if subtitle == "" {
|
||||
subtitle = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title := main
|
||||
if title == "" {
|
||||
title = first
|
||||
}
|
||||
if subtitle != "" && subtitle != title {
|
||||
title = title + ": " + subtitle
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
|
||||
// collection-type=series refine and group-position index) first, then the
|
||||
// classic calibre:series / calibre:series_index metas.
|
||||
func (d *opfDocument) readSeries() (series string, index float64) {
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Property, "belongs-to-collection") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(m.Value)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
refines := d.refinesFor(m.ID)
|
||||
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
|
||||
continue
|
||||
}
|
||||
if gp, ok := refinesProperty(refines, "group-position"); ok {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
return name, index
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
switch m.Name {
|
||||
case "calibre:series":
|
||||
series = m.Content
|
||||
case "calibre:series_index":
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return series, index
|
||||
}
|
||||
|
||||
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
||||
func (d *opfDocument) itemByID() map[string]opfItem {
|
||||
m := make(map[string]opfItem, len(d.Manifest.Items))
|
||||
for _, it := range d.Manifest.Items {
|
||||
if it.ID != "" && it.Href != "" && it.MediaType != "" {
|
||||
m[it.ID] = it
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// firstSpineItem returns the manifest item for the first spine idref.
|
||||
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
|
||||
if len(d.Spine.Itemrefs) == 0 {
|
||||
return opfItem{}, false
|
||||
}
|
||||
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
// isRasterMedia reports whether a manifest media-type is an image but not an
|
||||
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
|
||||
// cover images.
|
||||
func isRasterMedia(mediaType string) bool {
|
||||
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if mt == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(mt, "image/")
|
||||
}
|
||||
|
||||
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
|
||||
// 1. manifest item with properties containing "cover-image"
|
||||
// 2. <meta name="cover" content="ID"> resolved through the manifest
|
||||
// 3. the first spine item being a raster image itself (store manga)
|
||||
//
|
||||
// Returns the OPF-relative href of the cover image, or "".
|
||||
func (d *opfDocument) findRasterCoverInOPF() string {
|
||||
// 1. properties="cover-image" (space-separated property list)
|
||||
for _, it := range d.Manifest.Items {
|
||||
for _, prop := range strings.Fields(it.Properties) {
|
||||
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. meta name="cover" content=<manifest image id>
|
||||
byID := d.itemByID()
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Name, "cover") {
|
||||
continue
|
||||
}
|
||||
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
mt := strings.ToLower(it.MediaType)
|
||||
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// coverPageHref returns the OPF-relative href of the cover *page* document to
|
||||
// mine for an embedded image: the guide's type="cover" reference when
|
||||
// present, otherwise the first spine item (Calibre renders the latter).
|
||||
func (d *opfDocument) coverPageHref() string {
|
||||
for _, ref := range d.Guide.References {
|
||||
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
|
||||
return ref.Href
|
||||
}
|
||||
}
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
if it.Href != "" && !isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
|
||||
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
|
||||
// significant characters). Guards the scheme-less dc:identifier fallback
|
||||
// against URLs and UUIDs sharing the same slot.
|
||||
func isISBNLike(v string) bool {
|
||||
digits := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits++
|
||||
case r == '-' || r == ' ':
|
||||
// separator
|
||||
case (r == 'X' || r == 'x') && i == len(v)-1:
|
||||
digits++ // ISBN-10 check character
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return digits == 10 || digits == 13
|
||||
}
|
||||
|
||||
// findImageReferenceInPage extracts the first raster image reference from a
|
||||
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
|
||||
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
|
||||
// Returns the reference relative to the page document, or "".
|
||||
func findImageReferenceInPage(pageContent []byte) string {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(start.Name.Local) {
|
||||
case "img":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
|
||||
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
|
||||
// series with collection-type and group-position refines.
|
||||
func TestParseOPFContentTitleTypeAndSeries(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 id="t1">The Main Title</dc:title>
|
||||
<dc:title id="t2">The Subtitle</dc:title>
|
||||
<meta refines="#t1" property="title-type">main</meta>
|
||||
<meta refines="#t2" property="title-type">subtitle</meta>
|
||||
<dc:subject>Programming</dc:subject>
|
||||
<dc:subject>Algorithms</dc:subject>
|
||||
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
|
||||
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
|
||||
<meta refines="#coll1" property="collection-type">series</meta>
|
||||
<meta refines="#coll1" property="group-position">4.5</meta>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
}
|
||||
if metadata.Genre != "Programming" {
|
||||
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
|
||||
}
|
||||
if len(metadata.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
|
||||
// embedded commas as single tags (Library of Congress style headings).
|
||||
func TestParseOPFContentSubjectsWithCommas(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>A Study in Scarlet</dc:title>
|
||||
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if len(metadata.Tags) != 1 {
|
||||
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// 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