README
¶
mdpdf
Convert Markdown to PDF. One binary, pure Go, no browser or native dependencies.

Installing
mdpdf is a single self-contained binary — no runtime, no browser, no native libraries. Pick whichever line matches your platform.
macOS and Linux — Homebrew
brew install mfreydl/tap/mdpdf
Windows — Scoop
scoop bucket add mfreydl https://github.com/mfreydl/scoop-bucket
scoop install mdpdf
Homebrew and Scoop install from the mfreydl/homebrew-tap and mfreydl/scoop-bucket repositories, which each release updates automatically.
Any platform — with Go installed
go install github.com/mfreydl/mdpdf@latest
Any platform — download a binary
Grab the archive for your OS and architecture from the
latest release, unpack it,
and put mdpdf somewhere on your PATH. Builds are published for Linux,
macOS, and Windows on both amd64 and arm64, with a checksums.txt
alongside them.
The binaries are not code-signed. macOS Gatekeeper and Windows SmartScreen may warn on a directly-downloaded binary; the Homebrew and Scoop paths avoid this.
From source
git clone https://github.com/mfreydl/mdpdf.git
cd mdpdf
just build # or: go build -o mdpdf.exe .
just install-local # or: go install .
Confirm whichever route you took:
mdpdf --version
Usage
mdpdf notes.md # -> ./notes.pdf (in the CURRENT directory)
mdpdf notes.md report.pdf # -> ./report.pdf (explicit output path, used as-is)
cat notes.md | mdpdf # stdin -> ./out.pdf
cat notes.md | mdpdf - a.pdf # stdin with explicit output ("-" means stdin)
mdpdf notes.md --theme atlas # apply a theme (see Themes below)
mdpdf --help # print usage
mdpdf --version # print version (add --json for machine-readable)
Rules (exact behavior — rely on these)
- Input: first argument is a Markdown file path.
-, or a pipe with no argument, reads stdin. - Output: second argument is the output path, used verbatim. When omitted,
the PDF is written to the current working directory (not the input's
directory), named after the input file with its extension swapped to
.pdf— never stacked:myinputfile.md -> myinputfile.pdf,notes -> notes.pdf. Stdin with no output argument writesout.pdf. - On success, prints the output path to stdout and exits 0. Existing output files are overwritten.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success; the output path was printed to stdout |
| 1 | Failure (missing input file, empty input, unwritable or locked output). Message goes to stderr as mdpdf: <error> |
| 2 | Invoked with no input at all from an interactive terminal; usage printed to stderr |
Running mdpdf bare at a prompt prints usage and exits 2 rather than blocking
on stdin — a converter that silently waits for typed input looks like a hang.
This follows the same convention as rg and fzf. Piping still works with no
arguments, and an explicit mdpdf - still reads the terminal, so interactive
typing terminated with Ctrl-D is available when you actually want it. Empty
input is an error rather than a blank PDF.
Terminal detection handles MinTTY (git bash), where an interactive terminal is
a named pipe and so defeats the ordinary isatty check.
What it handles
CommonMark via gomarkdown, plus the GitHub-flavored extensions: headings,
bold/italic, inline code, fenced code blocks (with syntax highlighting), lists,
blockquotes, links, images, horizontal rules, tables, autolinks, and
definition lists. Output is Letter portrait, core PDF fonts.
Known gap: ~~strikethrough~~ parses, and the ~~ markers are removed, but
the text is not drawn with a line through it — md2pdf's ast.Del handler
is a no-op. Avoid strikethrough where the distinction carries meaning.
Note that NewPdfRenderer leaves the parser's Extensions field at zero, so
the extensions must be set explicitly after construction — without that,
tables render as raw pipe text and ~~strikethrough~~ stays literal. That
assignment in main.go is load-bearing; TestTableExtensionEnabled
guards it.
Themes
mdpdf notes.md --theme atlas # built-in presentation theme
mdpdf notes.md --theme dark
mdpdf notes.md --theme ./mytheme.json # your own
Three built-ins: light (default), dark, and atlas — a
presentation-grade theme with deep navy headings on a warm ivory page, a teal
link accent, brick-red inline code, a white-on-navy table header, and a serif
italic blockquote. Atlas styles every element the renderer exposes, so it also
works as a worked example of the format.
Flags may go anywhere: mdpdf notes.md out.pdf --theme atlas and
mdpdf --theme atlas notes.md are equivalent. (The stdlib flag package
stops at the first positional argument, which is why parsing is hand-rolled —
mdpdf notes.md --theme dark would otherwise be silently ignored.)
Writing your own
Start from a built-in and edit it:
mdpdf --dump-theme atlas > mytheme.json
Every key is optional. A theme is applied as an overlay on top of light,
so anything you omit keeps its default — a file containing only H1 is a
valid theme. This differs from md2pdf's own custom-theme mode, which replaces
the entire style set and leaves omitted elements zeroed (invisible text at
size 0). TestPartialThemeKeepsDefaults guards the overlay behavior.
Styleable elements: Normal, H1–H6, Link, Backtick (inline code),
Code (fenced blocks), Blockquote, THeader, TBody, plus a top-level
BackgroundColor. Each takes:
| Field | Values |
|---|---|
Font |
Arial, Times, or Courier — the built-in PDF fonts |
Style |
any of b, i, u combined (e.g. bi), or "" |
Size |
points; fractional allowed |
Spacing |
line spacing |
TextColor / FillColor |
{"Red":0-255,"Green":0-255,"Blue":0-255} |
Notes: a theme file must not have a UTF-8 BOM (mdpdf strips one if present,
but hand-written files are cleaner without). IndentValue is accepted in the
JSON but ignored — the renderer recomputes it from the font metrics.
Text encoding
Input is UTF-8. Output uses the core PDF fonts, whose glyph set is CP1252 — Latin text, smart punctuation, and common symbols render; CJK and emoji do not.
The renderer is constructed with the cp1252 unicode translator. Without it,
em dashes and curly quotes are emitted as raw UTF-8 bytes and drawn as
mojibake (— becomes â€"). This affects all input paths, not just
pipes; it is the single most likely thing to regress if the renderer setup is
edited.
Two input hazards are also handled automatically:
- A leading UTF-8 BOM is stripped (it otherwise breaks a heading on line 1).
- Double-encoded UTF-8 is repaired. This is defense-in-depth for one
specific shell: Windows PowerShell 5.1's
Get-Contentreads a BOM-less UTF-8 file as ANSI/CP1252 while the pipeline re-encodes UTF-8, socat notes.md | mdpdfhands over already-corrupted bytes (an 18,230-byte file arrives as 18,634). git bash, PowerShell 7+, and cmd do not do this — they pass bytes through unchanged, so most users never trigger the repair. It is all-or-nothing across the whole document, so correctly-encoded text is never touched; seeTestUndoMojibakeLeavesGoodTextAlone.
If you hit mojibake under PowerShell 5.1 and want to fix it at the source
rather than rely on the repair, pass the path (mdpdf notes.md) or pipe
explicitly with Get-Content -Raw -Encoding utf8 notes.md | mdpdf.
Design notes
- Rendering is
github.com/solworktech/md2pdf/v2(gomarkdown + fpdf), chosen for zero native dependencies. - That library has a bug: theme setup emits a page-background drawing op ahead
of the
%PDF-header, which strict parsers reject.sanitize()in main.go strips the junk and shifts the xref byte offsets to match. Verify any change to output handling by parsing the PDF, not just opening it. github.com/mattn/go-isattyis the only other dependency, used solely for MinTTY-aware terminal detection.go test ./...covers output naming and the encoding repair. The false-positive test is the important one: a bug inundoMojibakewould silently corrupt every document, so run the tests before shipping a change.
Trying it
example.md in this directory exercises every styled element. Render it in
each theme to compare:
mdpdf example.md light.pdf && mdpdf example.md dark.pdf --theme dark && mdpdf example.md atlas.pdf --theme atlas
Contributing
main is protected: land changes through a pull request. Branch, push as often
as you like — branch builds never publish — and use a manual run to publish a
pre-release from a branch when you want to test the real artifact:
gh workflow run CI --ref my-branch -f no-publish=false
Merging the PR is what cuts the release. To land something without releasing,
put [no-publish] in any commit on the branch — the check reads the merge
commit and the commits it brings in, because a merge commit's own message is
just "Merge pull request #N from ...".
Tasks run through just; just on its own
lists every recipe. The single pre-PR gate is:
just check
That is fmt-check, vet, test, build, and smoke — exactly what CI runs
on both Linux and Windows.
Versioning and publishing
Versions follow the portable SOP:
<major>.<minor>.<patch> published from main
<major>.<minor>.<patch>-<branch>.<index> any other branch
for example 0.2.0-mcf-TEN-54-add-toc.3. The root VERSION file is a
floor, not the truth — the highest published vX.Y.Z git tag is the real
head, because CI publishes far more often than the file is edited. Resolving
from the file alone would eventually publish underneath something already
released. scripts/version.sh takes the higher of the two, so that cannot
happen; it also refuses to reuse a version that is already tagged.
just version # what the VERSION file says
just version-next # what a CI build of this branch would produce
just version-head # the highest published tag
just bump-minor # raise the base (also bump-patch, bump-major)
just version-set 1.4.0
Do deliberate bump-minor / bump-major on a branch before its first CI run.
A base that already leads the published head is taken verbatim, so the bump
survives; anything at or below the head is advanced past it.
VERSION records the version that is published. CI writes it back to main
the moment a release is cut, so it is never stale and never needs a human to
update it. To aim the next release higher, run just bump-minor (or
bump-major) on a branch — a base that already leads the published tag is
taken verbatim.
Publishing tags
v<version> and hands off to GoReleaser, which builds all six OS/arch
archives, generates checksums and a changelog, creates the GitHub Release, and
updates the Homebrew tap and Scoop bucket.
Whether a build publishes
The control is a negative — no-publish. Absent means publish. The default
comes from the branch, and either a commit message or a manual run can change
it:
| Publishes? | |
|---|---|
Push to main |
yes |
| Push to any other branch | no |
Any commit in the merge contains [no-publish] |
no |
Manual run, no-publish = false |
yes, on any branch |
Manual run, no-publish = true |
no |
Precedence is manual choice → commit marker → branch default, so an explicit run always wins and the marker can only ever suppress.
git commit -m "docs: fix a typo [no-publish]" # merge to main without cutting a release
gh workflow run CI --ref my-branch -f no-publish=false # publish a pre-release from a branch
The marker is matched case-insensitively anywhere in the message (subject or
body) and accepts [no-publish], [no publish], or [no_publish]. It is read
from the commit being built.
Tags are created at publish time, not on every version bump — a tag marks an artifact that exists. Branch builds resolve and record a pre-release version but do not tag unless you publish them deliberately, which is what keeps "highest tag == highest published version" true; that equivalence is exactly what the resolver relies on.
Locally:
just snapshot # build every platform archive into dist/, publish nothing
just publish-check # validate .goreleaser.yaml
just releases # list published GitHub Releases
just reconcile # compare local state against what is actually published
just reconcile reads the published side rather than trusting VERSION, and
flags the states that mean a pipeline half-finished: a tag that never reached
the remote (a local publish nobody can see), or a tag with no GitHub Release
(the run died between tagging and upload, consuming a version but shipping
nothing).
Build provenance
Every binary can identify itself without access to this repo — the artifact-side equivalent of OCI labels on a container image:
mdpdf --version
# mdpdf 0.2.1 (a1d0b9d, 2026-08-05T13:44:19Z) go1.25.5 linux/amd64
mdpdf --version --json
# {"version":"0.2.1","commit":"a1d0b9d...","built":"2026-08-05T13:44:19Z",
# "go":"go1.25.5","platform":"linux/amd64"}
Released builds get this from GoReleaser ldflags; just build stamps the same
fields locally. A plain go build falls back to the VCS data the Go toolchain
records on its own, so the commit and build time still appear, plus
"dirty": true when the working tree had uncommitted changes.
The tap and bucket need a TAP_TOKEN repository secret with write access to
those repos — the default GITHUB_TOKEN cannot push to another repository.
Without it CI still publishes the GitHub Release and just skips the tap and
bucket, so the pipeline stays green before they exist.
License
MIT — see LICENSE.
The demo GIF
demo/mdpdf.gif is generated from demo/mdpdf.tape by
VHS — the demo is committed as source,
not as a recording, so it can be regenerated when the commands change instead
of quietly going stale:
just demo
Every command in the tape really runs, against a binary built from the working
tree, so the output in the GIF is mdpdf's actual output. Needs vhs (>= 0.11),
ttyd, and ffmpeg.
Normally you never touch this. The
Demo GIF workflow renders on Linux — where the
shell is already bash and ttyd behaves — and commits the result itself. It
runs automatically after every release, so the GIF always shows the current
version, and whenever the tape changes. To force a render:
gh workflow run "Demo GIF"
It also runs automatically when demo/mdpdf.tape changes. It is deliberately
not run on every push: the demo records mdpdf --version, whose build
timestamp differs every run, so the GIF's bytes always change — a per-push job
would commit a ~100KB binary on every commit and bloat history permanently.
Dispatch it manually after changing the CLI's visible output.
Two notes for anyone regenerating it on Windows: VHS picks the platform default
shell, which is cmd, so the tape drops into bash as its first hidden step —
Set Shell bash is not used because VHS invokes it with flags Git Bash
rejects. On Linux that drop-in is a harmless nested shell, so one tape serves
both.
Documentation
¶
Overview ¶
mdpdf converts Markdown to PDF.
Usage:
mdpdf [input.md] [output.pdf] cat notes.md | mdpdf
Input comes from a file path argument, or from stdin when no path is given (or when the path is "-"). The output path is the optional second argument; when omitted, the PDF is written to the current directory, named after the input file with its extension swapped to .pdf (notes.md -> notes.pdf). Stdin input with no output argument writes out.pdf.