Markdown → PDF/PNG CLI with bundled color-emoji rendering, inline image embedding (local and remote), and Mermaid diagrams — all hermetic, no headless browser, no system fonts required.
md2pdf renders a Markdown file to either PDF or per-page PNGs through a
hand-rolled typst::World, embedding the Twemoji Mozilla COLRv0 font so
emoji rendering does not depend on the host system.
This README also doubles as a fixture document — the Showcase section at the bottom exercises every supported Markdown feature, so the file round-trips cleanly through the tool:
md2pdf README.md # → README.pdf
md2pdf --format png README.md # → README-1.png, README-2.png, …
md2pdf [--strict] [--font-scale SCALE] [--out PATH] [--format pdf|png] <FILE>
By default the output is written next to the input file:
--format pdf(default) →<input-stem>.pdfnext to the input (e.g.README.md→README.pdf).--format png→<input-stem>-<N>.pngnext to the input (e.g.notes.md→notes-1.png,notes-2.png, …).
The --format flag is the source of truth for the output format. The
--out path's extension is never sniffed to infer the format.
When --out is provided, the trailing extension is preserved only when
it matches --format; otherwise the format extension is appended
literally to the path-as-typed (Client-defined behavior; see the
governing Understanding U-b2bf02):
| invocation | resulting output |
|---|---|
--out foo.pdf (default --format pdf) |
foo.pdf |
--out foo.png (default --format pdf) |
foo.png.pdf |
--format png --out bar.png |
bar-1.png (stem = bar) |
--format png --out bar.pdf |
bar.pdf-1.png |
--out foo (default --format pdf) |
foo.pdf |
Per U-915ef2, every PNG page filename is one-indexed and uses dynamic zero-padding derived from the total page count:
| total pages | filenames |
|---|---|
| 1 | <stem>-1.png |
| 9 | <stem>-1.png … <stem>-9.png |
| 10 | <stem>-01.png … <stem>-10.png |
| 100 | <stem>-001.png … <stem>-100.png |
--strict (per U-6173fb) escalates any warning (image-pipeline,
mermaid, emitter, or Typst-compile) to a hard error and exits with code
6. The gate is format-agnostic: --strict behaves identically for
PDF and PNG.
cargo build --release
PNG output uses typst-render (an in-engine rasteriser) and tiny-skia
to produce PNG bytes; both are pinned to exact versions in Cargo.toml
per the supply-chain posture U-7c65ac / U-e61b99 and add no
new transitive surface beyond what was already present in the Typst
crate family.
assets/fonts/Twemoji.Mozilla.ttf— the Mozilla-maintained COLRv0 build of the Twitter emoji set. License: CC-BY 4.0 for the artwork. Source: https://github.com/mozilla/twemoji-colr (release v0.7.0).assets/images/rolled-paper.png— illustration by Round Icons, via Unsplash+. Used as the fixture for the local-file image-embedding path.
The remote illustration referenced under Showcase › Image embedding › Remote URL is Workplace isometric vector illustration by Getty Images, via Unsplash+.
Everything below this point exists so the README itself exercises the full feature surface when rendered through md2pdf. Treat it as both a demo and a smoke-test fixture.
Bold (like this), italic (like this), inline code, and even
GitHub-flavoured strike-through all survive the round-trip. Color
emoji come from the bundled Twemoji COLRv0 font, so 🚀 🐙 🦀 ☕️ 🎯 🌈
render identically on Linux, macOS, and Windows.
- Ordered lists.
- With nested
- bullet points,
- that keep going,
- to arbitrary depth, and
- across mixed numbering.
- Back to the top level.
| Feature | Status | Notes |
|---|---|---|
| PDF output | ✅ | Default; deterministic |
| PNG output | ✅ | One file per page, zero-padded |
| Color emoji | ✅ | Bundled Twemoji, no system fonts |
| Local images | ✅ | Relative to the input Markdown |
| Remote images | ✅ | https:// fetched via ureq + rustls |
| Mermaid | ✅ | Flowcharts and sequence diagrams |
fn main() -> Result<(), Box<dyn std::error::Error>> {
let md = std::fs::read_to_string("README.md")?;
let pdf = md2pdf::render_to_pdf(&md)?;
std::fs::write("README.pdf", pdf)?;
Ok(())
}"The best documentation is a document you can actually compile."
md2pdf resolves Markdown image references through a small pipeline
that handles http(s)://, file://, and plain relative paths
uniformly, then decodes PNG/JPEG (or passes SVG through) before handing
the bytes to Typst. Both illustrations below come from
Unsplash+ — the first is bundled in this
repo, the second is fetched live every time the document is rendered.
Network failures or unsupported formats degrade gracefully into a
labelled placeholder box and a md2pdf: warn: image: line on stderr —
unless --strict is in effect, in which case the run aborts with exit
code 6.
The reference above is a plain relative path; md2pdf resolves it against the directory of the input Markdown file.
Fenced ```mermaid blocks are intercepted and rendered through
md2pdf's in-process Mermaid sub-engine. No headless browser, no
Node.js.
flowchart LR
A[Markdown input] --> B{Block type?}
B -- prose --> C[Typst emitter]
B -- image --> D[Image pipeline]
B -- mermaid --> E[Mermaid engine]
C --> F[typst::World]
D --> F
E --> F
F --> G{--format?}
G -- pdf --> H[PDF bytes]
G -- png --> I[PNG pages]
sequenceDiagram
participant U as User
participant CLI as md2pdf
participant W as typst::World
participant FS as Filesystem
U->>CLI: md2pdf notes.md
CLI->>FS: read notes.md
CLI->>CLI: parse + emit Typst
CLI->>W: compile(body)
W-->>CLI: PDF / PNG bytes
CLI->>FS: write notes.pdf
CLI-->>U: exit 0
The same two illustrations from the previous section, laid out in a single-row, three-column HTML table whose row fills the page width: the left image takes 50%, a centre text column takes the remaining 20%, and the right image takes 30%.
|
One scroll on the left, one workstation on the right, and a narrow column of prose wedged in between to prove that mixed image/text rows survive the trip through the renderer. |
|
md2pdf recognizes a small subset of inline and block HTML for
documents that need richer layout than CommonMark provides:
- Pagebreak directive — an HTML comment
<!-- pagebreak -->at the top level inserts a hard page break. Recognized payload variants:pagebreak,page-break,page break,pageBreak, case-insensitive, with leading/trailing whitespace tolerated. Rejected variants (pagebreaks,pageb reak, anything else) fall through as a literal HTML comment. - HTML tables —
<table>/<thead>/<tbody>/<tr>/<td>/<th>blocks render as Typst tables. Supported CSS in thestyleattribute:width,height,padding,vertical-align,text-align,border-collapse,border,background-color. Color values: 16 CSS Level 1 named colors plus 3-digit and 6-digit hex. Supported attributes:colspan,rowspan,align,valign. The<img>tag is honored inside table cells (routed through the same image pipeline as Markdown images, with cell-relative percent sizing). - Recognized inline HTML (anywhere, not just in tables) —
<b>,<strong>,<i>,<em>,<br>. Attributes are tolerated and ignored (e.g.<b class="foo">still classifies as a recognized bold open). Mismatched closes and unrecognized tags fall through to literal display.
Anything outside this subset falls through as raw text — no scope
creep into a general HTML / CSS engine. Unreachable image URLs and
malformed tables emit warnings (and escalate under --strict).
For exhaustive examples and the full recognized-element matrix, see
the fixtures under tests/fixtures/qa_html_*.md.
