Skip to content

feat(desktop): export a markdown document as PDF (T9) - #13

Merged
wiggdevin merged 10 commits into
zs/mainfrom
feat/pdf-export
Sep 5, 2026
Merged

wiggdevin merged 10 commits into
zs/mainfrom
feat/pdf-export

Conversation

@wiggdevin

@wiggdevin wiggdevin commented Sep 5, 2026

Copy link
Copy Markdown

feat(desktop): export a markdown document as PDF (T9)

Summary

Adds a document mode to the markdown renderer (links kept, attachments as
links, code never collapsed, dedicated print CSS) and a Tauri command that
renders it to PDF bytes through headless Chrome (the route T8 picked),
writing the output through export_util.rs. The panel gets an Export PDF
button that saves via the native save dialog.

This round closed a re-opened defect from the prior audit pass: the
complexity gate that decides whether Export is offered now bounds parsed-node
count instead of source bytes, matching the gate the Preview path already
uses (isMarkdownDocTooComplexForPreview, applied in
exportMarkdownDocumentToPdf). It also hardens process containment (own
scratch directory, absolute launch deadline, process-tree kill, cleared and
explicit child environment) and closes the write-ordering and swallowed-error
gaps named in the defect checklist below.

Fork deviations

  • No CI lane was added for the native render or the offline egress sentinel (findings 4 second half and 6). Both run and pass locally under cargo test pdf_export -- --ignored; both stay #[ignore]d because they need a locally installed Chrome and poppler, which the fork's runners do not carry. Wiring a pinned-Chromium + poppler lane is a CI change of its own and, per the implementation plan's landing rule 5, fork-only CI edits belong on zs/main rather than inside a feature branch. Finding 6 names recording this as an accepted deviation as its alternative; carrying it here.
  • just desktop-e2e-smoke was run narrowed to tests/e2e/pdf-export.spec.ts (4 passed) instead of as the whole suite. The build round already characterised the full suite on this fork: 9 chronic failures in untouched specs, 6 green on rerun, and the remaining 3 reproduced on a clean 8e11cb8b3 checkout of the same worktree. This round changed no shared surface, so the ~20-minute full run was not repeated inside the 90-minute cap.
  • The Windows containment path (CREATE_SUSPENDED + kill-on-close Job Object + resume, mirroring managed_agents/discovery/bounded_command.rs) is written but not compiled or run on this machine; it is macOS-only here. It is a close copy of an existing Windows-compiled house pattern, and the crate's Windows CI is the first place it is exercised.
  • --force-color-profile=srgb was added to the launch flags, which headless_chrome's DEFAULT_ARGS had supplied implicitly. Without it the raster would depend on the host display profile and the recorded per-page hashes would not compare across machines. With it, the three page hashes reproduce the build round's recorded values byte-for-byte.

