Skip to content

feat: read a PDF as its own pages, with the narration painted on - #149

Merged
NeoVand merged 15 commits into
mainfrom
feat/native-pdf-view
Aug 27, 2026
Merged

feat: read a PDF as its own pages, with the narration painted on#149
NeoVand merged 15 commits into
mainfrom
feat/native-pdf-view

Conversation

@NeoVand

@NeoVand NeoVand commented Aug 27, 2026

Copy link
Copy Markdown
Owner

What changed

A PDF can now be read as its own pages, with the narration painted onto the paper where it actually sits — down to the word.

The markdown stays underneath, unchanged. It is still what the voice, the assistant and the study layer read, so this is a choice of presentation, not a different import: every PDF has both views and switching is instant. A separate import path would have meant a PDF you can see but cannot listen to.

In the page view you get: the passage being spoken highlighted where it was printed, with the current word lit inside it; double-click any sentence to play from there, with a hover wash showing what a click will take; follow-along scrolling that yields the moment you scroll yourself; your highlights and margin notes; the assistant's pointer; the contents panel; and zoom.

How the two are reconciled

The reader's passages come from LiteParse's markdown; the page view draws the PDF. Joining them is the whole problem, and the bridge is that LiteParse can emit a box for every word it read (emitWordBoxes) from the same parse that produced the markdown — so both sides descend from the same glyphs.

Placement is a monotonic affine-gap alignment of a page's words against the passages anchored to it, plus a second pass inside whatever region a run of failures was bracketed into (that pass is what places table rows). Geometry is recomputed per page from the stored bytes with targetPages, ~16ms a page, rather than stored — exactly what the DocumentPageInfo comment anticipated.

Measured on real papers: 91% of passages placed on "Attention Is All You Need", 92% on "Deep Residual Learning" (two-column), with 80–83% of words boxed individually. The remainder is almost entirely synthesized narration for degenerate tables, which never appears on the page in that form.

Two findings worth recording, both from real papers rather than intuition:

  • textItems come back in raster order while the markdown is in reading order. On a two-column page the boxes interleave the columns line by line, and a monotonic alignment finds almost nothing — 57% placed. A recursive XY-cut that splits at vertical gutters before horizontal ones recovers it: 57% → 92%.
  • pdf.js display renders never finish in a hidden tab (they continue via requestAnimationFrame). Anything pdf.js-rendered has to be verified under Playwright, not a preview pane.

Themes

The original pages are printed onto the reader's own paper. A PDF page being white with black ink on it is a fact about the paper it was made for, not the room it is read in — and the reading view has always drawn the same document on the theme's paper in the theme's ink, so the page view now uses those very colours, read off the stack as --reader and --reader-ink-strong.

It is one duotone ramp: white → paper, black → ink, greys proportional. Under Sunny that is a barely visible warming; under Cocoa the page is warm dark brown with cream type; under Midnight a full inversion. Light and dark are the ramp's endpoints rather than separate code paths.

Colour is left alone — remapping it would be recolouring the author's work rather than the paper under it — and photographs and rendered figures are lifted out by their operator-list rects and put back whole, so a figure keeps its own background on a dark page. 41ms for a retina page.

Drawing, which is where this actually breaks

A paper with a 163,000-operator figure on one page taught most of these. pdf.js renders one page at a time, so a slow page can take the view down with it:

  • A page already being drawn is never queued again; a page the reader has left is abandoned mid-draw (RenderTask.cancel); draws go nearest-the-reader-first.
  • Pages are painted off-screen and handed over whole, so a canvas keeps showing what it has until the next picture is finished — no clear-then-fill flash on redraw, and no strobe while a zoom slider is dragged.
  • Bitmaps are released against a pixel budget rather than the moment a page leaves view. A canvas is sized when its draw begins, so an abandoned draw leaves a full-size bitmap with nothing recorded against it; budgeting by the record let those reach ~300 MB after a few sweeps at retina scale. Budgeting by the pixels holds it at ~134 MB.

Verification

  • npm run lint
  • npm run check
  • npm run test:coverage — 522 unit tests; domain/pdf-layout.ts and domain/page-tone.ts added to the coverage include list at 96% and 98%
  • Relevant Chromium/visual journey — 46 e2e including all 12 committed visual baselines (unchanged)

New e2e coverage, each checked to fail against the code it guards: placement lands on the printed line rather than where reflowed text would put it; reading ahead survives pages placing underneath it; no bitmap is released across three sweeps of a six-page document; the drawing tracks its sheet through a zoom drag (360px of lag before, ≤2px after); the drawn paper samples equal to the theme's --reader.

