diff --git a/internal/book/archive.go b/internal/book/archive.go index ad526c9..0b7b869 100644 --- a/internal/book/archive.go +++ b/internal/book/archive.go @@ -2,10 +2,15 @@ package book import ( "archive/zip" + "bytes" "errors" + "fmt" "io" + "os" "path/filepath" "strings" + "sync" + "sync/atomic" rardecode "github.com/nwaples/rardecode/v2" ) @@ -73,30 +78,118 @@ func (a *zipArchive) Close() error { return a.rc.Close() } // -- rar --------------------------------------------------------------------- -type rarArchive struct{ entries []entry } +// rarArchive opens plain entries on their own. A solid entry depends on every +// file before it, so solid entries share one sequential reader, which starts +// over only for a file at or before its position. +type rarArchive struct { + path string + entries []entry -type rarEntry struct{ f *rardecode.File } + mu sync.Mutex + solid *rardecode.ReadCloser + next int // archive index of the file the solid reader returns next + closed atomic.Bool // stops a long decode without waiting for it +} -func (e rarEntry) Name() string { return e.f.Name } -func (e rarEntry) Open() (io.ReadCloser, error) { return e.f.Open() } +type rarEntry struct { + a *rarArchive + f *rardecode.File + index int // position among all archive files, directories included +} + +func (e rarEntry) Name() string { return e.f.Name } +func (e rarEntry) Open() (io.ReadCloser, error) { + if !e.f.Solid { + return e.f.Open() + } + return e.a.openSolid(e) +} func openRar(path string) (archive, error) { files, err := rardecode.List(path) if err != nil { return nil, err } - a := &rarArchive{} - for _, f := range files { + a := &rarArchive{path: path} + for i, f := range files { if f.IsDir { continue } - a.entries = append(a.entries, rarEntry{f}) + a.entries = append(a.entries, rarEntry{a, f, i}) } return a, nil } +// openSolid decodes up to the entry and returns its bytes. +func (a *rarArchive) openSolid(e rarEntry) (io.ReadCloser, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed.Load() { + return nil, os.ErrClosed + } + if a.solid == nil || a.next > e.index { + a.closeSolid() + rc, err := rardecode.OpenReader(a.path) + if err != nil { + return nil, err + } + a.solid, a.next = rc, 0 + } + for a.next <= e.index { + var err error + if a.next > 0 { + // Decode the rest of the current file here, where Close can stop + // it; Next would decode it without checking. + _, err = io.Copy(io.Discard, solidReader{a}) + } + var h *rardecode.FileHeader + if err == nil { + h, err = a.solid.Next() + } + if err == nil && a.next == e.index && h.Name != e.f.Name { + err = fmt.Errorf("RAR entry %d is %q, want %q", e.index, h.Name, e.f.Name) + } + if err != nil { + a.closeSolid() + return nil, err + } + a.next++ + } + data, err := readBounded(solidReader{a}, maxPageBytes) + if err != nil { + a.closeSolid() + return nil, err + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +// solidReader reads the current solid file until the archive closes. +type solidReader struct{ a *rarArchive } + +func (r solidReader) Read(p []byte) (int, error) { + if r.a.closed.Load() { + return 0, os.ErrClosed + } + return r.a.solid.Read(p) +} + +// closeSolid requires a.mu. +func (a *rarArchive) closeSolid() error { + if a.solid == nil { + return nil + } + err := a.solid.Close() + a.solid = nil + return err +} + func (a *rarArchive) Entries() []entry { return a.entries } -func (a *rarArchive) Close() error { return nil } // List readers open per entry +func (a *rarArchive) Close() error { + a.closed.Store(true) + a.mu.Lock() + defer a.mu.Unlock() + return a.closeSolid() +} // hiddenEntry reports junk entries: AppleDouble forks, hidden files, // and __MACOSX resource directories. diff --git a/internal/book/book.go b/internal/book/book.go index 6cf1e37..1705d80 100644 --- a/internal/book/book.go +++ b/internal/book/book.go @@ -1,6 +1,7 @@ package book import ( + "archive/zip" "bytes" "context" "errors" @@ -55,9 +56,10 @@ type Book struct { chaps []Chapter chapErr error - mu sync.Mutex - cache map[int]image.Image // small LRU-ish cache - order []int + mu sync.Mutex + cache map[int]image.Image // small LRU-ish cache + order []int + cachePixels int loading map[int]chan struct{} encoded map[int]encodedPage @@ -81,6 +83,7 @@ const ( cacheSize = 8 maxPageBytes = 64 << 20 maxPagePixels = 32_000_000 + maxCachePixels = 2 * maxPagePixels maxMetadataBytes = 1 << 20 ) @@ -134,6 +137,9 @@ func Open(path string) (*Book, error) { } arc, err := openArchive(path) if errors.Is(err, errNotArchive) { + if bytes.HasPrefix(head, []byte("PK\x03\x04")) { + return nil, fmt.Errorf("%s: %w", filepath.Base(path), zip.ErrFormat) + } if looksLikeXML(head) { b, xmlErr := openFB2File(path) if xmlErr == nil || !bytes.Contains(head, []byte("%PDF-")) { @@ -470,9 +476,12 @@ func (b *Book) PageContext(ctx context.Context, i int) (image.Image, error) { if _, ok := b.cache[i]; !ok { b.cache[i] = img b.order = append(b.order, i) - for len(b.order) > cacheSize { + b.cachePixels += img.Bounds().Dx() * img.Bounds().Dy() + // Large scans take hundreds of megabytes each; keep their total bounded too. + for len(b.order) > cacheSize || b.cachePixels > maxCachePixels { evict := b.order[0] b.order = b.order[1:] + b.cachePixels -= b.cache[evict].Bounds().Dx() * b.cache[evict].Bounds().Dy() delete(b.cache, evict) } } diff --git a/internal/book/book_test.go b/internal/book/book_test.go index e856742..6785251 100644 --- a/internal/book/book_test.go +++ b/internal/book/book_test.go @@ -5,7 +5,10 @@ import ( "bytes" "context" "encoding/binary" + "errors" + "fmt" "image" + "image/color" "image/png" "io" "os" @@ -109,6 +112,73 @@ func TestOpenRejectsUnknownFormats(t *testing.T) { if _, err := Open(filepath.Join(t.TempDir(), "absent.cbz")); err == nil || !strings.Contains(err.Error(), "absent.cbz") { t.Fatalf("missing file: %v", err) } + // A download cut short keeps the ZIP signature but loses the directory. + var archive bytes.Buffer + z := zip.NewWriter(&archive) + if w, err := z.Create("page.png"); err != nil { + t.Fatal(err) + } else if _, err := w.Write(epubImage(t)); err != nil { + t.Fatal(err) + } + if err := z.Close(); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "partial.cbz") + if err := os.WriteFile(path, archive.Bytes()[:archive.Len()/2], 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open(path); !errors.Is(err, zip.ErrFormat) || !strings.Contains(err.Error(), "partial.cbz") { + t.Fatalf("truncated ZIP: %v", err) + } +} + +func TestOpenSolidRAR(t *testing.T) { + b, err := Open("testdata/solid.cbr") + if err != nil { + t.Fatal(err) + } + defer b.Close() + if b.Len() != 3 || !b.pages[1].(rarEntry).f.Solid { + t.Fatalf("%d pages; the fixture needs solid files after the first", b.Len()) + } + // Each page is a 4-pixel-wide strip, 2+i rows high, in red, green and blue. + want := []color.RGBA{{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}} + for _, i := range []int{2, 0, 1, 2, 1} { // backwards restarts the shared reader + data, err := readEntry(b.pages[i], maxPageBytes) + if err != nil { + t.Fatalf("page %d: %v", i, err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil || img.Bounds() != image.Rect(0, 0, 4, 2+i) || color.RGBAModel.Convert(img.At(1, 1)) != want[i] { + t.Fatalf("page %d decoded wrong: %v", i, err) + } + } + if _, err := b.Page(2); err != nil { + t.Fatal(err) + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + if _, err := readEntry(b.pages[2], maxPageBytes); !errors.Is(err, os.ErrClosed) { + t.Fatalf("read after close: %v", err) + } +} + +func TestPageCacheBoundsDecodedPixels(t *testing.T) { + var data bytes.Buffer + if err := png.Encode(&data, image.NewGray(image.Rect(0, 0, 5000, 5000))); err != nil { + t.Fatal(err) + } + b := newBook("book", false) + for i := range 3 { + b.pages = append(b.pages, memEntry{fmt.Sprintf("%d.png", i), data.Bytes()}) + if _, err := b.Page(i); err != nil { + t.Fatal(err) + } + } + if len(b.cache) != 2 || b.cache[0] != nil || b.cachePixels != 2*5000*5000 { + t.Fatalf("decoded cache kept %d pages, %d pixels", len(b.cache), b.cachePixels) + } } func TestOpenEmbeddedPDFMarker(t *testing.T) { diff --git a/internal/book/chapters.go b/internal/book/chapters.go index 14c1d16..f338d12 100644 --- a/internal/book/chapters.go +++ b/internal/book/chapters.go @@ -3,6 +3,7 @@ package book import ( "encoding/xml" "os" + "path" "path/filepath" "sort" "strings" @@ -102,9 +103,10 @@ func (b *Book) folderChapters() []Chapter { var chs []Chapter cur := "\x00" for i, pg := range b.pages { - d := "" - if k := strings.IndexByte(pg.Name(), '/'); k >= 0 { - d = pg.Name()[:k] + // The innermost folder names the chapter, so a single wrapping folder still splits. + d := path.Dir(pg.Name()) + if d == "." { + d = "" } dirs[d] = true if d != cur { diff --git a/internal/book/chapters_test.go b/internal/book/chapters_test.go index 2cfd16d..91f0e05 100644 --- a/internal/book/chapters_test.go +++ b/internal/book/chapters_test.go @@ -295,6 +295,11 @@ func TestComicChapterFallbacks(t *testing.T) { t.Fatalf("chapters = %#v, %v", got, err) } } + // One wrapping folder still splits by its subfolders. + b := &Book{pages: []entry{memEntry{name: "Vol 1/Ch 1/1.png"}, memEntry{name: "Vol 1/Ch 2/2.png"}}} + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, []Chapter{{"Vol 1/Ch 1", 0, 0}, {"Vol 1/Ch 2", 1, 0}}) { + t.Fatalf("nested folders = %#v, %v", got, err) + } chapters := []Chapter{{"Later", 5, 0}, {"Earlier", 1, 0}, {"Nested", 5, 1}, {"Appendix", 3, 0}} for page, want := range map[int]int{0: -1, 1: 1, 4: 3, 5: 2, 99: 2} { if got := CurrentChapter(chapters, page); got != want { diff --git a/internal/book/epub.go b/internal/book/epub.go index 862b2f7..87d7f31 100644 --- a/internal/book/epub.go +++ b/internal/book/epub.go @@ -56,6 +56,14 @@ func (n *epubNode) allText() string { if n.name.Space == "http://www.w3.org/1999/xhtml" && (n.name.Local == "rt" || n.name.Local == "rp") { return } + // Titles read like the page: epub:switch shows its default, MathML its alternative text. + if n.name.Space == "http://www.idpf.org/2007/ops" && n.name.Local == "case" { + return + } + if alt := n.attr("alttext"); n.name.Space == "http://www.w3.org/1998/Math/MathML" && n.name.Local == "math" && strings.TrimSpace(alt) != "" { + b.WriteString(alt) + return + } b.WriteString(n.text) for _, c := range n.children { walk(c) @@ -277,9 +285,7 @@ func openEPUB(arc archive, filename string) (*Book, error) { if err != nil { return nil, err } - if e.files[name] == nil { - return nil, fmt.Errorf("EPUB missing manifest resource: %s", name) - } + // A missing file fails only when the book reads it; unused fonts often go missing. if _, ok := manifest[id]; ok || resources[name] != "" { return nil, fmt.Errorf("EPUB duplicate manifest id or resource: %s", id) } diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index 2ddde1e..81d2850 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -381,10 +381,25 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour return nil } switch n.name.Local { - case "script", "iframe", "object", "embed", "audio", "video", "math", "switch", "base": + case "script", "base": return fmt.Errorf("unsupported EPUB element: %s", n.name.Local) - case "style": + case "style", "embed": return nil + case "switch": + if n.name.Space == "http://www.idpf.org/2007/ops" { + // epub:switch falls back to its plain XHTML default branch. + for _, c := range n.children { + if c.name.Space == n.name.Space && c.name.Local == "default" { + return walk(c) + } + } + return nil + } + case "math": + // MathML reads as its alternative text when it has one, else as its text below. + if alt := n.attr("alttext"); n.name.Space == "http://www.w3.org/1998/Math/MathML" && strings.TrimSpace(alt) != "" { + return walk(&epubNode{text: alt}) + } case "i", "em", "cite", "dfn", "var": italic++ defer func() { italic-- }() @@ -396,7 +411,13 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour defer func() { pre-- }() } if n.name.Space != "http://www.w3.org/1999/xhtml" && n.name.Local != "svg" { - return fmt.Errorf("EPUB unsupported element namespace: %s", n.name.Space) + // Foreign markup such as MathML or Word's o:p contributes only its text. + for _, c := range n.children { + if err := walk(c); err != nil { + return err + } + } + return nil } isHeading := len(n.name.Local) == 2 && n.name.Local[0] == 'h' && n.name.Local[1] >= '1' && n.name.Local[1] <= '6' block := epubBlock(n.name.Local) @@ -534,6 +555,9 @@ func (l *epubLayout) svg(e *epubPackage, name string, n *epubNode, resources map } func validateEPUBReferences(name string, n *epubNode) error { + if n.name.Space == "http://www.idpf.org/2007/ops" && n.name.Local == "case" { + return nil // epub:switch lays out only its default branch + } if n.name.Local == "script" || n.name.Local == "base" { return fmt.Errorf("EPUB unsupported element: %s", n.name.Local) } @@ -541,6 +565,12 @@ func validateEPUBReferences(name string, n *epubNode) error { if a.Name.Local == "href" && n.name.Local == "a" && n.name.Space == "http://www.w3.org/1999/xhtml" { continue // Render link labels without navigation or resource loading. } + switch n.name.Local { + case "audio", "video", "source", "track", "iframe", "embed": + if a.Name.Local == "src" || a.Name.Local == "poster" { + continue // Media shows its fallback text and never loads. + } + } if a.Name.Local == "href" || a.Name.Local == "src" || a.Name.Local == "poster" { if _, err := epubPath(name, a.Value); err != nil { return err diff --git a/internal/book/epub_test.go b/internal/book/epub_test.go index 455580e..f12ee64 100644 --- a/internal/book/epub_test.go +++ b/internal/book/epub_test.go @@ -229,7 +229,7 @@ func TestOpenEPUBRejectsInvalidContent(t *testing.T) { {"missing id", "OPS/book.opf", `idref="first"`, `idref="absent"`, "missing manifest"}, {"duplicate id", "OPS/book.opf", `id="last"`, `id="first"`, "duplicate manifest"}, {"external manifest", "OPS/book.opf", `text/z.xhtml`, `https://example.org/ch.xhtml`, "local resource"}, - {"missing resource", "OPS/book.opf", `text/z.xhtml`, `text/missing.xhtml`, "missing manifest resource"}, + {"missing resource", "OPS/book.opf", `text/z.xhtml`, `text/missing.xhtml`, "missing resource"}, {"unsupported spine", "OPS/book.opf", `application/xhtml+xml`, `text/html`, "unsupported spine"}, {"empty spine item", "OPS/text/z.xhtml", `

