Skip to content

Add the page.pdf capture leg, capture-only via POST /_screenshot-card - #6120

Open
lukemelia wants to merge 5 commits into
mainfrom
cs-12976-pagepdf-capture-leg-with-pagebyte-bounds-capture-only-via
Open

lukemelia wants to merge 5 commits into
mainfrom
cs-12976-pagepdf-capture-leg-with-pagebyte-bounds-capture-only-via

Conversation

@lukemelia

@lukemelia lukemelia commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Second PR in the Cards to PDF chain (CS-12976); its base (#6118, the identity axes) is merged, so this diff reads clean against main.

A singular type: 'pdf' capture spec now produces a paged document of the settled render:

  • Engine (prerender/utils.ts): a pdf branch in captureOneEntry runs the identical settle sequence, then emulates the media the spec asks for (screen today) and calls page.pdf({ printBackground: true, preferCSSPageSize: true }). page.pdf() renders print media by default, so pinning the media keeps the paged document on the same styles the raster path captures; the override is cleared afterward — best-effort, so a dying page surfaces its real failure rather than the reset's — so a pooled page carries no media into the next capture. Layout width is the paper's, not a viewport's: Page.printToPDF lays the document out at the paper's content width, where an author's @page { size: … } rule wins and Chrome's default paper applies otherwise. The result reports pageCount instead of pixel dimensions (ScreenshotCaptureResult.width/height become optional; the declared-capture path is unaffected — its own result type keeps required dims), and the response's contentType is derived from the capture (application/pdf for pdf) instead of hardcoded.
  • Bounds, enforced post-render (the fullPage late-check pattern, since page count and byte size exist only after Chrome paginates): 20 pages / 10 MB via checkPdfCaptureBounds, each an error naming the cap, never a truncation. Page counting scans Chromium's uncompressed /Type /Page dictionaries with the page-tree /Count as a floor (countPdfPages).
  • Spec rules: pdf is singular-only (one contentType per response, so no pdf in batch entries) and refuses the raster geometry — fullPage, clip, target, and viewport (pagination lays out at paper width, so a viewport would be an inert axis minting distinct ledger identities over byte-identical documents; a viewport spelling the engine default still elides and parses) — at the shared parse, with defensive engine guards for in-process callers that assemble capture entries directly.
  • Capture-only posture (persistence is the next PR in the chain): the GET _screenshot/ DSL refuses type=pdf by name (that surface persists everything it captures and cannot yet serve paged documents), the POST handler derives no persist identity for pdf specs (url: null, bytes in base64), and the worker task's guard hashes pdf renders to null so no producer can land one in the ledger.

Testing

  • media-cache-dsl-test.ts: pdf parses on POST (singular-only, crop-mode and viewport refusals, with the default-valued viewport spelling still admitted), the GET surface refuses type=pdf with its own wording, countPdfPages unit coverage (page dictionaries, /Count floor, zero-marker case), checkPdfCaptureBounds coverage of both caps' boundary and error wording against synthesized buffers, and the task refuses to persist a pdf render under any claimed identity — all green locally.
  • screenshot-card-test.ts: a POST pdf capture gets no persist identity and no served URL while the encoding rides the job args — green locally.
  • prerendering-test.ts (real Chrome): a pdf capture of a tall card returns %PDF- bytes with contentType: application/pdf and a page count, and the next raster capture on the same pooled page still renders at the default viewport. A probe card that is short under screen media and 60000px under print pins the media: the screen-media pdf stays within the page cap, where a print render would page well past it. A 30000px-tall card errors on the page cap by name, with no truncated document riding the error.
  • runtime-common and realm-server typecheck clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ±    0      1 suites  ±0   28m 30s ⏱️ - 2h 7m 13s
1 030 tests  - 3 776  1 030 ✅  - 3 762  0 💤  - 14  0 ❌ ±0 
1 032 runs   - 3 789  1 032 ✅  - 3 775  0 💤  - 14  0 ❌ ±0 

Results for commit d591caf. ± Comparison against earlier commit 3ab2260.

Realm Server Test Results

    1 files  ±0    225 suites  ±0   1h 20m 5s ⏱️ -56s
3 073 tests +2  3 073 ✅ +2  0 💤 ±0  0 ❌ ±0 
3 112 runs  +2  3 112 ✅ +2  0 💤 ±0  0 ❌ ±0 

Results for commit d591caf. ± Comparison against earlier commit 3ab2260.

@lukemelia
lukemelia added this pull request to stack #6122 September 15, 2026 03:42
@lukemelia
lukemelia force-pushed the cs-12976-pagepdf-capture-leg-with-pagebyte-bounds-capture-only-via branch from 98d867b to 718793d Compare September 15, 2026 18:45

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Lens: traced the pdf capture leg end to end — the engine branch and its post-render page/byte bounds, the singular-only and crop-mode refusals on both the shared parse and the engine's own guard, the contentType wiring from engine → render-runner → response, the optional-dims re-typing and its consumers, and the capture-only posture across the ledger identity, the GET DSL, and the worker hash guard. The base branch's type/media axes were re-checked only where this diff reads them.

Bottom line: one thing to settle before merge — the PDF renders under print media, not the screen media the render settled under, which both the code comments and the media-axis design say otherwise (inline thread on utils.ts). It's a decision, not a mechanical fix, and blocks only if you wanted screen. Everything else lands: the bounds fail by name rather than truncate, the refusals are enforced on both the parse and the engine guard, and the capture-only wiring keeps pdf out of the ledger consistently. Two minor non-blocking nits:

  1. Stale comment in runtime-common/index.ts. On ScreenshotPrerenderResponse, the line "The engine produces only image/png today" is falsified by this PR — the engine now emits application/pdf. The change right above widened ScreenshotCaptureResult for exactly that output, so update this comment in the same pass. Evergreen nit.

  2. pageCount is dropped from the POST response. In handle-screenshot-card.ts, a pdf capture has spec === undefinedentryKey === undefined → the capture-only normalization (result.captures.map(...)) runs and rebuilds each entry as name/url/width/height/deviceScaleFactor/base64. The engine computes pageCount and render-runner carries it on captures[i], but it never reaches the response — so a pdf caller (the only surface pdf reaches today) gets the bytes without the page count already computed for the bounds check. If that's intended, ignore; if a pdf POST should expose it, add ...(c.pageCount !== undefined ? { pageCount: c.pageCount } : {}) to the map. Follow-up, not this PR.

Comment thread packages/realm-server/prerender/utils.ts Outdated
A singular `type: 'pdf'` capture spec now paginates the settled render
with page.pdf (printBackground, preferCSSPageSize — an author's @page rule
wins, Chrome's default paper otherwise) instead of rasterizing it. The
same settle sequence runs first; the result reports pageCount instead of
pixel dimensions, and the response carries contentType application/pdf.

Bounds are enforced post-render, the fullPage late-check pattern: 20 pages
/ 10 MB, each an error naming the cap rather than a truncation. pdf output
is singular-only (one contentType per response) and refuses the raster
crop modes (fullPage/clip/target) at the shared parse, with defensive
engine guards for direct prerender-server callers.

pdf stays capture-only: the GET DSL refuses type=pdf (a surface that
persists everything it captures cannot yet serve paged documents), the
POST handler derives no persist identity for pdf specs, and the worker
task's guard hashes pdf renders to null so no producer can land one in
the ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukemelia
lukemelia force-pushed the cs-12976-pagepdf-capture-leg-with-pagebyte-bounds-capture-only-via branch from 718793d to f3916db Compare September 15, 2026 19:55
page.pdf() renders under print media regardless of what the page settled
under, so the media axis — the caller's control over media type — could
not be honored: a spec asking for the default screen media still produced
a print-media document. Emulate the requested media (screen today, the
only value the parse admits) before page.pdf(), and clear the override in
finally so a reused pooled page carries no media into the next capture.

Cover it with a probe card that is 200px under screen media and 60000px
under print: a screen-media pdf stays within the page cap, a print one
would page well past it. Correct two comments the pdf leg falsified — the
engine now produces application/pdf, and honors pdf as an output type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Review-body nits, dispositions:

  1. Stale index.ts comments — fixed in 4bc828c. ScreenshotPrerenderResponse.contentType and the type field now say the engine produces image/png and application/pdf (and honors pdf as an output type), rather than "only image/png today".

  2. pageCount on the POST response — leaving it out, as a follow-up. pdf is capture-only until the serving surfaces land, and the response shape stays as-is for now; a caller can derive the count from the bytes meanwhile. Worth revisiting when the persist/serve legs arrive.

@lukemelia
lukemelia marked this pull request as ready for review September 15, 2026 20:31
@lukemelia
lukemelia requested review from a team and FadhlanR September 15, 2026 20:31
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] This review went after what page.pdf() actually renders (checked against Chromium over CDP, driving Page.printToPDF with puppeteer 25.2.1's own pdf() defaults), the capture-only posture across every producer and consumer of the new encoding, and pooled-page hygiene. It did not run the realm-server or prerender suites.

One blocking decision: the paged document is not the viewport render. Page.printToPDF re-lays the document out at the paper's content width — 816 CSS px under puppeteer's letter/zero-margin defaults — whatever viewport the capture settled at, so viewport is inert for pdf output and the render-parity claim doesn't hold for anything width-dependent. Detail and measurements in the reply on the media thread in prerender/utils.ts. Everything else is small.

Recommendations:

  1. Decide paper-from-viewport vs. keeping Chrome's paper and correcting the parity claim — reply on the media thread in prerender/utils.ts.
  2. Make the finally media reset best-effort so a dead page doesn't mask the real failure — inline on that block.
  3. Reword the "a direct prerender-server caller could still send one" note; that route runs the same shared parse — inline on the crop-mode guard.
  4. Cover the two pdf caps, and decide whether a pre-render extent bound is worth it — inline on the cap block.
  5. The one shipped client of this surface writes pdf bytes into a .png file: EXTENSION_BY_CONTENT_TYPE in packages/boxel-cli/src/commands/screenshot.ts maps only the three image types and falls back to png, while the CLI passes a --spec file's captureSpec through verbatim — so {"card": "…", "captureSpec": {"type": "pdf"}} lands a PDF at <card>.png today. One line to add the mapping. Note that touching packages/boxel-cli/** puts this PR under the conventional-commit title rule, so a separate follow-up may be the cleaner home. Class: introduced here (the surface newly produces application/pdf); non-blocking either way.

Generated by Claude Code

Comment thread packages/realm-server/prerender/utils.ts
Comment thread packages/realm-server/prerender/utils.ts Outdated
Comment thread packages/realm-server/prerender/utils.ts Outdated
page.pdf() lays the document out at the paper's content width (the
card's @page size, or Chrome's default paper), not the capture
viewport, so a viewport on a pdf spec would be an inert identity axis
minting distinct ledger keys over byte-identical documents. Refuse it
at the shared parse alongside fullPage/clip/target (a default-valued
viewport still elides and parses) and in the engine's defensive guard,
and correct the render-parity comments to say what the paged document
actually is: the raster path's settle and media, laid out at paper
width.

Extract the post-render caps into checkPdfCaptureBounds so both error
messages and boundary comparisons are unit-testable against synthesized
buffers, and pin the page cap end-to-end with a 30000px-tall fixture.

Make the finally media reset best-effort so a dying page surfaces the
capture's real failure, and reword the defensive-guard comments: every
wire surface runs the shared parse; the guards exist for in-process
callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] All four review threads are addressed and resolved in d591caf, and the description is updated to the corrected render-parity claims plus the viewport refusal. The remaining review-body item — the CLI writing pdf bytes to a .png file — is fixed by 2a60e99 on fix-boxel-cli-pdf-capture-extension, opened as its own pull request since packages/boxel-cli changes fall under the conventional-commit title rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@backspace backspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Buck asked me to land the merge before reviewing, so this is posted from their account. The branch was conflicting against its base; I resolved and pushed the merge as aa365a0d. All four conflicts were unions between this branch's pdf capture-only posture and the base branch's target capture-only posture: the doc comment on the POST handler, the persist-identity predicate there, the same predicate in the worker task, and two test files that each added a "refuses to persist" case. Both predicates now exclude captures, target, and type: 'pdf'; both test cases are kept. runtime-common and realm-server typecheck clean on the result.