Review checklist

  • Privacy and network behavior are unchanged. Nothing new leaves the device; the page geometry and the pixels both come from the PDF already in local storage.
  • Storage/migration. No schema change and no migration: geometry is recomputed per page on demand and never persisted, which is what DocumentPageInfo's comment reserved. Documents written before this read identically.
  • Keyboard, screen-reader, contrast, reduced-motion. The stack takes focus so a keyboard can scroll it (WCAG 2.1.1), with an axe pass over the view in the suite. Follow-scrolling and mark transitions honour prefers-reduced-motion. Contrast is the theme's own ink-on-paper by construction.
  • Visual changes include desktop screenshots — captured during development (page view with a passage highlighted, two-column placement, a figure at 150%, the page under Cocoa/Sakura/Midnight) but not yet attached here; the CLI cannot upload them. Happy to drop them in.

Known limits, stated plainly

  • No text selection on the page yet, so select-to-play and select-to-annotate remain reading-view only. Existing annotations display in the page view; they just cannot be created there. A pdf.js text layer is the fix.
  • ~9% of passages do not place — degenerate tables and one- or two-token fragments. Those simply do not highlight; everything else on the page still does.
  • Two pre-existing quality problems were measured while working on this and are not addressed here: LiteParse downsamples embedded images about 7× linearly (the Transformer figure is 1520×2239 in the PDF and arrives as 219×323), which affects the reading view only; and multi-row table headers flatten badly. Both deserve their own change.

🤖 Generated with Claude Code

NeoVand and others added 15 commits August 26, 2026 22:56
The reader's passages come from the markdown LiteParse extracted; a native
page view has to draw the PDF itself. Reconciling the two is the whole
problem, and LiteParse's own word boxes (`emitWordBoxes`) are the bridge —
both sides descend from the same glyphs.

Placement is a monotonic affine-gap alignment of the page's words against
the words of the passages anchored to it, with a second pass over whatever
region a run of failures was bracketed into. Measured on "Attention Is All
You Need": 90% of passages placed, 82% of words boxed individually, under
70ms for the densest page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second reader view draws the original PDF and highlights the passage
being spoken where it actually sits on the paper, down to the word. The
markdown stays underneath as it always was — it is what the voice, the
assistant, and the study layer read — so the choice is presentation, not
a different import.

Geometry is recomputed per page from the stored bytes (LiteParse
`targetPages` + `emitWordBoxes`, tens of milliseconds a page) rather than
stored per document, as `DocumentPageInfo` anticipated. Pages draw and
place lazily as they come near the scrollport and hand their bitmaps back
when they leave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Highlights, margin notes, and the passage the assistant is pointing at
were only visible in the reading view; on the page view they simply went
missing, which reads as lost work. They paint as their own rectangles
here, ordered back to front — persistent ink under live emphasis — since
there is no cascade to settle it for us.

Also covers the layout service, which is how the window-join bug surfaced:
a caller waiting on a read someone else started was handed that caller's
page instead of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At 100% a page now sizes to the room it has, however narrow the window
is — a page you must scroll sideways to read is not a readable page.
Above 100% it overflows on purpose (that is what zooming into a figure is
for), and the stack centres safely so the left edge stays reachable.

The hover wash was mixed for the reader's own background and all but
vanished on white paper, leaving nothing to say the paper was clickable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Outline navigation looked up the heading's element and gave up when it
found none — which, in the page view, is always: the document there is a
picture of paper. It now addresses the heading's page instead, so clicking
a section actually goes somewhere.

The page also follows the assistant's fingertip while nothing is playing,
which is exactly when "show me this passage" is asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LiteParse hands back word boxes in raster order — every line of a
two-column page interleaved with the line beside it — while its markdown
reads the columns properly. A monotonic alignment between two streams
that disagree about order finds almost nothing: 57% of passages placed on
a two-column paper, against 90% on a single-column one.

A recursive XY-cut recovers the order, splitting at vertical gutters
before horizontal ones so a page breaks into columns before it breaks
into paragraphs. Two-column placement goes to 92%; the single-column
paper is unchanged at 91%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Following was a plain effect over state that changes for reasons having
nothing to do with the playhead: every page that finishes placing as you
scroll updates `placements`, and each of those updates re-ran the scroll.
Reading ahead was impossible — the document pulled itself back within a
second or two, over and over.

Following is now edge-triggered, the way the reflowed canvas has always
done it: one passage becoming current earns at most one jump to its page
and one settle onto its line. And a wheel or touch scroll stops following
outright, again matching the reflowed canvas — that listener was simply
missing here, so "Follow narration" never appeared and there was no way
to opt out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A paper with a 163,000-operation figure on page 12 made the view unusable:
that page tried to draw over and over and never appeared, and eventually
the tab went black.

Three causes, all in how draws were scheduled:

- Nothing stopped a page being queued for drawing while it was already
  being drawn. Draws run one at a time, so a page that takes seconds
  collected a queue of duplicate requests, and every other page waited
  behind them.
- A draw could not be abandoned. Scrolling past a heavy page left it
  holding the queue against the pages actually on screen.
