Repository navigation
Export PDFs with the Chrome command line, without puppeteer - #78
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- --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
marked this pull request as ready for review
October 16, 2026 03:25
Contributor
Author
|
Merged at reviewed head eb9e544 (squash 91c7cf8).
🤖 Generated with Claude Code |
This branch was successfully deployed
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 changes
bun run exportdrove 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,--screenshotand--print-to-pdf.?export=1hides the viewer's navigation bar and mounts only the current slide.<main>carriesdata-total-stepsanddata-current-step.loadevent fires.&settle=<ms>(0 included), an inline script in<head>holdsloadopen 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.<html>withdata-export-settled.--timeoutruns out, it stops the page load, which ends the hold unmarked, so a capture cannot wait forever.--size 1440x810 --scale 2sets the viewport (and page size) and the pixel ratio. The default stays 1920 × 1080 at scale 1.--url <origin>captures a deployed site. Without it, the script runsnext buildand serves that build.NEXT_PUBLIC_PRIVATE_PASSWORDin the URL fragment (#password=...).PasswordGateunlocks when the fragment matches the deck's password, and the browser never sends a fragment to the server.EXPORT_WAIT_MSandEXPORT_CONCURRENCYare validated.puppeteerleavesdevDependencies.What does not change
Without
?export=1, every deck renders as before. Three things are new on every page:data-total-stepsattribute;data-current-stepattribute;<head>that returns at once unless the URL hasexport=1and a whole-numbersettle.SlideshowViewerandPasswordGateare used only bySlideshowPage.PasswordGatenow also unlocks from#password=<the deck's password>. The password is already aNEXT_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/slidesbase 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=Nonce 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:Invariants and tests
191 tests pass.
Pure logic in
scripts/export-pdf-lib.mjs,lib/slide-url.tsandlib/export-hold.ts:stepUrlalways carriesslide=Nandexport=1, carriessettlewhen given (0 included), and carries a password only in the fragment, where it round-trips for any string.passwordFromHashreturns exactly whatencodeURIComponentwrote, and null without apasswordkey.buildSlideUrldrops the fragment from the address bar.exportSettleMsstays within 0 and the maximum for any input.parseIntSettingaccepts only whole numbers in range.parseDeckDomreads the count, the shown step, the settled mark (from<html>only), the password form and the title.checkCaptureaccepts only the requested step, settled.countPdfPagescounts page objects outside strings (nested and escaped parentheses included), streams and comments.redactdrops URL fragments and password values.shotNamepads so names sort in slide order.deckHtmllists the screenshots in order at the viewport size, breaks the page after all but the last, and escapes the title.mapLimitkeeps 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_SCRIPTopens a hold only forexport=1with a whole-numbersettle, and does nothing for twelve other URLs.settlems later and marks the page.MAX_HOLD_MS, unmarked.Real Chrome (skipped when Chrome is not installed):
loaduntilsettlems 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. Withsettle=0,loadwaits for the release.export=1and a whole-numbersettle.--timeoutends an unreleased hold, leaving the page unmarked.Verification
macOS, Chrome 155, remote debugging disabled by policy, on a machine under heavy load. Run at
14d2c06, before the merge with main:bls-taxsim-2026from a clean local build,--size 1440x810 --scale 2mn-house-2025with the right password, via--urlEXPORT_CONCURRENCY=abc,EXPORT_WAIT_MS=-5next buildprocess leftEXPORT_WAIT_MS=0from a fresh buildI 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_MSgives it more time.After the merge with main, at
bb8f5d1:bls-taxsim-2026from 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 onbb8f5d1.Review history
Round one (
042ecc0). It asked for changes:--timeout.b771967through63cbea8addressed them. They also removed a separate build folder, which made Next rewritetsconfig.jsonandnext-env.d.ts, together with an environment-variable gate bypass.Round two (
63cbea8). It asked for changes:/Type /Pageinside a string.14d2c06addresses 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 at63cbea8. The verification this description listed for that head ran without the hold. The table above is from14d2c06, with the hold working.Round three (
bb8f5d1, GPT-6.1 Sol). It approved with two nits:/streamas the keyword, ended a stream at the firstendstreaminstead of its/Length, and accepted a missingendstream;Rounds four to six (Opus 5.5, while the Codex lanes were closed).
05cb796parses streams by their/Lengthand drops the ceilings.05cb796found six more ways the new parser misread valid PDFs, among them a multi-digit indirect/Length, a hex string before>>, and NUL whitespace.2794103fixes them; the reviewer's 41 valid-PDF probes all count correctly.eb9e544fixes the review's three nits on2794103: arrays before/Length, the last definition of an indirect length, and truncated files, which now fail on a missing%%EOF.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:
/Lengthresolves 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.>throws only when no>follows anywhere in the file; otherwise the scan runs on to the next one.%%EOFcheck reads the last 1 KB, so NUL bytes or more than 1 KB of whitespace after%%EOFfail it. Chrome ends its files with%%EOF\n.🤖 Generated with Claude Code