diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index e2e6e56..655eb3e 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -1209,6 +1209,9 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 { metadata.SeriesNumber = embedded.SeriesNumber } + if metadata.ReadingDirection == "" && embedded.ReadingDirection != "" { + metadata.ReadingDirection = embedded.ReadingDirection + } if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() { metadata.PublishDate = embedded.PublishDate } @@ -1866,6 +1869,12 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) { if title := opf.selectTitle(); 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) if len(md.Creators) > 0 { metadata.Author = md.Creators[0] diff --git a/internal/services/media_scanner_opf.go b/internal/services/media_scanner_opf.go index 3f7c4dd..48de500 100644 --- a/internal/services/media_scanner_opf.go +++ b/internal/services/media_scanner_opf.go @@ -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 ; 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)) diff --git a/internal/services/media_scanner_opf_test.go b/internal/services/media_scanner_opf_test.go index 9c60d24..7117eeb 100644 --- a/internal/services/media_scanner_opf_test.go +++ b/internal/services/media_scanner_opf_test.go @@ -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 ` + + Dir Test + + +` + } + 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": ` + + RTL Book + + +`, + "OEBPS/p1.xhtml": `

hi

`, + } + 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) + } +}