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.
298 lines
11 KiB
Go
298 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|