From a8f0263028b9fdc460746c13a1805b2371eb59f8 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:01:53 -0400 Subject: [PATCH 1/5] Read solid CBR pages and bound decoded page memory Solid RAR entries depend on the files before them, so they share one sequential reader that restarts only for an earlier page. The decoded page cache also evicts by pixel count, and a truncated ZIP reports a ZIP format error. --- internal/book/archive.go | 94 ++++++++++++++++++++++++++++--- internal/book/book.go | 17 ++++-- internal/book/book_test.go | 70 +++++++++++++++++++++++ internal/book/testdata/solid.cbr | Bin 0 -> 389 bytes 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 internal/book/testdata/solid.cbr diff --git a/internal/book/archive.go b/internal/book/archive.go index ad526c9..e4d7933 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,103 @@ 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 + + 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 +} -type rarEntry struct{ f *rardecode.File } +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) { return e.f.Open() } +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 { + if a.closed.Load() { + a.closeSolid() + return nil, os.ErrClosed + } + 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(a.solid, maxPageBytes) + if err != nil { + a.closeSolid() + return nil, err + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +// 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/testdata/solid.cbr b/internal/book/testdata/solid.cbr new file mode 100644 index 0000000000000000000000000000000000000000..b40e4a1ed39bab2bf282d40ff0de794f5c7ee6e2 GIT binary patch literal 389 zcmWGaEK-zWXJk;=_Dhh9k(G^wm65TbfniU4gpDQ>Gj}5+%Q=Q6%}iNei~<{E8M(vq za}4zh4D|}~(z%$0oj=~mD%K1TIsCj%EhN?GTY$;G3L!TkW-mMO6=sWg!lwEyzPH>% zNmn;8&vC1i5SNLW60cudiPF8xCRHY8+CKa*|4jGQoq5=(FRL#@PCmVHhRmfuh4zit zUYarREqHd!wWw>$%J}nkt0&B9jy<`4UH?TVPP=J(;)TcEt9sHNPUlPzto>ZJm+ip} zj+~bu=gwnbxd3wRRMG8w55S#kgyvjF2I;9$hjYJdJ`wZr=D#C6OOMM{Nea#1D)sU1 z{YMPbolk~<%$mc%auH;f-_4J42(ygQ%yMCuoa%b)%5$R?yOu5YooVi##D{yyo l@CJu634ux(ph*mj3~!qm4=^yYfPxPx++@qc0-0G^7yyy}j}-s_ literal 0 HcmV?d00001 From 52d08bf037d555d21b53776f79cff1dbf7ceafd6 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:01:53 -0400 Subject: [PATCH 2/5] Name comic folder chapters by their innermost folder --- internal/book/chapters.go | 8 +++++--- internal/book/chapters_test.go | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) 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 { From 1c47a9b71e37f8a28979aac0808cd14df3c2cc1c Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:01:53 -0400 Subject: [PATCH 3/5] Open EPUBs with MathML, media fallbacks or missing unused files MathML reads as its alternative text, audio, video and object show their fallback content, epub:switch uses its default branch, and other foreign markup keeps its text. A manifest file that is missing fails only when the book reads it. --- internal/book/epub.go | 12 +++++++++--- internal/book/epub_render.go | 26 +++++++++++++++++++++++--- internal/book/epub_test.go | 24 +++++++++++++++++++++++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/book/epub.go b/internal/book/epub.go index 862b2f7..99874fe 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.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..55fd07c 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -381,10 +381,18 @@ 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": + // epub:switch falls back to its plain XHTML default branch. + return walk(n.child("default")) + case "math": + // MathML reads as its alternative text when it has one, else as its text below. + if alt := n.attr("alttext"); strings.TrimSpace(alt) != "" { + return walk(&epubNode{text: alt}) + } case "i", "em", "cite", "dfn", "var": italic++ defer func() { italic-- }() @@ -396,7 +404,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) @@ -541,6 +555,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..81a9883 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() From a6b3977bfbc30fc306a3ebe698c4a1fc5e8d58fb Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:20:26 -0400 Subject: [PATCH 4/5] Stop solid decodes on close and skip unused EPUB switch branches The solid reader now decodes skipped files in chunks that check for a closed book. Reference checks skip epub:case branches the layout never shows, and switch and alttext handling applies only to the epub and MathML namespaces. --- internal/book/archive.go | 25 ++++++++++++++++++++----- internal/book/epub.go | 2 +- internal/book/epub_render.go | 11 ++++++++--- internal/book/epub_test.go | 4 ++-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/internal/book/archive.go b/internal/book/archive.go index e4d7933..0b7b869 100644 --- a/internal/book/archive.go +++ b/internal/book/archive.go @@ -136,11 +136,16 @@ func (a *rarArchive) openSolid(e rarEntry) (io.ReadCloser, error) { a.solid, a.next = rc, 0 } for a.next <= e.index { - if a.closed.Load() { - a.closeSolid() - return nil, os.ErrClosed + 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() } - 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) } @@ -150,7 +155,7 @@ func (a *rarArchive) openSolid(e rarEntry) (io.ReadCloser, error) { } a.next++ } - data, err := readBounded(a.solid, maxPageBytes) + data, err := readBounded(solidReader{a}, maxPageBytes) if err != nil { a.closeSolid() return nil, err @@ -158,6 +163,16 @@ func (a *rarArchive) openSolid(e rarEntry) (io.ReadCloser, error) { 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 { diff --git a/internal/book/epub.go b/internal/book/epub.go index 99874fe..87d7f31 100644 --- a/internal/book/epub.go +++ b/internal/book/epub.go @@ -60,7 +60,7 @@ func (n *epubNode) allText() string { if n.name.Space == "http://www.idpf.org/2007/ops" && n.name.Local == "case" { return } - if alt := n.attr("alttext"); n.name.Local == "math" && strings.TrimSpace(alt) != "" { + 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 } diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index 55fd07c..ecd54c1 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -386,11 +386,13 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour case "style", "embed": return nil case "switch": - // epub:switch falls back to its plain XHTML default branch. - return walk(n.child("default")) + if n.name.Space == "http://www.idpf.org/2007/ops" { + // epub:switch falls back to its plain XHTML default branch. + return walk(n.child("default")) + } case "math": // MathML reads as its alternative text when it has one, else as its text below. - if alt := n.attr("alttext"); strings.TrimSpace(alt) != "" { + 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": @@ -548,6 +550,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) } diff --git a/internal/book/epub_test.go b/internal/book/epub_test.go index 81a9883..f12ee64 100644 --- a/internal/book/epub_test.go +++ b/internal/book/epub_test.go @@ -513,8 +513,8 @@ func TestEPUBReadsFallbacksForUnsupportedMarkup(t *testing.T) { 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.

`) + `

Case text.

Default text.

`+ + `

Word markup.

`) b, err := Open(writeEPUB(t, entries, ".epub")) if err != nil { t.Fatal(err) From 0a340df0f83850ddad229a57bd388c87d81e2f30 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:29:37 -0400 Subject: [PATCH 5/5] Pick only the epub default branch of epub:switch --- internal/book/epub_render.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index ecd54c1..81d2850 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -388,7 +388,12 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour case "switch": if n.name.Space == "http://www.idpf.org/2007/ops" { // epub:switch falls back to its plain XHTML default branch. - return walk(n.child("default")) + 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.