Spec: docs/specs/pdf-export.md.
Add typediagram.exportMarkdownPdf command to the VS Code extension. Right-click a .md file → generate <basename>.pdf next to it, with every typeDiagram fence embedded as a vector SVG inside the PDF. No save dialog, no prompts — just generate and write.
| Concern | Decision | Why |
|---|---|---|
| PDF engine | VS Code's bundled Electron via webview.printToPDF |
Zero new runtime binaries. Chromium preserves inline SVG as vector paths in the output PDF. Used by shd101wyy.markdown-preview-enhanced and similar extensions. |
| Markdown → HTML | markdown-it (already a devDep; promote to dependencies) |
We already ship it for the preview plugin tests. ~90 KB, pure JS, zero transitive deps. Alternatives (marked, remark) add more weight for no gain at our feature scope. |
| Fence → SVG | Reuse renderMarkdownSync from typediagram-core |
Already exists, already tested, already case-insensitive. Zero duplication. |
| SVG-in-HTML safety | Replace SVGs with sentinels <!--TD-SVG-${i}--> before markdown-it, substitute back after |
markdown-it html-escapes inline HTML by default unless html: true. Sentinel swap is safer than trusting the html flag — we control what gets through. |
| User prompts | NONE. Write <basename>.pdf next to the source, overwrite silently |
User directive: "just fucking generate it". No showSaveDialog, no overwrite confirmations. |
| Page size / margins | Hard-coded A4, 20mm all-around, in the HTML shell's @page rule |
MVP. Config added later only if users ask. |
| Theme | Single setting: typediagram.pdfExport.theme = light | dark (default light) |
Passes through to renderMarkdownSync. Page background is always white — PDFs are for printing. |
| Non-goals | Syntax highlighting for non-TD code blocks, TOC, page numbers, multi-file, Web VS Code | Each adds scope. Defer. |
markdown-it— move fromdevDependenciestodependenciesinpackages/vscode/package.json. No version bump. Must also bundle it intodist/extension.jsvia esbuild (already does this since the preview plugin uses it at test time; need to verify it's pulled into the extension bundle for runtime use too).
No other runtime deps.
packages/vscode/
src/
export-pdf.ts # [PDF-*] Command handler: compose HTML, print to PDF, write next to source.
test/
export-pdf.test.ts # [PDF-*] Unit tests: readMarkdown, composeHtml, sentinel round-trip,
# writeNextToSource path logic, mocked printToPDF returns %PDF- buffer.
export-pdf-e2e.test.ts # [PDF-E2E] Black-box: invoke the command via executeCommand against
# examples/doc.md, assert PDF is written next to source, assert first
# 5 bytes are %PDF-, assert buffer size > 1 KB, assert vector markers
# present in PDF stream.
packages/vscode/
src/
extension.ts # Register typediagram.exportMarkdownPdf command in activate().
# Route to export-pdf.ts. Add to subscriptions.
package.json # Promote markdown-it to dependencies.
# Add command contribution: typediagram.exportMarkdownPdf.
# Add menus:
# explorer/context when resourceLangId == markdown (or .md ext)
# editor/title/context when resourceLangId == markdown
# commandPalette when resourceLangId == markdown
# Add configuration: typediagram.pdfExport.theme.
test/vscode-mock.ts # Add mockPrintToPDF spy on webview panel.
# Add workspace.fs { readFile, writeFile } spies.
# Add window.showInformationMessage spy.
docs/specs/spec.md # Already updated: roadmap item 5 links to pdf-export spec.
Command handler in export-pdf.ts is one ~80 LOC exported function that composes four pure (or near-pure) helpers, each with its own spec ID so tests map 1:1 to code:
typediagram.exportMarkdownPdf(uri)
└─ exportPdf(uri, deps)
├─ [PDF-READ] readMarkdown(uri) → string
├─ [PDF-COMPOSE] composeHtml(src, { theme }) → Result<string, Diagnostic[]>
│ └─ (inside) renderMarkdownSync + sentinel swap + markdown-it.render
├─ [PDF-PRINT] renderHtmlToPdf(html) → Promise<Uint8Array>
└─ [PDF-SAVE] writeNextToSource(buf, uri) → Promise<vscode.Uri>
deps is a struct of the vscode-surface functions the handler uses — readFile, writeFile, createWebviewPanel, showInformationMessage. The test harness passes mocks; real code passes the real vscode namespace. This makes the top-level command trivially unit-testable without mocking the whole vscode module.
Logging via the existing Logger: every stage entry + exit + elapsedMs.
Strict order. Each step ends with its tests passing before moving on — per CLAUDE.md make test enforces this.
Write the stage that has no vscode dependency first. Max unit-testability, fastest feedback.
- Create
src/export-pdf.tswithcomposeHtml(src, opts)only. - Write
test/export-pdf.test.tswith[PDF-COMPOSE]assertions from spec. - Run
make test— typediagram-vscode coverage must stay ≥ threshold.
- Add
renderHtmlToPdf(html, deps)usingdeps.createWebviewPanel+panel.webview.printToPDF. - Extend
test/vscode-mock.tswith aprintToPDFspy that returns a fake%PDF-1.7\n...buffer sized > 1 KB. - Unit test
renderHtmlToPdf: assertsprintToPDFcalled once, panel disposed, returned buffer starts with%PDF-.
readMarkdown,writeNextToSource, and the top-levelexportPdfcomposer.- Unit tests for each
[PDF-READ],[PDF-SAVE], and the composer (mocking all four stages). - Assertion that
showSaveDialogis NEVER called (per user directive + spec[PDF-SAVE]).
- Register
typediagram.exportMarkdownPdfinactivate(). Add menu contributions inpackage.json. - Extension activation test: command is registered, subscriptions grow by 1.
- Run
make lint+make test— all green.
test/export-pdf-e2e.test.ts: invoke the command againstexamples/doc.mdwith the real core sync renderer, mockedprintToPDF. Assert:- Log lines appear in the JSONL file in order.
- Composed HTML contains inline SVG (not html-escaped).
writeFilecalled with URI ==doc.pdfsibling ofdoc.md.- Notification shown with "Open PDF" / "Reveal" actions.
- Add a test case to the existing electron harness (
test/electron/suite/) that:- Opens
examples/doc.md. - Invokes the command.
- Waits for the PDF to appear at
examples/doc.pdf. - Asserts first 5 bytes are
%PDF-and buffer > 1 KB. - Asserts vector-marker heuristic (
/Patternor path operators) is present in the PDF bytes. - Cleans up the PDF.
- Opens
Skipped on darwin-arm64 for the same reason the existing electron tests are skipped there.
- Update
packages/vscode/README.mdwith the new command. - Run
make cione last time — format, lint, test, build, bundle-size all green. - Coverage ratcheted via
scripts/ratchet-coverage.mjs.
| Risk | Mitigation |
|---|---|
webview.printToPDF doesn't exist on older VS Codes / Web VS Code |
Feature-detect at runtime; show error notification with version hint, log and abort. Engines in package.json already requires 1.75+; raise to 1.76+ (which ships printToPDF). |
| markdown-it escapes our inline SVG | Sentinel swap strategy (see spec [PDF-COMPOSE]). Tests assert the string TD-SVG- does NOT leak into output. |
| SVG not preserved as vector in PDF (rasterised) | Test [PDF-PRINT] decodes PDF bytes and asserts vector-op markers. If it fails, investigate Electron print options (printSelectionOnly, preferCSSPageSize, scale). |
| Bundle size regression | make bundle-size already enforced. markdown-it is ~90KB; acceptable. |
| Two commands on the same file fire concurrently | Command handler captures a per-uri lock in module scope; second invocation waits for first to finish. Logged warn "export-pdf already in progress for URI". |
| Extension bundle doesn't include markdown-it at runtime | esbuild must bundle it. Verify by grepping bundled dist/extension.js for the markdown-it module preamble. Add to VSIX-package.test.ts. |
Check items off as each lands with its tests passing.
- Spec file exists: docs/specs/pdf-export.md. ✅ committed.
- Roadmap updated in docs/specs/spec.md with item 5 linking to pdf-export spec. ✅ committed.
-
[PDF-READ]readMarkdown(uri, { readFile })— reads via injectedreadFile, decodes UTF-8, returns string. Rejects on nonexistent path. -
[PDF-COMPOSE]sentinel swap —extractSvgs(md)→{ skeleton, svgs[] }. Replaces every<svg ...>...</svg>block with<!--TD-SVG-${i}-->. -
[PDF-COMPOSE]re-injection —reinjectSvgs(html, svgs)substitutes sentinels back. -
[PDF-COMPOSE]composeHtml(src, { theme })— callsrenderMarkdownSync→ sentinel swap →md.render→ re-inject → wrap in shell. -
[PDF-SHELL]—buildShell(title, bodyHtml)returns self-contained HTML with@page A4and 20mm margin. No external refs. - Tests —
[PDF-COMPOSE]— markdown with 0 fences passes through; with N fences, exactly N<svg, zerotypediagram ```, no html-escaped SVG, no sentinel leak, light ≠ dark output, diagnostics surface for bad fences. - Tests —
[PDF-SHELL]— no external URLs,@pagepresent, system font stack present.
-
[PDF-PRINT]renderHtmlToPdf(html, { createWebviewPanel })— creates hidden panel, sets html, awaits load message, callsprintToPDF, disposes. - Mock printToPDF — extend
test/vscode-mock.tswithmockWebviewPrintToPdfreturningBuffer.from("%PDF-1.7\n" + "x".repeat(2048)). - Test —
[PDF-PRINT]— returned buffer starts with%PDF-, length > 1024,createWebviewPanelcalled once,disposecalled. - Test —
[PDF-PRINT]load handshake — if the webview never signals loaded, the promise rejects after a 10s timeout (don't hang the command indefinitely). - Test —
[PDF-PRINT]fallback — ifprintToPDFisn't a function (older VS Code), returns Result.err with a clear message.
-
[PDF-SAVE]writeNextToSource(buf, sourceUri, { writeFile })— sibling path with.md/.markdown→.pdf; else appends.pdf. - Test —
[PDF-SAVE]path mapping —foo.md→foo.pdf,foo.MARKDOWN→foo.pdf,notes.txt→notes.txt.pdf, subfolder URIs preserved. - Test —
[PDF-SAVE]NEVER calls showSaveDialog — hard assertionexpect(window.showSaveDialog).not.toHaveBeenCalled(). - Test —
[PDF-SAVE]overwrites silently —writeFilecalled even when the sibling already exists; no confirmation asked. -
exportPdf(uri, deps)— composes all four stages; logs at each transition; emits user notification on success with Open/Reveal actions. - Per-uri lock — concurrent invocation on the same URI waits for the first to finish; logged
warn. - Test — composer happy path — readFile returns markdown, composeHtml → renderHtmlToPdf → writeFile → notification. Order asserted via logger capture.
- Test — composer error propagation — any stage's Result.err surfaces as
showErrorMessage+errorlog entry.
-
activate()registerstypediagram.exportMarkdownPdf— wired toexportPdf(uri, realDeps). Added tocontext.subscriptions. -
package.json— command contribution, explorer/context menu, editor/title/context menu, commandPalette filter. -
package.json— configurationtypediagram.pdfExport.theme(enum light/dark, default light). -
package.json— promotemarkdown-ittodependencies. - Test — registration —
commands.registerCommandcalled with"typediagram.exportMarkdownPdf"; subscriptions length bumped by 1. - Test — activation log entry —
"extension activating"log line references the new command count.
- Test — against
examples/doc.md— realrenderMarkdownSync, mockedprintToPDF. Composed HTML has 1 inline<svg>;writeFilecalled withdoc.pdfURI; notification shown. - Test — log order — JSONL log contains
export-pdf invoked→composed HTML→rendered PDF→saved PDFin order.
-
test/electron/suite/export-pdf.spec.cjs— opensexamples/doc.md, invokes command, polls forexamples/doc.pdf, asserts%PDF-+ size > 1024 + vector markers. Cleans up. Skipped on darwin-arm64. - Test — bundled markdown-it — VSIX-package.test.ts asserts
dist/extension.jscontains the markdown-it module (grep for a stable string from markdown-it's preamble).
-
packages/vscode/README.md— new section documenting the export command + context menu entry + default output location. -
make ci— format, lint, test, build, bundle-size all green. - Coverage ratchet —
scripts/ratchet-coverage.mjsbumps vscode package thresholds upward. - Manual smoke — right-click
examples/doc.mdin the real VS Code → confirmdoc.pdfappears next to source with the ChatRequest/ToolResult diagram as a zoomable vector.