Skip to main content

Pure-Python markdown to PDF compiler. Zero system dependencies. Deterministic.

Project description

inkmd

Markdown to PDF, pure Python, zero dependencies. MIT-licensed. Deterministic.

pip install inkmd
inkmd in.md -o out.pdf

That's the whole install. No system packages, no fonts to install, no Chrome binary, no apt-get. Works the same on macOS, Linux, Windows, Alpine, AWS Lambda, a locked-down CI runner, or a Steam Deck.

A quarterly report rendered by inkmd, showing headings, a styled paragraph with strikethrough, a blockquote, a right-aligned table with tinted header, a bulleted list, and a fenced Python code block with a grey background.
examples/hero-sample.md rendered through inkmd: headings, inline styles, strikethrough, blockquote, GFM table, list, fenced code, autolinked URL and email all in one page.
See also examples/inkmd-brief.md, a two-page project brief written in inkmd-renderable markdown.

What you get

  • A single pure-Python wheel. No native extensions, no system libraries. Installs in under a second.
  • Faithful CommonMark plus the parts of GFM people actually use: tables, autolinks, strikethrough, fenced code with language tags. The supported features section has the full matrix.
  • PDFs that look right. Real AFM-driven kerning emitted via TJ arrays, clickable links, tinted code-block backgrounds, blockquote rules that stack for nested quotes, table alignment, headings that breathe.
  • Byte-identical output for the same input. No clocks, no random IDs. Useful for version control, signed PDFs, audit trails, reproducible CI.
  • Two layers of API: a CLI and a compile() / render_file() library function. The whole public surface is two functions.

Why this exists

Markdown to PDF is a solved problem in theory and a minefield in practice. Every other tool brings heavy system dependencies that don't survive the trip into an Alpine container, a Lambda function, or a Windows machine without admin rights.

Tool What goes wrong
wkhtmltopdf Deprecated since 2023. Unpatched CVEs.
Chrome headless / Puppeteer 200MB+ install. 5 to 15s cold-start latency.
WeasyPrint Needs Pango, cairo, GObject (350 to 550MB of system packages). Breaks on Alpine and Windows.
Pandoc + LaTeX 3GB texlive install.
PyMuPDF-based tools Don't build on Alpine musl.
borb AGPL, so unusable in closed-source or commercial projects without a paid licence.

inkmd runs anywhere Python runs. It's the markdown-to-PDF compiler you'd write yourself with a free weekend if you didn't want to take a dependency on a browser.

Use cases

  • CI documentation pipelines. Compile READMEs, release notes, or changelogs to PDF as a build artefact, in a stripped-down container, without apt-get.
  • Agent-generated documents. LLM agents that need to deliver a PDF (CVs, reports, summaries) can call inkmd.compile() directly. No subprocess, no shell-out, no Chrome.
  • Reproducible audit trails. Hash the markdown, hash the PDF, and the same input gives the same output bytes. Useful for compliance, signed reports, version-controlled docs.
  • Serverless rendering. Lambda plus zero system dependencies equals a PDF endpoint that cold-starts in well under a second.
  • Restricted environments. Locked-down CI runners, embedded hardware, anywhere installing a 200MB browser isn't an option.

Status

v0.1, feature-complete, MIT-licensed. 501 tests across 24 files. Stdlib-only, Python 3.9+. Byte-deterministic output. The torture test covers everything inkmd can render.

Install

From PyPI:

pip install inkmd

Or grab the single-file zipapp (no pip install required). Each tagged release attaches an inkmd.pyz of around 300 KB that you can drop anywhere Python 3.9+ is available:

curl -L -o inkmd.pyz https://github.com/eagredev/inkmd/releases/latest/download/inkmd.pyz
python inkmd.pyz in.md -o out.pdf

Or build it yourself from a checkout:

python scripts/build_zipapp.py    # produces dist/inkmd.pyz

Usage

CLI

inkmd in.md -o out.pdf              # file in, file out
inkmd in.md > out.pdf               # file in, stdout out
inkmd < in.md > out.pdf             # stdin in, stdout out
inkmd in.md -o out.pdf --page-size A4 --family times
inkmd in.md -o out.pdf --no-autolinks
inkmd --version

