From 83957c5273aed7b46b1bc793d870cd96b3d483c9 Mon Sep 17 00:00:00 2001 From: "Wesley O. Nichols" Date: Tue, 9 Jun 2026 15:48:38 +0200 Subject: [PATCH] feat: add cheatsheet-pdf plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a Markdown document into a compact, print-ready PDF cheatsheet (dense single-page, or single-sheet two-sided) via pandoc → HTML → headless Chromium. The bundled build_cheatsheet.py automates the density tuning: it binary- searches the largest uniform zoom whose render still fits --max-pages, so the sheet fills the page without spilling. Stdlib-only; shells out to pandoc and a Chromium browser, naming no harness-specific tools. The SKILL.md captures the two non-obvious layout traps as durable knowledge: table-layout:fixed (wide tables otherwise overflow and paint over the neighbouring column) and rendering the title outside the column flow (a column-spanning title corrupts downstream positions). Includes the standard registration: codex sidecar symlink, marketplace and README catalog entries, CODEOWNERS, and a 4-case eval suite (positive / two-sided / negative-longform / edge-overlap). Passes validate, lint, and stats. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 11 + .codex/skills/cheatsheet-pdf | 1 + CODEOWNERS | 1 + README.md | 6 + evals/cases/cheatsheet-pdf/cases.yaml | 83 +++++++ .../cheatsheet-pdf/.claude-plugin/plugin.json | 10 + plugins/cheatsheet-pdf/README.md | 78 ++++++ plugins/cheatsheet-pdf/package.json | 18 ++ .../skills/cheatsheet-pdf/SKILL.md | 86 +++++++ .../cheatsheet-pdf/assets/cheatsheet.css | 142 +++++++++++ .../scripts/build_cheatsheet.py | 225 ++++++++++++++++++ 11 files changed, 661 insertions(+) create mode 120000 .codex/skills/cheatsheet-pdf create mode 100644 evals/cases/cheatsheet-pdf/cases.yaml create mode 100644 plugins/cheatsheet-pdf/.claude-plugin/plugin.json create mode 100644 plugins/cheatsheet-pdf/README.md create mode 100644 plugins/cheatsheet-pdf/package.json create mode 100644 plugins/cheatsheet-pdf/skills/cheatsheet-pdf/SKILL.md create mode 100644 plugins/cheatsheet-pdf/skills/cheatsheet-pdf/assets/cheatsheet.css create mode 100644 plugins/cheatsheet-pdf/skills/cheatsheet-pdf/scripts/build_cheatsheet.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 552fe0e..c681281 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -64,6 +64,17 @@ "url": "https://nothingfancy.ai" }, "source": "./plugins/launching-google-ai" + }, + { + "name": "cheatsheet-pdf", + "version": "0.1.0", + "description": "Renders a Markdown document into a compact, print-ready PDF cheatsheet — a dense single-page (or single-sheet, two-sided) reference — using pandoc and headless Chromium, auto-shrinking the layout to fit a target page count. Use when asked to make a cheatsheet, turn a Markdown/README/notes file into a printable one-page PDF, build a quick-reference or pin-up sheet, or \"fit this on one page\". Not for long-form documents, slide decks, or faithfully reproducing a source document's existing formatting.", + "author": { + "name": "Wesley O. Nichols", + "email": "wes@nothingfancy.ai", + "url": "https://nothingfancy.ai" + }, + "source": "./plugins/cheatsheet-pdf" } ] } diff --git a/.codex/skills/cheatsheet-pdf b/.codex/skills/cheatsheet-pdf new file mode 120000 index 0000000..aa615ca --- /dev/null +++ b/.codex/skills/cheatsheet-pdf @@ -0,0 +1 @@ +../../plugins/cheatsheet-pdf/skills/cheatsheet-pdf \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index d2ad1cd..52b0ffa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -10,6 +10,7 @@ /plugins/nano-banana-prompting/ @wesnick /plugins/gemini-tts-prompting/ @wesnick /plugins/launching-google-ai/ @wesnick +/plugins/cheatsheet-pdf/ @wesnick # Shared infrastructure /.github/ @wesnick diff --git a/README.md b/README.md index 8679fd6..e857e5b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ This is the preferred mode when you want a narrow, focused session. | [gemini-tts-prompting](plugins/gemini-tts-prompting/) | Crafts and reviews prompts for Google's Gemini 3.1 Flash TTS using audio tags, voice style instructions, and pacing controls — narration, audiobooks, IVR, accessibility, multilingual content. | | [launching-google-ai](plugins/launching-google-ai/) | Opens Google Gemini or Google Stitch in the browser with a pre-filled prompt and optional Gemini tool selection (image, video, music, deep research, canvas, guided learning) via URL parameters. | +### Documents + +| Plugin | Description | +|--------|-------------| +| [cheatsheet-pdf](plugins/cheatsheet-pdf/) | Renders a Markdown document into a compact, print-ready PDF cheatsheet (dense single-page or single-sheet two-sided) via pandoc and headless Chromium, auto-shrinking the layout to fit a target page count. | + ## Philosophy Read [`AGENTS.md`](AGENTS.md) before authoring. The short version: diff --git a/evals/cases/cheatsheet-pdf/cases.yaml b/evals/cases/cheatsheet-pdf/cases.yaml new file mode 100644 index 0000000..8fffbec --- /dev/null +++ b/evals/cases/cheatsheet-pdf/cases.yaml @@ -0,0 +1,83 @@ +# Regression suite for cheatsheet-pdf skill + +description: "cheatsheet-pdf skill regression cases" + +tests: + # --- Positive: the core ask --- + - description: "Positive — turn a Markdown reference into a one-page PDF" + vars: + prompt: | + I have a KEYBINDINGS.md file with a bunch of tables and short + sections. Can you make it into a single-page printable PDF cheatsheet + and open it for me? + assert: + - type: contains-any + value: + - "build_cheatsheet.py" + - "pandoc" + - type: contains-any + value: + - "--open" + - "--max-pages" + - type: llm-rubric + value: | + The response should use the cheatsheet-pdf skill: run the bundled + build_cheatsheet.py against KEYBINDINGS.md to produce a one-page + PDF (max-pages 1, the default) and open it. It should rely on the + script's auto-fit rather than hand-tuning font sizes, and ideally + mention verifying the rendered output, not just the page count. It + should confirm pandoc + a Chromium browser are available. + + # --- Positive: two-sided + verify discipline --- + - description: "Positive — single sheet two-sided, content too dense for one side" + vars: + prompt: | + This API_REFERENCE.md is pretty long. Make it a printable cheatsheet — + I'm fine with a single sheet printed on both sides if it won't fit on + one side legibly. + assert: + - type: contains + value: "--max-pages 2" + - type: llm-rubric + value: | + The response should pass --max-pages 2 (single sheet, two-sided), + and should treat legibility as the deciding factor: if one side + would require illegibly small text, prefer two-sided rather than + shrinking into a wall of tiny text. Bonus if it inspects the + rendered PDF to confirm legibility. + + # --- Negative: long-form document, not a cheatsheet --- + - description: "Negative — long-form report should not become a cheatsheet" + vars: + prompt: | + I wrote a 12-page quarterly report in REPORT.md. Please convert it to + a nicely formatted PDF I can email to the board. + assert: + - type: not-contains + value: "--max-pages 1" + - type: llm-rubric + value: | + The response should NOT force this into a dense one-page cheatsheet. + A 12-page board report is long-form reading, where flow matters more + than density. The skill should decline the cheatsheet treatment and + recommend a normal Markdown-to-PDF path (e.g. pandoc with a readable + single-column theme) instead. + + # --- Edge: overlapping text in output (the table-overflow trap) --- + - description: "Edge — diagnose overlapping text in a rendered cheatsheet" + vars: + prompt: | + I built a cheatsheet PDF from my Markdown but it looks broken — in the + right-hand column the text from a big wide table is printed on top of + the other column's text. What's going on and how do I fix it? + assert: + - type: contains + value: "table-layout: fixed" + - type: llm-rubric + value: | + The response should diagnose this as horizontal overflow: a wide + table sized itself larger than its column and painted over the + neighbouring column. The fix is `table-layout: fixed` on tables + (which the bundled stylesheet already sets), forcing cells to wrap + within the column. It should NOT misattribute the overlap to a + page-break or vertical-spacing problem. diff --git a/plugins/cheatsheet-pdf/.claude-plugin/plugin.json b/plugins/cheatsheet-pdf/.claude-plugin/plugin.json new file mode 100644 index 0000000..8be79b3 --- /dev/null +++ b/plugins/cheatsheet-pdf/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "cheatsheet-pdf", + "version": "0.1.0", + "description": "Renders a Markdown document into a compact, print-ready PDF cheatsheet — a dense single-page (or single-sheet, two-sided) reference — using pandoc and headless Chromium, auto-shrinking the layout to fit a target page count. Use when asked to make a cheatsheet, turn a Markdown/README/notes file into a printable one-page PDF, build a quick-reference or pin-up sheet, or \"fit this on one page\". Not for long-form documents, slide decks, or faithfully reproducing a source document's existing formatting.", + "author": { + "name": "Wesley O. Nichols", + "email": "wes@nothingfancy.ai", + "url": "https://nothingfancy.ai" + } +} diff --git a/plugins/cheatsheet-pdf/README.md b/plugins/cheatsheet-pdf/README.md new file mode 100644 index 0000000..d25ff58 --- /dev/null +++ b/plugins/cheatsheet-pdf/README.md @@ -0,0 +1,78 @@ +# cheatsheet-pdf + +Renders a Markdown document into a compact, print-ready PDF cheatsheet. + +## What it does + +Turns a Markdown reference into a dense, good-looking PDF that fits on a single printable side — or one sheet, two-sided. The pipeline is Markdown → HTML → PDF: + +- **`pandoc`** converts the Markdown (tables, code, links) to HTML. +- **A print-tuned stylesheet** (`assets/cheatsheet.css`) supplies the cheatsheet look: balanced multi-column flow, color-coded section bars, boxed code, callouts. +- **Headless Chromium** renders the HTML to PDF, doing the column layout and page breaking. + +The headline feature is **auto-fit**: rather than hand-tuning font sizes until the content fits, the build script binary-searches the largest uniform zoom whose render still respects the page budget (`--max-pages`, default 1). The sheet ends up as full as it can be without spilling over. + +Two layout details are load-bearing and documented in the skill: tables use `table-layout: fixed` (otherwise a wide table overflows and paints over its neighbour), and the title renders outside the column flow (a column-spanning title corrupts downstream positions). + +## Requirements + +External tools on PATH (not npm/pip packages): + +- `pandoc` +- a Chromium browser — `google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, or `brave-browser` + +The build script is stdlib-only Python (`uv run` or `python3`). + +## Usage + +### Build a cheatsheet + +```sh +uv run plugins/cheatsheet-pdf/skills/cheatsheet-pdf/scripts/build_cheatsheet.py NOTES.md --open +``` + +```sh +# single sheet, two-sided +uv run .../build_cheatsheet.py NOTES.md --max-pages 2 +# three columns, A4 +uv run .../build_cheatsheet.py NOTES.md --columns 3 --page-size a4 +# fixed density, no search +uv run .../build_cheatsheet.py NOTES.md --no-fit --scale 0.9 +``` + +### Claude Code + +```sh +claude --plugin-dir ./plugins/cheatsheet-pdf +``` + +### pi + +```sh +pi -e ./plugins/cheatsheet-pdf +``` + +### Codex + +See the root [`.codex/INSTALL.md`](../../.codex/INSTALL.md). + +## Harness support + +| Feature | Claude Code | Codex | pi | +|---|---|---|---| +| Skill (SKILL.md) | ✅ | ✅ | ✅ | +| Bundled script + CSS | ✅ | ✅ | ✅ | + +Pure skill content plus a harness-neutral script — runs identically in all three. The script shells out to `pandoc` and a Chromium browser; it names no harness-specific tools. + +## Evals + +Regression cases live in [`../../evals/cases/cheatsheet-pdf/cases.yaml`](../../evals/cases/cheatsheet-pdf/cases.yaml). + +```sh +uv run evals/framework/run.py --skill cheatsheet-pdf +``` + +## License + +[CC BY-SA 4.0](../../LICENSE) — made by [Nothing Fancy](https://nothingfancy.ai). diff --git a/plugins/cheatsheet-pdf/package.json b/plugins/cheatsheet-pdf/package.json new file mode 100644 index 0000000..6ddf676 --- /dev/null +++ b/plugins/cheatsheet-pdf/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nothingfancy/cheatsheet-pdf", + "version": "0.1.0", + "description": "Renders a Markdown document into a compact, print-ready PDF cheatsheet.", + "keywords": ["pi-package", "agent-skill", "cheatsheet", "pdf", "pandoc", "markdown"], + "author": { + "name": "Wesley O. Nichols", + "email": "wes@nothingfancy.ai", + "url": "https://nothingfancy.ai" + }, + "license": "CC-BY-SA-4.0", + "pi": { + "skills": ["./skills"] + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + } +} diff --git a/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/SKILL.md b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/SKILL.md new file mode 100644 index 0000000..022f919 --- /dev/null +++ b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/SKILL.md @@ -0,0 +1,86 @@ +--- +name: cheatsheet-pdf +description: Renders a Markdown document into a compact, print-ready PDF cheatsheet — a dense single-page (or single-sheet, two-sided) reference — using pandoc and headless Chromium, auto-shrinking the layout to fit a target page count. Use when asked to make a cheatsheet, turn a Markdown/README/notes file into a printable one-page PDF, build a quick-reference or pin-up sheet, or "fit this on one page". Not for long-form documents, slide decks, or faithfully reproducing a source document's existing formatting. +--- + +# cheatsheet-pdf + +Turns a Markdown reference into a dense, good-looking PDF that fits on a single printable side (or one sheet, two-sided). The pipeline is Markdown → HTML → PDF: `pandoc` does the content conversion, a print-tuned stylesheet does the look, and headless Chromium does the layout and rendering. + +The guiding principle: **let the renderer do the layout, and let a search find the density.** Don't hand-tune font sizes until it fits — the bundled script does that automatically by finding the largest uniform zoom that still respects the page budget. + +## When to Use + +- The user asks to "make a cheatsheet", "turn this into a one-pager", "fit this on a single page", or "make a printable reference" from a Markdown file (a README, notes, a command list, an API summary). +- The user wants a pin-up / quick-reference sheet: dense, scannable, multi-column, color-coded sections. +- The user has a Markdown doc with tables, code blocks, and short sections that should collapse onto one sheet. +- The user wants a single-sheet two-sided handout (`--max-pages 2`). + +## When NOT to Use + +- **Long-form documents** (reports, articles, manuals) where reading flow matters more than density — use a normal Markdown-to-PDF path (`pandoc -o out.pdf`) with a readable single-column theme, not this. +- **Slide decks / presentations** — use Marp, reveal.js, or Slidev. +- **Faithful reproduction** of an existing document's formatting (a styled spec, a branded template) — this re-flows content into a cheatsheet layout and will not preserve the original look. +- **No renderer available** — this needs both `pandoc` and a Chromium browser on PATH. If neither is installable, fall back to `pandoc`'s own PDF engines and say so. +- The content genuinely doesn't fit one side and shrinking would make it illegible — say so and recommend `--max-pages 2` rather than producing a 4pt wall of text. + +## The tools + +Two bundled files do the work: + +- `scripts/build_cheatsheet.py` — the build script (stdlib-only, run with `uv run`). +- `assets/cheatsheet.css` — the default print stylesheet (the cheatsheet look). + +Both `pandoc` and a Chromium browser (`google-chrome`, `chromium`, …) must be on PATH. Confirm before promising output. + +## Quick start + +```bash +uv run scripts/build_cheatsheet.py NOTES.md --open +``` + +That auto-fits onto **one** Letter page and opens it in the default viewer. The script prints the page count and the zoom it settled on. + +Common variations: + +```bash +# Single sheet, two-sided: +uv run scripts/build_cheatsheet.py NOTES.md --max-pages 2 + +# Three columns / A4: +uv run scripts/build_cheatsheet.py NOTES.md --columns 3 --page-size a4 + +# Pin the density yourself (skip the search): +uv run scripts/build_cheatsheet.py NOTES.md --no-fit --scale 0.9 + +# Custom stylesheet: +uv run scripts/build_cheatsheet.py NOTES.md --css my-theme.css +``` + +## How it works (and why) + +**1. The title is pulled out of the column flow.** A leading `# Title` line is rendered as a full-width banner *above* the multi-column container. Making the title a column-spanning element inside the flow instead causes the renderer to miscalculate positions for everything after it. Keep the title outside the columns. + +**2. Content flows into balanced columns.** Multi-column layout is the whole trick: it packs dense reference material into far less vertical space than a single column, which is what lets a long doc collapse onto one side. Two columns is the default; three suits very short line lengths. + +**3. Tables are constrained, not free.** The stylesheet sets `table-layout: fixed` on every table. This is load-bearing, not cosmetic: a wide table left to size itself will grow past its column and **paint on top of the neighbouring column** — text overlapping text. Fixed layout forces cells to wrap within the column. If you ever see overlapping text in the output, an unconstrained-width element is the cause. + +**4. Auto-fit finds the density.** With a page budget (`--max-pages`, default 1), the script binary-searches the largest whole-sheet zoom whose render still fits the budget. Fewer pages is monotonic in zoom, so the search is well-defined. The result fills the page as much as possible without spilling — the same outcome as hand-tuning font sizes, found in ~7 renders. `--no-fit --scale X` bypasses it. + +## Verify the output + +Page count is necessary but not sufficient — **look at the rendered PDF**, don't just trust the page number. Open it (`--open`) or read it back. Check specifically for: + +- **Overlapping text** → a width-unconstrained element (almost always a table missing `table-layout: fixed`, or a very long unbroken token). +- **Illegibly small text** → the budget is too tight; recommend `--max-pages 2` or fewer columns instead of squinting. +- **A nearly-empty trailing page** → the budget allows slack; usually fine, but a smaller `--max-pages` or more content may pack better. + +## Customizing the look + +Edit `assets/cheatsheet.css` (or pass `--css` with a copy). Safe to change: fonts, colors, the section-header bars, spacing, borders. Leave three knobs to the script — it appends overrides for them and will fight manual edits: `@page { size }`, `.cols { column-count }`, and `html { zoom }`. + +## Known edges + +- **Emoji** render in color only if a color-emoji font (e.g. Noto Color Emoji) is installed; otherwise they fall back to monochrome or boxes. +- **Very long unbreakable tokens** (a 90-char URL with no separators) can still force a wide column. `overflow-wrap: anywhere` handles most; pathological cases may need a manual `` or a shortened link in the source. +- **`zoom`** is a Chromium feature; the rendering path assumes a Chromium-family browser, not Firefox or WebKit. diff --git a/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/assets/cheatsheet.css b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/assets/cheatsheet.css new file mode 100644 index 0000000..b55cdc6 --- /dev/null +++ b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/assets/cheatsheet.css @@ -0,0 +1,142 @@ +/* + * Default print stylesheet for compact Markdown cheatsheets. + * + * Design intent (not decoration): + * - Multi-column flow packs dense reference material into minimal vertical + * space, which is what lets a long doc collapse onto one printable side. + * - `table-layout: fixed` is load-bearing: without it, a wide table grows + * past its column and paints over the neighbouring column. Keep it. + * - The title is rendered OUTSIDE `.cols` (see build_cheatsheet.py) so it + * spans full width without a column-span element fighting the flow. + * + * The build script may append overrides after this file: + * - `@page { size: ... }` from --page-size + * - `.cols { column-count: N }` from --columns + * - `html { zoom: }` from auto-fit / --scale + * Tune the look here; leave those three knobs to the script. + */ + +@page { + size: Letter; + margin: 0.42cm 0.45cm; +} + +* { box-sizing: border-box; } + +html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + +body { + font-family: "DejaVu Sans", "Noto Sans", "Helvetica Neue", Arial, sans-serif; + font-size: 7.4pt; + line-height: 1.22; + color: #16181d; + margin: 0; +} + +.cols { + column-count: 2; + column-gap: 0.42cm; + column-fill: balance; + orphans: 2; + widows: 2; +} + +/* Full-width title banner, outside the multi-column flow. */ +.sheet-title h1 { + font-size: 13pt; + margin: 0 0 3pt 0; + padding-bottom: 1.5pt; + border-bottom: 1.6px solid #2d6cdf; + color: #1b3a6b; + letter-spacing: 0.2px; +} + +h2 { + font-size: 8.4pt; + margin: 5pt 0 2pt 0; + padding: 1.2pt 3.5pt; + background: #2d6cdf; + color: #fff; + border-radius: 2px; + break-after: avoid; +} + +h2:first-of-type { margin-top: 1.5pt; } + +h3 { + font-size: 7.6pt; + margin: 3pt 0 1.5pt 0; + color: #1b3a6b; + break-after: avoid; +} + +p { margin: 2pt 0; } + +a { color: #1f5bbf; text-decoration: none; word-break: break-word; } + +ul, ol { margin: 2pt 0; padding-left: 11pt; } +li { margin: 0.8pt 0; } + +code { + font-family: "DejaVu Sans Mono", "Noto Sans Mono", "Courier New", monospace; + font-size: 6.9pt; + background: #eef1f6; + padding: 0.3pt 1.5pt; + border-radius: 2px; + color: #b1264c; +} + +pre { + background: #1d2230; + color: #e7eaf0; + border-radius: 3px; + padding: 3pt 5pt; + margin: 2.5pt 0; + font-size: 6.7pt; + line-height: 1.26; + white-space: pre-wrap; + overflow-wrap: anywhere; + break-inside: avoid; +} +pre code { background: none; color: inherit; padding: 0; font-size: inherit; } + +table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + margin: 2.5pt 0; + font-size: 6.9pt; +} +thead { display: table-row-group; } +tr { break-inside: avoid; } +td, th { overflow-wrap: anywhere; } +th, td { + border: 0.5pt solid #c5ccd8; + padding: 1.1pt 2.4pt; + text-align: left; + vertical-align: top; +} +th { + background: #dde4ef; + color: #1b3a6b; + font-weight: 700; +} +tr:nth-child(even) td { background: #f5f7fb; } + +blockquote { + margin: 2.5pt 0; + padding: 2pt 4pt; + background: #fff7e6; + border-left: 2.5pt solid #e0a32e; + border-radius: 2px; + font-size: 7.0pt; +} +blockquote p { margin: 1pt 0; } + +hr { + border: none; + border-top: 0.5pt dashed #aab3c2; + margin: 3pt 0; +} + +h2, h3, pre, blockquote { break-inside: avoid; } diff --git a/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/scripts/build_cheatsheet.py b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/scripts/build_cheatsheet.py new file mode 100644 index 0000000..1a07a6c --- /dev/null +++ b/plugins/cheatsheet-pdf/skills/cheatsheet-pdf/scripts/build_cheatsheet.py @@ -0,0 +1,225 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Render a Markdown document into a compact, print-ready PDF cheatsheet. + +Pipeline: Markdown --(pandoc)--> HTML fragment --> wrapped in a print-tuned +HTML doc --(headless Chromium)--> PDF. The layout is multi-column and dense so +a long reference collapses onto a single printable side. + +The interesting part is `--fit`: it searches for the largest uniform zoom that +still fits within `--max-pages`, so the sheet fills the page as much as possible +without spilling over. That replaces hand-tuning font sizes by trial and error. + +External tools required (not Python packages): + - pandoc (Markdown -> HTML) + - a Chromium browser (HTML -> PDF): google-chrome, chromium, etc. + +Stdlib only, so it runs under `uv run` or plain `python3`. + +Examples: + uv run build_cheatsheet.py NOTES.md + uv run build_cheatsheet.py NOTES.md -o ref.pdf --max-pages 2 --open + uv run build_cheatsheet.py NOTES.md --columns 3 --page-size a4 + uv run build_cheatsheet.py NOTES.md --no-fit --scale 0.9 +""" + +from __future__ import annotations + +import argparse +import html +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_CSS = Path(__file__).resolve().parent.parent / "assets" / "cheatsheet.css" + +BROWSER_CANDIDATES = ( + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", + "microsoft-edge", + "brave-browser", +) +PAGE_SIZES = {"letter": "Letter", "a4": "A4", "legal": "Legal"} + +# Leaf page objects in the PDF. Chromium leaves these greppable in raw bytes; +# the negative lookahead avoids matching the /Pages tree node. +_PAGE_RE = re.compile(rb"/Type\s*/Page(?![s])") + + +def find_tool(names: tuple[str, ...]) -> str | None: + for name in names: + if path := shutil.which(name): + return path + return None + + +def split_title(markdown: str) -> tuple[str | None, str]: + """Pull a leading `# Title` line out so it can render full-width, above + the multi-column flow, instead of as a column-spanning element.""" + lines = markdown.splitlines() + for i, line in enumerate(lines): + if not line.strip(): + continue + if line.startswith("# ") and not line.startswith("##"): + title = line[2:].strip() + return title, "\n".join(lines[i + 1 :]) + break + return None, markdown + + +def markdown_to_fragment(markdown: str, pandoc: str) -> str: + result = subprocess.run( + [pandoc, "-f", "gfm", "-t", "html5"], + input=markdown, + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.exit(f"pandoc failed:\n{result.stderr.strip()}") + return result.stdout + + +def build_html( + markdown: str, css: str, *, pandoc: str, columns: int, page_size: str, scale: float +) -> str: + title, body_md = split_title(markdown) + fragment = markdown_to_fragment(body_md, pandoc) + + overrides = [f"@page {{ size: {page_size}; }}", f".cols {{ column-count: {columns}; }}"] + if abs(scale - 1.0) > 1e-6: + overrides.append(f"html {{ zoom: {scale:.4f}; }}") + + header = "" + if title: + header = f'

{html.escape(title)}

' + head_title = html.escape(title) if title else "cheatsheet" + + return ( + "\n" + '\n' + f"{head_title}\n" + f"\n" + f'\n{header}\n
\n{fragment}\n
\n\n' + ) + + +def render_pdf(html_doc: str, out_pdf: Path, browser: str, workdir: Path) -> int: + html_path = workdir / "cheatsheet.html" + html_path.write_text(html_doc) + result = subprocess.run( + [ + browser, + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--no-pdf-header-footer", + f"--print-to-pdf={out_pdf}", + str(html_path), + ], + capture_output=True, + text=True, + ) + if not out_pdf.exists(): + sys.exit(f"browser failed to produce a PDF:\n{result.stderr.strip()}") + return len(_PAGE_RE.findall(out_pdf.read_bytes())) + + +def fit_scale(render, max_pages: int, lo: float = 0.5, hi: float = 2.0) -> tuple[float, int]: + """Largest zoom in [lo, hi] whose render fits within max_pages. + + Feasibility is monotonic (smaller zoom => fewer/equal pages), so this is a + binary search on the feasible boundary, maximizing page fill.""" + pages_hi = render(hi) + if pages_hi <= max_pages: + return hi, pages_hi + pages_lo = render(lo) + if pages_lo > max_pages: + print( + f" note: content exceeds {max_pages} page(s) even at min zoom " + f"{lo} ({pages_lo} pages); using {lo}.", + file=sys.stderr, + ) + return lo, pages_lo + + best, best_pages = lo, pages_lo + for _ in range(7): + mid = (lo + hi) / 2 + pages = render(mid) + print(f" fit: zoom {mid:.3f} -> {pages} page(s)", file=sys.stderr) + if pages <= max_pages: + best, best_pages, lo = mid, pages, mid + else: + hi = mid + return best, best_pages + + +def open_file(path: Path) -> None: + opener = "open" if sys.platform == "darwin" else "xdg-open" + if tool := shutil.which(opener): + subprocess.Popen([tool, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + print(f" (no '{opener}' found; open {path} manually)", file=sys.stderr) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Render a Markdown cheatsheet to PDF.") + ap.add_argument("input", type=Path, help="source Markdown file") + ap.add_argument("-o", "--output", type=Path, help="output PDF (default: .pdf)") + ap.add_argument("--css", type=Path, default=DEFAULT_CSS, help="print stylesheet") + ap.add_argument( + "--max-pages", + type=int, + default=1, + help="page budget (default 1; use 2 for a single-sheet two-sided)", + ) + ap.add_argument("--columns", type=int, default=2, help="number of columns (default 2)") + ap.add_argument("--page-size", choices=PAGE_SIZES, default="letter") + ap.add_argument("--scale", type=float, default=1.0, help="fixed zoom (ignored unless --no-fit)") + ap.add_argument("--no-fit", action="store_true", help="skip auto-fit; use --scale verbatim") + ap.add_argument("--open", action="store_true", help="open the PDF in the default viewer") + args = ap.parse_args() + + if not args.input.is_file(): + sys.exit(f"input not found: {args.input}") + pandoc = find_tool(("pandoc",)) or sys.exit("pandoc not found on PATH") + browser = find_tool(BROWSER_CANDIDATES) or sys.exit( + "no Chromium browser found on PATH (tried: " + ", ".join(BROWSER_CANDIDATES) + ")" + ) + + css = args.css.read_text() + markdown = args.input.read_text() + out_pdf = (args.output or args.input.with_suffix(".pdf")).resolve() + page_size = PAGE_SIZES[args.page_size] + + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + + def render(scale: float) -> int: + doc = build_html( + markdown, css, pandoc=pandoc, columns=args.columns, page_size=page_size, scale=scale + ) + return render_pdf(doc, out_pdf, browser, workdir) + + if args.no_fit: + pages = render(args.scale) + scale = args.scale + else: + scale, pages = fit_scale(render, args.max_pages) + render(scale) # leave the chosen render as the final output + + print(f"Wrote {out_pdf} — {pages} page(s) at zoom {scale:.3f}.") + if args.open: + open_file(out_pdf) + return 0 + + +if __name__ == "__main__": + sys.exit(main())