feat(scanner): store EPUB reading direction at scan time
Release / build-and-push (push) Successful in 2m33s
Release / build-and-push (push) Successful in 2m33s
Fixed-layout EPUBs lean on the reading_direction column (the web reader forces book.dir = rtl from it when the file didn't set direction itself), but the scanner never populated it for EPUBs - only ComicInfo fed it. Meanwhile real Japanese EPUBs declare page-progression-direction on the OPF spine, which foliate reads client-side but nothing stored. Read the spine attribute in the structured OPF parser and map it into ReadingDirection in parseOPFContent (EPUB2/3, case-insensitive, plus 'right-to-left'/'left-to-right' spellings); undeclared stays empty rather than forcing ltr, preserving the editor's Auto default. Sidecar OPFs are metadata-only documents without spines, so the Calibre path is a no-op. The merge gap-fill copies an embedded-only direction into a blank sidecar field, and hand-set values keep winning through the existing OverrideReadingDirection protection. Tests: declared rtl/RTL/ltr, undeclared and unknown values staying empty, plus sidecar-wins vs embedded-fills merge cases. Existing manga EPUBs declaring rtl (verified live in-library) pick the value up on their next scan, feeding the API and reader config mobile clients consume.
This commit is contained in:
@@ -1209,6 +1209,9 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
|
|||||||
if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 {
|
if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 {
|
||||||
metadata.SeriesNumber = embedded.SeriesNumber
|
metadata.SeriesNumber = embedded.SeriesNumber
|
||||||
}
|
}
|
||||||
|
if metadata.ReadingDirection == "" && embedded.ReadingDirection != "" {
|
||||||
|
metadata.ReadingDirection = embedded.ReadingDirection
|
||||||
|
}
|
||||||
if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() {
|
if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() {
|
||||||
metadata.PublishDate = embedded.PublishDate
|
metadata.PublishDate = embedded.PublishDate
|
||||||
}
|
}
|
||||||
@@ -1866,6 +1869,12 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
|||||||
if title := opf.selectTitle(); title != "" {
|
if title := opf.selectTitle(); title != "" {
|
||||||
metadata.Title = title
|
metadata.Title = title
|
||||||
}
|
}
|
||||||
|
// Reading direction from the OPF spine's page-progression-direction.
|
||||||
|
// Sidecar OPFs are metadata-only documents without a spine, so this is a
|
||||||
|
// no-op for them and only fires on real books.
|
||||||
|
if dir := opf.pageProgressionDirection(); dir != "" {
|
||||||
|
metadata.ReadingDirection = dir
|
||||||
|
}
|
||||||
// Author (first creator)
|
// Author (first creator)
|
||||||
if len(md.Creators) > 0 {
|
if len(md.Creators) > 0 {
|
||||||
metadata.Author = md.Creators[0]
|
metadata.Author = md.Creators[0]
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ type opfDocument struct {
|
|||||||
Items []opfItem `xml:"item"`
|
Items []opfItem `xml:"item"`
|
||||||
} `xml:"manifest"`
|
} `xml:"manifest"`
|
||||||
Spine struct {
|
Spine struct {
|
||||||
Itemrefs []struct {
|
PageProgressionDirection string `xml:"page-progression-direction,attr"`
|
||||||
|
Itemrefs []struct {
|
||||||
IDRef string `xml:"idref,attr"`
|
IDRef string `xml:"idref,attr"`
|
||||||
} `xml:"itemref"`
|
} `xml:"itemref"`
|
||||||
} `xml:"spine"`
|
} `xml:"spine"`
|
||||||
@@ -184,6 +185,22 @@ func (d *opfDocument) readSeries() (series string, index float64) {
|
|||||||
return series, index
|
return series, index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageProgressionDirection returns the OPF spine's reading direction as
|
||||||
|
// "rtl" or "ltr", or "" when the file declares none (callers treat that as
|
||||||
|
// unknown, not as left-to-right). EPUB2/3 declare this on <spine>; it is
|
||||||
|
// what foliate reads client-side, and the DB column feeds clients (and the
|
||||||
|
// web reader's fixed-layout override) that need it up front.
|
||||||
|
func (d *opfDocument) pageProgressionDirection() string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(d.Spine.PageProgressionDirection)) {
|
||||||
|
case "rtl", "right-to-left":
|
||||||
|
return "rtl"
|
||||||
|
case "ltr", "left-to-right", "default":
|
||||||
|
return "ltr"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
||||||
func (d *opfDocument) itemByID() map[string]opfItem {
|
func (d *opfDocument) itemByID() map[string]opfItem {
|
||||||
m := make(map[string]opfItem, len(d.Manifest.Items))
|
m := make(map[string]opfItem, len(d.Manifest.Items))
|
||||||
|
|||||||
@@ -221,3 +221,77 @@ func TestResolveOPFPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseOPFContentPageProgressionDirection verifies the EPUB spine's
|
||||||
|
// page-progression-direction feeds ReadingDirection, and that an undeclared
|
||||||
|
// direction stays empty rather than forcing left-to-right.
|
||||||
|
func TestParseOPFContentPageProgressionDirection(t *testing.T) {
|
||||||
|
makeOPF := func(spineAttrs string) string {
|
||||||
|
return `<?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>Dir Test</dc:title></metadata>
|
||||||
|
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||||
|
<spine` + spineAttrs + `><itemref idref="p1"/></spine>
|
||||||
|
</package>`
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spineAttrs string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"rtl declared lowercase", ` page-progression-direction="rtl"`, "rtl"},
|
||||||
|
{"rtl declared uppercase", ` page-progression-direction="RTL"`, "rtl"},
|
||||||
|
{"ltr declared", ` page-progression-direction="ltr"`, "ltr"},
|
||||||
|
{"undeclared stays empty", ``, ""},
|
||||||
|
{"unknown value stays empty", ` page-progression-direction="sideways"`, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
metadata, err := parseOPFContent([]byte(makeOPF(tt.spineAttrs)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOPFContent() error: %v", err)
|
||||||
|
}
|
||||||
|
if metadata.ReadingDirection != tt.want {
|
||||||
|
t.Errorf("ReadingDirection = %q, want %q", metadata.ReadingDirection, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeMetadataReadingDirectionGapFill verifies the sidecar wins when it
|
||||||
|
// declares a direction, while an embedded-only direction fills the blank.
|
||||||
|
func TestMergeMetadataReadingDirectionGapFill(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
epubPath := filepath.Join(dir, "dir.epub")
|
||||||
|
files := map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/package.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>RTL Book</dc:title></metadata>
|
||||||
|
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||||
|
<spine page-progression-direction="rtl"><itemref idref="p1"/></spine>
|
||||||
|
</package>`,
|
||||||
|
"OEBPS/p1.xhtml": `<html><body><p>hi</p></body></html>`,
|
||||||
|
}
|
||||||
|
writeEPUB(t, epubPath, files)
|
||||||
|
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
|
||||||
|
// Sidecar blank -> embedded rtl fills it
|
||||||
|
merged, err := s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergeMetadata() error: %v", err)
|
||||||
|
}
|
||||||
|
if merged.ReadingDirection != "rtl" {
|
||||||
|
t.Errorf("ReadingDirection = %q, want embedded rtl fill", merged.ReadingDirection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar ltr wins over embedded rtl
|
||||||
|
merged, err = s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar", ReadingDirection: "ltr"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergeMetadata() error: %v", err)
|
||||||
|
}
|
||||||
|
if merged.ReadingDirection != "ltr" {
|
||||||
|
t.Errorf("ReadingDirection = %q, want sidecar ltr preserved", merged.ReadingDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user