First heading

Hello reader & friends.

Second paragraph.

`, "", "no readable content"}, {"malformed XML", "OPS/text/z.xhtml", ``, "", "XML"}, @@ -507,6 +507,28 @@ func TestEPUBHeadingTargetsFirstTextPage(t *testing.T) { } } +func TestEPUBReadsFallbacksForUnsupportedMarkup(t *testing.T) { + entries := epubFixture() + replaceEPUB(entries, "OPS/book.opf", "", ``) + replaceEPUB(entries, "OPS/text/z.xhtml", "First heading", `Heading Ione x2`) + replaceEPUB(entries, "OPS/text/z.xhtml", "

Second paragraph.

", `

Energy E and x.

`+ + ``+ + `

Case text.

Default text.

`+ + `

Word markup.

`) + b, err := Open(writeEPUB(t, entries, ".epub")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + want := "Heading one x^2\nHello reader & friends.\nEnergy E=mc^2 and x.\nVideo fallback.\nDefault text.\nWord markup." + if got := textEPUBPage(t, b, 0); got != want { + t.Fatalf("text: %q", got) + } + if chapters, err := b.Chapters(); err != nil || len(chapters) == 0 || chapters[0].Title != "Heading one x^2" { + t.Fatalf("chapters: %#v, %v", chapters, err) + } +} + func TestEPUBBodyStylesListsAndPre(t *testing.T) { long := strings.TrimSpace(strings.Repeat("wrap ", 30)) + " end" entries := epubFixture() diff --git a/internal/book/testdata/solid.cbr b/internal/book/testdata/solid.cbr new file mode 100644 index 0000000..b40e4a1 Binary files /dev/null and b/internal/book/testdata/solid.cbr differ