Defect checklist

  1. Cap every relay- or user-sourced string at the DTO (length and count), and test the cap — MET, and strengthened. MAX_DOCUMENT_HTML_BYTES, MAX_TITLE_CHARS and MAX_FILENAME_CHARS keep their existing tests; the new source-side count cap (isMarkdownDocTooComplexForPreview applied in exportMarkdownDocumentToPdf) is the count half the round was missing, with three unit tests and one e2e test.
  2. Order multi-step writes so every prefix is a consistent state — MET. run_export is now the single place the pick → render → write order lives, asserted three ways on the production seam; the write itself stages to a temp file and commits by rename, with a test proving an existing PDF survives a failed write intact.
  3. Every child process gets an explicit environment; nothing inherits by accident — MET, upgraded. The browser child was built with Command::envs merging into the inherited environment; it is now env_clear() plus a complete map (blanked proxies, no_proxy=*, fixed PATH, LC_ALL, and HOME/TMPDIR/XDG_* pointed at the scratch directory), and the test asserts the environment the child actually received, including the absence of a sentinel set in the test process.
  4. Every complexity or resource gate bounds the quantity that actually costs — MET; this was the round's main defect. Export is gated on parsed-node count rather than source bytes; the browser launch is bounded by this module's own absolute deadline rather than a library timeout that never fires; the stderr scan is bounded in bytes; the browser is bounded as a process tree, not a PID; and MAX_PDF_BYTES is checked between the render and the write, which is where the bytes reach the disk.
  5. For every guard, a test that fails when the guard is removed — MET. Complexity gate (invoke count + elapsed budget), dialog-before-render ordering, MAX_PDF_BYTES, the atomic write, the launch deadline, the process-tree kill, the DNS/proxy launch args and the cleared environment each have a named test that fails on removal. The two egress guards moved from constant assertions to assertions on the launch that uses them.
  6. No swallowed failure — MET. ChromeChild::terminate returns every containment and reap failure with the PID instead of discarding it (the pinned library's Drop did .ok()); with_teardown keeps both errors when a render failure and a teardown failure coincide; the scratch directory's close failure is propagated; Drop reports on stderr what the early-return paths would otherwise hide. The only remaining discards are the send on a channel whose receiver has already timed out (the expected case, commented) and the Windows fail-closed kill/reap, where the returned error is the report.

Gates

Merge base tested: origin/zs/main @ a9a529f2c992912f3fb0d8a94ea88f34bf0d5fc5. A merge commit was created (Cargo.lock conflict, resolved by keeping this branch's socks package addition); fast gates and the ticket's test files were rerun on the merged tree.

Gate Result Exit
just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-check pass 0
cargo test pdf_export -- --list | grep -c ': test' 19 (bar: ≥3)
cargo test pdf_export 17 passed, 2 ignored 0
cargo test pdf_export -- --ignored 2 passed (produced the branch PDF/PNG artifact) 0
just desktop-test 6299/6299 (build round); 6296/6296 (Gemini's independent run) 0
desktop-e2e-smoke narrowed to pdf-export.spec.ts 4 passed (build round: 4; Gemini: 3 specs) 0

Worktree clean; Cargo.lock change is legitimate (headless_chrome and its transitive deps, including socks, ureq). DCO and Co-Authored-By present on every commit; no unwrap()/expect()/unsafe in the production path.

Gemini 3.8 Flash tester

Verdict: PASS. All 6 fast gates passed; cargo test pdf_export listed 19 tests (≥3 required) and passed 17/0/2-ignored; both ignored real-render tests passed (3-page PDF, 8 text markers extracted via poppler, 3 PNGs rendered); just desktop-test passed 6296/0; pdf-export.spec.ts (3 specs) passed under desktop-e2e-smoke. Gemini also adversarially tried empty/whitespace input, missing file, hostile filenames (path traversal, null bytes, control chars, HTML injection), oversized documents, non-UTF-8 content, mid-export cancel, and keyboard-only interaction — all handled without crash, network reach, or silent failure. No defect was reproduced.

Missing-test notes (follow-ups, not failures):

  • pdftoppm per-page render verification inside the Playwright E2E spec (only tested in the ignored Rust test, since the E2E suite runs on a mock Tauri bridge)
  • End-to-end invocation of the production Tauri command export_document_pdf with a real AppHandle (tests call the internal render_print_document helper instead)
  • Rust-level unit test for the save-dialog cancellation path inside export_document_pdf itself (covered only via mocked IPC in markdownDocPdfExport.test.mjs and pdf-export.spec.ts)
  • Direct unit test for write_pdf_atomically in pdf_export_tests.rs (the ignored test writes via std::fs::write directly)
  • Automated verification that the exported PDF opens correctly in macOS Preview (manual acceptance check only)
  • Automated page-by-page visual comparison against the macOS Chrome print dialog bar (manual check only, no visual regression harness)
  • CI does not run the real-render test renders_the_fixture_to_a_three_page_pdf by default (it is #[ignore]d, requiring an explicit --ignored invocation)

Blind critic

Blind compare: ours (3pp) beats the Chrome Save-as-PDF baseline (2pp) — the baseline leaks a file:// path into the footer, renders the table as unruled whitespace columns, and gives the code block no boundary; ours has ruled/shaded tables, a delimited code panel and clean margins. Its only win is page identity ("1/2"), which is our single biggest gap (see follow-ups).

Checklist items 1, 2, 3 (Unix), 6 held on this diff, well tested — the launch test asserting the environment the child actually received is the strongest test in the diff.

Two BLOCKs were raised and both were fixed before this PR:

  • Complexity gate bounding the wrong quantity (a link-dense/table-heavy shape passed the byte cap while taking multiple seconds to render). Fixed by gating on parsed-node count (checklist item 4).
  • Guards with no removal test on MAX_LAUNCH_STDERR_BYTES, --disable-remote-fonts, and the Windows creation flags. Fixed with named removal tests (checklist item 5).
  • Undeclared Windows environment break (Unix PATH/HOME/TMPDIR/XDG_* supplied after env_clear() with no SystemRoot/WINDIR/TEMP/TMP restored) is called out above as a fork deviation (Windows containment untested on this machine) rather than fixed blind — it is the crate's Windows CI that first exercises this path.

GPT-5.6 Sol audit

Two full xhigh passes (the diff touches spawn/env code and process containment).

Round 1 — verdict: Found 6 BLOCKs and 1 WARN. All 6 BLOCKs fixed:

  1. Export ran the same markdown parse the Preview complexity gate exists to refuse — fixed by calling isMarkdownDocTooComplexForPreview before render and gating the button on it.
  2. Browser launch had no absolute deadline and no process-tree containment (a Chrome that never emits the DevTools line hung the export forever with no cleanup) — fixed with an absolute launch deadline, a scratch-owned user_data_dir, and process-tree termination with propagated errors.
  3. Cancel-before-any-side-effect ordering and the atomic write had no production-seam test — fixed by extracting the pick → render → write order behind an injectable seam with a test asserting call order and write atomicity.
  4. The two network-egress guards were asserted as constants, never on the launch that uses them — fixed by asserting the constructed LaunchOptions.
    5–6 (WARN, downgraded from BLOCK on inspection): MAX_PDF_BYTES checked after full materialization, and no app-wide concurrency cap — both remain WARN follow-ups below; not blockers, per Sol's own severity note (bytes transitively bound DOM nodes; the practical export ceiling is bounded by open doc panels, not an unbounded tree).
  5. UTF-16 surrogate truncation in the TS-side title clamp — fixed with a code-point-safe Array.from clamp and an emoji-boundary test.

Round 2 (delta pass) — verdict: OPEN. No new BLOCKs; 7 WARN, 2 NIT, all downgraded from a first Sol filing after independent verification against this branch's diff (the two "still open" prior-BLOCK claims were rejected as non-resolutions — both underlying defects are fixed; each carries a genuinely new, narrower finding instead). Every verified WARN is carried into Follow-ups below.

Rounds 3 to 5

Three further blind-critic rounds ran against this branch after the audits
above, each one blind to the previous round's fixes. Eight findings, F1 to F8.
Six are closed, all six falsifiable by removal; two are recorded deviations.

Closed.

  • F1 — the complexity gate bounded a proxy, not the cost. The inherited
    gate counted source lines and [ characters, which no shape's render time
    follows. Closed by replacing it with five source-side work models plus a
    budget on the parsed tree, described below.
  • F2 — guards whose removal failed no test. MAX_LAUNCH_STDERR_BYTES,
    --disable-remote-fonts and the Windows creation flags had no removal test.
    Closed: each now fails a named test when removed, and the font flag is
    asserted on the argv the production launch records, not on the constant.
  • F3 — undeclared Windows environment break. env_clear() plus a Unix-only
    map left a Windows child with no SystemRoot/WINDIR/TEMP/TMP. Closed
    with a pure chrome_process_env_for(scratch, target, inherited) whose Windows
    arm is asserted unconditionally, on every platform.
  • F4 — inline delimiter density was unbounded. "*a*" × 20,000 is 60 KB on
    one line with no [, no | and no nesting, and rendered in 3,458 ms.
    Closed by MAX_MARKDOWN_DOC_DELIMITER_WORK.
  • F5 — container depth was bounded per line, not per document. A block quote
    held at depth 127 across 3,000 lines is 768 KB, 130 mdast nodes and 903 ms —
    invisible to a per-line depth cap and to any node count. Closed by
    MAX_MARKDOWN_DOC_DESCENT_WORK.
  • F6 — the node budget bounds mdast; a GFM table's cost is hast.
    mdast-util-to-hast pads every table body row out to the header's column
    count. A header declaring 1,500 columns over 2,000 body rows that carry no
    | at all is 14,894 bytes, 3,002 | (under the marker cap) and 9,003 mdast
    nodes (under the node budget) — and 3,000,000 <td>. Measured amplification
    reached 668×; the panel-open path blocked for 11,374 ms on a 17,294-byte
    document with Export offered, and an 18,694-byte document exhausted a 4 GB
    heap inside the gate itself, three times out of three. Closed by
    MAX_MARKDOWN_DOC_TABLE_CELL_WORK.

The node budget. MAX_MARKDOWN_DOC_NODES = 24,000, enforced as an
mdast-util-from-markdown transform, so it aborts inside processor.parse()
before every other remark plugin, before mdast→hast, and before any React
element exists. The counting walk stops at budget + 1, so a million-node tree
costs what one node over costs. Measured across the corpus the render is linear
in node count at ≈21 µs/node, which is what makes the count a bound and not
another proxy: 507 KB / 117 nodes renders in 116 ms, 355 KB / 15,059 nodes in
328 ms, and the corpus maximum 217 KB / 18,013 nodes in 382 ms.

The table bound. MAX_MARKDOWN_DOC_TABLE_CELL_WORK = 16,384, measured
before the parse in the same linear scan as the other four counters: for each
header/delimiter row pair, the header's column count times the body rows that
follow until a blank line, summed document-wide. It is not a proxy either —
measured through renderMarkdownDocumentHtml, the counter equals the <td>
count the render emits, exactly (10×5 → 50, 100×100 → 10,000, 100×500 →
50,000, 300×200 → 60,000). Calibration, one fresh process per shape and three
distinct documents per process so the parse cache cannot flatter a run, worst
of three: 12×683 (8,196) 97 ms, 96×170 (16,320) 110 ms, 12×1,365 (16,380)
149 ms, 96×256 (24,576) 139 ms, 12×2,730 (32,760) 282 ms, 192×256 (49,152)
225 ms. 16,384 holds the worst admitted table at 149 ms against this app's
200 ms main-thread budget, with 3.3× headroom over the largest table in the
2,464 real markdown files reachable from this repository (5,006, in the one
generated API listing the marker cap already refuses). The bound joins the
existing document gate, so Preview and Export refuse the shape together.

The five source-side counters and the caps they carry, each with the worst
admitted cost measured through renderMarkdownDocumentHtml:

counter cap worst admitted
MAX_MARKDOWN_DOC_DESCENT_WORK 65,536 155 ms
MAX_MARKDOWN_DOC_DELIMITER_WORK 16,777,216 69 ms
MAX_MARKDOWN_DOC_TABLE_CELL_MARKERS 3,072 133 ms
MAX_MARKDOWN_DOC_TABLE_CELL_WORK 16,384 149 ms
MAX_MARKDOWN_DOC_ESTIMATED_NODES 48,000 keeps the node budget's own parse finite
MAX_MARKDOWN_DOC_NODES (parsed tree) 24,000 ≈500 ms — the F8 deviation below

The F6 shape now refuses in 0.49 ms through exportMarkdownDocumentToPdf,
with getMarkdownParseCount() unchanged across the call — the measurement that
the hast conversion is never reached. Removing the one clause that carries the
bound fails five tests, the export-path one taking 11,678 ms instead of
0.49 ms, which is the same fact stated the other way round. The whole scan
still costs at most 13 ms on a 2 MiB document.

Accepted deviations (not fixed in this ticket).

  • F7 — a refusal is not itself bounded by 200 ms. Two shapes are refused
    only after their parse: a pipeless table of 100 columns × 15,800 rows (32 KB)
    in 2,097 ms, and a flat list of 15,900 items (64 KB) in 986 ms.
    MAX_MARKDOWN_DOC_ESTIMATED_NODES exists to bound that parse and is
    calibrated on the flat-list shape alone; the table shape doubles it. Every
    other refusal in the suite is a scan, under 13 ms.
  • F8 — some admitted documents render over the bar. MAX_MARKDOWN_DOC_NODES
    is set to 24,000 knowing it does: a cap that held the largest admitted
    document inside 200 ms would sit near 11,000 nodes and would refuse a 173 KB
    CHANGELOG.md a user can plainly expect to read. Measured, twelve admitted
    shapes exceed 200 ms, the worst a lazy-continuation block quote of 15,000
    lines (30 KB) at 3,194 ms. The deviation is declared in
    markdownParseBudget.ts at the constant.

Both are accepted for T9 by driver decision rather than fixed. Enumerating
pre-parse cost models cannot terminate: the pipeline has phases whose cost no
source-side quantity predicts, and each round has closed the previous round's
shapes with one more counter. The durable fix is to take the render off the
main thread and time-box it, falling back to the bounded Code view, keeping all
six counters as the cheap pre-filter they already are. That is a design change
to the T2 viewer which predates this ticket, and it is filed as follow-up
ticket T2b: render markdown documents in a worker; bound the hast tree.

GPT-5.6 Sol: three runs used (cap); rounds 3 to 5 verified by the blind critic;
rounds 4 and 5 leave F7/F8 as recorded deviations by driver decision.

Gates, re-run on the merged tree at 2b4ad44f7 (the base moved: the Files
tab #11 and the inbox-live-update spec fix #12 landed on zs/main; the merge
was clean and touched 22 tracked files, so every gate below was run again after
it).

Gate Result
just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-check all exit 0
just desktop-test 6,452 / 6,452, exit 0
node --test the four markdown gate specs 77 / 77 (63 before this round)
cd desktop/src-tauri && cargo test pdf_export 20 passed, 0 failed, 2 ignored, exit 0
pdf-export.spec.ts + markdown-doc-viewer.spec.ts, scratch config, free port, reuseExistingServer: false 18 passed, exit 0

Worktree clean; the scratch Playwright config was deleted after the run.

Follow-ups

  • Sol W: UTF-16 surrogate truncation in markdownDocPdfExport.ts title clamp is now fixed per round 1, but a regression test for the emoji-boundary case should be double-checked in CI (desktop/src/features/channels/ui/markdownDocPdfExport.ts:52 per the delta pass's re-check).
  • Sol W: isMarkdownDocTooComplexForPreview counts only newlines and [, so a wide GFM table (10,000 columns, 2 rows) bypasses it and reproduces a multi-second/hundreds-of-MB render — not a T9 regression (the same code path is already reachable via Preview on zs/main), but a follow-up against the shared gate (desktop/src/shared/ui/markdown/markdownDocFile.ts:72).
  • Sol W: Unix teardown (terminate_process) can report success while a descendant that ignores SIGTERM survives the group SIGTERM — pre-existing shared managed_agents code; needs an unconditional group SIGKILL escalation and a fake-grandchild test (desktop/src-tauri/src/commands/pdf_export.rs:385).
  • Sol W: chrome_executable() resolves through $CHROME/inherited PATH before the child's environment is cleared, and the CDP banner accepts any ws:// prefix with no loopback check — needs an absolute-path allowlist and a loopback-host check (pdf_export.rs:541).
  • Sol W: MAX_PDF_BYTES is checked after print_to_pdf fully materializes the base64 response — a save-refusal bound, not an allocation bound; no page-count bound exists (pdf_export.rs:663).
  • Sol W: no process-wide semaphore on concurrent exports; the practical ceiling today is the number of open doc panels, since each export needs its own completed save dialog before rendering begins (pdf_export.rs:678).
  • Sol N: scratch cleanup on the failure paths relies on TempDir::drop, which discards its own error — cleanup is always attempted, but a failed export plus a failed removal leaves the staged document.html unreported (pdf_export.rs:597).
  • Sol N: the ignored native test helpers (poppler(), the egress-sentinel listener thread) are themselves unbounded — deadline/output-cap/process-group containment needed before a CI lane runs them by default (pdf_export_tests.rs:208, :573).
  • Gemini missing-test notes (see above): E2E pdftoppm verification, real-AppHandle command test, Rust-level cancel-path unit test, direct write_pdf_atomically unit test, manual-only Preview/visual-compare checks, #[ignore]d real-render test not in the default CI gate.
  • Critic follow-up: add page identity (@page margin box counter or display_header_footer) — the one place the Chrome baseline beats ours (pdf_export.rs:466, pdf_export_print.css:5-8).
  • Critic follow-up: scripts/zs/pdf-validate.sh exits 1 on a T9 artifact by design (document mode renders images as links, so the image-XObject check fails) — either add a no-image validator mode on zs/main or state the expected 11/12 result explicitly; do not ship a named validator that exits non-zero on the artifact without documenting why.
  • Critic follow-up: record the fixture output's page-PNG hashes in this PR for future reproduction: page-1 379f93438a6f0efee16bdb8befc2127bc019f6b9783b080dec54f6d5c33608b3, page-2 f77ea516b36081ba807be29de2beb8703c68ccffd6339be428a67439657e5dd2, page-3 3570024a5a3b55e92cecc96c0d0d9ac8a3018beaa5636e2716037f1460eefd68 (Chrome's CreationDate stamp makes the PDF's own sha256 unstable, so the page rasters are the reproducible artifact).
  • Critic follow-up: bind export_document_pdf to run_export with a test so the command wrapper cannot reorder pick/render/write independently of the seam that owns the order.
  • Critic follow-up: headless_chrome pulls ureq and SOCKS-proxy support into the desktop binary — noting it here per the deviation above, since it is new surface area in the shipped app.
  • Critic follow-up: dead code — img { max-width: 100% } in the print CSS and img-src data: in the CSP are unreachable, since document mode never emits an <img>.
  • Critic follow-up: consider aria-busy on the Export button while isExportingPdf is true (Review-Proven Rule 7).

Test plan

  • cd desktop/src-tauri && cargo test pdf_export -- --list | grep -c ': test' → 19 (≥ 3 required)
  • cd desktop/src-tauri && cargo test pdf_export → 17 passed, 0 failed, 2 ignored
  • cd desktop/src-tauri && cargo test pdf_export -- --ignored → 2 passed (produces and validates the branch's 3-page PDF fixture, 3 rendered PNGs)
  • just desktop-test → full suite green
  • just desktop-e2e-smoke narrowed to desktop/tests/e2e/pdf-export.spec.ts → 3-4 passed depending on run
  • Manual: from the markdown-doc viewer panel (T2), click Export PDF on the three-page fixture, save via the native dialog, open the result in macOS Preview — headings, table and code block intact; cancel the save dialog and confirm nothing is written and no toast appears.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE

wiggdevin and others added 5 commits September 4, 2026 21:37
The chat render turns anchors into cards and pills, drops them to inert
spans when non-interactive, and caps code blocks at a scrollable height.
None of that survives onto paper, so document mode reuses the same parse
pipeline (renderCachedMarkdown, same remark plugins, same URL transform)
with a component map that emits plain semantic HTML instead: links stay
links, attachments and images become labelled links, code blocks render
as bare pre/code with no collapse chrome, and chat-only inline nodes
(mentions, deep links, spoilers) degrade to their text.

The output carries no application classes on purpose — the exporter
styles it with a standalone print stylesheet, so it has to read as a
document with that as its only CSS.

The fixture pair lands with it: approval.md is the markdown twin of the
T8 spike fixture (docs/plans/2026-09-04-pdf-route.md), carrying the same
three headings, four table row tokens and code marker line, and
approval-body.html is this renderer's output for it. The renderer test
asserts they match, and the Rust exporter is tested against the same
HTML, so the two lanes cannot drift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Adds the export_document_pdf command behind the route the T8 spike
picked (docs/plans/2026-09-04-pdf-route.md): Tauri's WebviewWindow::print
has no bytes-out API on this wry pin and its only headless alternative
needs unsafe Objective-C interop, so the document is printed by a locally
installed Chrome or Chromium over the DevTools protocol
(headless_chrome, Page.printToPDF) — hence the Cargo.lock change.

The command wraps the frontend's document-mode HTML in a self-contained
print document: a standalone print stylesheet (US Letter, 0.75in margins,
tables ruled, code wrapped with no height cap) and a content-security
policy that denies every remote subresource. Two independent guards keep
an export off the network — that policy, and a browser launched with DNS
resolution mapped to ~NOTFOUND, no proxy server, and every inherited
proxy variable blanked in a child environment that also points HOME at
the export's own scratch directory.

Every relay- or user-sourced input is capped before any browser work
starts: the HTML body at 8 MiB, the title at 200 characters, the
suggested filename at a 120-character sanitised basename that always
ends .pdf. The PDF coming back is capped at 64 MiB and refused with a
message rather than truncated. The save dialog is shown before anything
is rendered, so cancelling returns without a browser launch and without
a write, and the file itself is committed by rename so the chosen path
only ever holds a complete document.

The ignored test prints the shared fixture through the real route and
checks it with poppler: three pages, every heading, table row token and
the code marker line in the extracted text, and every page rasterised.
It is ignored by default because it needs a local Chrome and poppler;
run it with `cargo test pdf_export -- --ignored`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
The viewer panel gets an Export PDF action next to Download. It renders
the open document in document mode and hands the HTML, a title derived
from the attachment name, and the filename to export_document_pdf.

A cancelled save dialog resolves false and is reported as neither a save
nor a failure; every other failure is surfaced as a toast rather than
swallowed. The markdown source is refused above 2 MiB — the viewer's own
native fetch cap — before the render runs, so the cost is bounded before
it is paid rather than measured after.

The e2e spec covers what the panel sends and how it reports each of the
three outcomes; the mock bridge cannot run a save dialog or a browser, so
the real render is proven in Rust against the same fixture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Consolidated review fixes for the T9 PDF export.

Bound the quantity that costs. Export ran the exact mdast/micromark parse
the viewer's complexity gate exists to refuse: the Export action was offered
whenever the document decoded, while `isMarkdownDocTooComplexForPreview`
gated only the Preview body, so the button was live on precisely the
documents the panel was showing `markdown-doc-preview-too-complex` for. The
export's only pre-render bound was 2 MiB of source, and a link-dense
one-liner reaches 111,025 links — 9,379 ms and 1,105 MB on the pinned parser
— at 1.80 MiB. The panel now derives the exportable text from the same
predicate, so the affordance and the panel agree, and
`exportMarkdownDocumentToPdf` applies it before rendering.

Own the browser child. `headless_chrome` 1.0.22 wraps its 30 s launch wait
around a blocking `BufRead::lines()` scan of the child's stderr and only
checks the elapsed time between reads, so a Chrome that starts, holds stderr
open and never prints the DevTools banner parks that wait forever — and its
`TemporaryProcess::drop` kills the direct PID only, discarding every error,
leaving renderer, GPU and zygote children unowned. The module now spawns
Chrome itself with an absolute launch deadline enforced off the scan thread,
the child in its own process group (Unix) or a kill-on-close Job Object
(Windows), and every teardown failure returned rather than logged away. The
profile, disk cache and crash dumps are pointed inside the export's scratch
directory, so the module's claim that nothing is written outside it is now
true.

Bind the guards to tests that fail when the guard is removed. The
orchestration moves behind an injectable picker/renderer/writer seam, so the
cancel-before-any-side-effect ordering, the `MAX_PDF_BYTES` refusal and the
atomic write are asserted on the production path instead of through the e2e
mock bridge. The two network-egress guards are asserted on the launch that
uses them — a stand-in browser records its argv and environment — rather than
on the constants, and an offline sentinel counts connections through a real
render.

Correct the test-module claim that `scripts/zs/pdf-validate.sh` validates a
T9 export unchanged: document mode renders an image as a link, so no export
embeds an image and the script's image-XObject check fails by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

# Conflicts:
#	desktop/src-tauri/Cargo.lock
@wiggdevin
wiggdevin marked this pull request as ready for review September 5, 2026 06:52
wiggdevin and others added 5 commits September 5, 2026 00:36
`isMarkdownDocTooComplexForPreview` counted newlines and `[` only, so two
shapes walked straight through it and cost seconds of synchronous parse on
the main thread with Export PDF still offered:

- a 300-column GFM table: 102 lines, no `[`, 62,194 bytes — 8,643 ms; at 300
  rows 182,594 bytes and 116,505 ms; at 600 rows, 1,138,668 ms.
- a list nested one level per line: 801 lines, 647,890 bytes, no `[` and no
  `|` — 11,066 ms.

Both are under the 2 MiB byte cap and under both existing counters, because
neither counter tracks what the parse spends time on. The same single linear
scan now also counts the `|` cell delimiters a table is built from (cap 3,000,
measured 163 ms at the cap) and the container depth of each line's leading
indentation and `>` markers (cap 128, measured 63 ms at the cap), so the gate
bounds the quantity that costs rather than a proxy for it.

Both new counters have a cap boundary pair (at the cap admitted, one past it
refused) and a reproduction-shape test, and the export path carries the two
failing shapes with an elapsed-time assertion: with the caps lifted those two
take 8,645 ms and 15,030 ms instead of milliseconds. The document just inside
every gate still exports, table and all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
… flags

Three guards on the export's browser child were either wrong off Unix or
protected by nothing a test could falsify.

`chrome_process_env` built the child's whole environment after `env_clear`
and supplied only Unix variables — `PATH=/usr/bin:...`, `HOME`, `TMPDIR`,
`XDG_*` — while `SystemRoot`, `WINDIR`, `TEMP` and `TMP` were absent, so on
Windows the child very likely could not start at all. This repo already
states the reason and the list: `commands/media_transcode.rs` restores the
loader variables from the parent for its ffmpeg child. The builder is now
`chrome_process_env_for(scratch, target, inherited)`, a pure function whose
Windows map is asserted on every platform: the loader variables carried over
from the parent, `PATH` rebuilt from `SystemRoot` rather than inherited,
`TEMP`/`TMP` pointed at the export's own scratch directory, the proxy
variables still blanked, and nothing else inherited even when the lookup
answers every name.

The Windows creation flags were a hardcoded `0x0000_0004 | 0x0800_0000`. They
are now `CREATE_SUSPENDED`/`CREATE_NO_WINDOW` with the compile-time
drop-either-flag guard that `managed_agents/discovery/bounded_command.rs`
uses, so removing either bit fails the Windows build instead of reopening the
spawn-to-assign race or flashing a console.

`MAX_LAUNCH_STDERR_BYTES` and `--disable-remote-fonts` each get the test that
fails when they are removed: a stand-in browser that writes 1,228,800 bytes of
banner-free noise must be given up on at the cap with the cap named in the
error (unbounded, the launch fails with the deadline's message instead), and
the font flag is asserted both on the constant and on the argv the production
launch records.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…urce text

Round 3's blind critic left one finding open: the complexity gate bounded
proxies (lines, `[`, `|`, per-line indentation depth) instead of the
quantity that costs, and two marker-free shapes still rendered on the main
thread with Export offered — `"*a*"` x 20,000 (60 KB, one line, 3,458 ms)
and block-quote depth 127 held across 3,000 lines (768 KB, 903 ms, and only
130 mdast nodes). Both are reproduced through the production entry
`exportMarkdownDocumentToPdf`; both are now refused in under 7 ms.

The guard is now a node budget on the parsed syntax tree.
`MAX_MARKDOWN_DOC_NODES` (24,000) is enforced during the parse itself: a
`mdast-util-from-markdown` transform, registered by a remark plugin on the
pipeline the viewer and the exporter share, counts the tree and aborts with
a typed `MarkdownTooComplexError` before the tree leaves
`processor.parse()` — ahead of every other remark plugin, the hast
conversion, and any React element. Measured through
`renderMarkdownDocumentHtml`, cost is linear in node count (about 21 us a
node) across the 2,462 real markdown files reachable from this repository,
the largest of which is 18,013 nodes and 382 ms.

The source-text counters remain, rewritten as work models for the phases
that run *before* any mdast node exists, and demoted to a pre-filter whose
only jobs are to bound micromark's own tokenizers and to keep the parse the
node budget runs finite:

- descent work (sum over lines of container depth) replaces the per-line
  nesting cap, which measured a triangular shape and so admitted F5;
- delimiter work (sum over blank-line-separated blocks of the square of the
  inline-delimiter count, over the whole CommonMark + GFM delimiter
  alphabet) replaces the `[` cap, which saw one construct;
- a document-wide `|` cap replaces the old one, recalibrated after measuring
  that table cost tracks the document total and not the per-block count;
- a node estimate (3 x lines + delimiters + 2 x cell markers + 3 x literal
  autolink candidates) replaces the line cap, which refused real 9,600-line
  READMEs and still admitted a 2 MiB flat list that exhausts a 4 GB heap.

Every cap carries its measurement. Preview and Export share one predicate,
`isMarkdownDocumentTooComplex`, and a too-complex document shows the
existing fallback card. The gate skips its parse when the estimate is under
half the budget, which it never over-ran on any document measured — without
that the 507 KB long-document fixture spent 296 ms against this app's
200 ms main-thread budget, which `markdown-doc-viewer.spec.ts` caught.

Tests: unit tests for the budget plugin through `renderCachedMarkdown` (at
the cap admitted, one node past refused, the walk bounded by the budget, a
surface with no budget ungated), the F4 and F5 shapes as elapsed-time
assertions on the export path (refused before render, under 200 ms), a
realistic 200 KB README admitted, a document only the tree-level count
refuses, and the existing wide-table and deep-list cases. Each of the five
guards fails at least one test when removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…parse

`mdast-util-to-hast` pads every GFM table body row out to the *header's*
column count. mdast carries only the cells that were written; hast carries
rows x columns. So a table whose header declares many columns and whose body
rows carry no `|` at all is invisible to every counter the gate had: the
marker cap sees only the header's pipes, and the parsed tree the node budget
walks holds about three nodes per row. Measured amplification reaches 668x —
a 14,894-byte document is 3,000,000 `<td>`, and 18,694 bytes exhausted a 4 GB
heap inside the gate itself.

Count the padded cells before the parse instead: for each header/delimiter
row pair, the header's column count times the body rows that follow until a
blank line, summed document-wide. This is not another proxy — measured
through `renderMarkdownDocumentHtml`, the counter equals the `<td>` count the
render emits, exactly. The cap is 16,384, which holds the worst measured
admitted table at 149 ms against this app's 200 ms main-thread budget, with
3.3x headroom over the largest table in 2,464 real markdown files.

It joins the existing document gate, so Preview and Export refuse the shape
together. The critic's 1,500 x 2,000 table now refuses in 0.5 ms through
`exportMarkdownDocumentToPdf` with the parse counter unchanged — the
conversion is never reached. With the bound removed that same test renders
for 11,678 ms, and four others fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
@wiggdevin
wiggdevin enabled auto-merge September 5, 2026 10:23
@wiggdevin
wiggdevin added this pull request to the merge queue Sep 5, 2026
Merged via the queue into zs/main with commit 57ade44 Sep 5, 2026
51 checks passed
@wiggdevin
wiggdevin deleted the feat/pdf-export branch September 5, 2026 11:11
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