Library

import inkmd

# Compile markdown text to PDF bytes
pdf_bytes = inkmd.compile(md_text)

# Or convert files directly
inkmd.render_file("in.md", "out.pdf")

# Options (same on both functions)
pdf_bytes = inkmd.compile(
    md_text,
    page_size="A4",          # or "letter" (default)
    family="times",          # or "helvetica" (default)
    autolinks=False,         # opt out of GFM bare-URL/email detection
)

The public API is intentionally narrow: two functions, no classes to instantiate, no state to manage. The CLI is a thin argparse wrapper around compile().

Supported markdown

CommonMark

Feature inkmd
Paragraphs with line wrapping Yes
ATX headings (# to ######) Yes
Setext headings (=== / ---) Yes
Ordered lists, arbitrary start Yes
Unordered lists (- / * / +) Yes
Nested lists, mixed marker types Yes
Tight vs. loose list detection Yes
Blockquotes Yes
Nested and multi-paragraph blockquotes Yes
Blockquotes wrapping any block type Yes
Fenced code blocks Yes
Code block language tag (info string) Yes
Indented code blocks Yes
Code spans (`code`) Yes
Emphasis (*, _) Yes
Strong emphasis (**, __) Yes
Triple *** becomes nested italic-bold Yes
Rule of 3 plus intraword-underscore Yes
Backslash escapes Yes
Thematic breaks Yes
Inline links [text](url) Yes
Inline link titles Yes
Angle-bracket autolinks <url> Yes
Images ![](...) v0.2
Reference-style links v0.2
HTML blocks / inline HTML not planned

GFM extensions

Feature inkmd
Pipe tables Yes
Table column alignments Yes
Bare URL autolinks (https://..., www....) Yes
Bare host autolinks (host.tld/path) Yes
Email autolinks Yes
Strikethrough ~~text~~ Yes
Task lists - [ ] / - [x] v0.2

Visual output

  • Clickable PDF /Link annotations on every URL, inline links and autolinks alike.
  • Blue underlined link text.
  • Light-grey background tint behind fenced code blocks.
  • Thin grey vertical rules for blockquotes. Stacked side-by-side for nested quotes.
  • Tinted table headers with full grid borders and per-column alignment.
  • AFM-correct kerning emitted via TJ arrays (Helvetica and Times both fully kerned).
  • Strikethrough drawn as a thin horizontal bar at glyph mid-height.

Typography

  • Helvetica family (default) or Times family. Code uses Courier.
  • Standard PDF letter and A4 page sizes.
  • WinAnsi character encoding: em-dash, en-dash, curly quotes, ellipsis, most Western European glyphs.
  • Codepoints outside WinAnsi (CJK, Cyrillic, emoji, most non-Latin scripts) render as ? in v0.1. v0.2 lifts this with font embedding.

Determinism

inkmd produces byte-identical PDF output for the same markdown input on every platform, every Python version, every run. No real-time clocks, no random IDs, no platform-dependent iteration order.

If you hash the markdown and the PDF, the relationship is stable forever. Useful for version-controlled documents, signed/hashed PDFs, reproducible CI builds, and audit trails.

What inkmd doesn't do yet

Feature When Why
Images v0.2 Needs decoding plus embedding logic; out of scope for v0.1
TTF / OTF font embedding v0.2 v0.1 uses PDF's 14 base fonts. Tiny output, no font files to ship, but limits codepoints to WinAnsi
Task lists v0.2 GFM extension; needs list-marker prefix scan
Headers, footers, page numbers v0.2 Needs a per-page chrome system
Page-splitting for oversized tables v0.2 Tables currently place atomically and overflow if taller than a page
Tables inside blockquotes v0.2 Table detection runs at document level only
Tagged PDF / PDF/UA accessibility v0.3+ Under consideration
PDF/A archival format n/a Not planned
Math (LaTeX-style) n/a Out of scope. Use Pandoc + LaTeX.
HTML passthrough n/a Out of scope by design. inkmd is markdown to PDF, not HTML to PDF.
Themes / CSS n/a Out of scope. Markdown's value is its constraints.

How it works

Four layers, each strictly above the previous:

  1. parser is a single-pass container-aware block parser plus a CommonMark inline tokeniser. Produces a frozen-dataclass AST.
  2. render lowers AST blocks to RenderedBlock records with runs, spacing, indent, decorations. Carries font and link state through inline nesting.
  3. layout wraps runs into pages, positions each PositionedRun against the page coordinate system, emits background rectangles for code blocks, vertical rules for blockquotes, underline plus annotation pairs for links, and bars for strikethrough.
  4. pdf serialises pages into PDF bytes. Text via Tj/TJ-with-kerning, graphics via rg/re/f, link annotations via per-page /Annots arrays.

No layer imports a higher one. The whole pipeline is around 3,500 lines of pure-Python logic plus 4,700 lines of generated AFM kerning tables. That's it. For a deeper walk-through (the emphasis algorithm, AFM kerning, determinism mechanics), see docs/internals.md. The complexity profile is in LIZARD-AUDIT.md.

A note on font rendering in v0.1

inkmd v0.1 uses PDF's 14 base fonts (Helvetica, Times, Courier, Symbol, ZapfDingbats and their variants). These are spec-mandated to be available in every conforming PDF reader, so we don't ship any font files. The output stays tiny and dependency-free.

The trade-off is that the actual rendering depends on which Helvetica (or Times, etc.) the reader's system provides:

  • macOS ships Helvetica Neue (real Helvetica). Renders as designed.
  • Windows with Adobe Reader ships real Helvetica. Renders as designed.
  • Linux typically substitutes Nimbus Sans (URW++'s free Helvetica clone). Renders very similarly but with slightly different side bearings, so spacing between glyphs can look subtly different.
  • Mobile (iOS / Android) ships system Helvetica or Roboto variants. Mostly fine.

The advance widths are correct everywhere (PDF readers honour the AFM-published metrics), so layout (page breaks, line wrapping, paragraph flow) is identical across systems. What varies is the precise glyph shape within each advance-width box, which can produce slightly different visual spacing.

For most use cases this is fine. If you need pixel-identical rendering across every system (signed or archival documents, for example), wait for v0.2 font embedding, which will bundle font outlines inside each PDF.

Roadmap

  • v0.1: Core CommonMark + GFM subset, library + CLI, MIT, deterministic. Shipped.
  • v0.2: Font embedding (full Unicode), images, task lists, headers/footers/page numbers, page-splitting for oversized tables, tables-in-blockquotes.
  • v0.3: Tagged PDF, accessibility, TOC generation, cross-references.
  • post-v1.0: Optimisations, additional page sizes, PDF/A consideration.

Licence

MIT. See LICENSE.

Acknowledgements

The 14 standard PDF fonts and their AFM metric files are public-domain artefacts published by Adobe (adobe-type-tools/Core14_AFMs). PDF format reference: ISO 32000-1.

About

Built by Dylan Moir with Claude as a pair-programming collaborator. If inkmd saves you a fight with WeasyPrint or a 200 MB Chrome install in your CI, a star on the repo is plenty.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

inkmd-0.1.0.tar.gz (158.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

inkmd-0.1.0-py3-none-any.whl (114.4 kB view details)

Uploaded Python 3

File details

Details for the file inkmd-0.1.0.tar.gz.

File metadata

  • Download URL: inkmd-0.1.0.tar.gz
  • Upload date:
  • Size: 158.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.1

File hashes

Hashes for inkmd-0.1.0.tar.gz
Algorithm Hash digest
SHA256 82792755054733c221c5b19875ba13091658ad325c3954e41878ee135d994d73
MD5 c085beb9eef6811bcab6a45073fe109c
BLAKE2b-256 78284ade664c85da3310e8d5b964e92aed92cd5d15fafb88b379ce6336c6ee8d

See more details on using hashes here.

File details

Details for the file inkmd-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: inkmd-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 114.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.1

File hashes

Hashes for inkmd-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 71174b4007f2efe2439650c749ec9652c5d8a5d848d3a79ad393177fb5b095d0
MD5 2e51058f7a8d6fead7432f5c8e8bf123
BLAKE2b-256 dcd819e5d27d14ed99469f5a7415ba60dceacdf468d056c1de7d5662be218752

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page