feat: read a PDF as its own pages, with the narration painted on - #149
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theDocumentPageInfocomment 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:
textItemscome 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%.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
--readerand--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:
RenderTask.cancel); draws go nearest-the-reader-first.Verification
npm run lintnpm run checknpm run test:coverage— 522 unit tests;domain/pdf-layout.tsanddomain/page-tone.tsadded to the coverage include list at 96% and 98%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
DocumentPageInfo's comment reserved. Documents written before this read identically.prefers-reduced-motion. Contrast is the theme's own ink-on-paper by construction.Known limits, stated plainly
🤖 Generated with Claude Code