- Bitmaps were released the moment a page left the scrollport, so
  scrolling back and forth redrew everything each pass — 24 releases over
  three sweeps of a six-page document, where none is right.

Draws are now abortable (pdf.js `RenderTask.cancel`), never duplicated,
ordered nearest-the-reader-first, and released against a memory budget
rather than on leaving view. Layout no longer waits on the bitmap either:
a page drawn at another zoom is briefly soft instead of the wrong size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A canvas is sized the moment its draw begins, so a draw that is abandoned
leaves a full-size bitmap behind with nothing recorded against it. The
budget counted the record rather than the pixels, so those went unseen and
accumulated: 300 MB after a few sweeps of a twenty-page paper at retina
scale, which is the shape of a tab going black.

An abandoned draw now gives its bitmap straight back, and the budget is
measured over the canvases themselves. Same sweeps: 134 MB, held at the
budget rather than climbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging the zoom slider made the pages flash black and back. Two causes,
one of them mine from the previous fix.

Pages were painted straight into the canvas the reader is looking at.
pdf.js clears it, then fills it in over however many frames the page
needs — a flash on every redraw, and a strobe while a slider reports every
percent it passes through. Worse, the previous commit had abandoned draws
hand their bitmap back immediately, which turned each of those aborts into
a blank page rather than a stale one.

Pages are now painted off-screen and handed over whole, so a canvas keeps
showing the picture it has until the next one is finished, and abandoning
a draw costs the reader nothing. The width drawn at also settles for a
beat before anything redraws, so one drag of the slider is one redraw
rather than twenty-five abandoned ones; layout still follows the slider
immediately, so the pages resize under it and are briefly soft instead of
absent.

Measured on the paper that prompted this, at retina scale, dragging
100%→150%: no blank frame in 392 samples of the pages on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things about zooming.

The canvas was pinned in pixels to the size it was last drawn at, so the
sheet and the highlights — which follow the current zoom — grew away from
the drawing and only met it again when the redraw landed. Measured across
one drag to 140%, the sheet led the drawing by up to 360px. The renderer
now reports the size it drew and leaves the canvas to its owner's layout:
the page view stretches one to fill its sheet, and the peek, which has no
layout of its own, sets the size itself. Same drag: 2px, which is the
sheet's border.

And a white page is a poor thing to hand someone reading in the dark. The
original pages now have a tone — paper as printed, dimmed, or inverted for
night — which follows the theme until the reader picks one. Toning is
applied to the whole sheet rather than the canvas, so a highlight is still
worked out against white paper the way a highlighter behaves and only then
follows the page into the dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f their own

The tone picker was a second place to say something the reader had already
said. A dark theme is a statement about the light they are reading in, so
the page follows it: white paper under a light theme, dimmed under a dark
one, live as the theme changes. The preference, its storage, its header
button and the icon added for it are all gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dimming only made a white page a grey page. Under a dark theme the page is
now inverted where it is drawn, with the hue rotated back so red warning
text stays red and a green curve on a plot stays green rather than turning
magenta.

What inverting ruins is pictures — a rendered figure or a photograph comes
out as a negative. Those are drawn as image objects, and the page's
operator list says exactly where each one lands, so they are put back
afterwards untouched. Line art, rules and type are not images and take the
inversion, which is what makes the page read as dark rather than merely
inverted. A page that is already dark is left alone.

Highlights blend by screen rather than multiply on a dark page, or they
would have nothing to darken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A PDF page is white with black ink on it, which is a fact about the paper
it was made for, not about the room it is being read in. The reading view
has always drawn the same document on the theme's paper in the theme's
ink; the original pages now use those very colours, read off the stack as
`--reader` and `--reader-ink-strong`, so the two views agree by
construction and switching between them is a change of typesetting rather
than of lighting.

The transform is one duotone ramp: what was white becomes the theme's
paper, what was black becomes its ink, greys carry across proportionally.
Under Sunny that is a barely visible warming; under Cocoa the page is warm
dark brown with cream type; under Midnight it is a full inversion. Same
arithmetic in all ten themes — the light and dark cases stopped being
separate code and became the endpoints of the ramp.

Colour is left alone, because remapping it would be recolouring the
author's work rather than the paper under it: red warning text stays red,
a green curve on a plot stays green. The line between paper-and-ink and
colour is chroma, crossed gradually so a coloured glyph's soft edge does
not fringe. Photographs and rendered figures are lifted out and put back
whole, as before.

41ms for a retina page, against a render that costs several hundred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pages are pictures with nothing to tab to, so the stack that scrolls
them had no way in from a keyboard — a keyboard reader could open the view
and then not move it. It takes focus itself now, which is the case WCAG
2.1.1 asks this for, and an axe pass over the view guards it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NeoVand
NeoVand merged commit 184799a into main Aug 27, 2026
5 checks passed
@NeoVand
NeoVand deleted the feat/native-pdf-view branch August 27, 2026 07:10
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