Lens: convergence on the four open threads, re-verified against Chromium rather than against the replies. Bottom line: no blocking issues — this is ready to land. Two one-line follow-ups below, neither worth holding for.

Thread dispositions

  1. Media / viewport parity — resolved. With the media pinned, a declarative page's pdf is byte-identical across viewport widths (checked against Chromium over Page.printToPDF, warm-up pages discarded — the first two pages of a launch are not byte-stable), so refusing viewport costs nothing and the paper-width claim in capture-spec.ts holds as written. Worth noting the refusal's scope is right: deviceScaleFactor, which it deliberately leaves admitted, is not inert — dsf 1/2/3 produce three different documents on a page carrying a min-resolution query — so it stays a live axis rather than an inert one minting identities.
  2. Best-effort media reset — resolved. The finally now wraps emulateMediaType() in try/catch like the file's other pooled-page restores.
  3. Guard comment wording — resolved. Both guards name the shared parse as covering every wire surface and scope themselves to in-process callers.
  4. Cap coverage — resolved. Both caps are unit-covered at the boundary and one past with exact wording, so a flipped comparison or swapped message can't ship green. I checked countPdfPages against real Chrome output rather than synthesized buffers: 2000px→2, 5000px→5, 21120px→20, 30000px→29 pages — so the count the cap is enforced on is accurate, and the 30000px fixture clears the 20-page cap by 9. Declining the pre-render extent bound is the right call; a scrollHeight estimate can't know the card's @page size.

