Skip to content

Speed up PDF and DJVU page turns - #14

Merged
hawkff merged 6 commits into
mainfrom
ppm-pages
Oct 1, 2026
Merged

hawkff merged 6 commits into
mainfrom
ppm-pages

Conversation

@hawkff

@hawkff hawkff commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Render PDF and DJVU pages as PPM instead of PNG. pdftoppm paid for libpng compression on every page and the reader paid for the PNG decode on the way back in; ddjvu already wrote PPM, which the pipeline re-encoded as PNG for the same decode. A binary PPM decoder registered with the image package reads the converter output in one pass. PageBytes converts PPM to PNG for the browser, the one consumer that needs an encoded image. On a near-blank 2000px page, pdftoppm alone drops from 100ms to 32ms per call; pages with content compress slower, and the Go-side PNG decode goes away.

  • Start pdftohtml alongside pdftotext when extracting a PDF text layer instead of after it.
  • Prefetch both pages of the next spread; the second page rendered cold on every spread turn.
  • Resolve PDF page links in files whose name contains '#'. pdftohtml writes name.html#N and the parser cut at the first '#'.
  • Keep the current page when the book behind a bookmark fails to open.

Summary by CodeRabbit

  • Bug Fixes
    • PPM-rendered pages are now served in a format that can be displayed by the app.
    • PDF links to pages resolve correctly when document names contain #.
    • If opening a different book fails, the current page is retained.
  • Performance
    • Pages in the next page or spread are prefetched after rendering.

Note

Speed up PDF and DjVu page turns by caching PPM and prefetching next pages

  • Page renderers now return raw binary PPM output from pdftoppm and ddjvu instead of converting to PNG in-process; a new registered P6 PPM decoder in ppm.go handles decoding, and Book.PageBytes converts cached PPM pages to PNG when served.
  • PDF text extraction now runs pdftohtml link extraction concurrently with pdftotext, cancelling it when text extraction fails. Relative links whose document name contains # and ends in a numeric fragment are now treated as internal page links.
  • The UI prefetches the full next page or spread plus the one after it after each rendered frame, instead of a single prefetch.
  • Fixes a bug where selecting a bookmark whose book failed to open would still attempt page navigation.
  • Behavioral Change: cached converter pages are stored as PPM and served as PNG; toolPage page filenames now use the .ppm extension instead of .png.

Macroscope summarized 673b29d.

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no outstanding findings remain.

Summary

The PR caches PDF and DJVU pages as PPM, converts them to PNG when serving browser requests, starts PDF link extraction alongside text extraction, and adjusts page prefetching and navigation.

  • The latest change cancels the concurrent pdftohtml process when text-layer preparation returns early.
  • The previous browser-conversion concern was explicitly accepted, and the previous extraction-work concern is fixed.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[PDF or DJVU converter] --> B[PPM page cache]
  B --> C[Image decoder]
  B --> D[PageBytes PNG conversion]
  D --> E[Browser]
Loading

Reviews (2) · Last reviewed commit: "Stop the PDF link pass when the word pas..."

hawkff added 5 commits October 1, 2026 18:52
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.
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.
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.
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.
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.
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 49 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b87f5d14-6f83-4155-b6fc-c0a7f95237c4

📥 Commits

Reviewing files that changed from the base of the PR and between d00be63 and 673b29d.

📒 Files selected for processing (1)
  • internal/book/pdftext.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9593423b-41c4-4b27-8098-30ac43da42e4

📥 Commits

Reviewing files that changed from the base of the PR and between aaf465c and d00be63.

📒 Files selected for processing (6)
  • internal/book/book.go
  • internal/book/pdftext.go
  • internal/book/ppm.go
  • internal/book/tool.go
  • internal/book/tool_test.go
  • internal/ui/ui.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The changes update PPM page rendering and PNG responses, PDF text and link processing, and UI page prefetching and menu navigation.

Changes

PPM page flow

Layer / File(s) Summary
PPM output and decoding
internal/book/tool.go, internal/book/ppm.go
PDF and DJVU renderers return PPM page data. A P6 decoder validates supported headers and decodes pixel data into opaque RGBA pixels.
PageBytes PNG response
internal/book/book.go, internal/book/tool_test.go
PageBytes returns copied non-PPM data and converts PPM data to PNG. Tests cover decoder behavior, malformed input, and PNG responses from PageBytes.

PDF text and links

Layer / File(s) Summary
PDF extraction and link targets
internal/book/pdftext.go, internal/book/tool_test.go
pdfTextLayer starts pdftohtml concurrently with pdftotext and passes the XML output to pdfLinks. Relative link targets use the final # to find an integer page suffix.

Page prefetching

Layer / File(s) Summary
Next page or spread prefetch
internal/ui/ui.go
After rendering, the UI schedules prefetches for every page in the next page or spread.

Menu book navigation

