Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 101 additions & 8 deletions internal/book/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 13 additions & 4 deletions internal/book/book.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package book

import (
"archive/zip"
"bytes"
"context"
"errors"
Expand Down Expand Up @@ -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
Expand All @@ -81,6 +83,7 @@ const (
cacheSize = 8
maxPageBytes = 64 << 20
maxPagePixels = 32_000_000
maxCachePixels = 2 * maxPagePixels
maxMetadataBytes = 1 << 20
)

Expand Down Expand Up @@ -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-")) {
Expand Down Expand Up @@ -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)
}
}
Expand Down
70 changes: 70 additions & 0 deletions internal/book/book_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"io"
"os"
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions internal/book/chapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package book
import (
"encoding/xml"
"os"
"path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/book/chapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions internal/book/epub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
b.WriteString(n.text)
for _, c := range n.children {
walk(c)
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading