name: to-pdf description: Export a markdown file to a PDF stylized like gustav.im — Satoshi font, opinion-prose layout, signal-flow background. Use when the user asks to convert / export / render a .md file to PDF, or runs /to-pdf.
to-pdf — markdown → PDF, gustav.im style
Purpose
One command, one file in, one PDF out. Pixel-honest reproduction of the gustav.im opinion-page styling — Satoshi font (embedded), neutral grey palette, generous prose spacing, signal-flow texture tiled in the background — written for paper, not screen (light mode always).
Scope
In scope:
- Single
.mdfile → sibling.pdf. - Frontmatter parsing (
title,subtitle,created/date,author). When a title is present a dedicated cover page is generated (GE mark, title, subtitle, then adate · gustav.immeta line, vertically centred). The date is interpreted by the skill: frontmattercreated/dateif given, otherwise the render date (today). If there's no frontmattertitle, a leading# H1is promoted to the cover title and the paragraph right after it (if any) becomes the subtitle — both are then stripped from the body so they don't repeat. No title anywhere → no cover. The cover carries no footer and is not counted; page numbering starts at1on the first content page. - Plain-markdown subset: ATX headings, paragraphs, lists (ul/ol), GFM tables, code fences + inline code, blockquotes, HR, bold / italic / strike / links.
- Mermaid diagrams:
```mermaidfences render to SVG inside the headless-Chrome pass. The diagram source never leaves the machine; only the mermaid library is fetched from a CDN (jsdelivr), so amermaidexport needs network at render time. - Satoshi
woff2resolved from~/code/gustav.im/public/fonts/, embedded as data-URI; falls back to system sans if missing. - Signal-flow background generated as a static SVG and tiled per page (subtle —
~5–10%opacity).
Out of scope:
- Multiple files / globs / directories. (Run once per file.)
- Custom themes beyond the gustav.im light/dark palettes, alternate page sizes — A4 fixed.
- Inline HTML, footnotes, raster images, MathJax, citations. Add later if needed.
- Replacing
/researchor any library workflow — this is presentational only, not capture.
Workflow
Resolve input.
$ARGUMENTSis a path to a.mdfile. Error if missing or not.md.Run the script. From the workspace root:
node .opencode/skill/to-pdf/scripts/to-pdf.mjs <file.md> [--dark] [--out <path>] [--keep-html] [--no-page-numbers]- Default output: sibling
.pdf(same dir, same basename). --darkswitches to the gustav.im dark palette; default output becomes<basename>-dark.pdf. Run twice (without and with--dark) to produce both.--outoverrides the destination (use when you need a non-default path; pairs with--darkcleanly).--keep-htmlretains the intermediate HTML in/tmpand prints its path on stdout.--no-page-numbersomits the per-pagen / totalfooter numbering (on by default; auto-suppressed on single-page docs).
- Default output: sibling
Tell the user the output path. One line. No recap.
Don't commit the PDF automatically. PDFs are generated artefacts — let the user decide whether it belongs in the repo. If they do want it committed, follow normal capture rules (
add pdf <basename>as the commit subject, no prefix).
Rules
- Normalise metadata into frontmatter before export. The paragraph right after a leading
# H1becomes the cover subtitle. If that paragraph is pseudo-metadata (Date: … Status: … Author: …, key-value runs, "Inputs:", etc.), it makes an ugly cover — move it into YAML frontmatter (title,subtitle,created/date,author) and delete it from the body first. Only a real one-sentence summary belongs as the subtitle; if there isn't one, leave it out — no subtitle beats a metadata dump. - Light is the default. Dark mode is opt-in via
--dark— meant for on-screen reading, not paper. Don't auto-emit dark unless the user asked. - Don't shell out to
pnpm installornpxfor deps. The script is zero-dep on purpose. If the markdown subset is too narrow for a real document, expand the inline parser — don't pull inmarked. - Resolve Chrome lazily. macOS path first (
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome), then Chromium / Linux fallbacks, then PATH. If none, surface the failure — don't silent-fail. - The font path is config, not gospel. Honour
GUSTAV_IM_ROOTenv var if set; otherwise probe~/code/gustav.im, then~/code/takt/gustav.im. Missing font → fall back, don't crash. - Tile the background, don't stretch. It's a
repeat-ybackground-imageonbodywithprint-color-adjust: exact. Tiling lets the texture repeat across multi-page documents instead of one stretched copy that disappears after page 1. - Page numbers + brand are Chrome's native print footer, not DOM nodes. They're drawn via CDP
Page.printToPDFwithdisplayHeaderFooter+footerTemplate(the--print-to-pdfCLI flag can't emit footers, so the script drives Chrome over its debug WebSocket — needs Node ≥ 22 for the globalWebSocket). Chrome positions the footer on every page and fills thepageNumber/totalPagesspans itself: no arithmetic, no page-count pre-pass, no per-page drift, and it stays correct across forced breaks, unbreakable tables, and mermaid diagrams. The earlier DOM approach (absolute-positioned labels placed byk × page-heightarithmetic + amin-heightstretch) drifted and landed mid-page the moment any block left pagination slack — don't reintroduce it. The footer sits in a small bottom print margin; the full-bleedhtmlbackground still paints across it (printBackground: true), so the edge-to-edge texture is preserved. One render normally; a single-page doc is re-rendered once without the number to drop a lone1 / 1(detected by inflating the PDF's FlateDecode streams vianode:zliband counting/Type /Pageleaves). - Cover page is a separate render, merged zero-dep. Because Chrome's native footer can only show physical page numbers (verified:
pageRanges:"2-"still prints2 of N, not1 of N-1), the only way to keep the cover footer-less and number content from1is to render them as two independent PDFs — content as its own doc (so its footer counts1..M), the cover with no footer — then prepend. The merge is done in-process (concatPdfs) by exploiting that Chrome emits clean PDF 1.4 with classic xref tables: renumber the content's objects past the cover's, rewrite refs (dict portions only — never inside binary streams), give both page-tree roots a shared new/Pagesparent, and emit a fresh xref +/Catalog. Don't reach forpdfunite/pdf-lib— keep it dependency-free. - Coalesce glyph runs so the text is copy-pasteable (
coalesceGlyphRuns). Chrome on macOS emits PDF text as Type3 fonts with every glyph individually positioned (<G> Tj+ per-glyphTd); macOS Preview/PDFKit then can't reassemble words — copy splits, drops, and reorders letters mid-word (verified with a PDFKit oracle; tagging/ligatures/fonts make no difference, it's the per-glyph positioning). The fix is a post-process pass over the final PDF: for each content stream, merge consecutive per-glyph shows into[<G> kern <G> …] TJarrays (kern = font advance − original offset, so on-page layout is identical), then emit onetotalDx 0 Tdper run so the line matrix ends where Chrome's per-glyphTds left it (skip this and continuation lines overlap). Track bareTjseparately fromTd: ligature/ActualTextspans often emit a bare glyph show, and treating the nextTdas relative to that bare glyph creates visible/copy gaps likeDefin ition. Zero-dep (node:zlib), rebuilds the xref. Only coalesce text ≥ 10pt — the 8pt native footer has a multi-font structure that doesn't round-trip and isn't meaningful copy text, so leave it as Chrome rendered it. SetTOPDF_NOCOAL=1to bypass for debugging. - Never push. PDFs are local artefacts; the user commits + safe-push handles the rest if they want it tracked.
Contracts & signature lines
When the document is a contract or any printable form that needs signature, date, or fill-in lines:
Use 4+ consecutive underscores (
____________) in the markdown source. They render as one continuous full-width rule via.ml-rule— works inline mid-sentence ("Signed by ____________ on ____________") or as a standalone line above a label.Never use
***,---, or rows of asterisks.***/---render as<hr>(a thin horizontal divider across the whole page, not a signature field); asterisks render as bold/italic. Neither produces a usable signing line.The rule fills its container, so width is controlled by what wraps it — a short paragraph, a table cell, or a sentence fragment. Put the label on the line below, not above the rule glyphs.
Example markdown:
____________________________ Name (print) ____________________________ Signature ____________________________ DateEdit the
.mdfirst, not the script. If the user's draft uses***, dashes, prose ("sign here"), or any other ad-hoc placeholder, rewrite the markdown to use____rules before exporting. The markdown is the source of truth — patching presentation in the renderer is out of scope.
Files
.opencode/skill/to-pdf/
SKILL.md
scripts/
to-pdf.mjs — zero-dep node script (markdown parse + HTML template + Chrome invocation)