Layer / File(s) Summary
Check book open before navigation
internal/ui/ui.go
If opening the selected book fails, the UI returns the open command without navigating to the selected page.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant pdfTextLayer
  participant pdftotext
  participant pdftohtml
  participant pdfLinks
  pdfTextLayer->>pdftotext: Run text extraction
  pdfTextLayer->>pdftohtml: Start XML extraction concurrently
  pdftotext-->>pdfTextLayer: Return text output
  pdftohtml-->>pdfTextLayer: Return XML output through buffered channel
  pdfTextLayer->>pdfLinks: Pass XML output
Loading

Merge Risk: ⚪ Minimal · up to d00be

No actionable merge-blocking issue remains. The rendering and navigation changes are mergeable after normal checks; canceling unused PDF link extraction is optional cleanup.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to d00be

Page-size limits and link filtering remain intact, and failed bookmark opens now preserve the current book and page. However, browser page requests now repeat image conversion independently, allowing concurrent requests to multiply memory and CPU work. The demonstrated exposure is limited to the local reader process.

Retained concerns

  • Medium · security · inferred: Moving PNG conversion to PageBytes makes every PPM page request perform a fresh decode and encode, even for a cached page. Conversion occurs outside page-load coordination and its output is not cached. Once browser serving is enabled, concurrent local clients can multiply this work within the reader process. Previously, repeated requests copied already encoded PNG data. Per-page limits constrain each conversion but do not bound aggregate request-driven memory and CPU consumption.
Security review details

Security Blast Radius

  • inferred — The demonstrated attackable scope is availability of one local reader process and its published active-book pages after browser serving starts. A client that can reach the bound loopback port with the expected Host can request pages repeatedly. The inspected change does not establish remote-network, cross-tenant, privilege-escalation, or persistent-data exposure.

Security Findings and Attack Paths

  • inferred — Repeated concurrent requests for the same cached PDF or DJVU page now independently allocate a decoded image and perform PNG encoding. The loading lock only coordinates acquisition of cached PPM, not these conversions. This introduces request-driven resource amplification compared with copying cached PNG; actual exhaustion thresholds have not been measured.

Trust Boundaries and Controls

  • observed — PDF-authored link targets still pass through the existing absolute-URL scheme allowlist: http, https, and mailto are accepted, while file URLs are rejected. The fragment change uses the last numeric fragment for internal navigation. Direct implementation inspection resolves the supplied rejected-file-URL proof gap, and base comparison shows that scheme filtering predates this PR.

Resilience and Maintainability Implications

  • observed — Same-page loads and text preparations retain coordination and cleanup state, and closed books prevent subsequent cache publication. Parallel pdftohtml can outlive an early text-extraction return, but its buffered result channel, two-minute tool timeout, bounded output, and book-close cancellation constrain that work; an unbounded leak is not established.

Hardening Proposals

  • proposed — Bound concurrent serving conversions or coalesce them per book and page with a byte-bounded PNG cache. Propagate request cancellation into conversion work so abandoned requests do not continue consuming conversion resources.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main objective: improving PDF and DJVU page-turn performance through faster rendering, decoding, prefetching, and related optimizations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread internal/book/pdftext.go

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium book/pdftext.go:15

When pdftotext fails, pdfTextLayer returns nil while pdftohtml keeps running for up to toolTimeout, consuming a converter process whose result is no longer used. Cancel a child context and wait for the pdftohtml goroutine before returning from this error path.

	n := strconv.Itoa(page)
+	toolCtx, cancel := context.WithCancel(ctx)
+	defer cancel()
 	// 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)
+		out, _ := runToolContext(toolCtx, "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, "-")
+	out, err := runToolContext(toolCtx, "pdftotext", "-cropbox", "-bbox-layout", "-f", n, "-l", n, path, "-")
 	if err != nil {
+		cancel()
+		<-linkXML
 		return nil
 	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/book/pdftext.go around lines 15-25:

When `pdftotext` fails, `pdfTextLayer` returns `nil` while `pdftohtml` keeps running for up to `toolTimeout`, consuming a converter process whose result is no longer used. Cancel a child context and wait for the `pdftohtml` goroutine before returning from this error path.

Evidence trail:
`internal/book/pdftext.go:16-36` (d00be63); `internal/book/tool.go:16-21,38-75` (d00be63); `internal/book/select.go:108-159` (d00be63); `internal/book/book.go:216-236` (d00be63).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 673b29d. pdfTextLayer now derives a child context and cancels it on return, so an early exit kills pdftohtml instead of letting it run to completion or timeout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child context cancels pdftohtml, but early returns still do not wait for its goroutine to exit. Please receive from linkXML after cancellation before returning, as proposed, so the converter cleanup completes before pdfTextLayer returns.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving the early returns as they are. The goroutine's only effect is a send into a buffered channel; cancellation kills pdftohtml and runToolLimited's Wait reaps it a few milliseconds later. Nothing reads the result or watches for the goroutine to finish, so blocking the caller on that reap would only delay PrepareText storing the empty layer. On the normal path the receive before pdfLinks already joins the two.

Comment thread internal/book/book.go Resolved
Comment thread internal/book/pdftext.go Resolved
@hawkff
hawkff merged commit bc9b297 into main Oct 1, 2026
9 checks passed
@hawkff
hawkff deleted the ppm-pages branch October 1, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant