Skip to content

Export PDFs with the Chrome command line, without puppeteer - #78

Merged
MaxGhenis merged 11 commits into
mainfrom
export-without-cdp
Oct 16, 2026
Merged

MaxGhenis merged 11 commits into
mainfrom
export-without-cdp

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What changes

bun run export drove Chrome through puppeteer's DevTools connection. That fails on machines where an administrator disables remote debugging (Chrome prints "DevTools remote debugging is disallowed by the system admin"). The script now runs headless Chrome only through its command line: --dump-dom, --screenshot and --print-to-pdf.

  • Export mode. ?export=1 hides the viewer's navigation bar and mounts only the current slide. <main> carries data-total-steps and data-current-step.
  • Capture at a held load event. Chrome's command line captures a page when its load event fires.
    • With &settle=<ms> (0 included), an inline script in <head> holds load open by opening a document in a hidden iframe, and the viewer releases the hold that many milliseconds after the slide mounts (lib/export-hold.ts). Live embeds get that long, in real time, to render, and Chrome's site isolation stays on.
    • A hold that a release ends marks <html> with data-export-settled.
    • When Chrome's --timeout runs out, it stops the page load, which ends the hold unmarked, so a capture cannot wait forever.
  • Checked captures. Each Chrome run writes the screenshot and prints the DOM it captured. The script rejects, and retries, a capture that does not show the requested step with the settled mark: a slide that never mounted, or one the timeout cut off.
  • PDF. The screenshots print to a PDF, one page per step. The script then counts the PDF's pages, outside strings, streams and comments, and fails unless every step has one. The document title comes from the deck page.
  • Size and scale. --size 1440x810 --scale 2 sets the viewport (and page size) and the pixel ratio. The default stays 1920 × 1080 at scale 1.
  • Deployed or local. --url <origin> captures a deployed site. Without it, the script runs next build and serves that build.
  • Private decks.
    • The script passes NEXT_PUBLIC_PRIVATE_PASSWORD in the URL fragment (#password=...). PasswordGate unlocks when the fragment matches the deck's password, and the browser never sends a fragment to the server.
    • A locked gate releases the hold at once, so a capture sees the form without waiting for the timeout.
    • Error messages drop URL fragments and password values, so the password stays out of logs.
  • Failures.
    • A watchdog stops a Chrome that hangs.
    • A step that fails in the parallel pass is retried afterwards on its own with twice the time.
    • A failure, Ctrl-C or SIGTERM stops the build (now asynchronous), every Chrome the script started and the server, and removes the screenshots.
    • EXPORT_WAIT_MS and EXPORT_CONCURRENCY are validated.
  • Dependencies. puppeteer leaves devDependencies.

What does not change

Without ?export=1, every deck renders as before. Three things are new on every page:

  • the data-total-steps attribute;
  • the data-current-step attribute;
  • a small inline script in <head> that returns at once unless the URL has export=1 and a whole-number settle.

SlideshowViewer and PasswordGate are used only by SlideshowPage.

PasswordGate now also unlocks from #password=<the deck's password>. The password is already a NEXT_PUBLIC_ variable checked in the browser, so this adds a way to supply it and no way around it.

The old script rewrote <img> sources that lacked the /slides base path before capturing. This one captures what the page renders, so a deck with that defect shows it in the PDF as it does on the site; the fix belongs in the deck (BasePathImage).

This branch merges main's deep-link fix (#79); the viewer reads ?slide=N once in a lazy initializer, and export mode works on top of it.

Why not Chrome's virtual time

The first version of this PR used --virtual-time-budget. Two problems came out of running it:

  • The budget covers a cross-site iframe only with site isolation off, a security setting the capture then had to disable.
  • Virtual time pauses while any request is pending, and nothing caps it. Captures of slides with live embeds timed out repeatedly, three times in a row for one slide.

Invariants and tests

191 tests pass.

Pure logic in scripts/export-pdf-lib.mjs, lib/slide-url.ts and lib/export-hold.ts:

  • stepUrl always carries slide=N and export=1, carries settle when given (0 included), and carries a password only in the fragment, where it round-trips for any string.
  • passwordFromHash returns exactly what encodeURIComponent wrote, and null without a password key. buildSlideUrl drops the fragment from the address bar.
  • exportSettleMs stays within 0 and the maximum for any input. parseIntSetting accepts only whole numbers in range.
  • parseDeckDom reads the count, the shown step, the settled mark (from <html> only), the password form and the title. checkCapture accepts only the requested step, settled.
  • countPdfPages counts page objects outside strings (nested and escaped parentheses included), streams and comments.
  • redact drops URL fragments and password values.
  • shotName pads so names sort in slide order. deckHtml lists the screenshots in order at the viewport size, breaks the page after all but the last, and escapes the title.
  • mapLimit keeps input order, never exceeds the limit, rejects a limit that would run nothing, and after a failure starts nothing new and waits for running items.

The hold script, in jsdom (these run in CI without Chrome):

  • EXPORT_HOLD_SCRIPT opens a hold only for export=1 with a whole-number settle, and does nothing for twelve other URLs.
  • A release ends it exactly settle ms later and marks the page.
  • Only the first release counts.
  • An unreleased hold ends after MAX_HOLD_MS, unmarked.

Real Chrome (skipped when Chrome is not installed):

  • Printing. A five-screenshot deck prints as five pages of 1080 × 607.5 pt.
  • Release. The hold delays load until settle ms after the release, well before Chrome's 20 s timeout, so a release that did nothing would fail. It also marks the page and removes the frame. With settle=0, load waits for the release.
  • No hold. No hold opens without export=1 and a whole-number settle.
  • Timeout. --timeout ends an unreleased hold, leaving the page unmarked.
  • Watchdog. It stops a Chrome whose page load cannot finish, and the error names the page without the password, raw or encoded.

Verification

macOS, Chrome 155, remote debugging disabled by policy, on a machine under heavy load. Run at 14d2c06, before the merge with main:

Check Result
bls-taxsim-2026 from a clean local build, --size 1440x810 --scale 2 exit 0, 36 pages, 194 s including the build, every capture checked as the requested step, settled
Tracked files changed by that build 0
Private deck mn-house-2025 with the right password, via --url exit 0, 21 pages
Same deck with a wrong password, and with none exit 1, "This deck is private", no file written
EXPORT_CONCURRENCY=abc, EXPORT_WAIT_MS=-5 exit 1 with the valid range
SIGINT during a capture exit 130, temporary directory removed, no child process left, no file written
SIGTERM during the local build exit 143, no next build process left
EXPORT_WAIT_MS=0 from a fresh build exit 0, 36 pages

I rendered the embed pages of the 36-page export and checked them by eye: no navigation bar on any page. The OBBBA explorer, CliffWatch, the TAXSIM web runner, the validation dashboard and axiom.org all drew. Settle time gives an embed time; it does not prove the embed finished. At the default 8 s, CliffWatch is still computing ("Finding cliffs…"). A longer EXPORT_WAIT_MS gives it more time.

After the merge with main, at bb8f5d1: bls-taxsim-2026 from a clean local build exported in 168 s, 36 pages, every capture checked, no server or tracked-file change left behind. CI (build, lint, tests) passes on bb8f5d1.

Review history

  • Round one (042ecc0). It asked for changes:

    • worker-failure cleanup;
    • signal handling;
    • setting validation;
    • server-start guards;
    • an untested pagination claim;
    • a comment that overstated --timeout.

    b771967 through 63cbea8 addressed them. They also removed a separate build folder, which made Next rewrite tsconfig.json and next-env.d.ts, together with an environment-variable gate bypass.

  • Round two (63cbea8). It asked for changes:

    • a capture could record a slide that never mounted;
    • errors printed the password fragment;
    • the synchronous build blocked signal cleanup;
    • the release test passed even with a no-op release;
    • a timing threshold could flake;
    • the page count could count /Type /Page inside a string.

    14d2c06 addresses each one.

    Fixing the release test exposed a bug that the earlier description missed. The hold script's regex sat in a template literal that dropped its backslash (/^\d+$/ became /^d+$/), so no hold opened at 63cbea8. The verification this description listed for that head ran without the hold. The table above is from 14d2c06, with the hold working.

  • Round three (bb8f5d1, GPT-6.1 Sol). It approved with two nits:

    • the PDF page counter could read the name /stream as the keyword, ended a stream at the first endstream instead of its /Length, and accepted a missing endstream;
    • two release tests kept a 10-second ceiling that could flake.
  • Rounds four to six (Opus 5.5, while the Codex lanes were closed).

    • 05cb796 parses streams by their /Length and drops the ceilings.
    • The review of 05cb796 found six more ways the new parser misread valid PDFs, among them a multi-digit indirect /Length, a hex string before >>, and NUL whitespace. 2794103 fixes them; the reviewer's 41 valid-PDF probes all count correctly.
    • eb9e544 fixes the review's three nits on 2794103: arrays before /Length, the last definition of an indirect length, and truncated files, which now fail on a missing %%EOF.
    • The review approved eb9e544. The suite passes, 201 tests with the Chrome tests running, and the real exports count 36, 36, 36 and 21.

Known limits of the page count

These came up in review. None applies to the PDFs Chrome writes, and in each case a malformed input makes the export fail on a wrong count rather than pass:

  • An indirect /Length resolves by searching the raw file, so a copy of its object's text later in the file, inside stream data, a string or a comment, would win. Chrome writes direct lengths.
  • A hex string missing its > throws only when no > follows anywhere in the file; otherwise the scan runs on to the next one.
  • The %%EOF check reads the last 1 KB, so NUL bytes or more than 1 KB of whitespace after %%EOF fail it. Chrome ends its files with %%EOF\n.

🤖 Generated with Claude Code

The export script drove Chrome through puppeteer's DevTools connection,
which fails where an administrator disables remote debugging. It now runs
headless Chrome only through --dump-dom, --screenshot and --print-to-pdf.

- ?export=1 hides the viewer's navigation bar, and <main> carries
  data-total-steps so a script can read the step count.
- The script captures ?slide=N&export=1 for every step and prints the
  screenshots to a 16:9 PDF.
- --url <origin> captures a deployed site; without it the script builds
  into .next-export with NEXT_PUBLIC_EXPORT_BUILD=1, which skips the
  password gate so private decks still export.
- puppeteer leaves devDependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-slides Ready Ready Preview Oct 16, 2026 3:22am UTC

Request Review

- --size sets the viewport and the PDF page size; --scale sets the pixel
  ratio, so a deck laid out for a laptop fills the page and stays sharp.
- Chrome's virtual time budget covers only the page's own process, so a
  cross-site iframe was still blank when it ended. Capturing with site
  isolation off keeps embeds in that process.
- The PDF's document title comes from the deck page's title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A capture of one slide also mounted its neighbors, so a neighbor's live
embed loaded in the background and could keep Chrome from settling: one
run timed out three times on a slide next to an embed. Export mode now
mounts the current slide alone.

Virtual time stops while a request is pending, so the script also passes
--timeout to cap the wait in real time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gment

Review of 042ecc0 asked for changes.

- A failed capture left the other Chrome processes running while the
  script removed their files. Chrome processes are now tracked: the first
  failure stops scheduling, stops the running captures and waits for them.
- SIGINT and SIGTERM stop Chrome and the server and remove the screenshots.
- EXPORT_CONCURRENCY=abc became NaN and skipped every screenshot.
  EXPORT_WAIT_MS and EXPORT_CONCURRENCY are validated, and mapLimit
  rejects a limit that would run nothing.
- Server start handles a failed spawn and bounds each readiness request.
- The script counts the PDF's pages and fails unless every step has one.
  Tests print a deck with Chrome and check page count and size, and check
  that the watchdog stops a Chrome that hangs.
- --timeout bounds the page load only; the comments now say so.
- --site-isolation keeps Chrome's site isolation on for untrusted sites.

Building into .next-export made Next rewrite tsconfig.json and
next-env.d.ts, and those rewrites had been committed. The separate build
and the NEXT_PUBLIC_EXPORT_BUILD bypass are gone. A private deck now
unlocks from the password in the URL fragment, which also works against
a deployed site and never unlocks without the real password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a machine under heavy load, three live embeds rendering at once
outran the watchdog: one run failed on the CliffWatch, dashboard and
axiom.org slides, which each capture in 2 to 8 seconds alone. A step that
fails in the parallel pass is now retried afterwards on its own with a
doubled watchdog and page-load cap, up to three attempts in all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome's virtual time budget pauses while any request is pending, and it
covers a cross-site iframe only with site isolation off. Captures of slides
with live embeds timed out repeatedly, and nothing capped the wait.

Export mode now holds the page's load event: an inline script in <head>
opens a document in a hidden iframe, and the viewer releases the hold
`settle` milliseconds after the slide mounts. Chrome captures at load, in
real time, with site isolation on. When --timeout runs out, Chrome stops
the page load, which ends the hold, so a capture cannot wait forever.

The --site-isolation option and the virtual time flags are gone.
EXPORT_WAIT_MS is now the settle time (default 8000 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… hold

The second review of 63cbea8 asked for changes.

- The hold script's regex sat in a template literal, which dropped its
  backslash: /^\d+$/ became /^d+$/, so no hold ever opened. It now uses
  [0-9], and jsdom tests run the generated script, so this is caught
  without Chrome.
- A capture could record a slide that never mounted, since Chrome's
  --timeout captures whatever is there. A hold ended by a release now
  marks <html> with data-export-settled, the viewer exposes
  data-current-step, and each capture dumps its DOM; the script rejects
  and retries a capture that is not the requested step, settled. settle=0
  still waits for the slide to mount.
- Chrome error messages included the capture URL with its #password
  fragment. They now drop fragments and password values.
- The local build ran through spawnSync, which blocked the signal
  handlers. It now runs asynchronously and cleanup stops it.
- The release test passes only if the release ends the hold well before
  Chrome's timeout, and the ordinary-page test checks that no hold opened
  instead of timing load.
- The PDF page count skips string literals, streams and comments, so a
  title like (/Type /Page) cannot add a page.
- A locked password gate releases the hold at once, so a capture sees the
  form without waiting for Chrome's timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	components/core/SlideshowViewer.tsx
#	lib/slide-url.test.ts
…lings

- The page counter read the name /stream as the keyword, ended a stream at
  the first 'endstream' in its data, and accepted a stream with no end.
  A stream now starts only at the keyword after a dictionary's >>, its data
  is skipped by the dictionary's direct /Length (or up to endstream when the
  length is an indirect reference), and a stream that does not end where it
  should throws, so the export fails instead of miscounting.
- The release tests required load within 10 s although Chrome's timeout is
  20 s. The settled mark already rules out a release that did nothing, so
  the ceiling is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…direct /Length

Review of 05cb796 found that the new stream parsing still miscounted
valid PDFs:

- /Length 16 0 R read as /Length 1: the digits backed off until the
  negative lookahead passed. Indirect lengths are now parsed whole and
  resolved against the object in the file; the scan falls back to the
  next endstream only when that object is missing.
- A hex string right before >> (/ID <AB>>>) passed for a dictionary
  bracket. Hex strings are now skipped like literal strings, so every
  << and >> left is a dictionary bracket, and dictionaryStart starts at
  the right character.
- The keyword check used a 64-character window and JavaScript's \s,
  which misses NUL. It now scans back over PDF whitespace and requires
  >> and an end of line after the keyword.
- /Length inside a nested dictionary could be read. Only top-level keys
  count now.
- The settle=0 hold test checks that the release removed the frame.

All 41 valid-PDF probes from the review count correctly, and the 36-, 36-
and 21-page Chrome exports still count 36, 36 and 21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Length

Nits from the review of 2794103:

- Chrome writes the page objects near the end of the file, so a file
  cut off before its trailer still counted every page. countPdfPages now
  requires the file to end with %%EOF, and an unterminated literal or
  hex string throws instead of swallowing the rest of the file.
- /Length inside an array (/Foo [/Length 0]) was read as the stream's
  length. Arrays are now stripped with nested dictionaries.
- An indirect /Length resolved to its first definition. It now takes the
  last, as a PDF updated incrementally does.

The 36-, 36-, 36- and 21-page Chrome exports still count correctly, and
merged.pdf cut at its trailer now throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 16, 2026 03:25
@MaxGhenis
MaxGhenis merged commit 91c7cf8 into main Oct 16, 2026
5 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged at reviewed head eb9e544 (squash 91c7cf8).

  • Reviews: GPT-6.1 Sol approved bb8f5d1 with nits, after requesting changes on 042ecc0 and 63cbea8. An independent Opus 5.5 review, used while the Codex lanes were closed, approved eb9e544 after requesting changes on 05cb796.
  • Checks at eb9e544: Build, Lint, Tests and Vercel passed; gh pr checks exited 0.
  • Verification: the 36-step BLS deck exported from a clean local build, with every capture checked as the requested step, settled. The private deck exported with the right password and was refused with a wrong one or none. SIGINT and SIGTERM cleanup and setting validation were checked. Details are in the description.
  • Known limits of the page count are listed in the description; none applies to Chrome's output.

🤖 Generated with Claude Code

@MaxGhenis
MaxGhenis deleted the export-without-cdp branch October 16, 2026 03:26

This branch was successfully deployed

1 active deployment
Preview – policyengine-slides — eb9e5443 Deployed Oct 16, 2026 by vercel[bot]
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