Non-blocking follow-ups

  1. The CLI still writes pdf bytes into a .png file. EXTENSION_BY_CONTENT_TYPE in packages/boxel-cli/src/commands/screenshot.ts maps only the three image types and falls back to png, and --spec passes a file entry's captureSpec through verbatim — so {"card": "…", "captureSpec": {"type": "pdf"}} lands a PDF at <card>.png. One line ('application/pdf': 'pdf') fixes it. Class: introduced here, since the surface newly produces application/pdf. Touching that package puts the PR under the conventional-commit title rule, so a separate follow-up is probably the cleaner home.
  2. pageCount never reaches a POST caller. The engine computes it and render-runner carries it on captures[i], but in handle-screenshot-card.ts a pdf spec leaves entryKey undefined, so the capture-only normalization rebuilds each entry as name/url/width/height/deviceScaleFactor/base64. A pdf caller therefore gets width: null, height: null and no page count, on the one surface pdf reaches. Spreading ...(c.pageCount !== undefined ? { pageCount: c.pageCount } : {}) into that map closes it. Class: introduced here.

CI on the merge commit is still running, so this approval is on the code, not on a green run.


Generated by Claude Code

Base automatically changed from cs-12975-add-output-encoding-type-and-media-axes-to-the-capture-spec to main September 16, 2026 06: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.

3 participants