feat(scanner): store EPUB reading direction at scan time

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:
John O'Keefe
2026-09-13 13:13:25 -04:00
parent e944f11415
commit 7fe2ef407e
3 changed files with 101 additions and 1 deletions
+9
View File
@@ -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]
+17
View File
@@ -58,6 +58,7 @@ type opfDocument struct {
Items []opfItem `xml:"item"` Items []opfItem `xml:"item"`
} `xml:"manifest"` } `xml:"manifest"`
Spine struct { Spine struct {
PageProgressionDirection string `xml:"page-progression-direction,attr"`
Itemrefs []struct { Itemrefs []struct {
IDRef string `xml:"idref,attr"` IDRef string `xml:"idref,attr"`
} `xml:"itemref"` } `xml:"itemref"`
@@ -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)
}
}