mdpdf

command module
v0.1.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 17 Imported by: 0

README

mdpdf

CI

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

mdpdf in a terminal

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)

  1. Input: first argument is a Markdown file path. -, or a pipe with no argument, reads stdin.
  2. 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 writes out.pdf.
  3. 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, H1H6, 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-Content reads a BOM-less UTF-8 file as ANSI/CP1252 while the pipeline re-encodes UTF-8, so cat notes.md | mdpdf hands 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; see TestUndoMojibakeLeavesGoodTextAlone.

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-isatty is 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 in undoMojibake would 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL