feat(desktop): export a markdown document as PDF (T9) - #13
Merged
Merged
Conversation
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
marked this pull request as ready for review
September 5, 2026 06:52
`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>
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.
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 PDFbutton 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 inexportMarkdownDocumentToPdf). It also hardens process containment (ownscratch 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
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 onzs/mainrather than inside a feature branch. Finding 6 names recording this as an accepted deviation as its alternative; carrying it here.just desktop-e2e-smokewas run narrowed totests/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 clean8e11cb8b3checkout of the same worktree. This round changed no shared surface, so the ~20-minute full run was not repeated inside the 90-minute cap.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=srgbwas added to the launch flags, whichheadless_chrome'sDEFAULT_ARGShad 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
MAX_DOCUMENT_HTML_BYTES,MAX_TITLE_CHARSandMAX_FILENAME_CHARSkeep their existing tests; the new source-side count cap (isMarkdownDocTooComplexForPreviewapplied inexportMarkdownDocumentToPdf) is the count half the round was missing, with three unit tests and one e2e test.run_exportis 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.Command::envsmerging into the inherited environment; it is nowenv_clear()plus a complete map (blanked proxies,no_proxy=*, fixedPATH,LC_ALL, andHOME/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.MAX_PDF_BYTESis checked between the render and the write, which is where the bytes reach the disk.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.ChromeChild::terminatereturns every containment and reap failure with the PID instead of discarding it (the pinned library'sDropdid.ok());with_teardownkeeps both errors when a render failure and a teardown failure coincide; the scratch directory's close failure is propagated;Dropreports 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'ssockspackage addition); fast gates and the ticket's test files were rerun on the merged tree.just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-checkcargo test pdf_export -- --list | grep -c ': test'cargo test pdf_exportcargo test pdf_export -- --ignoredjust desktop-testdesktop-e2e-smokenarrowed topdf-export.spec.tsWorktree clean;
Cargo.lockchange is legitimate (headless_chromeand its transitive deps, includingsocks,ureq). DCO andCo-Authored-Bypresent on every commit; nounwrap()/expect()/unsafein the production path.Gemini 3.8 Flash tester
Verdict: PASS. All 6 fast gates passed;
cargo test pdf_exportlisted 19 tests (≥3 required) and passed 17/0/2-ignored; both ignored real-render tests passed (3-page PDF, 8 text markers extracted viapoppler, 3 PNGs rendered);just desktop-testpassed 6296/0;pdf-export.spec.ts(3 specs) passed underdesktop-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):
pdftoppmper-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)export_document_pdfwith a realAppHandle(tests call the internalrender_print_documenthelper instead)export_document_pdfitself (covered only via mocked IPC inmarkdownDocPdfExport.test.mjsandpdf-export.spec.ts)write_pdf_atomicallyinpdf_export_tests.rs(the ignored test writes viastd::fs::writedirectly)renders_the_fixture_to_a_three_page_pdfby default (it is#[ignore]d, requiring an explicit--ignoredinvocation)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:
MAX_LAUNCH_STDERR_BYTES,--disable-remote-fonts, and the Windows creation flags. Fixed with named removal tests (checklist item 5).PATH/HOME/TMPDIR/XDG_*supplied afterenv_clear()with noSystemRoot/WINDIR/TEMP/TMPrestored) 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
xhighpasses (the diff touches spawn/env code and process containment).Round 1 — verdict:
Found 6 BLOCKs and 1 WARN.All 6 BLOCKs fixed:isMarkdownDocTooComplexForPreviewbefore render and gating the button on it.user_data_dir, and process-tree termination with propagated errors.LaunchOptions.5–6 (WARN, downgraded from BLOCK on inspection):
MAX_PDF_BYTESchecked 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).Array.fromclamp 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.
gate counted source lines and
[characters, which no shape's render timefollows. Closed by replacing it with five source-side work models plus a
budget on the parsed tree, described below.
MAX_LAUNCH_STDERR_BYTES,--disable-remote-fontsand 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.
env_clear()plus a Unix-onlymap left a Windows child with no
SystemRoot/WINDIR/TEMP/TMP. Closedwith a pure
chrome_process_env_for(scratch, target, inherited)whose Windowsarm is asserted unconditionally, on every platform.
"*a*"× 20,000 is 60 KB onone line with no
[, no|and no nesting, and rendered in 3,458 ms.Closed by
MAX_MARKDOWN_DOC_DELIMITER_WORK.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.mdast-util-to-hastpads every table body row out to the header's columncount. 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 mdastnodes (under the node budget) — and 3,000,000
<td>. Measured amplificationreached 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 anmdast-util-from-markdowntransform, so it aborts insideprocessor.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 treecosts 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, measuredbefore 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:MAX_MARKDOWN_DOC_DESCENT_WORKMAX_MARKDOWN_DOC_DELIMITER_WORKMAX_MARKDOWN_DOC_TABLE_CELL_MARKERSMAX_MARKDOWN_DOC_TABLE_CELL_WORKMAX_MARKDOWN_DOC_ESTIMATED_NODESMAX_MARKDOWN_DOC_NODES(parsed tree)The F6 shape now refuses in 0.49 ms through
exportMarkdownDocumentToPdf,with
getMarkdownParseCount()unchanged across the call — the measurement thatthe 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).
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_NODESexists to bound that parse and iscalibrated on the flat-list shape alone; the table shape doubles it. Every
other refusal in the suite is a scan, under 13 ms.
MAX_MARKDOWN_DOC_NODESis 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.mda user can plainly expect to read. Measured, twelve admittedshapes 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.tsat 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 Filestab #11 and the
inbox-live-updatespec fix #12 landed onzs/main; the mergewas clean and touched 22 tracked files, so every gate below was run again after
it).
just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-checkjust desktop-testnode --testthe four markdown gate specscd desktop/src-tauri && cargo test pdf_exportpdf-export.spec.ts+markdown-doc-viewer.spec.ts, scratch config, free port,reuseExistingServer: falseWorktree clean; the scratch Playwright config was deleted after the run.
Follow-ups
markdownDocPdfExport.tstitle 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:52per the delta pass's re-check).isMarkdownDocTooComplexForPreviewcounts 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 onzs/main), but a follow-up against the shared gate (desktop/src/shared/ui/markdown/markdownDocFile.ts:72).terminate_process) can report success while a descendant that ignores SIGTERM survives the group SIGTERM — pre-existing sharedmanaged_agentscode; needs an unconditional group SIGKILL escalation and a fake-grandchild test (desktop/src-tauri/src/commands/pdf_export.rs:385).chrome_executable()resolves through$CHROME/inheritedPATHbefore the child's environment is cleared, and the CDP banner accepts anyws://prefix with no loopback check — needs an absolute-path allowlist and a loopback-host check (pdf_export.rs:541).MAX_PDF_BYTESis checked afterprint_to_pdffully materializes the base64 response — a save-refusal bound, not an allocation bound; no page-count bound exists (pdf_export.rs:663).pdf_export.rs:678).TempDir::drop, which discards its own error — cleanup is always attempted, but a failed export plus a failed removal leaves the stageddocument.htmlunreported (pdf_export.rs:597).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).pdftoppmverification, real-AppHandlecommand test, Rust-level cancel-path unit test, directwrite_pdf_atomicallyunit test, manual-only Preview/visual-compare checks,#[ignore]d real-render test not in the default CI gate.@pagemargin box counter ordisplay_header_footer) — the one place the Chrome baseline beats ours (pdf_export.rs:466,pdf_export_print.css:5-8).scripts/zs/pdf-validate.shexits 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 onzs/mainor state the expected 11/12 result explicitly; do not ship a named validator that exits non-zero on the artifact without documenting why.379f93438a6f0efee16bdb8befc2127bc019f6b9783b080dec54f6d5c33608b3, page-2f77ea516b36081ba807be29de2beb8703c68ccffd6339be428a67439657e5dd2, page-33570024a5a3b55e92cecc96c0d0d9ac8a3018beaa5636e2716037f1460eefd68(Chrome'sCreationDatestamp makes the PDF's own sha256 unstable, so the page rasters are the reproducible artifact).export_document_pdftorun_exportwith a test so the command wrapper cannot reorder pick/render/write independently of the seam that owns the order.headless_chromepullsureqand SOCKS-proxy support into the desktop binary — noting it here per the deviation above, since it is new surface area in the shipped app.img { max-width: 100% }in the print CSS andimg-src data:in the CSP are unreachable, since document mode never emits an<img>.aria-busyon the Export button whileisExportingPdfis 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 ignoredcd 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 greenjust desktop-e2e-smokenarrowed todesktop/tests/e2e/pdf-export.spec.ts→ 3-4 passed depending on run🤖 Generated with Claude Code
https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE