feat(scanner): store EPUB reading direction at scan time
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:
John O'Keefe
2026-09-13 13:13:25 -04:00
parent e944f11415
commit 3ab294bf81
3 changed files with 101 additions and 1 deletions
+18 -1
View File
@@ -58,7 +58,8 @@ type opfDocument struct {
Items []opfItem `xml:"item"`
} `xml:"manifest"`
Spine struct {
Itemrefs []struct {
PageProgressionDirection string `xml:"page-progression-direction,attr"`
Itemrefs []struct {
IDRef string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
@@ -184,6 +185,22 @@ func (d *opfDocument) readSeries() (series string, index float64) {
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.
func (d *opfDocument) itemByID() map[string]opfItem {
m := make(map[string]opfItem, len(d.Manifest.Items))