From d01be3f9c0aeae61ec183ae9ceed06d366980d7b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:52:53 -0400 Subject: [PATCH 1/6] Render PDF and DJVU pages as PPM pdftoppm wrote PNG, so every page turn paid for libpng compression in poppler and for the PNG decode on the way back in. ddjvu already wrote PPM, which ppmToPNG re-encoded as PNG for the same decode. Register a binary PPM decoder with the image package and let both converters write PPM. The page pipeline decodes it in one pass. PageBytes converts PPM to PNG for the browser, the one consumer that needs an encoded image. ppmToPNG goes away. --- internal/book/book.go | 31 ++++++++++++++++------ internal/book/ppm.go | 53 ++++++++++++++++++++++++++++++++++++++ internal/book/tool.go | 45 +++++--------------------------- internal/book/tool_test.go | 21 ++++++++++----- 4 files changed, 97 insertions(+), 53 deletions(-) create mode 100644 internal/book/ppm.go diff --git a/internal/book/book.go b/internal/book/book.go index da56c3e..a52a6cb 100644 --- a/internal/book/book.go +++ b/internal/book/book.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "image" + "image/png" "io" "os" "path/filepath" @@ -15,7 +16,6 @@ import ( _ "image/gif" _ "image/jpeg" - _ "image/png" _ "golang.org/x/image/bmp" _ "golang.org/x/image/webp" @@ -277,7 +277,8 @@ func (b *Book) lockPage(ctx context.Context, i int) (func(), error) { } } -// PageBytes returns a copy of the cached encoded bytes of page i (for HTTP serving). +// PageBytes returns a copy of the cached encoded bytes of page i (for HTTP +// serving). Converter pages sit in the cache as PPM and leave as PNG. func (b *Book) PageBytes(i int) ([]byte, string, error) { if i < 0 || i >= len(b.pages) { return nil, "", fmt.Errorf("page %d out of range", i) @@ -289,16 +290,30 @@ func (b *Book) PageBytes(i int) ([]byte, string, error) { } cached, ok := b.encoded[i] b.mu.Unlock() - if ok { - return bytes.Clone(cached.data), cached.mime, nil + data, mime := cached.data, cached.mime + if !ok { + unlock, err := b.lockPage(context.Background(), i) + if err != nil { + return nil, "", err + } + data, mime, err = b.pageBytes(context.Background(), i) + unlock() + if err != nil { + return nil, "", err + } } - unlock, err := b.lockPage(context.Background(), i) + if mime != ppmMIME { + return bytes.Clone(data), mime, nil + } + img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { + return nil, "", fmt.Errorf("decode page %d: %w", i+1, err) + } + var out bytes.Buffer + if err := (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(&out, img); err != nil { return nil, "", err } - defer unlock() - data, mime, err := b.pageBytes(context.Background(), i) - return bytes.Clone(data), mime, err + return out.Bytes(), "image/png", nil } // pageBytes requires lockPage. The cache holds at most eight pages and maxPageBytes. diff --git a/internal/book/ppm.go b/internal/book/ppm.go new file mode 100644 index 0000000..eda28d6 --- /dev/null +++ b/internal/book/ppm.go @@ -0,0 +1,53 @@ +package book + +import ( + "bufio" + "errors" + "fmt" + "image" + "image/color" + "io" +) + +// ppmMIME is the type the page pipeline sees for converter output. Browsers +// do not display it; PageBytes converts it to PNG. +const ppmMIME = "image/x-portable-pixmap" + +// pdftoppm and ddjvu write binary PPM, which decodes in one pass. PNG would +// cost a compression round trip on every page turn. +func init() { image.RegisterFormat("x-portable-pixmap", "P6", decodePPM, decodePPMConfig) } + +func ppmHeader(r *bufio.Reader) (w, h int, err error) { + var magic string + var depth int + if _, err := fmt.Fscan(r, &magic, &w, &h, &depth); err != nil || magic != "P6" || depth != 255 || w < 1 || h < 1 || w > maxPagePixels/h { + return 0, 0, errors.New("unsupported PPM page") + } + _, err = r.ReadByte() // one whitespace byte precedes the pixels + return w, h, err +} + +func decodePPMConfig(r io.Reader) (image.Config, error) { + w, h, err := ppmHeader(bufio.NewReader(r)) + return image.Config{ColorModel: color.RGBAModel, Width: w, Height: h}, err +} + +func decodePPM(r io.Reader) (image.Image, error) { + br := bufio.NewReader(r) + w, h, err := ppmHeader(br) + if err != nil { + return nil, err + } + img := image.NewRGBA(image.Rect(0, 0, w, h)) + row := make([]byte, 3*w) + for y := range h { + if _, err := io.ReadFull(br, row); err != nil { + return nil, errors.New("truncated PPM page") + } + pix := img.Pix[y*img.Stride : (y+1)*img.Stride] + for x := range w { + pix[4*x], pix[4*x+1], pix[4*x+2], pix[4*x+3] = row[3*x], row[3*x+1], row[3*x+2], 255 + } + } + return img, nil +} diff --git a/internal/book/tool.go b/internal/book/tool.go index 239810b..3d0ec46 100644 --- a/internal/book/tool.go +++ b/internal/book/tool.go @@ -1,13 +1,10 @@ package book import ( - "bufio" "bytes" "context" "errors" "fmt" - "image" - "image/png" "io" "os/exec" "path/filepath" @@ -85,24 +82,21 @@ type toolPage struct { djvu bool } -func (p toolPage) Name() string { return "page-" + strconv.Itoa(p.page) + ".png" } +func (p toolPage) Name() string { return "page-" + strconv.Itoa(p.page) + ".ppm" } func (p toolPage) Open() (io.ReadCloser, error) { return p.open(context.Background()) } +// open renders the page as binary PPM, which the registered decoder reads in +// one pass; see ppm.go. func (p toolPage) open(ctx context.Context) (io.ReadCloser, error) { n := strconv.Itoa(p.page) - var data []byte - var err error + args := []string{"pdftoppm", "-cropbox", "-scale-to", renderedPageSize, "-f", n, "-l", n, "-singlefile", p.path} if p.djvu { - data, err = runToolContext(ctx, "ddjvu", "-format=ppm", "-size="+renderedPageSize+"x"+renderedPageSize, "-page="+n, p.path) - if err == nil { - data, err = ppmToPNG(data) - } - } else { - data, err = runToolContext(ctx, "pdftoppm", "-png", "-cropbox", "-scale-to", renderedPageSize, "-f", n, "-l", n, "-singlefile", p.path) + args = []string{"ddjvu", "-format=ppm", "-size=" + renderedPageSize + "x" + renderedPageSize, "-page=" + n, p.path} } + data, err := runToolContext(ctx, args[0], args[1:]...) if err != nil { return nil, err } @@ -139,33 +133,6 @@ func openRendered(path string, djvu bool) (*Book, error) { return b, nil } -// ppmToPNG converts the binary PPM ddjvu writes into PNG for the page pipeline. -func ppmToPNG(data []byte) ([]byte, error) { - r := bufio.NewReader(bytes.NewReader(data)) - var magic string - var w, h, depth int - if _, err := fmt.Fscan(r, &magic, &w, &h, &depth); err != nil || magic != "P6" || depth != 255 || w < 1 || h < 1 || w > maxPagePixels/h { - return nil, errors.New("ddjvu wrote no usable PPM page") - } - if _, err := r.ReadByte(); err != nil { // one whitespace byte precedes the pixels - return nil, err - } - pix := make([]byte, 3*w*h) - if _, err := io.ReadFull(r, pix); err != nil { - return nil, errors.New("ddjvu wrote a truncated PPM page") - } - img := image.NewRGBA(image.Rect(0, 0, w, h)) - for i := range w * h { - copy(img.Pix[4*i:], pix[3*i:3*i+3]) - img.Pix[4*i+3] = 255 - } - var out bytes.Buffer - if err := (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(&out, img); err != nil { - return nil, err - } - return out.Bytes(), nil -} - // openDOC keeps the headings in antiword's DocBook output. func openDOC(path string) (*Book, error) { out, err := runTool("antiword", "-x", "db", path) diff --git a/internal/book/tool_test.go b/internal/book/tool_test.go index 4e6e9d2..cb26233 100644 --- a/internal/book/tool_test.go +++ b/internal/book/tool_test.go @@ -22,12 +22,13 @@ func requireTools(t *testing.T, tools ...string) { } } -func TestPPMToPNG(t *testing.T) { - data, err := ppmToPNG([]byte("P6\n2 1\n255\n\xff\x00\x00\x00\x00\xff")) - if err != nil { - t.Fatal(err) +func TestDecodePPM(t *testing.T) { + ppm := "P6\n2 1\n255\n\xff\x00\x00\x00\x00\xff" + config, format, err := image.DecodeConfig(strings.NewReader(ppm)) + if err != nil || "image/"+format != ppmMIME || config.Width != 2 || config.Height != 1 { + t.Fatalf("config: %+v %s, %v", config, format, err) } - img, err := png.Decode(bytes.NewReader(data)) + img, _, err := image.Decode(strings.NewReader(ppm)) if err != nil || img.Bounds() != image.Rect(0, 0, 2, 1) { t.Fatalf("decoded page: %v", err) } @@ -37,10 +38,18 @@ func TestPPMToPNG(t *testing.T) { t.Fatal("pixel colors changed") } for _, bad := range []string{"P5\n2 1\n255\n\xff\xff", "P6\n2 1\n255\n\xff", "P6\n0 1\n255\n", "P6\n99999 99999\n255\n"} { - if _, err := ppmToPNG([]byte(bad)); err == nil { + if _, _, err := image.Decode(strings.NewReader(bad)); err == nil { t.Fatalf("accepted %q", bad) } } + b2 := &Book{pages: []entry{memEntry{"page.ppm", []byte(ppm)}}} + data, mime, err := b2.PageBytes(0) + if err != nil || mime != "image/png" { + t.Fatalf("served PPM as %s, %v", mime, err) + } + if served, err := png.Decode(bytes.NewReader(data)); err != nil || served.Bounds() != image.Rect(0, 0, 2, 1) { + t.Fatalf("served PNG: %v", err) + } } func TestOpenPDF(t *testing.T) { From a4c25fbb15a2d6e33a448994549fb14ca7c65124 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:53:23 -0400 Subject: [PATCH 2/6] Read PDF links alongside the text layer pdftotext and pdftohtml each parse the document for one page. The link pass waited for the word pass to finish; start it first and join the two when the words are in. --- internal/book/pdftext.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/book/pdftext.go b/internal/book/pdftext.go index ad6cdeb..87d3111 100644 --- a/internal/book/pdftext.go +++ b/internal/book/pdftext.go @@ -13,6 +13,12 @@ import ( // pdftotext is a paragraph; its lines carry a trailing space until the last. func pdfTextLayer(ctx context.Context, path string, page int) []textLine { n := strconv.Itoa(page) + // pdftohtml parses the document again; let it run alongside pdftotext. + linkXML := make(chan []byte, 1) + go func() { + out, _ := runToolContext(ctx, "pdftohtml", "-xml", "-i", "-q", "-zoom", "1", "-f", n, "-l", n, "-stdout", path) + linkXML <- out + }() out, err := runToolContext(ctx, "pdftotext", "-cropbox", "-bbox-layout", "-f", n, "-l", n, path, "-") if err != nil { return nil @@ -27,7 +33,7 @@ func pdfTextLayer(ctx context.Context, path string, page int) []textLine { if w <= 0 || h <= 0 { return nil } - links := pdfLinks(ctx, path, page, w, h) + links := pdfLinks(ctx, <-linkXML, path, page, w, h) var lines []textLine for _, flow := range pg.children { for _, block := range flow.children { @@ -140,15 +146,10 @@ func pdfLinkTarget(href string) (target string, internal, ok bool) { return "", false, false } -// pdfLinks returns the link rectangles of a page as crop box fractions. -// pdftohtml works in the media box, so pdfinfo supplies the offset when the -// boxes differ in size. -func pdfLinks(ctx context.Context, path string, page int, cropW, cropH float64) []pdfLink { - n := strconv.Itoa(page) - out, err := runToolContext(ctx, "pdftohtml", "-xml", "-i", "-q", "-zoom", "1", "-f", n, "-l", n, "-stdout", path) - if err != nil { - return nil - } +// pdfLinks returns the link rectangles of a page as crop box fractions, read +// from the pdftohtml XML of that page. pdftohtml works in the media box, so +// pdfinfo supplies the offset when the boxes differ in size. +func pdfLinks(ctx context.Context, out []byte, path string, page int, cropW, cropH float64) []pdfLink { doc, err := parseXML(out, maxDocumentTokens) if err != nil { return nil From 7a1eafddcfd9d91988c0da9e94f501ef8d2fb2f9 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:53:24 -0400 Subject: [PATCH 3/6] Resolve PDF page links behind a '#' in the file name pdftohtml writes page destinations as name.html#N. Cutting at the first '#' lost every internal link in a PDF whose file name contains one. Cut at the last. --- internal/book/pdftext.go | 7 ++++--- internal/book/tool_test.go | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/book/pdftext.go b/internal/book/pdftext.go index 87d3111..b91453a 100644 --- a/internal/book/pdftext.go +++ b/internal/book/pdftext.go @@ -138,9 +138,10 @@ func pdfLinkTarget(href string) (target string, internal, ok bool) { } return "", false, false } - if _, page, found := strings.Cut(href, "#"); found { - if _, err := strconv.Atoi(page); err == nil { - return "#" + page, true, true + // The document name precedes the page and may itself contain '#'. + if i := strings.LastIndexByte(href, '#'); i >= 0 { + if _, err := strconv.Atoi(href[i+1:]); err == nil { + return href[i:], true, true } } return "", false, false diff --git a/internal/book/tool_test.go b/internal/book/tool_test.go index cb26233..a9249b7 100644 --- a/internal/book/tool_test.go +++ b/internal/book/tool_test.go @@ -301,6 +301,9 @@ func TestPDFTextLayerSelectionAndLinks(t *testing.T) { if _, internal, ok := pdfLinkTarget("Tricky name.html#12"); !internal || !ok { t.Fatal("page reference with spaces") } + if target, internal, ok := pdfLinkTarget("issue#2.html#12"); target != "#12" || !internal || !ok { + t.Fatalf("page reference behind a '#' in the document name: %q", target) + } if _, _, ok := pdfLinkTarget("file:///etc/passwd"); ok { t.Fatal("file link survived") } From d962983ea72e59d4ba18e125694a13ea3027c8a9 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:53:36 -0400 Subject: [PATCH 4/6] Prefetch both pages of the next spread The prefetch after a frame warmed one page past the current spread, so each spread turn rendered its second page cold. For PDF and DJVU that is a converter run on the critical path. --- internal/ui/ui.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 10320f5..c12c63b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -398,12 +398,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.webtoon && p.webScroll != 0 { rerender = true } - ready := frameReadyAfterPaint(p, msg.pane, msg.slot, msg.gen, rerender) - step := 1 - if m.spreadActive() { - step = 2 + cmds := []tea.Cmd{frameReadyAfterPaint(p, msg.pane, msg.slot, msg.gen, rerender)} + // Warm the whole next page or spread. + for pg := msg.page + m.slotCount(); pg < msg.page+2*m.slotCount(); pg++ { + cmds = append(cmds, m.prefetch(msg.pane, pg)) } - return m, tea.Batch(ready, m.prefetch(msg.pane, msg.page+step)) + return m, tea.Batch(cmds...) case frameReadyMsg: p := m.panes[msg.pane] From d00be631c326c398e14a49d6b08216cff48e5e8d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:53:36 -0400 Subject: [PATCH 5/6] Keep the page when a bookmarked book fails to open Picking a bookmark in another book opened that book and jumped to the page. When the open failed, the jump still ran against the book already in the pane. --- internal/ui/ui.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c12c63b..5512eab 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -845,6 +845,9 @@ func (m Model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if it.path != "" && (p.book == nil || p.book.Path != it.path) { mm, cmd := m.openBook(m.active, it.path, nil) mdl := mm.(Model) + if b := mdl.panes[mdl.active].book; b == nil || b.Path != it.path { + return mdl, cmd // the open failed; keep the current page + } mdl2, cmd2 := mdl.goTo(mdl.active, it.page) return mdl2, tea.Batch(cmd, cmd2) } From 673b29d1539ae7aaee00a0534d776db262d1ee98 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:03:54 -0400 Subject: [PATCH 6/6] Stop the PDF link pass when the word pass fails --- internal/book/pdftext.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/book/pdftext.go b/internal/book/pdftext.go index b91453a..88d8f8d 100644 --- a/internal/book/pdftext.go +++ b/internal/book/pdftext.go @@ -13,7 +13,10 @@ import ( // pdftotext is a paragraph; its lines carry a trailing space until the last. func pdfTextLayer(ctx context.Context, path string, page int) []textLine { n := strconv.Itoa(page) - // pdftohtml parses the document again; let it run alongside pdftotext. + // pdftohtml parses the document again; let it run alongside pdftotext, + // and stop it when the words do not come. + ctx, cancel := context.WithCancel(ctx) + defer cancel() linkXML := make(chan []byte, 1) go func() { out, _ := runToolContext(ctx, "pdftohtml", "-xml", "-i", "-q", "-zoom", "1", "-f", n, "-l", n, "-stdout", path)