Skip to content
aliperdehanPublic

About

One command from Markdown to a good-looking PDF: a Pandoc wrapper with smart defaults, project-file discovery and a multi-engine fallback chain

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

pdfmd

Test PyPI

One command from Markdown to a good-looking PDF. pdfmd wraps Pandoc and fills in everything you would otherwise have to remember: sensible fonts and margins, the right Markdown dialect, your project's metadata/preamble/filter files, and a fallback chain across every PDF engine you have installed. The same document also builds to a Word or OpenDocument file that follows its page setup, fonts and LaTeX (see Word and OpenDocument output).

Contents: Why · Install · Usage · What it does automatically · Project files · Kits · Build stamps, history and snapshots · Reference · License

All the sections
$ pdfmd lecture
FOUND     metadata: metadata.yaml + preamble.tex
DEFAULTS  margin 1in, font STIX Two Text, code in JetBrains Mono, title from heading
AUTO: READER. Use --verbose to see in full
OK    lecture.md > lecture.pdf  (lualatex)

FOUND is what pdfmd picked up from the folder, DEFAULTS what it applied because nothing said otherwise (the global metadata of pdfmd --setup, the title taken from a leading # heading, a wide table whose columns it balanced), and the AUTO: line the other decisions it made. Files of one folder are listed under it once (metadata: metadata.yaml + preamble.tex; --setup can list each with its path instead). Each line is cut at the width of the terminal (110 characters when the output is piped): +3 more (--verbose). --verbose prints every decision with its reason. The closing line names the file the build made, OK lecture.md > lecture.pdf; --setup sets what stands between the two.

How a path is written (3.31.41). Relative to the folder you ran pdfmd from, also when the file sits above it (../.pdfmd for a course folder's settings, up to 2 steps: path-up); further away ~/... under your home folder (any system), else in full. --path-style auto|relative|home|full and --path-up N for one run, options: {path-style: home, path-up: 3} in the config or in a folder's settings (--setup, under Messages); --full-paths is full. --verbose writes full paths too (unless --path-style is given).

In a folder with a single Markdown file you do not even need the name: cd into the folder and run pdfmd. It finds the file, its metadata, preamble and filters, and writes the PDF beside it (with several Markdown files it asks for one, or for -b to convert all of them or -r to join them). pdfmd --help is one short page; pdfmd --help all is the complete option list and pdfmd --help tables (or source, history, output ...) one subject at a time.

lecture.md rendered to PDF

That PDF came from examples/lecture.md, a plain Markdown file with no front matter and no configuration. Plain pandoc lecture.md -o lecture.pdf doesn't even get that far: its default engine, pdflatex, stops at the Δ on line 5 with an error. With a Unicode engine it would build, but with wide default margins, and with the # Title line as an ordinary section heading instead of a title.

Why

Pandoc can do nearly anything, but its defaults assume you pass the right flags every time: in practice a long command copied from an old shell history, or a Makefile in every folder. pdfmd turns that knowledge into defaults.

  • It decides per document. A file with no YAML front matter is ordinary GitHub-flavoured Markdown; one with front matter is written for Pandoc and is left alone. Front matter is what Pandoc itself reads as a metadata block (---, then a line that is not blank, then a closing --- or ...), so a file that opens with a horizontal rule is not one. Your own global metadata (pdfmd --setup) is the machine's, not the document's: it changes nothing here, while a metadata.yaml beside the document does mean Pandoc's Markdown. A file that uses Pandoc syntax gfm cannot read (raw LaTeX, ^[inline footnotes], grid tables, @citations) is read as Pandoc's Markdown too, and one that uses syntax gfm can take (H~2~O, x^2^, {#id}, ::: divs, definition lists) gets it added to gfm.
  • It finds your project files. A metadata.yaml, preamble.tex or <name>.lua beside the document (or in a metadata/ folder next to it) is used without a flag, so every document in a folder shares one house style.
  • It does not give up on the first engine. If lualatex fails or is missing it tries the next one, and says why each failed.
  • It writes real Word files that follow the page setup, fonts and LaTeX of the PDF (see Word and OpenDocument output), and EPUB, HTML, Typst, e-book and plain-text files.
  • It handles every script and emoji, installs missing fonts without admin rights (see Other scripts), and finds Глюкоза.md when you type pdfmd glyukoza.
  • It reads PDFs too: a PDF's own embedded source, or OCR (see PDF to Markdown).
  • Tables can be data: a ::: {.csv} block takes a CSV file or lines in the document (see Tables straight from a CSV file), and on the LaTeX route wide tables get their columns sized by content.
  • It keeps the history: pdfmd --history lists backups, commits and compiles, --history-restore puts a version back (see Build stamps, history and snapshots).
  • It shows what it did. Every automatic decision prints an AUTO line, --verbose shows the exact Pandoc command, and every default can be switched off.

Install

pipx install pdfmd-cli

That installs the pdfmd and pdfmd-cli commands in their own isolated environment. The default installation needs no C/C++ compiler or Windows Build Tools on any supported Python: YAML parsing is bundled as pure Python, and pypdf is the only required dependency. Python 3.9+ is supported; the tested versions are listed in the CI matrix. pip install "pdfmd-cli[yaml]" optionally adds installed PyYAML for its LibYAML speed-up; that extra may need a compiler when PyYAML has no wheel for your Python.

With pip on Windows:

py -m pip install pdfmd-cli
py -m pdfmd --setup

Pip installs both commands into Python's Scripts folder, which is often absent from Windows PATH; py -m pip warns when it is. Until that folder is on PATH, use py -m pdfmd. In --setup, choose Windows user PATH and add the Scripts folder named by pip's warning, or run py -m pdfmd --user-path add "C:\path\to\Python\Scripts". Reopen the terminal afterwards.

The same installation with uv:

uv tool install pdfmd-cli        # install; update with: uv tool upgrade pdfmd-cli
uvx --from pdfmd-cli pdfmd file.md   # or run once without installing

On macOS, Homebrew installs it together with Pandoc and Typst, so nothing else is needed for a first PDF:

brew tap aliperdehan/pdfmd https://github.com/aliperdehan/pdfmd
brew trust aliperdehan/pdfmd       # Homebrew 7 and later load no formula of a tap you have not trusted
brew install aliperdehan/pdfmd/pdfmd

(The formula, Formula/pdfmd.rb, lives in this repository; the first command is needed because the repository is not named homebrew-.... The release workflow keeps it on the newest PyPI release.)

On Windows, Scoop installs it together with Python, Pandoc and Typst:

scoop bucket add pdfmd https://github.com/aliperdehan/pdfmd
scoop install pdfmd/pdfmd

(The manifest, bucket/pdfmd.json, lives in this repository; pdfmd sits in its own virtual environment, which scoop reinstall pdfmd rebuilds after Scoop updates Python. The release workflow keeps the manifest on the newest PyPI release and installs it with Scoop on a Windows runner first. For a LaTeX engine: scoop install tectonic.)

In a container, with nothing installed on the host but Docker (Pandoc, Typst, Tectonic and pdfmd's default fonts are inside):

docker run --rm -v "$PWD":/work ghcr.io/aliperdehan/pdfmd report.md

The PDF is written next to the source. On Linux add --user "$(id -u):$(id -g)", so the files belong to you and not to root. The tags, for both amd64 and arm64, each as latest/tex/office/full and as X.Y.Z/X.Y.Z-tex/X.Y.Z-office/X.Y.Z-full for one release (sizes unpacked, measured on arm64):

tag adds size
latest Pandoc, Typst, Tectonic (its files are fetched at build time, so a run needs no network), the default fonts 0.8 GB
tex TeX Live's LuaTeX and XeTeX: for documents that set engine: lualatex about 1.7 GB
office Calibre and LibreOffice: Word, ODT and EPUB output, Office files and SVG pictures 3.4 GB
full both 4.4 GB

TeX Live is optional everywhere: without it pdfmd uses Tectonic (XeTeX) or Typst, which cover most documents. docker/Dockerfile is one file for all four (--build-arg TEX=1 OFFICE=1).

To update later, run pipx upgrade pdfmd-cli. (The package is named pdfmd-cli because pdfmd on PyPI is an unrelated PDF-to-Markdown tool. The command is still pdfmd. For the latest unreleased code, use pipx install git+https://github.com/aliperdehan/pdfmd.)

For the full pipeline, pdfmd drives programs that pip can't install, so you also need (without them it still makes a plain PDF; see No Pandoc? below). The quickest way, with no admin rights, is pdfmd --install full, which puts Pandoc and Typst in pdfmd's own folders; the system installers below do the same job:

  • Pandoc
  • at least one PDF engine. Typst is the quickest start; a TeX distribution (MacTeX, TeX Live) gives the best results and is what you need for LaTeX packages and math-heavy documents. A TeX Live or MiKTeX that is installed but not on PATH (Windows: C:\texlive\2026\bin\windows) is found in its usual places and used for pdfmd's own runs; PDFMD_TEX_BIN names a folder for an install somewhere else, and pdfmd --doctor says where it found one. Windows: the installers of Pandoc, Typst, Tesseract, LibreOffice, Graphviz and Poppler, and npm install -g for Mermaid, often leave their folder off PATH. pdfmd looks in the usual install folders (and the registry's App Paths) and uses what it finds, and pdfmd --doctor says so. For an install somewhere else, name it in pdfmd --setup (section "Tool locations") or in the config's options:: pandoc-path, typst-path, chrome-path, tesseract-path, soffice-path, dot-path, mmdc-path (a program or its folder) and poppler-path (a folder). LibreOffice time limit: every LibreOffice run (Office files, spreadsheets, SVG pictures) may take soffice-timeout seconds, 300 by default; a run that takes longer is stopped together with the process it started, its file fails with the setting named, and a batch goes on. 0 never runs LibreOffice (it counts as not installed), a negative number (-1) removes the limit. It is a pdfmd-options key (one document), a config key and a row of pdfmd --setup ("Checks and limits"). Diagram time limit: every run of mmdc, dot or d2 (and of the rsvg-convert or inkscape that turns their SVG into a PDF or PNG) may take diagram-timeout seconds, 120 by default. A run that takes longer is stopped together with the processes it started (Mermaid starts a browser), the diagram stays as code with a warning that names the setting (--strict makes it an error), and that tool is not tried again in the build, so a browser that cannot start costs the limit once and not once per diagram (the Word and OpenDocument route runs Pandoc twice, so there it costs it twice). 0 draws no diagram, a negative number (-1) removes the limit. It is a pdfmd-options key (one document), a config key and a row of pdfmd --setup ("Checks and limits").
brew install pipx pandoc typst              # macOS, the quick start
brew install --cask mactex-no-gui           # optional: full LaTeX (large)
sudo apt install pipx pandoc texlive-xetex  # Debian/Ubuntu
sudo dnf install pipx pandoc texlive-xetex  # Fedora (add Typst with `pdfmd --install typst`)
sudo pacman -S python-pipx pandoc-cli typst texlive   # Arch

On any Linux, pdfmd --install full (Pandoc and Typst) needs no package manager and no root.

The extras that are Python packages (pdfmd --install math, tui, translit, batchocr ...) go into the Python pdfmd runs from; when that Python is managed by the system (Homebrew's, Debian's: "externally-managed-environment", PEP 668) pdfmd puts them in its own folder instead, ~/.local/share/pdfmd/python/3.14 (one per Python version), which it adds to the end of the module path when it starts. Nothing is installed into the system's Python.

py -m pip install --user pipx; py -m pipx ensurepath          # Windows
winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst

Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single files, CSV tables, a book, HTML output); see the Test and publish workflow. LaTeX engines aren't part of that automated test on Windows yet.

Optional extras: Quarto for .qmd files, pandoc-crossref for @fig:/@tbl: references, and LibreOffice for Office files.

Without pipx

pdfmd.py needs Python 3.9+ and runs straight from a clone, with the lib/ folder beside it (the pdfmd_* packages it is split into; a copy of pdfmd.py without them stops and says so). It also runs directly: YAML support is bundled, with installed pyyaml preferred when available. pypdf is optional for a checkout (PDF stamping is skipped with a warning without it):

git clone https://github.com/aliperdehan/pdfmd.git ~/pdfmd
echo 'alias pdfmd="python3 ~/pdfmd/pdfmd.py"' >> ~/.zshrc   # or ~/.bashrc

macOS's built-in /usr/bin/python3 is 3.9 (3.9.6), which runs pdfmd; a Python older than 3.9 is refused: pdfmd says so and exits.

Check what pdfmd can find on your system:

$ pdfmd --check-dependencies
OK    pandoc  (/opt/homebrew/bin/pandoc)
OK    1. lualatex  (/Library/TeX/texbin/lualatex)
OK    2. xelatex  (/Library/TeX/texbin/xelatex)
OK    3. pdflatex  (/Library/TeX/texbin/pdflatex)
OK    4. latexmk  (/Library/TeX/texbin/latexmk)
OK    5. tectonic  (/opt/homebrew/bin/tectonic)
OK    6. typst  (/opt/homebrew/bin/typst)
OK    7. weasyprint  (/opt/homebrew/bin/weasyprint)
MISS  8. wkhtmltopdf
...
OK    14. soffice  (/Applications/LibreOffice.app/Contents/MacOS/soffice)
OK    calibre (only when named: -e calibre)  (/opt/homebrew/bin/ebook-convert)
OK    quarto (only needed for .qmd files)  (/usr/local/bin/quarto)

The numbers are the fallback order, and also shortcuts: -e 6 means -e typst.

An order of your own. -e takes a comma list, tried in the order written and nothing else: -e typst,calibre,lualatex tries Typst, then Calibre, then LuaLaTeX. A word is an engine (or its alias), a family (tex, html, typst: its installed members, in place) or pandoc (every engine Pandoc drives; never Calibre). A word whose engine is not installed is passed over with a NOTE while another one of the list can run, and the build stops only when none can. pdf-engine: in pdfmd-options, the front matter, a metadata file or the config takes the same list (pdf-engine: [typst, lualatex]). Without -e the chain is the numbered one above, unchanged. A Typst older than 0.12 counts as not installed: -e typst stops with a line that says so and where a newer one comes from, a list goes on to its next engine, and the numbered chain passes it over (SKIP).

When an engine fails: engine-fallback (v3.33.71; --engine-fallback MODE, pdfmd-options: {engine-fallback: MODE}, a --setup row). Falling back is pdfmd's main trick, but a fallen-back PDF is a degraded one (a Typst PDF where a LaTeX error in the document stopped LuaLaTeX), and it used to replace the good PDF already there with exit 0. The modes:

  • auto (the default): when the PDF does not exist yet, as on (there is nothing good to lose). When it does, pdfmd looks up the engine that made it, first in its own build record of the document (the newest record of a build that wrote this file and did not fall back: a Typst PDF made after LaTeX failed is not followed, or a degraded build would lock itself in), else in the PDF's Producer and Creator (Typst, LuaTeX, XeTeX/xdvipdfmx, pdfTeX, WeasyPrint, Prince, a browser's Skia, LibreOffice, Calibre, groff). The chain then mirrors it: it starts with that engine, or with the first engine of the chain of the same family when that one is not in it (LuaTeX: lualatex, context; XeTeX: xelatex, tectonic; pdfTeX: pdflatex, latexmk; the browsers are one family), and the other engines follow in their usual order; and the build behaves as missing. No engine found (no record, a PDF of another program): as on.
  • on: any failure tries the next engine, as before 3.33.71.
  • missing: only an engine that cannot run here falls through, with a NOTE that says so (not installed, a Typst too old, an engine that stopped without an error about the document, a broken TeX installation or one that may not write). An engine that ran and reported an error in the document (TeX: a ! error line such as ! Undefined control sequence, a missing .sty or .cls; Typst: an error: with its place in the source) stops the build: exit 1, the engine's message and its cause as always, and the PDF already there is left byte for byte as it was (an engine writes the output only when it succeeded). A failure pdfmd does not recognise as the document's counts as the engine's.
  • off: the first engine that fails stops the build, whatever the cause.

An engine you pin is never reordered: -e ENGINE, -e A,B, pdf-engine: in the document, a metadata file, a folder or the config. engine-fallback still decides what a failure does, so auto with a pinned engine is missing when the PDF exists and on when it does not. A bare -e pins nothing. The closing line of a stopped build names the mode and the way back: FAIL report.md (lualatex failed; engine-fallback auto stopped the build, report.pdf kept; --engine-fallback on tries the next engine); --verbose says which mode is in force and where the chain starts (ENGINE engine-fallback auto: report.pdf was made by lualatex (build record 2026-11-10 09:03); starting with lualatex). -b decides per document, -r and -p per output. Outputs other than PDF (Word, HTML, EPUB ...), .typ and .html inputs built by their own engine, and the built-in renderers (no Pandoc) have no engine chain of this kind and are not affected.

-e calibre prints the document with Calibre's ebook-convert instead: Pandoc writes one HTML page (math as MathML, which Calibre draws with its own MathJax) and Calibre turns it into a PDF with the document's paper, margins, fontsize: and language, page numbers, and bookmarks from the headings. LaTeX in the document is made native or drawn as a picture, as for a Word file. Calibre is not in the numbered chain: it comes first when you name it (-e calibre, or pdf-engine: calibre in pdfmd-options:), and (v3.29.1) it and the browsers (-e edge, chrome, chromium, brave: Pandoc's HTML page, printed headless) are tried last when no engine is named and LaTeX, Typst and the HTML engines have all failed, ahead of LibreOffice; --doctor lists them as last resorts. A comma list of your own never adds them (-e pandoc is every engine Pandoc drives, not these).

With no Pandoc at all, Calibre reads the Markdown itself (v3.29): its own Markdown reader (tables, footnotes, fenced code, lists) prints it, before the rough built-in renderers, with the document's paper and margins. It has no math (formulas are text) and none of Pandoc's syntax, and says so (NOTE, WARN). -e calibre or -e native without Pandoc picks it; with Pandoc, -e calibre is the HTML route above.

The built-in renderers set the paper from papersize: (or --paper): a0-a6, b4-b6, c4, c5, letter, legal, tabloid, executive, folio, with landscape from papersize: a4,landscape, classoption: [landscape] or geometry:. A name they do not know is a NOTE and A4 (md2pdf has no executive, folio or tabloid either, and says so).

pdfmd --install calibre (v3.29) gets Calibre when it is missing (190 to 360 MB). In a terminal on a Mac or Windows it offers brew install --cask calibre or winget install --id calibre.calibre -e first. Otherwise (or on answering d) pdfmd downloads the release file for your machine and checks it against the SHA-256 GitHub lists for it, and refuses a download whose checksum is not listed: on macOS it opens the disk image read-only and copies calibre.app into ~/Applications, on Linux (x86-64, ARM) it unpacks the archive into pdfmd's data folder (no root, no desktop entries), and either way runs ebook-convert --version to check. Windows: winget, or the installer from calibre-ebook.com. Calibre is a program pdfmd runs, never one it contains.

A browser prints a Markdown document too (v3.29): -e edge, -e chrome, -e chromium or -e brave (or -e browser for the first one installed) has Pandoc write one self-contained HTML page and the browser, headless, print it with the document's paper and margins (papersize, geometry, margin: as @page) and 12pt text. The math is MathML, which the browser draws itself; LaTeX in the document is made native or drawn as a picture, as for a Word file. Like Calibre, a browser comes first when you name it and last when you do not (see above); an .html file already goes to one first when it has scripts or MathML.

Calibre also writes ebooks (v3.29): pdfmd book.md -o book.azw3 (or --to azw3) has Pandoc write an EPUB 3 (chapters, contents, the metadata, the math as MathML) and Calibre turn it into the target, book.azw3 beside the document. The twelve targets only Calibre writes are azw3, mobi, kepub, lit, lrf, pdb, rb, snb, tcr, pmlz, htmlz and txtz; each was converted back to text to check that its words, list and table are there. (Before v3.29 -o book.azw3 wrote a PDF under that name.) epub, docx, rtf, txt and fb2 Pandoc writes itself, with more of its own features (styles, tables, footnotes); they go through Calibre only when -e calibre is on the command line (a pdf-engine: calibre is a PDF setting), with a NOTE. -e calibre with another target (.tex, .html, .md) stops and lists what Calibre writes. One document at a time, not -b or -r.

The math of an ebook: a Kindle or a Kobo does not draw MathML, and a MOBI has no place for it. azw3, mobi, kepub, lit and lrf (and docx through Calibre) get each formula drawn by LaTeX as a picture, in the document's own preamble and with its \ce and siunitx, as a Word file's pictures are (needs a LaTeX engine and Poppler's pdftocairo); the plain formats (pdb, rb, snb, tcr, pmlz, txtz, rtf, txt, fb2) get the formulas as text, the simple ones with their superscripts and the rest as TeX, with one WARN that counts them, and so does a picture target on a machine with no LaTeX. epub and htmlz keep the MathML. A --mathml or --math-method of your own is kept.

An ebook can be the input too (v3.29): pdfmd book.epub builds book.pdf beside it, pdfmd book.mobi -o book.md writes its Markdown (with its pictures in media/) for you to edit, pdfmd book.azw3 --to docx a Word file. An EPUB is read by Pandoc; mobi, azw, azw3, azw4, kepub, lit, lrf, pdb, prc, chm, snb, tcr, rb, pmlz, htmlz and txtz first go through Calibre's ebook-convert into an EPUB (Calibre is needed for those, not for an EPUB). The closing line names the book. One book at a time, not with other files, -b or -r; a book with DRM, and an ebook of nothing but pictures (a comic), cannot be read. A formula a MOBI holds as a picture stays a picture. Calibre's own scaffolding in the converted book (an anchor for every file it split the book into, its page-break divs and classes) is dropped from the Markdown; with -o book.md the pictures are copied to a media/ folder beside it.

LibreOffice writes what Pandoc cannot too (v3.29): pdfmd letter.md -o letter.doc (Word 97) has Pandoc write the Word file (the same one -o letter.docx writes: your reference document, native equations and tables, pictures for what LaTeX alone can draw) and headless LibreOffice turn it into .doc. .odt uses that same Word route by default (see odt.engine below). rtf, txt and epub go through LibreOffice only with -e soffice (or -e libreoffice, -e office) first on the command line, with a NOTE; without it Pandoc writes them from the document itself, which is the better file. -e soffice with another target (.tex, .md) lists what it writes.

For everything at once, pdfmd --doctor is one page on the Markdown-to-PDF tools, fonts and emoji, PDF reading (batchocr, Tesseract, Poppler, OCR languages), your config file and the cache (and, when another program answers one of pdfmd's command names such as mdpdf or md2pdf, which), ending with the command that fixes each missing piece:

$ pdfmd --doctor
...
Reading PDFs (PDF to Markdown):
OK    batchocr 1.2.5
MISS  tesseract: not found
MISS  poppler: not found

1 thing(s) to fix:
  1. tesseract (scanned PDFs) and poppler (scanned PDFs)
       brew install tesseract poppler

"Installed" is not "works": a LaTeX engine can be on the PATH and still fail on a missing package or font. pdfmd --doctor --deep also builds a small page (maths, code, a table) with every engine it found, by running pdfmd itself in a scratch folder, and says which one built it and how long that took, or why it could not:

$ pdfmd --doctor --deep
...
Smoke test (a small page with maths, code and a table, built with each engine):
OK    lualatex: built a PDF in 8.7 s
MISS  xelatex: could not build it (! LaTeX Error: File `stix2.sty' not found.)
OK    typst: built a PDF in 1.1 s

The last resorts are tried too (v3.29): an installed browser (edge: built a PDF in 6.1 s) and Calibre, which also writes a small ebook (calibre:azw3: built an AZW3 ebook in 18.0 s), so a missing Qt library shows here and not in the middle of a book. It takes a few seconds per engine (a minute or more if TeX has to fetch packages); without --deep nothing is built.

The documents of your version: pdfmd --doc

pdfmd --doc lists the documents that came with the pdfmd you have, offline: readme (the manual), recipes (worked examples), agents (a short guide for a program or an AI assistant that runs pdfmd) and changelog. pdfmd --doc readme opens a web page of one in your browser (made once per version by Pandoc, kept in the cache); when the output is not a terminal, or with --print, it prints the Markdown instead, which is what a script or an assistant wants. --doc readme 3 shows the third ## section of it, --doc readme completion the first section whose title has that word, and --path says where the file is. --doc search WORDS lists the headings and lines that have every word, one per line as document: Heading > Subheading (line N): the line (the first 40 of them); pdfmd --doc search PDFMD_NO_PROMPT lists every line that names that variable, the agent guide's line about it among them. The documents are those of this version, copied into the package when it is built; a checkout reads the originals, and a zipapp (scripts/build_zipapp.py) carries them too.

Tab completion

pdfmd --completion bash|zsh|fish prints a completion script, generated from the command line itself so it never goes out of date:

pdfmd --completion zsh > ~/.zfunc/_pdfmd                              # ~/.zfunc in $fpath, then compinit
pdfmd --completion bash > ~/.local/share/bash-completion/completions/pdfmd
pdfmd --completion fish > ~/.config/fish/completions/pdfmd.fish

An older Pandoc, and a machine with no network

The oldest Pandoc pdfmd is tested with is 3.1.3 (what the claude.ai sandbox has); the whole test suite runs against it. An older one may work, and the first build with it says so once; pdfmd --doctor lists what a given Pandoc does without. With 3.1.3: a numbered equation in a Word file sits in a plain table instead of a borderless one (before 3.4), and --extract-tables leaves a table whose caption has a {#tbl:id} (before 3.8.2: Pandoc reads that as words, so the change could not be checked). A pandoc-crossref must be the release built for the Pandoc you run; one built for another is not run, with a warning, because it would put ?? where the numbers go.

Where pip install pdfmd-cli cannot reach the network (a sandbox with no DNS that only takes an upload), two ways:

python3 scripts/build_zipapp.py          # on a machine with pdfmd's source: dist/pdfmd.pyz, bundled pure-Python YAML and pypdf inside
python3 pdfmd.pyz report.md              # on the other: needs Python 3.9+, Pandoc and a PDF engine; unpacks itself once
                                         # into ~/.cache/pdfmd/pyz/ because Pandoc has to open the Lua filters as files

pip download pdfmd-cli -d wheelhouse     # or: a folder of wheels, carried over
pip install --no-index --find-links wheelhouse pdfmd-cli

No Pandoc? The built-in fallback

On a machine with no Pandoc, or Pandoc but no PDF engine, pdfmd does not stop: it builds a plain PDF with a pure-Python renderer that pip installed with it, and says so.

$ pdfmd notes.md
NOTE  Pandoc was not found: building with the built-in renderer (inkmd). The output is plain ...
NATIVE  notes.md via inkmd
WARN  native (inkmd): notes.md: 2 math expression(s) set as plain text (Unicode, sub/superscripts, display math centred); ...
OK    notes.md > notes.pdf
  • inkmd, written by Dylan Moir (MIT licence), is built in (a copy is vendored in lib/pdfmd_inkmd/, about 2 MB, standard library only, works offline, same input gives the same bytes). It reads GitHub-flavoured Markdown: headings, emphasis, lists, task lists, tables, code blocks, quotes, links, images, and PNG/JPEG. It cannot typeset math, so formulas are set as readable text instead: Greek letters and operators as Unicode, x^2 and x_i as super/subscripts, \frac{a}{b} as a/b, and display math ($$...$$, \begin{equation}, aligned) as its own centred lines. No bookmarks, no page numbers.
  • md2pdf (pymd2pdf, ReportLab based) is used when installed, for documents with footnotes, math or a title: pdfmd --install math (same as pip install "pdfmd-cli[math]"; about 150 MB, Python 3.11+, installs matplotlib so formulas render offline, centred, as real math; the few matplotlib cannot read are set as text like inkmd's). Footnotes, bookmarks and syntax-highlighted code come with it.
  • pdfmd --install emoji (about 11 MB; pdfmd-cli[emoji] is the pip way for the built-in renderer only) puts the colour emoji font in pdfmd's own fonts folder, where the built-in renderer and every engine that can draw colour emoji (Typst) find it; without it emoji print as [rocket]-style labels in the built-in renderer.
  • pdfmd --install math:mathjax (MathJax 4 and its STIX Two math font, about 12 MB to download, about 2 MB on disk) and pdfmd --install math:katex (KaTeX, about 1.5 MB) are for WeasyPrint's PDFs, not the built-in renderer: they typeset its math (see "WeasyPrint does not typeset math itself" below).
  • pdfmd --install core:tex (also cortex, about 50 MB) is the other core: Pandoc and Tectonic, the self-contained LaTeX engine (its release from GitHub, checked against the SHA-256 GitHub lists, into pdfmd's own tools folder). Tectonic fetches the TeX files a document needs from the network the first time, then works from its cache. core:typst (or core) is --install full below. pan-tec (see "Other command names") runs pdfmd on it.
  • pdfmd --install full leaves the fallback behind: it installs Pandoc (the real binary from PyPI's pypandoc_binary, about 35 MB, also --install pandoc / pdfmd-cli[pandoc]) and Typst (Typst's own release from GitHub, about 15 MB, also --install typst, checked against the SHA-256 GitHub lists) into pdfmd's own folders (~/.local/share/pdfmd/bin, %LOCALAPPDATA%\pdfmd\bin), with no admin rights. A Pandoc or Typst already on your PATH always wins; to remove them, pip uninstall pypandoc_binary and delete the typst file.

Every input is treated as GitHub-flavoured Markdown. Pandoc-only syntax is converted where possible (\newpage and <!-- pagebreak --> become a page break, as they do through Pandoc with pagebreak:, see Page breaks in every format; footnotes become endnotes for inkmd, $ prices are not mistaken for math) and removed otherwise (heading and image attributes, ::: divs, raw LaTeX, <!-- comments -->), with one warning per kind. CSV tables (::: {.csv file="data.csv"}) work as they do with Pandoc. The title, author and date in the front matter become a title block and the PDF's own title and author; other front-matter keys (documentclass, header-includes, pdfmd-options) are listed as not used. There are no filters, preambles, citations, slides, parts or report mode, and only Markdown input: for any of those, pdfmd --install full (or brew install pandoc typst, winget install JohnMacFarlane.Pandoc Typst.Typst).

The built-in renderer is chosen automatically only when there is no Pandoc route; a Pandoc build that fails never falls back to it. Ask for it with -e inkmd, -e md2pdf or -e native, or pdf-engine: inkmd in pdfmd-options. When it runs on a terminal and had to leave something out, pdfmd offers the upgrades above (set PDFMD_NO_PROMPT=1 to silence that; choosing "don't ask again" remembers it).

Usage

Every command below can be run from inside examples/.

One document

pdfmd lecture                 # finds lecture.md, writes lecture.pdf beside it
pdfmd lecture.md --open       # ...and opens it when done
pdfmd ~/notes/lecture.md -d   # write the PDF into the current directory instead
pdfmd lecture -w              # watch: rebuild on every save, until Ctrl+C
pdfmd a.md b.md               # several input paths: build each separately, like -b
pdfmd *                      # build inputs separately; skip PDFs and pictures in one NOTE

A bare name is looked up as <name>.md. The name can contain dots: pdfmd notes-v1.2 builds notes-v1.2.md.

If no file has exactly that name, pdfmd also looks at what each document is called inside: its title, and any pdfmd-options: {alias: ...}. Case, spaces, _, -, accents and script don't matter (pdfmd animportantdocument finds An Important Document.md; pdfmd glyukoza finds a document titled Глюкоза), and a unique start of a name or title works too, with a warning saying what it matched (pdfmd animp, pdfmd glucose). A name that fits two documents is an error, never a guess; --no-auto lookup switches the guessing off. An exact file name always wins.

Names in other scripts. Russian, Ukrainian, Belarusian and Kazakh Cyrillic are always understood. Other scripts are off until you ask, one pack per script, so nobody carries tables they don't use: --translit greek,hangul (or PDFMD_TRANSLIT=greek,hangul) makes pdfmd tyche find Τύχη.md and pdfmd unmyeong find 운명.md. Built in: greek, armenian, georgian, hebrew, arabic (consonants only, as the scripts write them), hangul (Revised Romanization) and kana (Hepburn). han gives pinyin without tones (pdfmd mingyun finds 命運.md) and other covers every remaining script; they use pypinyin (MIT) and anyascii (ISC), installed with pdfmd --install translit (pip install "pdfmd-cli[translit]"; about 3 MB). --translit all turns every pack on, --translit none even Cyrillic off, --translit list shows what is ready. These are for finding a name: plain letters, no tones, vowels left out where the script leaves them out. Names that romanize alike (homophones) are listed as ambiguous, never picked between. The same spelling rules find a section by its heading (doc#tyche).

Several documents in a folder: priority

Bare pdfmd builds the document of the folder. With more than one Markdown file there it used to stop with the list; now it chooses, and the AUTO MD line says what it beat and why:

$ ls
report.md  report.final.md  report_draft.md
$ pdfmd
AUTO MD    report.final.md  (over report.md [name: bare name], report_draft.md [name: draft])

Two things decide, in this order. Names settle which version of a document: a name that is another name plus words like final, rewritten, v3 (report.final, report_v3) beats the bare one, and one with draft, wip, old, backup, copy (report_draft) loses to it. They only count when both are there: report_draft.md beside notes.md is just another document, and lab2 is never a version of lab. Priority settles which document, and is written by the document itself, at the root of its front matter or in pdfmd-options:

---
title: Report
priority: 2            # or pdfmd-options: {priority: 2}
---
priority: means
final, last, max above every number. Degrees count: final final, FINAL!!! and final67 each beat the one before
1, 2, 3, 0.5 higher wins
nothing (none, null, nil, nill, empty) normal: above draft, below 1
draft, wip, 0 loses to every other document, builds when it is alone
never, ignore, skip, a negative number not in the list (bare pdfmd skips it; pdfmd NAME still builds it)

A priority written in any member of a family (report, report.final...) switches the names off for that family: you said what you meant. Equal tops are settled by the newest file, with a warning that names them all and what raised each; if their modification times are identical too, the choice stops with that list. A value that is neither a number nor a word is a warning and counts as nothing. An exact name is always the override: pdfmd report builds report.md whatever else is there. --no-auto priority brings back the old list-and-stop; so does no-auto: [priority] (or auto: {priority: off}) under options in a folder's .pdfmd/config.yaml or metadata.yaml or in the config file. What pdfmd wrote itself is never a candidate: report.assembled.md, report.gfm.md, report.ascii.md, report.ascii.gfm.md beside report.md, any file that carries the pdfmd-assembled mark, and hidden files, so a second bare pdfmd builds the same source as the first. pdfmd -w keeps the document it chose: a rebuild never chooses again.

--newest, --all, and the lists. pdfmd --newest builds the most recently changed file of the folder, whatever priorities and names say (only never still keeps a document out). pdfmd --all (or --include-never) counts the never documents too: -b, -r and --view include them, and a bare pdfmd ranks them as if they had written nothing. The never words keep a document out of what a folder gives: -b DIR, -r DIR, --view and a bare pdfmd; a document you name (pdfmd NAME, -r a.md b.md) is always built. A never in a file name (report_ignore.md beside report.md) hides it from a bare pdfmd only.

Why this one? pdfmd --which shows it without building anything: every Markdown file of the folder with what it says (its priority, its name's suffix), what happened to it (BUILT, left: name: draft, left: priority: never, tied) and when it last changed, then the rules in force with the files they came from. --newest, --all and --no-auto priority act in it as they would in a build. pdfmd --which NAME ... says which file each name finds (report -> report.md).

$ pdfmd --which
WHICH  bare pdfmd in . builds report.final.md
       report.final.md  BUILT                  name: final          2026-11-02 09:00
       report.md        left: name: bare name  nothing set          2026-11-01 09:00
       notes.md         left: priority: never  priority: never      2026-11-03 08:00
RULES  from the built-in default
       names: family   by: priority, newest
       never    never = ignore = skip = exclude  (leaves the list)
       ...

Your own words, and their order: ranking. The table is the default setting. A class is a named group of words; the ladder puts the classes in order, low to high, with two reserved members, unset (no priority written) and numbers. The default is ladder: [never, draft, unset, revised, numbers, final], with final: [final, last, max], draft: [draft, wip, old, backup, copy, tmp, test, 0] and so on. The same classes read a priority: value and a word in a file name (report.banana.md beats report.md once banana is above unset). To have banana beat cherry, every fruit beat every number, and still lose to final:

# .pdfmd/ranking.yaml  (or pdfmd-options: {ranking: ...} anywhere below)
classes:
  fruit: {words: [cherry, banana], above: numbers}     # a list is low -> high; [apple, [banana, cherry]]: the last two are equal

A class is [words] or {words: [...], ordered: false | degrees: true | exclude: true | names: false | above: CLASS | below: CLASS}: ordered: false puts all its words on one level, degrees lets repeats and a glued number raise the rank (like final), exclude takes the documents out of the list (like never), names: false keeps its words out of file names, and above/below put the class next to another on the ladder without restating it (two classes with the same place share a level and do not beat each other). A word may hold {n} for a number (v{n}, version {n}: the higher wins). Or write the whole ladder: again, a nested list being one level. Other keys: names: family | folder | off (default family: names only rank versions of one document; folder lets report.final beat every document of the folder; off reads no names) and by: [priority, newest] (what settles equal priority, in order: newest, oldest, name (the last by natural order); by: [priority] makes a tie an error again; by: [newest] ignores priority). names and by are rows of pdfmd --setup.

The settings are read only when there is a choice to make, from the nearest place to the documents: their own front matter (pdfmd-options: {ranking: ...}, classes and ladder only; a metadata file named after the document too), then, folder by folder from theirs upward, .pdfmd/ranking.yaml, the folder's metadata.yaml and the folder settings (.pdfmd/config.yaml, options: {ranking: ...}), then the global ranking.yaml beside the config file and the config's own options: {ranking: ...}. A nearer ladder, names or by replaces a farther one; classes merge by name. In one folder the order is ranking.yaml, then metadata.yaml, then the settings. inherit: false in a ranking.yaml (or the folder settings) stops the climb. Two documents that define a class differently are a warning, and the newer one's definition is used. A class that is on no ladder, or a word that is in two classes, is reported with the file that defined it.

Keep the old file: --no-rewrite

A build replaces an older lecture.pdf. With --no-rewrite it writes lecture (1).pdf instead (the first free number) and leaves the old file as it was; the OK line names the copy. A first build has no number.

pdfmd lecture --no-rewrite           # lecture.pdf, then lecture (1).pdf, lecture (2).pdf ...
pdfmd lecture --rewrite              # one run replaces it again, whatever the document or the setup says

Every output counts, the -o name and each file of a -b batch on its own (pdfmd - builds stdin.pdf the same way). It is also a document setting, pdfmd-options: {rewrite: false}, a folder's or your own default (pdfmd --setup, "Replace an older output of the same name"). The name is a template, rewrite-name, with {n}, {stem}, {ext} (with its dot) and {date}: {stem}-{n}{ext} gives lecture-1.pdf, {stem}_{date}-{n}{ext} gives lecture_20261026-1.pdf. The template needs {n} and no folder, otherwise the default is used with a warning. A watch (-w) always rewrites, or every save would make a copy.

Check a document before you build it

pdfmd report --check                  # read report.md (a scaffold and its parts too) and list what is wrong; builds nothing
pdfmd a.md b.md --check               # several files, one report each
pdfmd report --check --strict         # warnings fail too (exit 1), for CI
pdfmd report --check --check-ignore heading-jump,link-missing

--check reads the source, with no Pandoc and no LaTeX (the one exception: a document that uses the calculator is read once by Pandoc with the calculator's filter, which evaluates nothing, to see its names; without Pandoc a NOTE says they were not checked), and prints compiler-style lines (report.md:12: error: image not found: figs/a.png [image-missing]) that an editor can jump to, then CHECK report.md: 1 error(s), 2 warning(s). It exits 1 on an error, and on a warning too with --strict (a note never fails it). It finds:

check
image-missing, file-missing, link-missing an image (![](..), <img>, \includegraphics), a file= of a .csv div or a link to a local file that is not there
anchor-missing [x](#methods) with no heading or {#id} of that name (the ids Pandoc and GitHub make from a heading count)
id-duplicate, crossref-missing, ref-missing the same {#id} twice; a @fig:x, @tbl:x, @eq:x, @sec:x, @lst:x or \ref{x} nothing defines
cite-missing, cite-no-bibliography, bibliography-missing a @key the .bib, CSL JSON or references: lacks (with a "did you mean"), citations with no bibliography set, a bibliography file that is missing
math-digit $\geq$3, $\pm$10: Pandoc reads $...$ as math only when the closing $ is not followed by a digit, so this is text with the dollars printed (a build says the same as a WARN, and a \geq left in text mode rebuilds the document in another font); write $\geq 3$
footnote-missing, footnote-unused [^1] with no text, a [^1]: nobody uses
heading-jump, heading-space, heading-empty # then ###; #Heading (a paragraph); a heading with no text
fence-open, comment-open, div-open, div-stray, math-open a code fence, <!--, ::: div or $$ that is never closed, or a stray :::
front-matter, front-matter-duplicate YAML that does not parse (with its line) or never closes; a key set twice
calc-undefined, calc-redefined a calculator span, ::: calc line or {ans} that uses a name no span, line or constants file defines (one line per statement, with its line and part, in document order); a name defined more than once: a note listing where, and which definition a use below them sees (a normal build says nothing: redefining a name on purpose is how a report reuses V)

Code, comments, math and URLs are not read, so @decorator in a code block or me@x.org is not a citation. A document in parts is checked as one: a link to #methods in the scaffold is found in a part. A file with no front matter is taken for a fragment and its citations are not held against a missing bibliography, and neither are those of a document with a Lua filter or citation-engine (something else may do the citing). A part checked on its own cannot see the other parts' labels, so check the scaffold, not a part, to judge links across parts. Leave a check out for the project with pdfmd-options: {check-ignore: [heading-jump]} (a document, a metadata file or the config), or on one line with <!-- pdfmd-check: ignore image-missing --> on it or on the line above. pdfmd --check is for Markdown; the build's own WARN and --strict still catch what only a build can see.

Crop a PDF to what it draws

pdfmd --crop formula.pdf                          # formula-crop.pdf: every page cut to its ink, 2 pt of room
pdfmd --crop formula.pdf -o tight.pdf --crop-pad 0
pdfmd --crop a.pdf b.pdf -o cropped/              # several: -o is a folder
pdfmd formula.md --crop --crop-ignore-bottom 40   # a Markdown source is built first: formula.pdf, cropped
pdfmd formula.md --crop --to png --crop-page 1    # formula.png, the cropped first page, transparent, 300 dpi
pdfmd formula.pdf --crop --to svg                 # formula.svg
pdfmd report.pdf --to png                         # no --crop: the whole page(s) as pictures

The ink is what Ghostscript's bbox device finds painted on the page; only the page's media and crop box change, so the page stays vector and nothing is redrawn. It is what eqpop does to a rendered formula, on any machine that has Ghostscript and pypdf. A rotated page is handled; a page that draws nothing is left as it is, with a NOTE.

  • --crop-pad PT sets the room around the ink (default 2); --crop-ignore-bottom PT leaves the foot of each page out of the measurement (a page number: a pdfmd page carries one, so a formula built from Markdown wants --crop-ignore-bottom); --crop-page N keeps page N alone.
  • Several at once. --to png,pdf, --to png,docx,tex (no spaces, or quote the list): pdfmd runs once per format that is not a picture (the same command line with that --to: a plain pdf is an ordinary build, with its stamp and backup, unless --crop cuts it) and once for the pictures, which are cropped and drawn together. Each result sits beside the others; -o is then a folder. A document cannot yet ask for this itself (default-output takes one format).
  • Targets. --to pdf (the default), png, jpg (jpeg), tiff (tif), eps, ps or svg; -o x.png picks the format by its suffix. PNG is drawn by Ghostscript on a transparent background at --image-dpi (default 300); JPEG the same on white, at --image-quality (default 90); tiff (LZW, white, all pages in one file), eps (vector; one file per page, its box is the ink) and ps (vector, all pages in one file); SVG by pdftocairo (poppler) or else mutool (MuPDF). --to png|svg without --crop draws the whole page: a crop is asked for, never implied.
  • Pages. A PDF keeps all its pages, each cut to its own ink. A picture format is one picture per page (tiff and ps keep all pages in one file): NAME.png beside the document for a one-page result, a folder NAME/ with 001.png, 002.png ... for several (with a NOTE); --crop-page 1 gives the plain name. -o a.png with several pages gives the folder a/. --no-page-folder (or pdfmd-options: {page-folder: false}, or the --setup row) writes NAME-001.png ... beside the document instead.
  • Names. A Markdown source gives NAME.pdf|png|svg beside it (-d: in the current folder); a PDF source gives NAME-crop.pdf, so the source is never overwritten unless -o names it. The intermediate PDF of a Markdown source is built with your other options (-e, -y ...) but with no build stamp or backup.
  • --crop with --to docx, odt, html ... is an error: those have no page to cut.
  • Many files. Name them (pdfmd *.tex --crop -o out/, a PDF, Markdown or anything pdfmd builds), or use -b on a folder (pdfmd notes -b --crop -o out/ -j 4: its *.md, --recursive for subfolders; -j files at a time). -r makes one result of a report folder, named after the folder; -p draws a picture per slide.

A small editor

pdfmd --install tui           # once: prompt_toolkit, about 1 MB
pdfmd --edit lecture          # nano-like full screen; Ctrl-B saves and builds, Ctrl-P opens the PDF

pdfmd --edit (alpha) opens the Markdown file in a plain full-screen editor with the build one key away: Ctrl-S saves, Ctrl-B saves and builds (what pdfmd said stays at the bottom), Ctrl-P opens the result, Ctrl-W / F3 find, Ctrl-G go to a line, Ctrl-K / Ctrl-U cut and paste a line, Ctrl-Z / Ctrl-Y undo and redo, Ctrl-X leaves (asking once when there are unsaved changes); F1 lists them. Options given beside it (pdfmd --edit lecture -e lualatex) are used for the build, and a name that does not exist yet starts a new file. It is deliberately rough: a base for what comes later.

Edit a PDF in place. A PDF that carries its source (--attach-source; pdfmd --check-source says which do) can be edited and rebuilt where it lies:

pdfmd --edit report.pdf                          # the built-in editor, on the source as one file in a temporary folder
pdfmd --edit report.pdf --editor "code --wait"   # your own: the folder is watched and rebuilt on every save

A document is edited as the ONE file its source is: the text with its metadata, preamble, bibliography and style, and the .tex and CSV files it reads, as blocks after the text (<!-- pdfmd-at: ... --> comments say on which line each one is); only what is not text (your package, pictures, files in sibling folders) is beside it. You edit that file; Ctrl-B (or each save with --editor) builds it, and when you leave, report.pdf is replaced by the rebuilt one, which carries the source again, and the old one is kept in .backups/ beside it. Before it is replaced pdfmd checks that the rebuilt PDF still carries its source and everything the old one carried; a PDF without its source is refused (yours stays as it was), and what is missing is named. The temporary folder is removed when you leave. For the files it was made from, pdfmd --restore report.pdf (or --unpack). Nothing changes when you leave without editing, or when the last version does not build. The options of the first build are the ones the document and its metadata record; the command-line ones are the ones you give now, and the global metadata of pdfmd --setup is this machine's (a rebuild on another one can differ). A book built with -r is edited as a book: your editor gets every chapter file and each save rebuilds all of them in the order the book was built (the built-in editor opens the first chapter and names the others). --editor CMD also works on a Markdown file (a watch that rebuilds on every save, until the editor closes), and pdfmd --setup keeps a default editor.

In VS Code

pdfmd --init-vscode writes a .vscode/tasks.json here (build, build and open, watch, check, extract tables; Terminal > Run Build Task). The vscode/ folder of the repository is a small extension over the same command line: build (Cmd/Ctrl+Alt+B), build and open, build as Word/HTML/Typst/slides, extract tables (all, or chosen ones), file history, compare and restore, restore the source a PDF carries (right-click it). Every command it runs is written to the pdfmd output panel. Install it with npx @vscode/vsce package and code --install-extension pdfmd-vscode-*.vsix.

A size budget for images and output

pdfmd report --max-asset-mb 5 --max-output-mb 20     # warn about an image over 5 MB and an output over 20 MB
pdfmd report --check --max-asset-mb 5                # list the images over 5 MB (asset-large), nothing is built
pdfmd-options:
  max-asset-mb: 5        # one image or data file
  max-output-mb: 20      # the file that is made (a mail server's limit, a course site's upload limit)

There is no budget unless one is set. With one, a build says which image is over it before it starts (WARN report.md: figs/scan.png is 14.2 MB, over the 5 MB asset budget) and, when the file made is over the output budget, names it and the three largest images behind it. They are WARN lines, so --strict fails them. --max-asset-mb 0 switches a document's budget off for one run.

Start from a template

pdfmd --init                          # the templates: article, report, notes, slides, book, and yours
pdfmd --init article my-paper         # my-paper/my-paper.md + refs.bib, title, author and date filled in
pdfmd --init report .                 # into this folder, if none of its files exist yet
pdfmd --init notes week3 -V author="A. Author" -V title="Week 3"

Each template builds as it is (they are checked with --check --strict and built in the tests): an article with an abstract, an equation, a table and a citation with its .bib; a report with a contents page, numbered sections and a table read from a CSV file; notes (headings, math, a code block); slides (build with -p); and a book, a scaffold with its chapters in parts/. The title comes from the name, the author from -V author= or git config user.name. Nothing that exists is overwritten: pdfmd names the files in the way and writes nothing. Your own templates are a folder (or a single .md file) in the templates/ folder of pdfmd's config folder (pdfmd --init shows where), or any folder or .md file named by its path. In their files and file names {{name}}, {{title}}, {{author}}, {{date}} and {{year}} are replaced (__name__.md becomes my-paper.md); any other {{...}} stays as written. A description.txt (one line, for the list) and a next.txt (what to type next) in a template folder are not copied.

One section

pdfmd doc#onlyapart builds just the section whose heading is "Only a Part" (down to the next heading of the same or a higher level), written as doc.only-a-part.pdf beside the document. Headings are named the way files are: case, spaces and spelling don't matter, a unique start works (with a warning), and so does a {#label}. doc##yield asks for a level-2 heading, doc#results/yield for one under another, doc#a+b for several (doc@45-60 for every part numbered from 45 to 60), and pdfmd '#yield' uses the folder's only Markdown file. @ does the same as # (doc@yield, doc@@yield, pdfmd @yield), for the shells that read # as a comment. pdfmd doc --list-parts shows what can be named. In a document split into parts the same names work for headings inside the parts.

Anything with a {#label} can be named too: pdfmd doc#fig:setup builds just that figure, doc#eq:energy that equation, and likewise a table, a fenced div, a code block or a span (the figure's number restarts at 1).

Other output formats

The format is taken from -o's extension, or given explicitly with --to:

pdfmd lecture -o lecture.html
pdfmd lecture -o lecture.docx
pdfmd lecture --to typst -o lecture.typ
pdfmd lecture -o lecture.tex      # a complete, compilable .tex, not a fragment

Plain Markdown: --to gfm

pdfmd report --to gfm                # report.gfm.md, beside report.md, never over it
pdfmd report --to gfm -o README.md
pdfmd report --to gfm+raw            # Pandoc's own gfm writer, with its HTML, as it was

--to gfm writes Markdown that any viewer shows as it is (GitHub, VS Code, Obsidian, a chat window): no HTML, no {#id .class} braces, no Pandoc-only syntax. pdfmd runs everything it runs for any other target first (includes, parts, ::: {.csv} tables, pandoc-crossref, citations, your filters), then flattens what is left:

  • the title block is written (# Title, an *Author, Author — date* line, the abstract), since the front matter is not;
  • a table caption becomes **Table 1.** text above the table, a figure caption the same below the figure (the label is split at the number pandoc-crossref wrote, in any language); {#tbl:x} and {#fig:x} are gone;
  • <div>, <span>, <figure> and the bibliography's wrappers are unwrapped; section numbers (number-sections) are written into the headings; a link to a heading points at the anchor GitHub gives it, a link to a vanished id is text;
  • subscripts and superscripts are Unicode where every character has one (H₂O, x², CO₃²⁻) and _(..) / ^(..) where not (--gfm-scripts unicode, the default; html keeps <sub>, drop writes the plain text, ascii always writes _2, ^(q+z)); math stays $..$ and $$..$$ (--gfm-math fenced writes ```math);
  • raw HTML and raw LaTeX are read into Markdown (<b>x</b> becomes **x**, \textbf{x} too); a raw Typst piece, a LaTeX picture (tikzpicture, \chemfig, drawn with your preamble), a PDF image and an <img src="x.pdf"> become SVG files in <name>_files/ beside the output, linked relatively;
  • definition lists become a bold term and a paragraph; a table Markdown cannot hold (spans, several header rows, block cells) is written as an HTML table, the one piece of HTML that stays, and counted in a NOTE;
  • what no Markdown can carry (Word XML, a LaTeX macro nothing read) is left out, and a WARN counts it and shows the first.
pdfmd-options:
  gfm: {scripts: unicode, math: dollars, title: true}

-o notes.gfm.md is --to gfm (it is also the name a flat file gets by default from notes.md); -o notes.md alone still means Pandoc's Markdown, so name the target (--to gfm -o notes.md) to get the flat one under that name. The paragraphs are single lines (--wrap=none; pass --wrap=auto to Pandoc to change it). Image links stay as the document wrote them, relative to the document's folder (a NOTE says so when the output is written elsewhere). The result is meant to be final: what the flattening removes (ids, cross-reference numbers become text, citations become text) is not recoverable from it.

The flat file that carries its source: --keep-source

Flattening cannot be undone (ids, cross-reference numbers, citations and scripts are text now). So a Markdown output can end with its whole source in one HTML comment (the assembled Markdown, a manifest of the layout, the .csv data), and --restore writes them back. It carries what --attach-source puts into a PDF for the same bundle setting: the preamble.tex (found as a PDF build finds it, though the Markdown build itself does not read it) and, by default (sources), the .tex files the text and the preamble \input. The pictures are the difference: a PDF holds them, a Markdown file does not, so they come with --bundle (or --bundle all); without, the restored folder needs them put back. --no-bundle keeps the source and the preamble alone:

pdfmd report --to gfm --keep-source            # report.gfm.md ends with the source, compressed (packed)
pdfmd report --to gfm --keep-source readable   # the same, as escaped text you can read and diff
pdfmd --restore report.gfm.md --list           # what it carries
pdfmd --restore report.gfm.md                  # writes report.gfm.restored/ (never overwrites anything)
pdfmd-options:
  keep-source: true        # or readable; --no-keep-source turns it off for a run

Nothing in the source can close the comment early: packed is base64 of the compressed source (no -, < or > in it), readable escapes every > (so no --> or --!>), a backslash, a carriage return and a line-initial < or =, and undoes it exactly. A source with <!-- a <!-- b --> c -->, --> in a code block, CRLF line endings or the marker itself comes back byte for byte. The comment also stores a hash of the Markdown above it, so --restore can tell you the file was edited since. Comments are stripped from the kept copy unless strip-comments says otherwise (as for --attach-source); a whole build keeps its source, a part or a section built alone does not; a kept source over 1 MB gets a warning, since GitHub stops showing a Markdown file that large. It works for every Markdown output: --to gfm, gfm+raw, markdown, commonmark.

Plain text: --to txt

pdfmd report --to txt                # report.txt; the same as -o report.txt
pdfmd report --to txt --columns=100  # Pandoc's wrapping, 72 columns unless you say otherwise
pdfmd report --to plain              # Pandoc's own plain writer, as it was

--to txt is the flat build of --to gfm written by Pandoc's plain writer: includes, parts, ::: {.csv} tables, pandoc-crossref, citations, raw HTML and LaTeX and your filters all run first. What a text file shows: the title, the author line and the abstract, then headings underlined with = (level 1) and - (level 2) and numbered when the document numbers its sections; Table 1. caption above a table and [Figure 1. caption] for a figure; a link as words <https://address> (just the words when they are the address, or the link points into the document); no ** or _; Unicode sub/superscripts (H₂O, x², else _(..) / ^(..); --gfm-scripts drop or ascii change that, html means unicode here); math as Pandoc's plain writer sets it (E = mc²). A picture cannot be shown: its caption stands for it, and a Typst or LaTeX picture piece is left out with a WARN. pdfmd-options: {txt: {scripts: unicode, title: true}} holds the settings. Until 3.26.7 --to txt and -o x.txt meant Pandoc's own plain writer; that is --to plain.

Calculations inside the text: {=}, {ans} and ::: calc

pdfmd can compute the numbers of a lab report (or any document) for you, so a result is never typed by hand and a changed input changes every place that uses it (3.30.4 to 3.30.8; needs Pandoc, the built-in renderer shows the source as written). It has its own small arithmetic parser and runs nothing (no R, no Python, no load), so it is safe on a file from anybody.

1. Inline code followed at once by {=} is replaced by its value

Since `2026 - 2006`{=} years have passed, the angle is `sin(30*deg)`{=} and `round(4/3 * pi * 2.5^3, 1)`{=} cm3.

gives "Since 20 years have passed, the angle is 0.5 and 65.4 cm3." Inline code without {=} and code blocks are never touched.

2. {ans} inside the span prints the whole text with the value put in (the text is kept exactly as typed)

You write The document says
`2 - 4 = {ans}`{=} 2 - 4 = -2
`V = 20.60 - 0.00 = {ans:2dp} mL`{=} V = 20.60 - 0.00 = 20.60 mL
`C = n / V = {ans:4sf} M`{=} C = (the text) = 0.02027 M, and C is stored
`A = 4 - 2`{=} nothing: A is only stored
`A`{=}, `A * 2`{=} the value of A, of A * 2

The expression is what stands between the last = before {ans} and that = {ans}; everything before it is a label. A name is stored when it is a plain identifier (letters, digits, _: C_EDTA, ppm1) standing alone, or the word after a : ; or , (Run 1: V = ... stores V), and it is followed by = or by <- (R's assignment: `t18 <- 2.10092204`{=}, ppm1 <- C_dil * V = {ans:2dp}; the {ans} still goes after the last =). Text between the expression and the = breaks it (so 2 + 2 = {ans} is an error: so is not a number), and units go after {ans}, never inside an expression.

More than one {ans} in a span or a line (3.30.8). After text that does not end in =, a second {ans} prints the value just shown in its own format: n = t^2 * R^2 / e^2 = {ans:1dp}, so {ans:0dp} gives "... = 94.1, so 94". After text that ends in = it is a new calculation, and its expression is the longest tail of that text that reads as one, so the words before it are prose: n = 9.7 * 9.7 = {ans:1dp}, so round(n) = {ans:0dp} gives "... = 94.1, so round(n) = 94", and so k = round(n) = {ans:0dp} stores k.

3. Names (variables) keep the full value, not the rounded one: C_Ca = n_Ca / V = {ans:4sf} shows 0.02039 but stores 0.020386..., so `C_Ca * 2`{=} uses the full number and the numbers on the page cannot drift from one another. A name is found anywhere in the document, parts included (Results in 45-results.md may cite what 50-calculations.md defines). It means the nearest definition above the place that uses it, else the first one below; defining a name again is allowed (Run 2: V = ...) and the later uses see the later value. A name remembers the digits it was defined with, so `C_Ca`{=} prints 4 significant figures too. A name that is a function or constant (pi, sqrt, deg) is refused.

4. A ::: calc div does the same for its lines without any backticks

::: calc
Run 1: V = 20.60 - 0.00 = {ans:2dp} mL.\
C_EDTA = 0.4179 * 0.05000 / (100.09 * 0.5000 * V / 1000) = {ans:4sf} M

Run 2: V = 41.11 - 20.60 = {ans:2dp} mL.
:::

Only a line that holds {ans} is calculated; every other line, and the rest of the document, is plain text. ::: calc and ::: {.calc} are the same (as in Pandoc); {#id .calc} keeps its id. Up to the last {ans} a line has to be plain text; a line with bold, a link or math there is left as written with a WARN. What follows the last {ans} is copied as it is: y = 2 + 2 = {ans:1dp} ppm \ce{Ca^2+}, ... {ans} mol L^-1^, ... {ans} **M**, $..$ all print as typed. A `...`{=} span works inside the div too (a = 1 + 1 = {ans:1dp}, so a * 10{=0dp}), read in the order of the text, so it sees the definition of a above it and not a later one; the div's names are the document's names.

5. The numbers put in: {subs} (3.30.8). A lab report shows a calculation twice, with the names and with the numbers. Put {subs} between two = signs and pdfmd writes the expression with each name replaced by its value:

ppm1 = C_dil * V / 100.0 * 40.078 * 1000 = {subs} = {ans:2dp} ppm

prints ppm1 = C_dil * V / 100.0 * 40.078 * 1000 = 0.002039 * 34.41 / 100.0 * 40.078 * 1000 = 28.12 ppm. Each number is written in the digits its name was defined with ({ans:4sf} gives 0.002039, {ans:2dp} gives 34.41; a name defined by a hidden span uses calc: {digits}), but the computation always uses the full values, so the printed numbers may add up to a result that differs in the last digit from the printed one (a report says so once: "values are kept unrounded in the computation and rounded only for display"). Literals, operators, functions, pi and deg stay as typed; a negative value gets parentheses unless nothing before it could be taken for a sign ((-0.5)^2). It works in a span and in a ::: calc line.

To have it everywhere, set calc: {subs: true}: every {ans} that follows an expression holding names gets the substituted copy in front of it, without typing {subs}. An expression with no names (nothing to put in), a lone name, and a second {ans} that only repeats a value get no copy. For one line, {subs} forces it and {nosubs} (or {subs:off}) stops it.

Typeset, not typed: calc: {typeset: true} (3.32.14, off by default). A calculation line prints as a formula instead of the text it was typed as: Total DNA: m = c_s * V_el = {subs} = {ans:2dp} ng becomes Total DNA: m = cs · Vel = 28.07 · 75 = 2105.25 ng, with real subscripts and a centred dot, in the maths font of the document. The line is split in three: the label before the first name (Total DNA:, Run 1:) stays text, the formula (from the first name to the value) becomes maths, and what follows (the unit) stays text, tied to the value by a no-break space. It applies to {=} spans that print a calculation, the lines of a ::: calc div and the lines of a ```{=calc} block; a value on its own (`c_s`{=}, `2 + 2`{=}) stays a plain number in the sentence, and a table's cells are not touched. The rules: a name splits at its _: V_el is V with an upright el below, c_s is c with an italic s (one letter is an index, a longer word a label; several words after the base share one subscript, k_DNA_x), and digits after the letters are a subscript too: A260_s is A with 260, s; C_EDTA is CEDTA; a name that is a Greek word (rho, mu, Delta, pi) is the letter; any other word (ppm) is upright. * is a centred dot, ^ a superscript (x^(n+1) loses its brackets), |a - b| bars, sqrt cbrt abs floor ceil factorial their signs, sin ln log exp the usual operator names, other functions (round, mean, qt) upright, 1.2e-5 is 1.2 × 10-5, Inf is ∞. Each = starts its own piece of maths with a space before it, so a long line breaks in front of an =. It is the document's own maths (LaTeX, Typst, HTML, Word and OpenDocument typeset it); Markdown, GitHub Markdown and the plain-text writers have no maths and keep the text as typed. A formula that cannot be typeset prints as text, with one WARN calc:. Only what the document shows changes: the values, calc: {export: ...} and --check are the same either way.

Display mode: calc: {typeset: display} (3.32.15). The line above keeps its label and unit as ordinary text around the maths. With display each line of a ::: calc div or a {=calc} block is one centred display formula instead, \[ ... \] ($$ ... $$ in Markdown terms), and the label and the unit go inside it as \text{...}: Total DNA: m = cs · Vel = 2105.25 ng, centred, set in the maths font. A unit that is itself maths (ng/$\mu$L) is taken in too. A {=} span inside a sentence stays inline: a display formula would split the sentence. The price: a display formula does not wrap, so a very long line runs past the margin where inline mode would break it in front of an =; use {subs} sparingly there, or keep typeset: true. A line with anything but text and maths after its last {ans} (emphasis, a link) is typeset inline instead. --calc-typeset display and PDFMD_CALC_TYPESET=display do the same for one run.

Upright or italic, your choice: typeset-upright and typeset-italic (3.32.16). The default is by length: a one-letter piece of a name is a variable (italic), a longer one is a word (upright). A piece is the letters of a name's base or one word after an underscore: V_el has the pieces V and el, A260_s has A and s (digits are never styled). To go against the default, list the pieces:

pdfmd-options:
  calc:
    typeset: display
    typeset-upright: [V, s]    # V_el: an upright V; c_s: an upright s
    typeset-italic: [el, DNA]  # V_el, k_DNA: the word in italic

An upright piece is \mathrm{...}, which sets the letters in the roman face of the maths font, as one word and without the spaces TeX puts between variables (spaces inside the braces are ignored); an italic word of several letters is \mathit{...}, a real italic word (the bare letters DNA would be three variables, D N A). It applies wherever the piece stands, in every name and every line of the document. A list or a comma-separated string; --calc-typeset-upright V,s / --calc-typeset-italic el,DNA and PDFMD_CALC_TYPESET_UPRIGHT / _ITALIC do the same for a run. A piece in both lists, or one that is not letters (V_el, A260), is an error. Greek names are not styled: LaTeX's \mathrm does not make a Greek letter upright.

For one line only, write the same thing in its text (3.32.17): {upright:s,V} or {italic:el,DNA} anywhere in a ::: calc line, a ```{=calc} line or a {=} span: Total: m = c_s * V_el = {ans} ng {upright:s} sets the s of c_s upright in that line and nowhere else (every s of the line, not one of them: a piece looks one way in a line). The marker is taken out of the output with its space. It beats the document's lists; the same piece in both markers of a line is an error. There is no mark inside a name (V_[x]s): V_s has to stay one variable for the calculator, and Markdown rewrites most punctuation in a ::: calc line. No spaces inside the braces (Markdown splits a line at a space and the marker would break).

Known limitations of the styling (open, listed in TODO.md): Greek names (\alpha, V_\mu) cannot be set upright or italic, because \mathrm does not reach Greek letters in LaTeX (the glyph is missing) and the upright Greek commands of unicode-math are not shared by texmath and pdflatex; a marker styles every occurrence of its piece in the line, with no way to pick one; and a marker takes no spaces inside its braces.

6. Many definitions at once, and constants from a file (3.30.11). Hidden `name = value`{=} spans work for a few values; for a dozen use a block of statements, one per line, each as the text of a span (= or <- stores and prints nothing, a line with {ans} prints, blank lines are skipped, and a # starts a comment, a whole line or the end of one):

```{=calc}
t13 = 2.160369       # qt(0.975, 13)
t18 = 2.100922
C_dil = C_EDTA * 50.00 / 500.0 = {ans:4sf} M
```

Only the last line is printed. ```{=} (an empty format) means the same: Pandoc does not read it as a block, so pdfmd reads the lines again from your file (a document read from standard input has no file: write {=calc} there). The span spelling `x = 2`{=calc} is the same as `x = 2`{=}. A ::: {.calc .hide} div (or .hidden) holds statements in the same way and prints nothing; its lines are read as Markdown, so the raw block is safer when an expression has * or _ in it. Not the same thing: ```{.calc} is a code block with a class, shown as code and never calculated, which is how to show calc source.

To keep the values in one citable file, calc: {constants: tables/constants.csv} (or --calc-constants FILE) loads a CSV before the document is read, with the columns name,value[,digits[,note]] (a header row is fine; comma, semicolon or tab; # lines are skipped):

name,value,digits,note
t18,2.100922,4sf,"R qt(0.975, 18)"
stock,0.1000,,stock concentration

The text then writes `t18`{=} or `x = t18 * stock = {ans}`{=}. digits is the format {subs} and a bare `t18`{=} show for that name. The file is looked for beside the document, then beside its metadata files, then in the current folder. With --attach-source (or --keep-source, or --embed-metadata files) the file travels with the source, even when only a metadata file names it, and pdfmd --restore puts it back. A document that defines the same name again gets the usual WARN. Statistics: qt(0.975, 18) is also built in (see the functions below), so a constant that a function gives needs no file.

The mirror of that file is the export: calc: {export: tables/results.csv} (or --calc-export FILE, PDFMD_CALC_EXPORT for -j workers) writes the names of the calculator after a build that evaluated some, one row per name with the value of its last definition at full precision (%.17g) and its digits (4sf, 2dp, or empty), sorted by name:

name,value,digits
V,11.82,2dp
n,0.86749999999999972,3sf

It is read back by constants as it is (that is the format), so the results of one report can be the inputs of the next. It is an output, written beside the document unless the path is absolute, replaced whole (never half-written), and it does not travel with --attach-source; a value that is not a finite number is left out. A part built alone writes it only with the names from the parts above it in (seed-calc, see below); otherwise a NOTE says it was not written.

Digits. {ans:4sf} four significant figures (trailing zeros kept: 20.60, 0.02027; the digits before the point are never dropped, 1049 at 3sf is 1049), {ans:2dp} two decimal places (digits after the point: burette volumes). After a span a format works as well: `1/3`{=3sf} is 0.333. Without any format a value prints as R does: up to 7 figures, 20 and not 20.0. The default for a whole document is pdfmd-options: {calc: {digits: 4sf}}.

Operators: + - * /, ^ and ** (right to left, so -2^2 is -4), % and %% (remainder, -7 % 3 is 2), // and %/% (floor division, 7 // 2 is 3), parentheses, |a - b| (absolute value), and a % with no number after it is a percent sign, a label: 0.5 / 4 * 100% is 12.5. ×, ·, ÷ and − work. Numbers: 12, .5, 6.022e23. Constants: pi, deg (= pi/180), Inf. Functions: sin cos tan cot sec csc and asin acos atan acot (radians; atan(y, x) for two arguments), sinh cosh tanh asinh acosh atanh, sind cosd tand (degrees in) and asind acosd atand (degrees out), exp ln log (log(x, base)), log10 log2 sqrt cbrt abs sign floor ceil trunc, round(x, digits), min max (any number), hypot factorial. Statistics (3.30.10, lower tail, as in R): qnorm(p) and pnorm(z) for the normal distribution, qt(p, df) and pt(t, df) for Student's t. df may be a fraction (a Welch value such as 13.947) or Inf, so t = qt(0.975, 18) = {ans:4sf} prints 2.101 and qt(0.975, df_w) takes a name; qt is good to about 1e-12 and agrees with R's to the 12 figures it prints. Summaries of a few numbers (3.30.20; the numbers are written out or names, mean(R_A, R_B2)): sum mean median, var sd stdev sem (the sample ones, n - 1), rsd (the relative standard deviation in percent: rsd(10, 12, 11) is 9.09), ci(0.95, x1, x2, ...) (the half-width of the confidence interval, t * s / sqrt(n)), gstat(x1, x2, ...) (Grubbs' G, the largest |x - mean| / s) and gcrit(n, 0.05) (its two-sided critical value, as SciPy and the usual tables give it). They are ~R_E4 / mean(R_A, R_B2){=0dp} times lower works as written. These names may still be used for variables (mean = 5 and mean(1, 3) side by side); the older function names stay reserved. Angles are in radians unless the name ends in d: sin(30*deg) and sind(30) are both 0.5. A trig result closer to 0 than 1e-15 is 0 (sin(pi)).

round goes to the even digit on an exact half, as R and Python do (round(2.5) is 2, round(3.5) is 4, round(0.125, 2) is 0.12). For half away from zero (2.5 is 3) set calc: {round: away}.

Settings. Under pdfmd-options: (a document, a folder's metadata.yaml, the config file; pdfmd --setup has the same rows):

pdfmd-options:
  calc:
    enabled: true     # false (or `calc: off`) leaves every {=} and ::: calc as written
    digits: 4sf       # the default format: 4sf, 2dp; empty = as R prints
    round: even       # or away
    subs: false       # true puts the numbers into every calculation, see 5
    typeset: false    # true: each calculation as inline maths (names with subscripts, a centred dot); display: a centred formula per line
    typeset-upright: []   # pieces of names a typeset calculation sets upright, e.g. [V, s]
    typeset-italic: []    # pieces it sets italic, e.g. [el, DNA]
    noindent: lines   # the calculation lines of a ::: calc div have no first-line indent, its prose keeps the document's;
                      # true: no paragraph of the div is indented; false: the whole div gets the document's
    space: 0.75em     # room above and below a ::: calc div (pt, mm, cm, in, em); off for none
    align: left       # or center: the lines of a ::: calc div centred
    constants:        # a CSV of named values (name,value[,digits[,note]]), see 6
    export:           # a CSV the names are written to after a build (name,value,digits), the mirror of constants

On the command line: --calc / --no-calc, --calc-digits 4sf, --calc-round away, --calc-subs / --no-calc-subs, --calc-typeset [inline|display] / --no-calc-typeset, --calc-typeset-upright V,s, --calc-typeset-italic el,DNA, --calc-noindent [lines|all] / --no-calc-noindent, --calc-space 0.75em|off, --calc-align left|center, --calc-constants FILE, --calc-export FILE (the command line wins over the document).

When it cannot calculate (an unknown name, 1/0, asin(2), a name that depends on itself, {ans} without an = before it, a wrong format) the text is kept as written and pdfmd prints one WARN calc: ... naming it and the reason; nothing is dropped silently. Not done yet: calculating plain prose without {=} or a div, $..$ math, statistics on a column of a table, a mode that checks a number you typed, spacing the operators for you (C=n/V printed as C = n / V), .qmd spellings. docs/recipes.md 1.11 has the same in recipe form.

A part built alone sees the names of the whole document, in document order (3.30.19 for the parts above it, 3.31.44 for the parts below it). pdfmd report#60 builds the Discussion without the Calculations, so a `mean_A`{=2dp} there has nothing to find; pdfmd therefore has Pandoc read the whole document (the scaffold, then every part, the ones built marked) with the calculator only, nothing typeset (about a second), and the build starts from the names that came out. The rule is the full build's: a use sees the nearest definition above it, else the first one below it, and each value is the one the full build computes at that place, at full precision and with the digits it was defined with. Take an introduction that defines x = 2; a procedure that prints x and y; calculations that define y = x * 2 and then x = 3; a discussion that prints both. Built alone, the procedure prints 2 and 4 (y is not defined yet there, so it is the first definition below, computed while x was still 2), and the discussion prints 3 and 4, as the full build does. A name the part defines itself always wins over the other parts. The same holds for a section of an ordinary document (doc#procedure), with the sections above and below. Nothing is stored between builds, so editing part 50 and building part 60 shows the new number at once. The build says NOTE calc: 128 names taken from parts 10-50, 3 from parts 70-90 (defined below) (evaluated, not typeset). pdfmd-options: {seed-calc: off} or --seed-calc off gives both up (a name defined in a part left out is then undefined, with the WARN above); auto (the default) and scan are the same. Known bug (queued, 3eg): when you build two parts that are not next to each other (report#20+40), what the parts between them define counts as defined above both, so a number in part 20 can be the value a name gets only in part 30, and nothing says so. Build parts that are next to each other, or the whole document, until it is fixed.

A table of names (3.30.20). The shortest form is a div that holds the names: ::: calc-table, a line t# tinf, and :::. It makes a real table of the names (* any run of characters, ? one character, # a run of digits, so t# is t1, t2, t10 and not t_welch; a pattern lists its names in natural order, t2 before t10; a plain name stands where it is written), each with its value in the digits it was defined with, or digits="3dp" for all. labels="t1=$t_{1}$; V=Volume, mL" replaces the name in the first column (Markdown and math work; without name= the labels go to the rows in order); head="none" leaves the header out; a caption is a line inside the div, Table: Student's *t*, 95% (or : Student's *t*, 95% after a blank line, or caption="..."); something to cite needs the braces, ::: {#tbl:t .calc-table}, with the names and the caption in the div as before (names="t# tinf" also works, and then the other classes and attributes of the div go to the table). head="Symbol; Value" and labels are attributes too. A name counts as defined at the place of the div, as it does for a span there, so a table in an appendix shows the last value and one before the definitions shows the first. The braces take only .class, #id and key=value: {.calc-table t*} is not a div for Pandoc, and pdfmd says so with one WARN that gives the spelling. Anything that cannot be listed (a name that is not defined, a pattern that matches nothing, a value that cannot be computed) gives one WARN naming it and is shown as ? or left out; a div without names prints its own code where it stands.

Read it in the terminal: --view and --to ansi

pdfmd --view report                  # styled text in the window, through a pager when it is longer
cat notes.md | pdfmd --view          # a pipe is read as Markdown (also: pdfmd --view -)
pdfmd --view a.md b.md               # one stream, a dim rule with each file's name above it
pdfmd --view                         # the Markdown files here; with none, type or paste and press Ctrl-D
pdfmd report --to ansi               # report.ansi, the same text with its escape codes (also -o report.ansi)
pdfmd report --to ansi -o - | less -R   # -o - is standard output, for any format

What the window shows: headings, bold, italic, code, strike, links, lists with their numbers, block quotes, code blocks, rules, footnotes at the end, and every table as a box grid (a rule between rows, a bold header, columns as wide as their widest cell, right- and centre-aligned as the table says, cells wrapped to the window when it is narrower, wide characters counted as two; a cell that spans columns or rows is drawn across them, its text wrapped to the merged width, the borders inside it left out, and the crossings drawn as the neighbours ask: ┬ ┴ ┼ ├ ┤; with no Pandoc a Markdown pipe table cannot span, and an HTML <table> with colspan/rowspan is how a span reaches the built-in parser). The text is wrapped to the window's width (--width N for another; 120 when there is no window). Fenced code with a language (python, bash/sh, json, yaml, c, cpp, java, javascript, rust, go) has its keywords, strings, comments and numbers in colour; any other language, and every block when colour is off, stays as it was. Math is text: $\Delta G = -RT\ln K$ becomes ΔG = − RT ln K with the variables in italic, Mg^{2+} becomes Mg²⁺, a fraction (a+b)/(c+d). A formula on its own line ($$..$$) is centred and stacked where it can be: \frac{a}{b} on three rows with a bar as wide as the wider part, \sqrt{x} with an overbar (\sqrt[3]{x} with its index), \sum, \prod, \int and \lim with their limits above and below; everything else in the formula is set as the linear form sets it, and a formula with something that cannot be stacked (an aligned block, a script on a fraction) stays linear whole. Inline math stays linear. A picture is [image: alt] (path).

It is a build like any other, so discovery, metadata files, filters, citations and \ce{...} apply first (pdfmd reads Pandoc's AST; --to ansi writes the result to a file, --view to the screen). --view is --to ansi -o - plus a pager: $PDFMD_PAGER, else $PAGER, else the pager command you set (pdfmd-options: {pager_command: "bat --paging=always"}, in the document, a metadata file or the config, or a row in pdfmd --setup; the environment variables win over it), else less -FRX (it quits at once when the text fits the screen); --no-pager, pdfmd-options: {pager: false} or a row in pdfmd --setup turns the pager off. The styling is kept when the output is piped (pdfmd --view x.md | less -R) and removed by --color never or the NO_COLOR variable; -o - into a pipe is plain unless --color always. A link is words (address), or a clickable link in a terminal that draws them (iTerm2, WezTerm, Ghostty, VS Code) when the text is not paged. The notes of the build (OK, DEFAULTS) stay off the screen unless it failed or you gave --verbose; everything else a build prints goes to standard error, so standard output is the document and nothing else. --view -o x.ansi saves the file and shows nothing; --view with another --to is refused.

pdfmd - is the same idea for an ordinary build: the Markdown on standard input is built like a file, and the result is stdin.pdf (stdin.html, stdin.docx ... with --to) in the current folder, or the name -o gives it. The text sits in a hidden file in the folder while the build runs, so a relative picture or include resolves there, and the OK line calls it stdin.md. Plain pdfmd never reads standard input, a pipe included (a script's inherited pipe looks the same, so it builds the Markdown files of the folder, or says there are none and names pdfmd -); name the - when the pipe is the document.

echo '# Hi' | pdfmd -                 # stdin.pdf
cat notes.md | pdfmd - --to docx -o notes.docx

Limits: a formula of anything but fractions, roots, sums, products, integrals and limits stays linear (no tall brackets, no matrices); raw LaTeX, HTML and Word pieces are left out (--verbose counts them); code is coloured by a small tokenizer, not a parser (keywords, strings, comments and numbers, nothing finer). Without Pandoc the built-in parser reads the Markdown, so no filters, citations, footnotes or metadata files.

ASCII only: --to ascii

pdfmd report --to ascii               # report.ascii.txt: no byte above 127
pdfmd report --to ascii:gfm           # report.ascii.gfm.md (also ascii:markdown, commonmark, rst, org, asciidoc, plain)
pdfmd report -o notes.ascii.txt       # the name asks for it, as notes.gfm.md asks for --to gfm
pdfmd report -o notes.ascii.rst       # any text ending after .ascii: .gfm.md, .md, .rst, .org, .adoc
pdfmd report --to ascii --ascii-missing escape    # what has no ASCII form: \u65e5 instead of ?

For a terminal, an old mail gateway, a legacy system. The build is that of the format after the colon (txt when there is none), with the words mapped to ASCII before the writer sees them, so the writer escapes what the new spelling needs: a − at the start of a line that becomes - is written \-, not taken for a list; a table's columns are measured on the ASCII text. What is mapped, in this order: punctuation and spaces (curly quotes, — as --, … as ..., no-break spaces, soft hyphens), symbols (≤ <=, ± +/-, → ->, ° deg, ½ 1/2, © (c)), Greek letters by name (α alpha, Δ Delta, μm um; a Greek word is spelt: Σοφία Sophia), Cyrillic with its case (Привет Privet), accents (é e, ß ss, ł l), subscripts and superscripts (H_2O, x^2, CO_3^(2-); --gfm-scripts drop writes H2O), then the romanization packs for Hebrew, Arabic, Armenian, Georgian, Korean and Japanese kana, and Chinese and the rest when pypinyin or anyascii is installed (pdfmd --install translit). Math stays TeX, with macros ($\alpha \geq 2$); a link's address is percent-encoded (%C3%A9), not spelt. A character with no ASCII form is counted and named in a WARN, and written as ? (--ascii-missing question, the default), as \uXXXX (escape), as nothing (drop), or the file is not written at all (fail); pdfmd-options: {ascii: {missing: escape}} sets it per document. --keep-source on an ASCII file keeps the source packed (the readable form would be Unicode). It applies to text formats only: --to ascii:docx is refused.

Word and OpenDocument output

Plain Pandoc ignores papersize, geometry, mainfont and fontsize when it writes .docx or .odt: you get a Letter page in the default fonts. pdfmd writes them into the reference document Pandoc builds the file from, so the page and the text follow the same front matter as the PDF:

Front matter In the Word file
papersize: a4 (also letter, legal, a5, b5..., classoption: [a4paper, landscape]) page size and orientation
geometry: (a list, margin=2cm, top=1in,left=..., paperwidth=...) or margin: page margins
mainfont, sansfont, monofont, CJKmainfont, fontsize, linestretch, lang, indent body, heading and code fonts, sizes, line spacing, language, first-line indent

A .docx carries font names, not fonts, and the person opening it has Times New Roman, not STIX Two Text. So well-known fonts Word does not ship are mapped to the closest one it does (STIX Two Text, Libertinus, Latin Modern to Times New Roman; Helvetica Neue, Inter to Arial; JetBrains Mono, Menlo, Fira Code to Consolas), with an AUTO OFFICE note; office: {fonts: exact} keeps your names. Without any of these keys pdfmd's own look applies: black bold headings sized like a LaTeX article, a centred title block, booktabs-style table rules, Times New Roman and Consolas.

Your own template. A reference document decides the page and fonts itself, and pdfmd then changes only what the document's office: block names. It is found, in order, as office: {reference-doc: FILE}, <name>-reference.docx or reference.docx (.dotx works too, .odt/.ott for ODT, .pptx/.potx for slides) beside the document, in its metadata/ folder or beside its metadata files, and finally in ~/.config/pdfmd/. Pandoc's own --reference-doc is left alone. Only builds whose output is .docx/.odt/.pptx look for one. pdfmd --init-reference writes reference.docx (also odt, pptx): Pandoc's styles with pdfmd's look, to restyle in Word.

A template keeps its own style names. office: {styles: {Heading1: LRH1, BodyText: LRNormal, Table: TableGrid}} makes the Heading 1, Body Text and table Pandoc writes look like the template's own LR H1, LR Normal and Table Grid (a missing Pandoc style is created from the source); office: {replace: {NULAB-KICKER: "Course wording"}} rewrites text in the template's headers and footers; office: {title-page: true} gives the first page a page of its own, without header or footer.

A LaTeX package can tell pdfmd about itself. Beside a package's .sty (or in an office/ or pdfmd/ folder there) pdfmd looks for <package>-pdfmd.yaml, and for its Word support: <package>-reference.docx/.dotx, <package>-office.lua (the profile above) and <package>-office.yaml (the office: defaults, with by-option: entries for what an option of \usepackage[opt]{package} adds). A document that loads the package gets all of it with no setting of its own, and a package without these files behaves exactly as before.

<package>-pdfmd.yaml holds latex-keys:, the front-matter keys the package's macros are filled from (experiment: LabExperiment turns experiment: Titration into \renewcommand{\LabExperiment}{Titration} after the preamble), and an optional office: section, the same as <package>-office.yaml, for a package that wants one file:

latex-keys:
  experiment: LabExperiment
  group: LabGroup
office:
  reference-doc: house.dotx

The same file can list lua: (Lua filters beside the document besides <package>.lua, which a document that loads the package gets by name anyway), draft-skip: (commands: that take one argument and spans: of a start macro up to its stop macro, which the label scan's draft pass need not draw, as it skips chemfig) and picture-cache: (the macro names and the request-file suffix a package used for the picture cache before the protocol had neutral names, see "For a package author"). pdfmd names no package: all of this is found by reading the packages a document loads, in its preamble files or its header-includes:.

pdfmd-options:
  office:
    reference-doc: ../templates/house.dotx   # or leave it to be found
    papersize: a4                            # change just this on top of the template
    fonts: exact                             # keep STIX Two Text and friends as named
    style: plain                             # no pdfmd look (headings, title block, table rules)

--no-auto officeref turns the search off, --no-auto officestyle leaves Pandoc's default alone.

LaTeX in the document. Pandoc turns $x^2$ into a Word equation and drops everything else of LaTeX. For .docx and .odt pdfmd adds a filter that makes it native where it can:

  • \ce{Ag+(aq) + Cl-(aq) -> AgCl(s)} (mhchem) and \si{\gram\per\mole}, \qty{5}{\mL}, \num{1.5e-3} (siunitx) become formatted text with real subscripts, charges and units, and, inside math, Word equation pieces. \ce is read by a port of mhchem's own parser, so it comes out as LaTeX sets it; siunitx follows your \sisetup, \DeclareSIUnits and unit shortcuts, those of your own packages too. A formula with words over its arrow is an equation; the same in an HTML, EPUB, Markdown, text or Typst file and a PowerPoint deck, whose writers would drop them;
  • \begin{equation}, align, reaction... become Word equations with their number at the right;
  • raw figure, tabular, lists, \emph, \newpage... are read by Pandoc's own LaTeX reader;
  • \ref, \eqref, \cref, \Cref, \pageref and the "Figure 3." in front of a caption use the numbers LaTeX itself gave (pdfmd builds the PDF once, in its cache, and reads the .aux; reused while the sources are older), with the caption separator (labelsep=) and \crefnames of your preamble. Each reference is a link to what it names. A \label in a caption, in a block drawn as a picture or on any line of an equation becomes a bookmark there. In OpenDocument a labelled table, figure or div, and a citation's entry in the references, get one too, since its writer bookmarks none of them;
  • what is left is drawn by LaTeX: tikz, chemfig, a house style's own macros, math Pandoc cannot convert are compiled in your document's own preamble (the same fonts, packages and macros as the PDF, one fragment to a page), and the pages go into the Word file as vector pictures: an SVG with a 300 dpi PNG fallback (Word 2016 and later draw the SVG, LibreOffice and older readers the PNG). An inline fragment sits on the text's baseline, a wide one is scaled to the text width, a figure's caption stays text with its number. \includegraphics{plot.pdf} and ![](plot.pdf) (Word cannot show a PDF) are converted the same way. Pictures are cached by content, so a rebuild draws only what changed (pdfmd --clear-cache empties it). A fragment that will not compile is named in a warning and left out; the rest of the document is built. Needs pdftocairo (Poppler) and a LaTeX engine; without them the fragments are reported only.
  • a house style's own macros can be told to pdfmd: office.lua (or <name>-office.lua) beside the document, in its metadata/ folder or in ~/.config/pdfmd/, or office: {profile: FILE}, is a small Lua file: return {ignore = {"\\LabCaptionBegin"}, commands = {prelab = function(args) return {pandoc.Strong{pandoc.Str("[pre-lab " .. args[1] .. "]")}} end}} says which macros to drop and what the others mean in a Word file (a command may return text, or blocks such as a native table); an optional pandoc = function(doc, helpers) hook may restructure the whole document. The helpers include inlines(tex)/blocks(tex) (LaTeX read with the filter's own math, \ce and \si applied), read_file(name) (a file the document's \input would find), number_caption(table, "table"), labels(), preamble(), meta(), warn(text); a profile can thus build a house table from the package's own data, as nulabreport does for \chemicals.
  • what cannot be done at all is named in a warning (-v lists it).

pdfmd report.md --check-docx says what a Word build would make native and what it would draw as pictures, naming the environment or macro behind each (the tikzpicture environment, the macro \irspectrum), without drawing or writing anything: a quick way to see what is left to give a recipe (see the profile below).

pdfmd-options: {office: {latex: auto}} is the default; latex: images draws everything LaTeX says as a picture (formulas, \ce and units too, instead of making them native); latex: off or --no-auto officelatex skips all of it (Pandoc's own behaviour). office: {labels: off} skips the PDF build (numbers are then counted).

The same filter serves the other outputs when LaTeX is the problem: the soffice fallback (the Word file above, then LibreOffice: pdfmd -e soffice report.md is the .docx as LibreOffice draws it) now keeps the page size, margins and fonts, shows equations and draws what LaTeX can. A Typst or WeasyPrint PDF (named with -e, or after LaTeX failed) gets what the document says in LaTeX as native text or pictures instead of nothing, whenever it has LaTeX they would drop; raw: {typst: [typst]} (or office: {latex: off}) keeps Pandoc's own behaviour. HTML and Typst files (-o x.html, --to typst) do it on request (pdfmd-options: {office: {latex: auto}}).

Stopping part-way

--stop-at ends the build after a stage; everything before it runs as normal:

pdfmd report --stop-at markdown   # report.assembled.md: the parts joined into one file
pdfmd report --assemble-only      # the same, shorter
pdfmd report --stop-at tex        # the standalone .tex a LaTeX engine would get (= --to latex)

The assembled file holds just the document text (and is marked pdfmd-assembled: true), so pdfmd never joins its parts a second time.

By default it holds just the text. --embed-metadata also folds in what pdfmd finds beside the document (metadata, preamble, Lua filters, and the bibliography and CSL files the metadata names), so the file no longer needs them beside it. What the text points at otherwise (images, files a preamble \inputs) is not embedded: keep it where the document finds it, relative to the assembled file.

pdfmd report --merge                                           # everything: one file that builds alone, anywhere
pdfmd report --assemble-only --embed-metadata                  # metadata.yaml, preamble.tex, Lua filters, .bib/.csl
pdfmd report --assemble-only --embed-metadata metadata preamble   # only those
pdfmd report --assemble-only --embed-metadata --lua-mode ref   # name the filter instead of copying it

An embedded bibliography keeps only the entries the text cites, as an attached source does (--attach-bibliography all, or pdfmd-options.attach-bibliography: all, embeds the whole file); the .bib itself is left as it is.

An embedded Lua filter sits in a {=pdfmd} block at the end of the file. A Lua filter can run any command, so one only runs if this machine's pdfmd embedded it (otherwise it is skipped with a warning, unless you pass --trust-embedded).

--lua-mode apply runs the filters at assembly time instead, so the text already has their effect (approximate: Pandoc re-writes the text, and a filter that looks at FORMAT is embedded instead). --unpack goes the other way:

pdfmd report.assembled.md --unpack     # filters, preamble and metadata back into report.assembled.unpacked/

pdfmd finds report.assembled.unpacked/ beside report.assembled.md by itself (its metadata, preambles and Lua filters), and --unpack --slim strips the unpacked parts out of the assembled file, leaving the lean document plus that folder.

A document can name its own files and set what to embed, in pdfmd-options:

pdfmd-options:
  yaml: [base.yaml]        # metadata files (like -y); also `metadata:`, or grouped:
  metadata:
    preamble: my-preamble.tex
    lua-filter: my.lua
  embed: {lua: ref}        # what --assemble-only embeds without the flag

Named pdfmd-options.lua-filter and preamble files are read from the document, its metadata files, folder layers and the global setup. Each path is relative to the file that names it. The nearest list replaces lower lists; lua-filter+: and preamble+: append instead (key+=: is also accepted). Missing effective files are errors, and the ordinary Lua trust check applies. --setup has rows for both lists; unset, neither adds a named file. Discovered files still precede the named files, and --no-auto lua / --no-auto preamble suppress them.

Plain HTML output is a fragment with MathML maths by default; an explicit html.math, Pandoc maths flag or passed defaults file chooses the method. For a finished page, or one file with everything (images, CSS) inlined, ask for it, on the command line or in the document:

pdfmd lecture -o lecture.html --self-contained
pdfmd-options:
  default-output: html     # build to HTML when no format is given
  html: {self-contained: true, css: style.css}

OpenDocument output uses pdfmd-options: {odt: {engine: auto}} by default: the same complete Word build as .docx, then LibreOffice converts it to .odt. If LibreOffice is missing or conversion fails, Pandoc writes the .odt with one warning: raw Word blocks such as a cover or form and the Word route's extras are lost; install LibreOffice to keep them. odt.engine: soffice requires LibreOffice and errors on failure; odt.engine: pandoc uses Pandoc silently. -e soffice / -e pandoc on the command line wins over the key. In auto, an .odt/.ott reference document, named or discovered beside the source, selects Pandoc's styling with a NOTE. --setup offers the same OpenDocument writer choice.

The PDF carries its own source

A PDF can hold the document it was built from, so the file you send is also the file you can edit again. Ask for it, on the command line or in the document:

pdfmd report --attach-source     # the source goes into report.pdf as an attachment
pdfmd report --bundle            # ... and the image files too (the data and LaTeX inputs are carried anyway)
pdfmd --restore report.pdf       # write the folder back into report.restored/
pdfmd --check-source             # which PDFs in this folder carry their source (a file or folder works too)
$ pdfmd --check-source
NONE      WA1.pdf  no WA1.md beside it
SOURCE    WA3.pdf  WA3.md, 52 lines, pdfmd 3.28.22
NONE      WA5.pdf  WA5.md is beside it: pdfmd WA5.md --attach-source
CHECK     3 PDFs: 1 with the source, 2 without

--check-source (alias --sources) only reads the attachment list, so it is quick on a whole folder; it notes a Markdown file beside the PDF that is newer than the build, and exits 1 when a PDF has no source.

pdfmd-options:
  attach-source: true            # alias: embed-source
  bundle: sources                # the default: the files the text reads, no images
                                 # `true`: and the images · `all`: the whole folder · `false`: the source alone
  bundle-packages: true          # also your own LaTeX packages (see below)
  strip-comments: true           # the default for the attached copy; see "Comments"
  attach-bibliography: used      # or `all`
  attach-csl: false              # the default: the citation style is named, not attached (see below)

Put these in the shared metadata file and every document that finds it carries its source. The attachment is the assembled Markdown with the metadata, preamble, Lua filters and bibliography folded in, and the CSL style too when attach-csl: true (the file --assemble-only --embed-metadata writes), plus a manifest of the original layout, so --restore gives back report.md, metadata/, parts/ and the rest where they were, each as a standalone file (a symlinked metadata file or bibliography comes back as a copy). Any PDF viewer lists the attachments, and pdfmd --restore report.pdf --list shows them and what a rebuild needs. A restored folder builds the same document: checked on lab reports with a house style, a compound library, a shared bibliography and parts.

  • Images come out of the PDF itself. The PDF holds every picture it drew, so a plain attach needs no bundle for them: a JPEG comes back byte for byte, a PNG as the same pixels (alpha kept), and an SVG or PDF figure as a vector PDF (fig.svg becomes fig.pdf, and the restored Markdown follows).
  • The files the text reads travel with it. CSV data, LaTeX included with \input, listings: stored as separate attachments (files/...), found from the text, the preamble and, in turn, from the files found; also those only your own TeX tree resolves (\input{library} from texmf-home). A restored report builds again; only images are left out (the PDF has them). Over 100 MB (bundle-max-mb) only the source is attached. bundle: false (or --no-bundle) keeps the source alone; --bundle (bundle: true) stores the image files too, --bundle all the whole folder except output and housekeeping. Data whose file name a macro builds cannot be found: use all. A Markdown output (--keep-source) keeps the same files, but has no PDF to take pictures from: they come with --bundle.
  • --restore and --unpack stay short. They count what they write by folder; --verbose lists every file, and --restore --list always does.
  • Your own LaTeX packages (a house style in texmf-home) are recorded with their version, and --restore tells you what is missing; --bundle-packages stores them too: the style file, the <package>-pdfmd.yaml beside it (what the package tells pdfmd: its title-page keys, its picture-cache names), what it \inputs and the graphics it hands to \includegraphics (a logo named by \newcommand{\LogoFile}{logo}, not every word that happens to be a file's name). A package that sits in the document's own folder, as it does in a restored source, is kept when the source is attached again. Together with attach-csl a report that sets both in its shared metadata rebuilds on a machine with nothing of yours installed (checked in an empty home folder). The TeX distribution's own packages, fonts and Pandoc are not carried.
  • The text files are in the source's text. The .tex files a document reads and its CSV data are blocks of the attached pdfmd-source.md, after the text (.tex first, then the data) and before the bibliography and style at the very end, so one file holds everything you edit; the pictures and your package stay separate attachments (and a file that is not text, or too big, or whose line ends are mixed). <!-- pdfmd-at: ... --> comments say on which line each block is (an index after the front matter and one before each part of the text that names a file); pdfmd writes them whenever it writes the source and removes them wherever it reads one, so they never reach a build and a restore gives back your text as it was.
  • Files in sibling folders (\input{../schemes/x.tex}, a CSV in ../ir/) are stored too, up to two folders above the document's (never the home folder or above), as blocks of the text like the rest (their path starts with ..), and --restore puts the folder back in the same layout: the document in <folder>/<its folder's name>/, the siblings beside it. A file with Windows line ends (CRLF all through) is a block with an eol: crlf line and comes back byte for byte. pdfmd --edit report.pdf writes those files where the document reads them before every build, so editing a block is editing the file; a build of the source file alone (no --edit) never writes outside its own folder and says so when a file it reads from there is missing. A bibliography the metadata names by the real folder of a symlinked metadata file (a shared nulab.bib) is embedded, pruned to the entries the text cites.
  • A newer source is refused, not half restored. The manifest has a format number (2 since v3.28.45, when the chain of folders, the text files as blocks and the .. files came in). A pdfmd that reads up to 1 stops with was made by a newer pdfmd (...); upgrade pdfmd to restore it, and this one restores both 1 and 2.
  • Comments. Notes to yourself do not travel: the attached source loses its <!-- --> comments, the LaTeX preamble's % comments, the text between a .bib's entries, a CSL file's XML comments and the # comments of YAML files. Choose per kind: strip-comments: [preamble, bibliography] or {markdown: false} (--strip-comments-in, --keep-comments-in). Code blocks, \verb, \url and verbatim environments are never touched, and a % that closes a line keeps a bare %, because it swallows the line break. With strip-comments: false (or metadata kept) the attached source gives back your metadata files and the document's front matter byte for byte, comments and quoting included; --restore of such a PDF is a full source copy. The data files of .csv blocks (tables/*.csv) are always stored with the source.
  • Your global metadata (pdfmd --setup) stays yours. It counts in the source below the document's own files, as in a build, but --restore does not write it back. It names its keys instead, and a build of the restored folder uses the global metadata of the machine it runs on.
  • Only the bibliography entries you cite are attached (and what they cross-reference), not a shared file of hundreds; attach-bibliography: all keeps it whole, and nocite: '@*' keeps every entry.
  • The citation style is not attached unless you ask (--attach-csl, attach-csl: true, --embed-metadata csl for an assembled file). A style is 10-90 KB (apa.csl 86 KB, nu-labreport.csl 72 KB, ieee.csl 18 KB) and the global metadata sets one for every document, cited or not. A style is only ever attached when the document cites something. Without it the manifest records the name and --restore says that a build needs a style of that name (Pandoc's own csl/ folder, or beside the document); a house style that exists nowhere else should travel, so set attach-csl: true for it.
  • It is opt-in, and it is everything you wrote. An attached source includes what the author would not send in an email; check --list before sharing.
  • --restore never overwrites a file and never writes a path outside its folder. A folder you linked elsewhere is followed. A restored Lua filter is code that pdfmd runs beside the document, so restore names it: read it first if the PDF is not from you.
  • -r/--report builds attach their chapters too. A section build (doc#section) attaches nothing.

PDF to Markdown

The direction follows the files you give it:

pdfmd paper.pdf                  # -> paper.md, beside it
pdfmd paper.pdf -o notes/p.md    # an exact file; -o notes/ puts notes/paper.md
pdfmd paper.pdf --to txt         # plain text
pdfmd scan.pdf --lang eng+rus --ocr tesseract --export-images

A PDF that carries its own pdfmd source (above) is restored instead; --extract (or naming an output file) reads its pages. Everything else is read by batchocr: the text layer in reading order, OCR for scanned pages, headings, lists, tables, running headers removed, <!-- Page N --> markers (--no-page-markers, --page-breaks, --keep-headers). pdfmd only routes: any flag it does not know goes to batchocr unchanged, so the options of M1ck4's pdfmd (--ocr, --lang, --export-images, --page-breaks, --preview-only, --stats, -q, --output) work as they did (-o - writes the Markdown to standard output). Install the reader once:

pdfmd --install batchocr         # pip install batchocr[md] from its GitHub release,
                                 # then offers the system programs below

Scanned pages also need Tesseract (OCR) and Poppler (pdftotext, pdftoppm). Those are separate programs with their own builds for every platform, so pdfmd does not bundle them; --install batchocr finds your package manager (Homebrew, apt, dnf, pacman, zypper, apk, scoop, Chocolatey or winget), shows the exact command and, on an interactive terminal, runs it if you say yes (PDFMD_NO_PROMPT=1 only prints it). By hand:

brew install tesseract poppler                       # macOS (and Linuxbrew)
sudo apt install tesseract-ocr poppler-utils         # Debian/Ubuntu; dnf: tesseract poppler-utils
scoop install tesseract poppler                      # Windows (or choco install ...)

OCR languages are packs of their own. pdfmd --install ocr lists the Tesseract languages pdfmd can fetch, and pdfmd --install ocr:rus,kaz (or ru, kazakh, zh, ja...) downloads them into pdfmd's own folder, with no admin rights and each file checked against a checksum pinned in pdfmd (the compact tessdata_fast models, Apache-2.0; English and the orientation model come along). pdfmd scan.pdf --lang rus then uses that folder whenever it has every language you asked for, and Tesseract's own languages otherwise (a TESSDATA_PREFIX you set always wins). pdfmd --uninstall ocr:rus removes one. The package managers have them too: brew install tesseract-lang, apt install tesseract-ocr-rus, dnf install tesseract-langpack-rus. pdfmd --check-dependencies reports batchocr, Tesseract and Poppler.

Credits and licences: batchocr's Markdown structure stages are M1ck4's (MIT; that project is archived) and are vendored inside batchocr with their licence. PyMuPDF, which they use, is AGPL-3.0: it is installed as its own package by batchocr's md extra and is never part of pdfmd. The command name pdfmd is also used by that project and by an unrelated PyPI package of the same name; this one installs as pdfmd-cli (and pdfmd), so if two are installed use pdfmd-cli to be sure which one runs.

Bookmarks, header and footer, linked files

Ideas from mdpdf (MIT), done on the finished PDF with pypdf, so they work with every engine. All off unless you ask:

pdfmd report.md --bookmarks                    # PDF bookmarks from the headings, if the engine made none
pdfmd report.md --header "{title},,{date}" --footer ",Page {page} of {pages},{header}"
pdfmd report.md --attach-links                 # the local files the text links to travel inside the PDF
pdfmd report.md --pdf-author "A. Writer" --pdf-keywords "kinetics, rate"  --paper letter

A template is left,middle,right; {page} {pages} {header} {date} {title} are filled in per page ({header} is the current top-level heading; write \, for a comma inside a field). The text is Helvetica, so letters outside Latin-1 become their base letter or ?. --attach-links stores each linked file as linked/PATH and puts a paperclip in the margin beside the link.

Use it from Python

import pdfmd

pdfmd.convert_file("report.md", "docx")                      # -> Path("report.docx")
pdfmd.convert_file("report.md", "pdf", "out/report.pdf", variables={"geometry": "margin=2cm"},
                   extra_args=["--engine", "typst"])
html = pdfmd.convert_text("# Hi\n\nSome *text*.\n", "html")   # str for text formats, bytes for pdf/docx/odt/pptx/epub
rst = pdfmd.convert_text(open("notes.rst").read(), "docx", format="rst")

Each call runs the command-line tool (as pypandoc runs Pandoc), so a document builds exactly as pdfmd would build it, with all the auto-discovery of the document's folder, and nothing in the caller's process is touched. The source is not stamped or backed up unless you ask (stamp=True, backup=True). A failure raises pdfmd.PdfmdError (with returncode, stdout and stderr); check=False returns the path anyway. metadata= takes metadata files, variables= a dict of Pandoc variables, extra_args= anything else the command line takes, cwd= the folder it runs in and timeout= seconds.

A local server for the web UI and for your own tools: --serve and --api

pdfmd --serve [FOLDER] starts a small server for one project folder (the working folder by default) and prints one JSON line: {"api": 1, "url": "http://127.0.0.1:PORT/?t=TOKEN", "port": ..., "token": ..., "root": ...}. Open the url: it shows the project in the browser (a file tree, a Markdown editor, a live view rendered by Pandoc), from pages that ship inside pdfmd; nothing else is installed and nothing is fetched from the network. Ctrl-C stops it. The Mac app's web window uses the same page.

The server is meant to be safe on a shared machine: it listens on 127.0.0.1 only, on a random port; every request needs the token (the printed URL trades it for a cookie once, a script sends Authorization: Bearer TOKEN); a request is refused when its Host is not 127.0.0.1 or localhost (DNS rebinding) or, for a write, when its Origin is another site; files are reached only below the project folder (.., absolute paths and links out of the folder are refused) and the page is sent with a policy that stops a script in a document's raw HTML from running. No CORS header is sent, so no other site can read an answer. A request body over serve-max-request-mb (in the setup's options, 100 MB by default; 0 = request bodies switched off, so the studio is read-only and every save, new file or render is refused (403); a negative number = no limit) is refused (413) before it is read. On macOS and Linux a server that is told which process to watch, by the environment variable PDFMD_SERVE_PARENT (the Mac app sets it to its own process id), stops by itself when that process is gone (the app crashed), instead of idling on its port. A server started from a terminal, under nohup or by a script is not watched.

The JSON is a public contract for other programs too, and pdfmd --api METHOD PATH [JSON] answers one request on stdout without a server (JSON may be - to read it from standard input; the exit status is 1 for an error answer):

pdfmd --api GET /api/project                       # the folder: main document and why, discovered files, tree
pdfmd --api GET '/api/file?path=report.md'         # {text, encoding, lineEnding, mtime}
pdfmd --api PUT /api/file '{"path": "report.md", "text": "...", "mtime": "..."}'
pdfmd --api POST /api/file '{"path": "notes.md", "text": ""}'      # a new file; never overwrites
pdfmd --api GET '/api/outline?path=report.md'     # the headings
pdfmd --api POST /api/render '{"path": "report.md", "text": "# Hi"}'   # Pandoc's HTML with source positions
# GET /api/raw?path=photos/a.png    a picture or a PDF of the folder, as it is (the one answer that is not JSON; over --serve only)

Every answer carries "api": 1 (an addition keeps the number), and an error is {"api": 1, "error": ..., "code": ...} with an HTTP-like status. A save sends the mtime it read and gets 409 when the file changed on disk meanwhile, so an editor and a build never overwrite each other; it keeps the file's line endings (CRLF or LF) and byte-order mark. The live view's render runs the same filters a build does on pdfmd's own syntax (CSV tables, calc, callouts, page breaks and the rest: one list in pdfmd.py, document_filter_args, used by every writer and by this), so a ::: {.csv file=...} block is a table there as in a build. Those filters open only files of the project in the live view (and pdfmd's own cache and filter folder): a name in the text that leads out of the folder (.., an absolute path, a link) is refused with a warning, so a CSV beside the project or a preamble that a global setting names is not read there; a program a filter starts (a LaTeX picture, a diagram tool) is not held to the folder, and the live view does not use Pandoc's user data folder; what only the Markdown reader reads does not show yet (citations [@key] and : caption lines under tables stay as typed: Pandoc's CommonMark reader gives block positions but not those). Builds, checks, history and settings are not in the API yet.

Other command names: mdpdf, md2pdf, inkmd, batchmd and pan-tec

pdfmd also installs mdpdf, md2pdf, inkmd, batchmd and pan-tec commands that take those tools' own keys and do what they did, with pdfmd behind them:

mdpdf -o out.pdf -f ",,{page}" -a "Name" -p A4 chapter1.md chapter2.md
inkmd notes.md -o notes.pdf --page-size a4 --family times
cat notes.md | inkmd > notes.pdf            # standard input and output, like inkmd
inkmd notes.md -e lualatex                   # naming another engine uses it instead
batchmd ~/notes                              # every .md in the folder, as pdfmd -b ~/notes
batchmd -e inkmd                             # the current folder, with the built-in renderer

md2pdf answers the three tools that use that name: md2pdf -i notes.md -o notes.pdf -c style.css (jmaupetit's), md2pdf notes.md --stylesheet style.css (md-to-pdf's) and md2pdf notes.md --theme my.css (kxxoling's). A file alone is pdfmd notes.md. A style sheet is read by the HTML engines only, so -e html is added when one is given and no engine is named. What pdfmd has no counterpart for (Markdown extensions -e pymdownx..., -C, --pdf-options, --config-file, a built-in --theme github) is named on standard error and left out. If another md2pdf is installed too, whichever was installed last owns the command name.

pan-tec is the first version of pdfmd, a shell function around pandoc "$@" --pdf-engine=tectonic --pdf-engine-opt=--chatter=minimal (v3.29): pan-tec test.md -o ex.pdf is pdfmd -e tectonic test.md -o ex.pdf --pdf-engine-opt=--chatter=minimal, with everything else pdfmd does. Name another engine (-e typst) or a chatter level of your own and that is used instead.

batchmd is pdfmd -b: each Markdown file in the folder becomes its own PDF beside it. It takes the current folder when you name none (pdfmd -b alone refuses), and every other option is pdfmd's.

mdpdf turns bookmarks and attached links on and combines several inputs into one PDF; like the Go mdpdf it also takes the output as a second name (mdpdf notes.md notes.pdf). The npm mdpdf has other keys under the same name, and mdpdf answers those too: --style FILE (and the MDPDF_STYLES variable) is a style sheet, --format A4|Letter the paper, --orientation landscape a turned page, and --border, --border-top|-left|-bottom|-right the margins (mm, cm, in, px). A style sheet is read by the HTML engines only, so -e html is added unless an engine is named. --header/--footer are told apart by their value: a .html or .hbs file is the npm one's (Puppeteer markup, which pdfmd cannot use, so it is named on standard error and left out, with --h-height, --f-height, --no-emoji, --gh-style and --debug); anything else is the PyPI one's "left,middle,right" template. inkmd is pdfmd -e inkmd; like inkmd it writes the PDF to standard output when you give -o -, or no -o while output is piped (at a terminal it writes notes.pdf beside the file instead). The default page is pdfmd's, not Letter: pass -p letter / --page-size letter. If the original mdpdf or inkmd package is installed too, whichever was installed last owns the command name; both accept the original's keys.

Slides

pdfmd slides -p                   # Beamer slides; each heading starts a slide

Speaker notes (v3.32.2). A ::: notes block under a slide is dropped from the PDF unless you ask for it with slide-notes in pdfmd-options (or the config and the pdfmd --setup row "Speaker notes"): show puts a notes page after each slide, only makes the PDF the notes pages alone (to print), off (the default) leaves them out. It adds Beamer's show notes / show only notes as a header, so the deck's own classoption (aspectratio=169) stays; -V classoption=notes=show would replace it. It acts on a -p deck built with a LaTeX engine and nothing else.

---
title: Talk
pdfmd-options:
  slide-notes: show
---

# Results

The yield doubled.

::: notes
Mention the control run before the numbers.
:::

A Beamer slide from examples/slides.md

A whole folder

$ pdfmd notes -b
DEFAULTS  margin 1in, font STIX Two Text, code in JetBrains Mono, title from heading
AUTO: READER. Use --verbose to see in full
OK    notes/lecture.md > lecture.pdf
OK    notes/week1.md > week1.pdf
OK    notes/week2.md > week2.pdf

-b accepts files, folders or a mixture: pdfmd -b a.md b.md, pdfmd -b a.md notes/ or pdfmd -b *. Each folder supplies its .md files as before; explicit files can also use other Pandoc input formats. Several paths without -b do the same (pdfmd a.md b.md or pdfmd *); -r still joins them into one document, and a single path keeps single-file mode. Resolved duplicates are built once. Non-input paths, including PDFs, pictures and folders without Markdown, are skipped in one NOTE; if nothing remains, the command fails. Special single-file readers such as PDF-to-Markdown, Calibre ebook input and Quarto are excluded from batches; Pandoc-readable EPUB files are accepted. With two paths, a skipped PDF beside a selected input also gives the hint to name the output, use -o NAME.pdf. To build just that PDF as an input, name it alone.

Every input becomes its own output; -j N sets the number of workers over the whole list. -o DIR collects the results in a folder; -o FILE.pdf is refused (an existing directory with that name is allowed). Single-document options such as --watch and --section are refused as in folder batches. --bopen SPEC opens some of the finished files when the batch is done: f5 the first five, l5 the last five, fl10 the first five and the last five, e3 the third, sixth, ninth ... file, s3 the first, fourth, seventh ... (Python's [::3]), f2,l3 a mix, or none for all (any number works; the files open in the order the batch lists them). Add --recursive to include subfolders, and -o DIR to collect the PDFs somewhere else. A document with priority: never is left out of the batch (one NOTE names it); --all includes it. An explicitly named file is built even with priority: never. draft and the numbers do not matter here: they only choose one document.

A book or report from several files

$ pdfmd book -r -o book.pdf
OK    book/01-intro.md
OK    book/02-methods.md
DEFAULTS  margin 1in, font STIX Two Text
OK    REPORT  book.pdf

-r (also spelled --report or --book) joins every .md in the folder into a single PDF, in the order of each file's chapter: front-matter field. See examples/book/. -i FILE leaves one file out, and --exclude-unnumbered skips files without a chapter:.

How it is built. A report is built by the build of one document: the first file is the document and the others are joined after it, so a report gets everything a single document gets (the document and math fonts, -f/--font, the retry with another font when a glyph is missing, the engine chain, citation-engine, the cache, the style sheets, the budgets and --strict), and prints the same DEFAULTS line. The first file's front matter is the book's; the front matter of the later files is not merged (a NOTE names the keys one of them sets that the report does not use). Set pdfmd-options: {report-build: patch} (or the --setup row) for the older build, one Pandoc command over the list of files; it has the document and math fonts and the titles below, and not the font retry, the cache route or the other single-document steps.

Chapters. A report is chapters: unless the files or a metadata file name a documentclass:, a PDF or LaTeX report uses the report class and each level-1 heading is a \chapter (a new page, a large title). documentclass: book is kept, and so is article, where a level-1 heading stays a section (a NOTE says so).

Titles. Pandoc merges the front matter of several files and the later title: replaces the earlier, so a report of ten chapters was titled by its last chapter. With two or more files that have a title:, each title is now a chapter heading (level 1), the file's own headings go one level down, its subtitle: is a line under the heading, and so are its author: and date: when they differ from the book's (the first file's, else a metadata file's). The book then has no title page of its own unless a metadata file sets a title:. A single title is the book's, as before. pdfmd-options: {report-titles: first|last} keeps the first or the last title as the book's instead (no headings are added).

One long document in several files

For an article-style document that has grown too long to edit as one file (and unlike -r, one that should read as a single document, not a sequence of chapters), keep a scaffold with the front matter, and the text in a parts/ folder beside it, one file per section, each starting with its own heading:

report.md                    front matter only (title, author, ...)
parts/10-introduction.md
parts/20-methods.md
parts/30-discussion.md

Switch it on once, in a metadata.yaml shared by your documents, so the content files carry no typesetting:

pdfmd-options:
  parts: auto        # a document is a scaffold only if parts/ or sections/ exists
$ pdfmd report                  # the whole document, report.pdf
$ pdfmd report#methods          # just that part, report.methods.pdf
$ pdfmd report#discussion+appendix      # several: always in report order
$ pdfmd report#introduction     # 05-introduction.md and 10-introduction.md both, if both exist
$ pdfmd parts/20-methods.md     # same as report#methods
$ pdfmd report --list-parts
$ pdfmd old-report.md --split new-folder   # cut an existing single file into parts
$ pdfmd old-report.md --split new-folder --split-depth 2   # ...and subsections too

The parts are joined in filename order into one Pandoc run, so the result is identical to the same text in a single file: labels, citations and numbering work across parts, and paths are written relative to the scaffold's folder whichever part they are in. A part rebuilt alone is much faster to compile and still shows the numbers of the others: before it is typeset, pdfmd has Pandoc write the LaTeX of the whole document once more (about a second, no images, no engine), reads which headings, captions, equations and labels it holds, and has LaTeX replay them at the start of the part. A reference to a section, figure, table or equation outside the part then prints its number (Section 2, Equation (3), a \cref too), in the document's own number style (Roman sections, numbers per chapter), as a link that leads nowhere; the part's own sections, figures and equations continue from the parts before it. What the scan cannot count is whatever the document defines itself (a macro that opens a figure, say): that label stays ??, and the build says the numbers are approximate. So does a label after a caption in an environment the document defines itself (a table wrapper of a house style): it prints ??, never the number of the heading before it, and the build says how many there were. \pageref is always ??. The last full build's .aux (cache on, below) is exact and is used first; the scan fills in what it does not know. pdfmd-options: {seed-labels: auto|aux|scan|draft|off} or --seed-labels picks the source (draft runs LaTeX once over the whole document without output and without drawing its pictures, TikZ, pgfplots, chemfig and images, for exact numbers at a fraction of a compile, though a page reference into what was left out prints ??; --no-auto draftpictures draws them); off brings back the ??. A section of an ordinary document (doc#results) gets the same for the references it makes, but keeps numbering its own headings from 1.

For HTML, EPUB, Word, OpenDocument, Typst and PDF through an engine that is not LaTeX the same idea works differently: pdfmd hands Pandoc the whole document with each part in a marked div, lets pandoc-crossref number and resolve everything, and then keeps only the part you asked for (select.lua). Its headings, figures, tables and equations keep the full document's numbers, and [@sec:x], [@fig:y] to the rest resolve (as links that lead nowhere). Raw LaTeX \ref{..} is a LaTeX-only reference: use pandoc-crossref's syntax for those writers. See examples/parts/.

Faster rebuilds: the cache

pdfmd-options:
  cache: {aux: true}      # or: pdfmd report --cache

keeps LaTeX's cross-reference files between builds (in ~/.cache/pdfmd), so an unchanged document is typeset once instead of two or three times, and a part built on its own shows the exact numbers of the parts left out (taken from the last full build; without the cache they are counted from the sources, above). It never skips a build: Pandoc and LaTeX still process the whole document from the current sources and package every time, so a change shows in the next build. cache: {plots: true} (or --cache-plots; LuaLaTeX) additionally stores each whole picture of a package that takes part (nulabreport's plots and chemical schemes do) as a PDF in the document's cache folder and reuses it (a part built alone uses the whole document's, unless its preamble differs by more than the title) until the package, the preamble, the engine, TeX Live's packages (a tlmgr update) or a file the plot read changes (its data file, a file of the package's own: checked by SHA-1 of the contents before every build). Off by default; --no-cache for one build; pdfmd --clear-cache (or deleting the folder) is always safe.

For a package author: the protocol names no package. A style file that mentions pdfmdCacheDir takes part; pdfmd then defines \pdfmdCacheDir (the folder, with a trailing slash) and \pdfmdCacheVersion (3: the markers below are version 3) before the document. The package looks a picture up by its own key as <folder>/<key>.pdf with its size in <key>.dim (\pdfmdCacheWd, Ht, Dp), typesets inline on a miss, and appends <key>@@|@@<macro>@@|@@<arguments>@@|@@<context> to <job>.pdfmdreq. After the build pdfmd runs each request in a standalone job with the same preamble (\pdfmdCacheRender defined, so there is no lookup): the context, then \setbox0\hbox{<macro><arguments>}; that box is the page. A context may hold @@plain@@ (what follows is read with ordinary catcodes, not expl3's: settings with spaces in their names), @@runs=2@@ (typeset twice, for a picture whose arrows point at its own nodes) and @@margin=56pt@@ (the page is the box plus that on every side, \pdfmdCacheMargin in the .dim: for ink that overflows its box, which you include shifted back). If the job defines \pdfmdCachePost after the box, its text comes back as \pdfmdCachePostStored in the .dim, for you to run on a hit (a global the picture changes). nulabreport's first names (\LabPlotCacheDir, .plotreq, \Lab@pc@wd) still work as aliases.

Where the cache lives. By default in ~/.cache/pdfmd, one folder per document. Folders get moved (a lab report from Downloads into OneDrive), and a cache keyed by the old path would be lost: so each cache folder records which document it is, and a document with no cache under its new path takes over the one of the same document that has lost its original: the same content (SHA-256), or, if you edited it before moving it, the same name in a folder of the same name, when only one fits. A copy (the original still there) never takes the original's cache, and plot entries find their data files again under the new folders. Or keep the cache with the document:

pdfmd-options:
  cache: {aux: true, location: document}   # or: --cache-location document

puts it in a .cache/pdfmd folder beside the document (with a CACHEDIR.TAG, so backup tools skip it, and a .gitignore), which travels with the folder. It is yours to choose per document, in the config file for everything, or with --cache-location. --clear-cache clears both places. (The colour-emoji pictures LaTeX uses are in the same cache.) A word other than global or document stops a build that uses the cache; with the cache off it is a warning.

pdfmd --cache-info shows the cache folder and the size of each part: the documents' own caches (<hash>-<name>/), the build records (builds/), the folders every document shares (tex/, office/, images/, ...) and the loose files, and how much is stale: a document cache or a document's build records whose document is gone (each records its document: the cache in .pdfmd-source.json, a build record in its document: line; one that records none is counted as unrecorded, not stale). pdfmd --clear-cache WHAT clears one part of it: builds, documents, stale or all (the cache folder and the .cache/pdfmd of the folder you are in, as a bare --clear-cache without a document); each folder removed is printed with its size. A bare --clear-cache keeps its meaning (with a document its cache, without one all of it). Nothing outside the cache is touched: a symbolic link in it is removed as a link, never followed, and a cache folder that is itself a link is not cleared by all. pdfmd --doctor adds a NOTE when the cache is larger than cache.max-mb (the config file's options: {cache: {max-mb: 1000}}, the default, 1 GB; 0 never; pdfmd --setup, Cache).

Tables straight from a CSV file

Measured values:

::: {.csv file="data.csv"}
:::

A CSV file rendered as a table

Or write the data in the document itself, in a code block so that nothing in it is read as Markdown:

::: {.csv align="lrr"}
```
Compound,Mass (g),Price
Water,18.015,1.20
"Ethanol, dry",46.07,3.45
```
:::

: Masses and prices {#tbl:m}
  • Data without attributes may use the bare ::: csv (no braces): ::: csv with the lines inside builds the same table as ::: {.csv}. file=, align= and the other attributes need the braces. ::: csvfile and ::: csv-x are other classes. --extract-inline-csv moves a bare block's data to a file (the block then has braces, for the file=), --expand-tables turns it into a pipe table, and --check reads it as it reads the braced one.
  • To show CSV lines as text in a document that has .csv divs, switch the table off: pdfmd-options: {csv: off} (the short form of csv: {enabled: false}; a mapping such as csv: {rows: 30} leaves the tables on), --no-csv for a run (--csv turns them back on), or the row in pdfmd --setup. The filter is then not added and the div stays as written, a plain div with its text, without a warning.
  • Cells are read as Pandoc Markdown (H~2~O, $x^2$, \ce{...}, [@key]); reader="gfm" makes them plain GFM.
  • A caption is the line after the block (: Caption {#tbl:id}, or Table: Caption), or caption="Caption" inside the braces, with the identifier as #tbl:id there too. A caption in the attributes wins over a line after the block.
  • A relative file= is found beside the document (and along the resource path), wherever Pandoc runs. With both file= and data inside the block, the file is used.
  • The delimiter is delimiter=";" or its name (semicolon, tab, pipe, space, colon, comma), or pdfmd-options: {csv: {delimiter: semicolon}} for every table; without one it is guessed: whichever of , ; tab | splits the first lines into the same number of fields (the extension's own, comma or tab for .tsv, on a tie). When the guess is not what the file's name says (a ; file named .csv), the build warns and says how to name it.
  • Columns are aligned with align="lcr" (or align="left,center,right"), or by a second row in the data made of ---, :--, :-:, --: as in a pipe table; align= wins. If such a row is really data, say separator="none". widths="5,1,1" sets relative widths, and a second row with unequal dashes does too. Without either, pdfmd picks widths that suit the contents.
  • A spreadsheet (v3.29) works as the file: ::: {.csv file="results.xlsx" sheet="Run 2"} (.xlsx, .xlsm, .xls, .ods; sheet= is a name or a number counted from 1, the first sheet when it is left out). The sheet is read by openpyxl (xlsx and xlsm: the values the file stores, so a cell shown as 1.50 is 1.5, a date is 2026-01-02), else by pandas, else by LibreOffice (the sheet by number); none of them is a WARN that says what to install (pip install openpyxl). The converted CSV is kept in pdfmd's cache by the file's content, the attached source carries the spreadsheet itself, and --expand-tables reads it too.
  • The first row is the header unless you add header="false".
  • Large files are capped at 10 rows × 7 columns, so a huge CSV can't silently fill 40 pages. A cut table keeps both ends: the first 7 rows, a row of …, the last 3, and the first 5 columns, a … column, the last 2 (about any cap keeps its last ⌊√cap⌋: 16 + 4 of 20, 25 + 5 of 30; cut="head" or csv: {cut: head} keeps the first ones only). rows=all or cols=20 raise the cap. When a table is cut, the build warns (once per table, with how to show more) and the document shows a short note under the table, only for what was cut: (showing the first 7 and the last 3 of 15 rows). note=off on the div (or --csv-note off, or csv: {note: off}) leaves the note out, the … row still marks the cut, and note=full (--csv-note full) adds the advice to the note as earlier versions did. For every table of a document: --csv-rows N|all / --csv-cols N|all, or pdfmd-options: {csv: {rows: all, cols: 12, cut: ends, note: short, delimiter: auto, header: false, reader: gfm}} (in the front matter, a metadata file, or the config, where pdfmd --setup sets rows, columns, the cut, the note and the delimiter); a div's own attributes still win.

Keeping a short table on one page. A Lua filter can read the metadata key tablekeep (the one in nulabreport, a lab-report package, keeps a Markdown table of up to that many body rows whole: it moves to the next page instead of splitting). pdfmd sets it for a run without touching the metadata file: --table-keep N (0 lets every table break), or pdfmd-options: {table-keep: N} in the front matter, a metadata file or the config (also in pdfmd --setup). The command line wins over pdfmd-options, which wins over a tablekeep: in the metadata. Without any of them nothing is passed, and the filter uses its own default. A filter that does not read tablekeep ignores it. One table can say it for itself in its caption (Table: Raw data {.keep}, {.nokeep}, {keep=4}; several attribute words are fine: {#tbl:raw keep=4}), and a .csv block can say it on the div, ::: {.csv file="x.csv" keep=4} or ::: {.csv .nokeep}; the caption's own attributes win over the div's.

This works for every output format.

A caption typed by hand

Many documents caption a table with a bold line next to it, **Table 1: Results**. gfm has no table caption at all, and in Pandoc Markdown only : Results (or Table: Results) is one, so the engine numbers the table itself and the typed label is lost or printed twice. pdfmd can read such a line as the table's caption. It does so for any reader (the filter works on the parsed document), and only for a paragraph that is entirely one bold span, that starts with a label word (Table, and the word of the document's lang:: Tabelle, Таблица, Tablo ... ; more in table-caption-words), and stands right above a table that has no caption, or else right below one. Blank lines between them do not matter; a paragraph or a heading between them does, and a pipe table must start its own block (Pandoc reads a table that touches the line above as text). The label and its number are cut off, because the engine numbers the table (table-caption-label: keep leaves them); the rest keeps its markup, and a trailing {#tbl:id} becomes the table's id, so @tbl:id finds it.

It is off until you ask, because it changes documents that already have such lines: by default pdfmd changes nothing and says once NOTE table-captions: 2 lines next to a table read like captions (first: "Table 1: Results").

pdfmd-options:
  table-captions: on          # note (default: only say), on, off
  table-caption-need-label: off   # on (default); off: ANY line that is one bold span next to a table is its caption
  table-caption-colon-words: 2    # 0 (default, off); gfm: a paragraph `: Results` of at most 2 words is a caption too
  table-caption-words: [Tbl.]     # more label words

Every key is also a --setup row, at the config and folder levels.

Text tables Pandoc cannot read

Asked for "a table in plain text", chat models (NotebookLM, among others) write something like this:

+----------------------------------------------+
| Model Name          | Origin                 |
+----------------------------------------------+
| Replacement Model   | Africa;                |
| (Out of Africa II)  | ~200-100 ka            |
+----------------------------------------------+

It is not a Pandoc grid table: the borders have no + where the columns meet, the header has no +===+ line under it, and the | bars drift by a character here and there. Pandoc reads it as a table of one column without a header, and the text runs off the page. pdfmd reads it as a pipe table instead. Six spellings are repaired:

  • ASCII tables without the inner +, the lazy forms too: a border as short as +-+, rows without outer bars (1 | 2), middle borders left out. The table must be bordered at both ends and have a line with two cells, so a one-column grid table is not touched.
  • Box-drawing tables (┌─┬─┐ │ ├─┼─┤ └─┴─┘; double, rounded and heavy lines): what a terminal, Rich, pandas or tabulate prints. A rule with ═ or ━ under the first row is the header rule.
  • psql output: id | name, a rule ----+------, the rows, and the (2 rows) line, which goes with the table.
  • MySQL console output: a real grid table (+----+------+) with no header rule and three rules only. Pandoc reads its body lines as one row, so pdfmd takes each line as a row. A body line with an empty first cell (a wrapped line of a real grid table) stops this, and the table is left alone.
  • Org-mode tables (Emacs's outliner markup): | a | b |, a rule |---+---| (or just |-) under the header, more rules between row groups. Pandoc's markdown reader reads the + rule, gfm does not; pdfmd rewrites it either way. The rows above the first rule are the header, the later rules only group rows and are dropped. A row of alignment cookies (| <l> | <r> | <c> |, with a width <r10> or <10>) is dropped and sets the alignment of the columns (a width alone sets none). #+CAPTION: text right above the table becomes its caption (several lines are joined; any case), and #+NAME: tbl:id its identifier; a plain name is dropped. This works above any pipe table, one that needed no repair too (| a | b | or a | b with its :-|-: row). #+ATTR_*: lines above are taken: :align lcr sets the alignment (a cookie row wins; a pipe table's own separator row wins over it), every other option is dropped with a WARN. #+TBLFM: lines below (Emacs's spreadsheet formulas, the cells already hold the result) are dropped with a WARN, and never calculated. Other #+ lines stay as written. A caption needs Pandoc's markdown reader, so a file with no front matter that has a #+CAPTION: line is read with it instead of gfm.
  • | lines with no separator row: every line starts and ends with |, has two cells or more, the same number on each line, and no line is a separator (|---|---|). Pandoc makes a paragraph or a line block of it; the first row becomes the header. A real pipe table is never touched.

The bars are not trusted to line up: a row is split on its bars, and the first row is the header (every row above a header rule, when there is one). The lines between two borders are one row (the lines of a wrapped cell are joined with a space). When the body has no border between its lines, a line whose first cell is empty continues the row above, and any other line starts a row. pdfmd works on a temporary copy, so the file is never changed, and says WARN table-ascii: report.md, line 12: an ASCII table without a + at its column boundaries was read as a pipe table (4 columns, 4 rows) for each table. A row with another number of cells than the header is padded or cut, with a WARN of its own. A real grid table (+---+---+) and anything inside code, a comment or the front matter are left alone. A literal | inside a cell must be written \|; a cell with several paragraphs or a list needs a real grid table. The parts of a document in parts are read the same way; the no-Pandoc native renderer does not do it (yet).

pdfmd-options:
  table-ascii: repair         # repair (default), note (leave it, say so), off

Also a --setup row, at the config and folder levels.

Cell tags: one cell's alignment, and merged cells

A pipe table sets the alignment of a whole column in its separator row, and cannot merge cells at all. A tag in a cell does both for that cell. The tag can sit anywhere in the cell; it is cut out, with one space next to it.

| Name   | Scores       | << |
|:-------|-------------:|---:|
| vv     | A            | B  |
| x      | 1            | 2  |
| Total  | &c& 12       | << |
| ^^     | y            | z  |

Here Scores spans two columns (the << cell next to it belongs to the cell on its left), x and Total span two rows (vv and ^^ point at the cell they belong to), and 12 is centred over two columns.

A tag points at the cell that owns the text: << in a cell means "I belong to the cell on my left", so Scores &<<& would merge Scores into Name. The text of a covered cell is not lost: it is added to the owner's, on a new line, with a warning.

What a tag says

tag meaning
l r c horizontal alignment: left, right, centre
t m b vertical alignment: top, middle, bottom of the row (see below)
<< >> ^^ vv merge: this cell belongs to the neighbour on the left, right, above, below. The arrow points at the cell that holds the content; a chain of arrows is followed

Letters can be written in any order and case, with a comma, a space or nothing between them: &tl&, &T,L&, &top left&. A lone c is horizontal (as it always was); with one other letter it takes the free axis (mc, cm, ct and lc read as you would expect), and cc is the centre both ways, the same as mc. A lone m is vertical. Full words work too: top middle bottom left right center centre and merge-left merge-right merge-up merge-down (matched in lower case, ignoring -, _ and ., so merge_up and mergeup are the same word). Use up and down for merging, because top and bottom are the vertical alignment.

Three spellings, one meaning

  • &...& is the one to use: it takes anything, arrows included (&mc&, &<<&, &^^,t,c&, &merge-up,top&), and nothing else in Markdown or LaTeX means that. It must stand apart: at the start or end of the cell, or with whitespace around it (AT&T and R&D are text).
  • <...> takes the letters and words (<c>, <mc>, <merge-up, top>), never a symbolic arrow. <br>, <tr> and <tt> stay HTML (write bottom right as <rb> or <b,r>, top right as <rt>), and <b> and <center> are tags only when the cell has no closing </b> or </center>. The full-name form with commas is the safest.
  • A cell that is nothing but <<, >>, ^^ or vv needs no brackets.
  • {...} and «...» are off by default (K_{c} and \textbf{b} look like tags); table-tag-brackets: [angle, amp, brace] turns them on.

Code spans and math are never read, and a tag with a backslash before it (\<c>, \&c&) is plain text.

Vertical alignment

In LaTeX a row has one baseline, so a cell cannot be centred or put at the bottom by itself; a table that has a t, m or b cell is therefore drawn as a longtblr (the tabularray package, which pdfmd adds to the preamble), built from the same table: its columns and widths, header rows (repeated on every page, without tabularray's "Continued" texts), spans, caption and label. All cells of such a table start at the top of their row, so m and b mean the middle and the bottom of the row. A table with no vertical letter stays the ordinary Pandoc table. HTML gets a vertical-align style. Typst and Word do not draw it yet. Without tabularray (kpsewhich cannot find it) the vertical letters are ignored in a LaTeX build, with one NOTE. A table drawn this way is raw LaTeX to every filter that runs after pdfmd's cell filter (your own Lua filters, table-keep), so such a filter does not see it as a table; Beamer is untested. Cross-references (@tbl:id) and citations in the caption are resolved before the table is rebuilt, so they work.

With tablestyle: grid in the metadata (the key nulabreport and its kits use) a rebuilt table has every rule drawn (hlines, vlines) and its outer padding, instead of the booktabs rules. The style's own look (its cell padding, its caption below the table) is not copied: a rebuilt table is raw LaTeX to the style's filter.

Merging

  • The merged region must be a rectangle. If it is not, pdfmd says so (WARN cell-tags: ...) and merges nothing there.
  • An empty cell is never merged by itself; only an arrow merges.
  • A covered cell that holds text is not dropped: its text is added to the merged cell on a new line, with a WARN.
  • A cell of the header can span rows: the body rows it reaches become header rows (a pipe table has one header row).
  • A merge arrow that points outside the table is reported and ignored.
  • The column widths are worked out for the table as written, before the merge; a merged cell does not widen its columns.

HTML, Typst and Word get the spans from Pandoc's own table model; LaTeX gets \multicolumn and \multirow (and raw TeX around a one-column cell with its own alignment, because Pandoc's LaTeX writer ignores that). The Markdown writers write the tags back as &...&, which pdfmd reads again. Pandoc's grid tables merge cells already (a cell that spans lines of + borders); this is the short way for pipe tables. The native (no-Pandoc) renderer does not read the tags.

pdfmd-options:
  cell-align: on              # on (default), off: switches every cell tag off
  table-tag-brackets: [angle, amp]   # which spellings are read: angle, amp, brace, guillemet

Both are also --setup rows.

A table's attribute line: widths, heights, alignment

A pipe table has no way to say how wide a column is, how tall a row is, or how the cells sit in them. A line in braces right under the table (or under its Table: caption) does:

| Name   | Description                             | Qty |
|:-------|:----------------------------------------|----:|
| apples | a long description that wraps and wraps |   3 |
| pears  | short                                   |  12 |

{widths="3cm * 15%" row2.height=1.8cm col2.align=l,m valign=m}

Table: Sized {#tbl:sized}
setting meaning
widths="3cm * 15%" the width of every column: a length (cm mm in pt em), a percentage of the text width, * (a share of what the others leave) or auto (the text's own width). A column left out of an explicit list is *
col2.width=3cm, width=80% one column's width; the whole table's width
row3.height=1.5cm, height=1cm the least height of one row, of every row (a taller cell still grows its row). Row 1 is the header
align=c, col2.align=r,m, row1.align=c the alignment of every cell, of a column's, of a row's: the words of a cell tag
valign=m, col1.valign=bottom the vertical alignment: top, middle or bottom

Which setting wins, for a cell, from the most specific: its own tag, its row, its column, the column's :--: in the separator row, the table's align= / valign=, then table-align and table-valign of pdfmd-options (for every table of the document).

A line with no key=value in it ({#tbl:x}) is not an attribute line. Something that is wrong in one (col9.width in a table with three columns, a width that is not a length) is a WARN cell-tags and is left out; the rest is used.

LaTeX draws a table with a width, a height or a vertical alignment as a longtblr (see above). HTML gets percentage widths (<col style="width: 20%">), a height and a vertical-align style; absolute widths (3cm) and these settings in Typst and Word are not drawn yet. The Markdown writers write the line back. The native (no-Pandoc) renderer does not read it.

pdfmd-options:
  table-align: right          # left, center, right: every cell with nothing more specific (default: the column's own)
  table-valign: middle        # top, middle, bottom: setting it draws every table of a LaTeX build as a longtblr

Both are also --setup rows. cell-align: off switches the attribute line off as well: it is then an ordinary paragraph of the text.

Natural widths stay natural. Pandoc reads a pipe table with a source line longer than 72 characters as a table of fixed widths taken from the dashes of the separator row. Tags make lines longer, so pdfmd cuts the padding of the rows it rewrites and, if a line would still cross 72 that was under it before, tells the filters to give the table the widths it would have had (<pt nw>).

A line break inside a table cell

A cell cannot hold a blank line or \par, and a raw <br> is dropped by every writer but HTML (one<br>two printed onetwo in LaTeX, in a table or out of it). pdfmd reads every spelling of a line break and gives the target its own, as it does for page breaks:

written as kind
<br>, <br/>, <br /> (any case) raw HTML
\newline, \linebreak, \linebreak[2] raw TeX (Pandoc Markdown, not gfm)
#linebreak() raw Typst (a ```{=typst} block or span)
<w:r><w:br/></w:r> raw Word (```{=openxml})
\crlf raw ConTeXt

Each becomes the writer's line break: LaTeX \\ (in a table cell a box of lines), Typst \, Word a break run, HTML <br />. A spelling already in the target's own language stays exactly as written (\newline for LaTeX, <br> for HTML). The Markdown writers keep everything, except pdfmd's flat Markdown (--to gfm), which has no raw HTML and so gets a hard break. A paragraph or cell that is nothing but breaks is left alone (the space markup kind reads a lone <br>), and a break at the very start or end of a run of text is dropped. A backslash at the end of a line and two spaces at the end of a line are line breaks Pandoc reads itself.

It differs from page breaks in one thing, the default. Page breaks are off until you ask; line breaks are auto: every spelling is carried inside table cells, where there was no other way to get a second line, and text outside a table keeps what each format makes of it (a <br> in a LaTeX paragraph is still dropped). Ask for more with the families:

pdfmd-options:
  linebreak: true             # auto (the default: every family, table cells only), true / all (every family, everywhere),
                              # false, or a list of families, everywhere: [html, tex, typst, word, context]

--linebreak [KIND ...] (bare: all, everywhere) and --no-linebreak do the same for one run.

Also a --setup row, at the config and folder levels.

Turning tables into CSV blocks, and back

pdfmd --extract-tables report.md      # every table becomes a .csv block reading tables/<name>.csv
pdfmd --expand-tables report.md       # and back to ordinary tables

--extract-tables rewrites the document in place, after copying the old one to the backup folder, and says so. Pipe, simple, multiline and grid tables are all read (the ones inside code blocks are not tables). The files are named after the captions (Masses and prices gives tables/masses-and-prices.csv), or table1, table2 by position; --table-names a,b,c names them, and a list that does not match the number of tables is an error that changes nothing. --table-numbers 1,3-4 extracts just those tables (by position in the document) and leaves the rest; the names then go to the chosen ones. In a document in parts the data goes to the report's tables/ folder and an unnamed table is named after its part (discussion-table1.csv). The caption, the alignment and the widths the document chose stay as attributes of the block, and before anything is written Pandoc reads the old and the new document and the tables must come out the same. A table Pandoc cannot write as CSV (a list inside a cell, a cell spanning rows, no header row) is left as it is, with the reason; so is every .csv block already in the document, and if the check fails the whole document is left untouched and the command exits with an error. A document with no front matter is built as gfm, which reads pipe tables only, so only those are touched there. --tables-inline puts the data inside the document (in a fenced block) instead of in files, --tables-dir DIR picks the folder, and --dry-run shows the change without making it. --expand-tables is the reverse, every row included.

A .csv block that holds its data inside the document stays as it is, unless you pass --extract-inline-csv, which moves that data to a file as well. To keep one table or block as it is whatever you run, put <!-- pdfmd: ignore --> on the line above it (or under its caption); the comment survives comment stripping, because pdfmd's own comments are kept. A comment between a table and its caption would stop Pandoc from reading the caption as one, so there is no such position.

One style sheet for the page: --css, pdfmd.css, css-scope

A .css file styles the writers that read CSS: HTML, EPUB, the PDF the HTML engines make (WeasyPrint and the rest) and Calibre. pdfmd finds pdfmd.css or <name>.css beside the document (and in its metadata/ folder; --no-auto css turns the search off), takes css: from the front matter or a metadata file (Pandoc's own key), pdfmd-options: {css: file.css} (a name or a list; pdfmd --setup keeps one for every document) and --css FILE; they come in that order, the last after the others, as in CSS.

@page { size: A4; margin: 2cm 2.5cm }                 /* paper and margins */
body  { font-family: "STIX Two Text"; font-size: 11pt; line-height: 1.3; text-indent: 1.2em }
a     { color: #0645AD }                              /* links */
code, pre { font-family: "JetBrains Mono" }

With css-scope: all (pdfmd-options, or --css-scope all; the default is html) the sheet is read for the other writers too: those four rules become the paper, margins, font, size, line spacing, indent, link colour and code font that LaTeX, Typst and Word take, below what the document, its metadata and the command line set (--verbose says "body font-size 11pt -> fontsize=11pt" for each one it used, and names what it did not: tables, anything else). Only rules that say it plainly count: @page, html/body/:root, a, code/pre, h1-h5; the last one wins. A media query is read as a browser prints (v3.29): print and all apply, screen and the other media types do not, a comma is "or", only and not count, (orientation: landscape) is read against the sheet's own @page size, and a feature of the screen or of a width ((min-width: 600px)) is not for a printed page and is named. A body size LaTeX has no class for (anything but 10, 11 and 12 pt) is named, not used. It is a small reader on purpose: a CSS cascade has no counterpart in LaTeX.

Headings (v3.29): h1-h5 with a color, font-size (em, %, pt, px: of the body's size), font-family, font-weight or font-style are set for LaTeX too, as titlesec's starred formats (the numbering and the spacing stay the class's; h1 is \section, h2 \subsection ... h5 \subparagraph; h6 has no LaTeX level). It is done only for the plain article class, and never over a document that formats its headings itself: a titlesec, sectsty, KOMA or memoir command, or a \section of its own, in the preamble, the header-includes or a metadata file, is named in a CSS note and nothing is added (and when titlesec is not installed, one LaTeX warning says so). A family needs XeLaTeX or LuaLaTeX.

A cover: cover

pdfmd-options: {cover: true} puts the title block (title, subtitle, author, date) on a page of its own and starts the text on the next (v3.29): LaTeX's titlepage class option (for the article, report and book classes and KOMA's; another class is left alone), and a page break after the title block of an HTML-family PDF (WeasyPrint, a browser, Calibre) or an HTML page. cover: picture.png (relative to the document) puts that picture on the first page of such a PDF, before the title, and is the cover of an EPUB, so of the Kindle book Calibre makes from it (Pandoc's own cover-image: does the same for an EPUB). A Word, OpenDocument or Typst file says once that the key was not applied. Off by default: the title block stays on page 1.

Code: wrapping and line numbers

With a LaTeX engine a long line of code wraps at the margin instead of running off the page (a long path or option list breaks where the line is full), and {wrap=false} on one block, --no-code-wrap or code-wrap: false in pdfmd-options lets it run on. Line numbers are off until you ask for them:

``` {.python .numberLines startFrom=10}
def f(x):
    return x * 2
```

--line-numbers (or pdfmd-options: {line-numbers: true}, or the setting in pdfmd --setup) numbers every code block, --line-numbers 5 numbers every fifth line, {.noNumberLines} leaves one block out, and step=5 and numbersep=8pt adjust a single block.

Images: SVG and remote ones

LaTeX reads PDF, PNG and JPEG, not SVG. In a LaTeX build pdfmd converts each SVG the document names, once, with whatever is installed (rsvg-convert, Inkscape, cairosvg, svglib, or LibreOffice, which most machines have), keeps the PDF by the file's content, and uses it where the document wrote the SVG: a Markdown image, a raw \includegraphics{figure.svg}, a raw \includesvg{figure}. A raw \includegraphics also gets graphicx loaded, which Pandoc only does for a Markdown image (a PNG in one used to stop the build with "undefined control sequence"). A Markdown image from the web is fetched once and kept for a week, so the build does not need the network again, an SVG badge works, and a Pandoc built without HTTP support (Homebrew's is) no longer drops it; with no network there is one warning and the image's description stays. --no-auto svg and --no-auto remoteimages turn the two off, and pdfmd --doctor says which converter will be used.

Diagrams written as code

```mermaid
graph LR; Sample --> Reaction --> Product
```

```{.dot #fig:flow caption="The *flow* of the experiment" width=60% engine=neato}
digraph { sample -> reaction -> product }
```

```d2
sample -> reaction -> product
```

A fenced block whose class is mermaid, d2, dot or graphviz is drawn by the tool that reads that language and the picture takes its place: Graphviz (dot), D2 (d2) and Mermaid (mmdc). pdfmd asks each for the format the output takes: a PDF for LaTeX, an SVG for HTML (inside the file, so it can be sent on its own) and Typst (a PNG for Mermaid, whose SVG Typst cannot show), a PNG for Word and OpenDocument, and for flat Markdown (--to gfm) an SVG in the _files/ folder beside the output, except a Mermaid block, which stays as it is because GitHub draws it. A text output keeps the source. Drawings are kept in the cache by their content, so a rebuild does not draw them again. caption="..." makes a figure ({#fig:id} with pandoc-crossref numbers it), width= sizes it, engine= picks the Graphviz layout (dot, neato, fdp, sfdp, circo, twopi), theme= the Mermaid theme, layout= the D2 layout (dagre, elk). A tool that is not installed leaves the block as code with one warning (--strict makes it an error) and says how to install it; a diagram the tool cannot parse stays as code too, and the warning gives the tool's own words, and so does one whose tool does not finish in diagram-timeout seconds (120 by default; see "Diagram time limit" above). pdfmd --install diagram:d2 puts D2's release binary (17 MB, checked against the SHA-256 GitHub lists) in pdfmd's tools folder, and --install diagram:dot Graphviz built to WebAssembly (2 MB, run by node, which pdfmd fetches when there is none): it writes only SVG, which pdfmd converts for a PDF or Word picture with rsvg-convert or inkscape (D2 needs the same), and it sizes labels by Graphviz's own estimate of the font, a couple of percent off the real tool's. A tool of your own on PATH is always the one that runs. pdfmd --doctor shows which tools are there, and --no-auto diagrams turns the whole thing off. The attributes need Pandoc's Markdown (a file with front matter); a file read as gfm takes the plain ```dot form. Mermaid needs Chrome (mermaid-cli downloads one); in a sandbox PDFMD_MMDC_CONFIG=puppeteer.json is handed to mmdc -p.

Editable in LibreOffice, and one file that carries its data

--hybrid makes the PDF a LibreOffice hybrid PDF: besides its pages it carries an OpenDocument copy of the document (Original.odt, or .odp, .ods), and when LibreOffice opens the PDF it opens that copy as a document you can edit, instead of the pages in Draw. An Office file (pdfmd minutes.docx --hybrid) is converted by LibreOffice, so the copy is the file itself; a Markdown file gets an .odt built by pdfmd with its metadata, so what LibreOffice shows is the Word-route rendering of the source (not the LaTeX pages). pdfmd --restore report.pdf writes the ODF file back out. pdfmd-options: {hybrid: true} or pdfmd --setup makes it the default.

A single Markdown file can carry its data too: pdfmd report.md --assemble-only --embed-metadata files -o report.one.md folds in the images, CSV tables and included files the text points at (text as it is, anything else as base64), and the file builds on its own, in an empty folder; pdfmd --unpack writes them back byte for byte. It is left out of the default kinds because it can be big (bundle-max-mb caps it); for a PDF, --bundle stores the same files beside the source.

HTML, LaTeX, Typst and Word pieces inside Markdown

Markdown can carry raw pieces: <img>, <table>, <b> and <div> (HTML), \ce{...} or a tikz picture (LaTeX), a ```{=typst} block, Word or OpenDocument XML. Pandoc keeps the ones written in the format it is writing and drops every other, silently, so a document that mixes them loses pieces in every build. pdfmd-options.raw (or --raw) says which syntaxes take part in each output family, and carries the foreign ones over:

pdfmd-options:
  raw:
    tex:    [tex, html, typst]   # LaTeX builds: PDF through lualatex, xelatex ...
    typst:  [typst, html, tex]   # Typst builds
    html:   [html, tex, typst]   # HTML, EPUB, PDF through weasyprint and the other HTML engines (-e html)
    office: [office, html]       # Word and OpenDocument, and PDF through soffice
pdfmd report --raw               # everything for every family
pdfmd report --raw-for html=tex,typst --raw-for tex=html      # or per family, on the command line

A family takes the syntaxes it lists, in this way: its own syntax stays as written; HTML is read by Pandoc's HTML reader, so an <img> becomes the target's image (an SVG then goes through the image conversion below), a <table> a real table, <b>...</b> bold text and a <div> around Markdown a division, even when the opening and the closing tag sit in separate pieces; LaTeX is read by Pandoc's LaTeX reader (what it does not know, a custom macro, stays LaTeX and is lost outside LaTeX; Word builds keep their own LaTeX route, see Word and OpenDocument output); Typst is drawn by typst compile as a cropped vector picture, a PDF for LaTeX, Typst and Word, an SVG for HTML. A syntax a family does not list is dropped, its own included, which is odd but a clear way to say "this output has no HTML in it". A family not mentioned is left to Pandoc as it always was, raw: all lists everything, and --no-raw turns it off for a run. Pictures are kept in pdfmd's cache by their content. (An HTML file that is not self-contained points at the cache; use --self-contained to carry them.) A LaTeX picture Pandoc cannot read (a tikzpicture, circuitikz, pgfpicture, forest, \chemfig...) is drawn by a LaTeX engine with the standalone class, after the document's own preamble and header-includes, and placed as a vector picture in HTML, Typst and flat Markdown builds (an SVG for HTML and Markdown; a piece that does not compile is reported and left out). Not yet: HTML drawn as a picture, Word XML as anything but Word XML.

Page breaks in every format. Each output understands one way of writing a page break and drops the others: LaTeX takes \newpage, WeasyPrint a CSS-break <div>, Word only its own XML, Typst none of them. Ask for it and every spelling of the families you name becomes the output's own (LaTeX \newpage, Typst #pagebreak, a CSS break for HTML, EPUB and WeasyPrint, a Word or OpenDocument page break, a form feed in --to txt, <!-- pagebreak --> in --to gfm):

pdfmd-options:
  pagebreak: true              # every family; or a list of them:
  # pagebreak: [comment, tex]
family spellings
comment <!-- pagebreak -->, <!-- page-break -->, <!-- newpage -->, <!-- new-page -->, <!-- pdfmd: pagebreak -->
tex \newpage, \pagebreak, \pagebreak[3], \clearpage, \cleardoublepage (an odd page)
html <div style="page-break-after: always"></div> (or break-before: page), <div class="pagebreak"></div>, <hr class="pagebreak">
div an empty ::: pagebreak (or newpage)
typst a raw ```{=typst} #pagebreak()
word a raw ```{=openxml} page-break paragraph

It is on, every family, in the pdfmd and obsidian flavours (v3.32.24; before, off unless asked) and off with flavour: pandoc, gfm or commonmark; pagebreak: in the document, a metadata file, or pdfmd --setup under Universal markup names the families (or false) instead; --pagebreak [KIND ...] and --no-pagebreak decide for one run. A spelling already in the output's own language stays as written, and slides leave a break out with a note. The built-in renderer (no Pandoc) reads the comment and TeX spellings anyway.

Callouts in every format (v3.29, the first kind of universal markup after page breaks). GitHub's and Obsidian's > [!NOTE], a fenced ::: note and Quarto's ::: callout-tip are a block quote or a div to Pandoc, and print as plain text. Ask for them and each becomes a box of the writer's own: LaTeX a tcolorbox (a plain indented quote when that package is not installed), Typst a tinted block, HTML, EPUB and the HTML engines' PDFs a div.callout with its style, every other writer (Word, OpenDocument, Markdown ...) a block quote with a bold title:

pdfmd-options:
  markup: callouts             # or: markup: true (every kind there is)
> [!warning] Mind the gap
> The title is the rest of the first line, the body the lines after it.

::: {.tip title="Another way"}
A fenced div takes `note`, `tip`, `important`, `warning` or `caution` as its class, `title=` for the title.
:::

The five kinds are GitHub's (note, tip, important, warning, caution); Obsidian's others map to them (info, todo, example, abstract to note; success, check, hint to tip; question, help, faq to important; failure, danger, error, bug to caution; attention to warning), and an unknown name is a note titled with it. A fold marker ([!tip]-) is dropped. On, every kind but space, in the pdfmd and obsidian flavours (v3.32.24; before, off unless asked), and off with flavour: pandoc, gfm or commonmark; markup: in the document, a metadata file, or pdfmd --setup under Universal markup names the kinds (or false) instead; --markup [KIND ...] and --no-markup decide for one run. The built-in renderer (no Pandoc) does not read it yet, and there is no Word shaded box yet.

A table of contents where you put it (v3.29, markup: toc). [TOC], [[_TOC_]] and <!-- toc --> alone in a paragraph, or a raw \tableofcontents for a writer that is not LaTeX, become the contents at that place: LaTeX's \tableofcontents (filled in one build, with the page numbers), Typst's #outline(), a Word TOC field (Word offers to update it when the file is opened) and, for every other writer (HTML, EPUB, Markdown, OpenDocument ...), a nested list of links to the headings down to level 3, with a bold "Contents" above it. markup: [callouts, toc] or markup: true for both; --markup toc for one run. A marker inside a sentence is text.

Centred text (v3.29, markup: center). <center> ... </center> around blocks (the closing tag in its own paragraph), <div align="center">, <div style="text-align: center">, a fenced ::: center and, for writers that are not LaTeX, a raw \begin{center} ... \end{center} all centre what they hold: LaTeX's center environment, Typst's #align(center)[...], a text-align div in HTML, EPUB and the HTML engines' PDFs, and in a Word file the paragraph style PdfmdCenter (centred; the style is formatted in the file, a style of that name in your own reference document keeps its own look). OpenDocument and PowerPoint keep the text as it is and say so once. markup: [callouts, toc, center] or true for all; --markup center for one run.

Highlighted text (v3.29, markup: highlight). ==marked text== (across several words, with emphasis inside, over a line break) is highlighted: LaTeX with soul's \hl in a pale yellow (LuaLaTeX: lua-ul's \highLight, with the luacolor it needs, when both are installed; plain text when soul is not installed either), Typst with #highlight[...], HTML, EPUB and --to gfm with <mark>, Word with the character style PdfmdHighlight (a yellow shading); Pandoc's own Markdown output keeps ==...==, and OpenDocument keeps the text and says once that it is not highlighted. A lone == or one inside code stays as written; a closing == followed by punctuation (==end==.) works. markup: [callouts, toc, center, highlight] or true for all; --markup highlight for one run.

Underlined text (v3.32.26, markup: underline). <u>text</u> and <ins>text</ins>, which a Markdown reader keeps as raw HTML that LaTeX, Typst and Word drop, become Pandoc's own underline: LaTeX \ul (soul, or lua-ul under LuaTeX), Typst #underline[...], Word and OpenDocument an underlined run, HTML <u>; the Markdown family keeps the tags. Both tags must be in the same paragraph.

Blocks kept on one page (v3.29, markup: keep). A fenced ::: keep (also nobreak, keep-together), a div with style="break-inside: avoid" and, for writers that are not LaTeX, a raw \begin{samepage} ... \end{samepage} hold what they contain on one page where it fits: LaTeX's samepage environment, Typst's #block(breakable: false)[...], a break-inside: avoid div in HTML, EPUB and the HTML engines' PDFs. Word and OpenDocument keep the text and say once that it is not held. --markup keep for one run.

The paper (v3.30.42). --paper a4,landscape, papersize: a4,landscape, geometry: landscape and classoption: landscape turn the page on every engine: LaTeX (the size as a class option, the turn through geometry), Typst (flipped), WeasyPrint (@page size, which it ignored before: a papersize: letter printed A4), Calibre, the browsers, Word and the built-in renderers. Other spellings work too: a4paper, us-letter, A4, and a3, a2... (LaTeX classes have no a3; pdfmd gives it to geometry). --paper landscape alone turns whatever paper the settings name. --no-auto papersize leaves all of it to Pandoc.

Landscape pages (v3.29, markup: landscape). A fenced ::: landscape, a div with style="page: landscape" and, for writers that are not LaTeX, a raw \begin{landscape} ... \end{landscape} put what they hold on pages turned sideways, the document going back to portrait after it: LaTeX with pdflscape (the page is rotated in the PDF; lscape, or no turning, when it is not installed; a landscape environment that a kit's package, the class or your preamble.tex defines is used instead, and pdflscape is not loaded, v3.33.67), Typst with #page(flipped: true)[...], WeasyPrint and the browsers with a named page (@page landscape: the browsers get the document's paper turned; WeasyPrint's is its own A4 unless your own @page landscape says otherwise), Word with two section breaks (the file's own paper and margins, headers and footers, turned for the block; a block at the very start or end, or two in a row, make no empty page). Calibre's PDF cannot name page types, and OpenDocument has no sections made from here: they keep the text and say once that no landscape page was made. --markup landscape for one run.

Where the appendix starts (v3.29, markup: appendix). A raw \appendix, <!-- appendix --> or a heading with the class {.appendix} marks the start: the sections after it are lettered (A, B, A.1) in LaTeX (\appendix) and Typst (a heading numbering of letters and a counter reset). It matters for a document that numbers its sections (--number-sections); without numbers Typst gets nothing and LaTeX's \appendix changes nothing visible. Other writers keep their own numbering and say once that the lettering is not made.

Vertical space (v3.29, markup: space). A line of its own that is \vspace{1cm} (or \vspace*), \smallskip, \medskip, \bigskip (3, 6 and 12 pt, as in LaTeX), one or more <br> (an em each) or a raw #v(1cm) becomes space in every format: \vspace{..} in LaTeX, #v(..) in Typst, an empty div of that height in HTML, EPUB and the HTML engines' PDFs, and a few empty paragraphs (about one per 12 pt) in Word and OpenDocument; the Markdown family keeps what was written. A <br> inside a line of text is a line break as before.

Showing the comments of the text (v3.29, show-comments). An <!-- HTML comment --> is invisible in every output. With pdfmd-options: {show-comments: true} (or --show-comments for one run, --no-show-comments to switch it off; also a row in pdfmd --setup) each one is shown: a box labelled Comment in LaTeX, Typst, HTML, EPUB and the HTML engines' PDFs, a real Word comment in a .docx (anchored on the paragraph after it, so Word's review pane lists them), [comment: ...] in italics for one inside a paragraph. The Markdown family (--to gfm, markdown) keeps the comment as it is; OpenDocument and PowerPoint drop it, with a note once. pdfmd's own markers stay invisible (<!-- pdfmd: ... -->, <!-- pagebreak -->, <!-- toc -->, <!-- appendix -->), and so does an empty comment. It is off unless asked and is not one of the kinds markup: true turns on.

PDF images in HTML. A browser does not show a PDF in <img>, an SVG it does. For an HTML build (a web page, an EPUB, a PDF through WeasyPrint) every image that is a PDF, ![fig](plot.pdf) or <img src="plot.pdf">, is converted to an SVG with poppler's pdftocairo (or mutool, pdf2svg, inkscape) and stays vector; the first page is used and the SVG is kept by the PDF's content. It is on by default, since it only changes what would not have shown; --no-auto pdfimages turns it off. LaTeX and Typst read PDFs themselves, and Word has its own route.

Markdown inside raw LaTeX: \md{...}

Raw LaTeX in a Markdown file is passed to LaTeX untouched, so *emphasis*, $x^2$ and [@smith2020] inside a \textbf{...}, a \caption{...} or a hand-written tabular stay as typed. Wrap them in \md{...} and pdfmd reads them as Markdown:

See \textbf{\md{**bold**, $x^2$ and a citation [@smith2020]}}.

\begin{tabular}{ll}
one & \md{*it*, 50\% and [@smith2020]} \\
\end{tabular}

\begin{md}
A block with a list, a table, a citation [@smith2020]:

- first *item*
- second item
\end{md}

What the argument holds is part of the one document: citations are resolved by --citeproc (and listed in the bibliography), @fig:x by pandoc-crossref, math is typeset. \md{...} works in a paragraph, a heading, a list, a table cell and inside another raw command (\textbf{\md{x}}), and in a raw block such as a tabular or a figure for LaTeX, Beamer and ConTeXt (the other writers drop that raw block, as they always did). \begin{md}...\end{md} holds blocks (paragraphs, lists, tables) and works for every writer. The braces of the argument must balance: write a literal one as \{ or \} (a backslash escapes the next character); % and # are Markdown there, not a TeX comment or parameter; a | inside a pipe-table cell is written \|; a code span with a lone brace goes into \begin{md}, which counts no braces. Several paragraphs in \md{...} are joined with a line break. A command that merely starts with the letters (\mdseries) is not touched. A document that defines \md itself (\newcommand{\md}, \def\md, \newenvironment{md}) keeps its definition, and pdfmd says so once. An unbalanced \md{ is left as it was, with a WARN.

pdfmd-options:
  md-macro: on                # on (default), off

Also a --setup row. Only Markdown sources read by Pandoc: the native (no-Pandoc) renderer and a .tex source compiled directly do not read it. A file with no front matter that uses \md{...} is read with Pandoc's markdown reader instead of gfm (which has no raw LaTeX), the same way @citations already switch it.

Flavours: flavour

A flavour is a name for a set of reading choices: which Pandoc reader the file gets and which syntax of its own pdfmd reads. It is one setting, flavour (spelt flavor or reader as well): pdfmd-options: {flavour: obsidian} in a document, a metadata file, a folder's settings or the config file, --flavour obsidian (--flavor) for one run, or a row in pdfmd --setup.

Flavour Reads the file as pdfmd's own syntax
auto (the default) obsidian inside an Obsidian vault (a folder with .obsidian/ in it, the document's own or any above it), pdfmd everywhere else
obsidian pdfmd's own pick of reader (see What it does automatically) %%comments%% hidden, more of Obsidian's syntax in each release
pdfmd pdfmd's own pick of reader %%comments%% hidden
pandoc Pandoc's own default reader for the file, as pandoc would none: strict Pandoc
gfm, commonmark that reader, as it stands none

Any other value is a Pandoc reader, with +/- extensions (markdown, gfm+smart, commonmark_x): that reader is used for the file (it is --from for one document) and the flavour stays auto. --from on the command line wins over the setting. The presets are a table in the pdfmd_flavours package; later releases make every part of a preset adjustable, so that a flavour of your own is a few lines.

What the flavours read so far (the rest of Obsidian's syntax, and pdfmd's own, arrives piece by piece):

  • %%comments%% (v3.32.20, in obsidian and pdfmd). Obsidian hides them in its reading view, and so does pdfmd, in every output: A %%a note%% b prints A b, and a %% on a line of its own opens a comment of many lines (paragraphs, headings and lists included) up to the next %%. A comment is not looked into inside fenced code, inline code, math, the front matter or an HTML comment, so `%% x %%` stays as it is. A %% with no closing %% is a warning naming the line, since it is usually a forgotten closer. In an Obsidian vault it hides the rest of the note, as it does in Obsidian's reading view (checked there); in the pdfmd flavour, outside a vault, it stays text (a lone %% can be TeX or plain text). pdfmd-options: {unclosed-comment: hide} or keep (--unclosed-comment, also a row in pdfmd --setup) chooses either way. A later %% closes the earlier one, and an <!-- HTML comment --> hides the same way. With show-comments (--show-comments) they are drawn like HTML comments, as a box labelled Comment. What is in a comment decides nothing: a \ref{x} or an @key inside one does not switch the reader or turn on citations.

  • Typography, figures, bare links and emoji (v3.32.23, in obsidian and pdfmd). The gfm that pdfmd picks for a file with no front matter also reads +smart (curly quotes, -- and --- as dashes, ... as an ellipsis) and +implicit_figures (a picture alone in its paragraph is a figure, its alt text the caption). Pandoc's Markdown, which pdfmd picks for a file with front matter or a Pandoc-only syntax, also reads +autolink_bare_uris (https://example.org is a link) and +emoji (:smile:). Since 3.32.4 a file without front matter had read as gfm with none of these; flavour: gfm or pandoc gives the strict readers back.

  • pdfmd's own syntax on (v3.32.24, in obsidian and pdfmd, for a Markdown file). markup is on for callouts, toc, center, highlight, underline, keep, landscape and appendix (> [!note], [TOC], <center>, ==text==, <u>text</u>, ::: keep, ::: landscape, \appendix), and pagebreak for every family, with no setting. space stays off (a lone <br> is often just a blank line), and so do linebreak and raw: name them (markup: [space], linebreak: true, raw: true) to turn them on. A markup: or pagebreak: setting of any kind, false included, replaces the flavour's list; --markup and --no-markup decide for one run.

  • A single newline: single-newline (v3.32.27). Markdown's rule is that one newline inside a paragraph is a space; Obsidian shows it as a line break unless its "Strict line breaks" option is on. In the obsidian flavour inside a vault pdfmd does what that vault does: it reads strictLineBreaks from .obsidian/app.json (no key: Obsidian's default, a break). Every other flavour, pdfmd included, keeps the Markdown rule, so a source wrapped at 80 columns stays one paragraph. pdfmd-options: {single-newline: hard} (or soft; also a row in pdfmd --setup) decides for a document, a folder or every document, and beats the vault. A blank line is always a new paragraph, and two spaces or a backslash at the end of a line is a break whatever the setting. For one run --flavour gfm+hard_line_breaks does it.

  • Links between notes: [[Note]] (v3.32.26, in obsidian and pdfmd). A printed document has no other notes to open, so a wikilink is written as the text Obsidian shows for it: [[Note|an alias]] is an alias, [[Note]] is Note, [[Note#Heading]] is Note > Heading and [[#Heading]], a link inside the note, is Heading; [[Note\|alias]] works in a table. A name with _ or * in it is kept as text. Left as they are: ![[note]] (a note embed comes later), [[_TOC_]] (the contents marker) and [[1]](url) (a link whose text is [1]). Links become real links to a heading of the same document in a later release.

  • Pictures by name: ![[picture.png|300]] (v3.32.25, in obsidian and pdfmd). Obsidian's wikilink names a file, not a path: the vault finds it wherever it lies, so a picture can move without breaking the note, and every pasted picture is ![[Pasted image 2026....png]]. pdfmd reads the same spelling outside a vault as its own image embed. |300 is the width in pixels, |300x200 a width and a height, and any other text after the bar is the alt text (the caption of a picture alone in its paragraph: ![[chart.png|Yields by solvent|400]]). A pixel is 0.75 pt (embed-px-to-pt: one CSS pixel), and a sized picture stays within the text width in a LaTeX or HTML build (embed-fit: text-width, the default; none keeps the size given; a picture with no size is fitted by Pandoc anyway; Word scales to the page). The name is looked for as written (beside the document, or from the vault's top), in the vault's attachment folder (attachmentFolderPath of .obsidian/app.json), then by name anywhere below the vault, or outside a vault anywhere below the document's folder; the nearest wins when several share a name, with a note (embed-search: vault|folder|none narrows it). A name that is not found is a warning and a visible [missing image: name] in the output. Notes, PDFs and other files (![[note]], ![[doc.pdf]]) and anything in code, math, the front matter or an HTML comment are left as they are for now. All three settings are rows of pdfmd --setup.

flavour: pandoc (or gfm, commonmark) turns all of this off: %% is the text it looks like, and the markup and page-break spellings are left to Pandoc. Not covered yet: the chapters of a report or book build (-r, -b), the parts of a split document and a doc#section build do not see %%comments%% in a file that is not the main one.

Not just Markdown

pdfmd paper.tex          # compiled directly with a LaTeX engine: reruns until
                         # references settle, runs bibtex/biber, and leaves
                         # no .aux/.log clutter (--keep-aux keeps them)
pdfmd minutes.docx       # Word/PowerPoint/Excel/ODF: converted by LibreOffice
pdfmd analysis.qmd       # handed to Quarto, so code chunks actually run
pdfmd page.html          # built by WeasyPrint, or a browser if the page runs scripts or has MathML: no Pandoc
pdfmd note.typ           # compiled by Typst itself: the page setup in the file is kept
pdfmd notes.rst          # anything else Pandoc can read (give the extension)

A finished .typ or .html file goes straight to its own engine, because Pandoc would read it into its own model and write it out again, and the page size, the styles and the scripts would not survive the trip. For a page that is WeasyPrint first (it is quiet and needs no browser); a page with <script> or MathML (<math>, which WeasyPrint prints as flat text) goes to Chrome, Chromium, Edge or Brave (headless, printing the page), as does one WeasyPrint cannot draw, and wkhtmltopdf, Prince, pagedjs-cli and LibreOffice come after. -e edge (or weasyprint, chromium...) picks one. As with an Office file, such a document gets none of pdfmd's defaults, since it is finished; --apply-defaults (also in pdfmd --setup) gives it the title, author, date, language, font, font size, paper and margin of its metadata.yaml, ahead of its own settings so those win. A .tex file that no LaTeX engine can compile is tried through Pandoc, with a warning.

The reader comes from the file's extension, as in Pandoc, and a bare name is looked up as <name>.md, so give the extension for anything else. The automatic decisions about Markdown (READER, TITLE, citeproc, crossref below) apply to Markdown files only.

A plain-text file as Markdown (opt-in). pdfmd notes.txt --text-to-markdown reads a .txt file the way a person typed it and guesses its structure: headings (capitals, 1.2 Title, ==== underlines), bullet and numbered lists, tables from columns separated by runs of spaces, indented blocks as code, hard-wrapped lines joined into paragraphs. The words never change (the letters and digits of the result are the input's), and a text that measures as garbled (symbol soup, OCR noise, replacement characters) gets paragraphs only, with the rule that fired printed. MODE is auto (default), force (guess even in a garbled text) or paragraphs. It needs batchocr 1.2.5 (pdfmd --install batchocr), writes notes.md beside the text (never over a file that is there) and builds from it; with -o x.md the conversion is all it does. Off unless asked: --text-to-markdown, or options: {text-to-markdown: auto} in the config file (or pdfmd --setup).

What it does automatically

Most of these print an AUTO line, and each can be switched off individually.

AUTO kind When What happens
READER a Markdown file with no YAML front matter (and no metadata.yaml beside it; the global metadata of --setup does not count) reads the file as GitHub-flavoured Markdown (content-sized table columns, relaxed blank-line rules). What gfm can take as an extension is added to it when the file uses it (+subscript for H~2~O, +superscript, +attributes and +bracketed_spans for {#id .class}, +fenced_divs, +definition_lists, +fancy_lists for a. b. lists, +raw_attribute). What gfm cannot read at all (^[inline footnotes], raw LaTeX such as \textbf{..} or \begin{..}, grid and simple tables, (@) example lists, @citations) switches the file to Pandoc's Markdown. Code blocks, inline code, math and HTML comments are not looked at, so an example of the syntax in one changes nothing. --verbose says which syntax decided; --from markdown, a front-matter block, or flavour: (alias reader:) below changes it
TITLE a Markdown file with no front matter, first line is # Title that heading becomes the document title, and the remaining headings move up one level
MARGIN no margin or geometry set anywhere 1-inch margins instead of LaTeX's wide defaults
MAINFONT no mainfont: and no -f STIX Two Text (Times New Roman if it isn't installed), or a serif that has the letters when the document is mostly in a script STIX Two Text lacks (Kazakh Cyrillic, say); rebuilt as a whole in DejaVu Serif if a glyph is still missing, with a WARN that names the font, the code points and the new font (STIX Two Text lacks U+2265; rebuilt in DejaVu Serif). A font you name is never changed except by fallback: document
MATHFONT pdfmd chose the main font itself (no -f, no mainfont:/mathfont: in the document or its metadata), the engine is LuaLaTeX, XeLaTeX, Tectonic or Typst (Pandoc 3.12+), and the companion is installed the math font that goes with the text font: STIX Two Math for STIX Two Text, DejaVu Math TeX Gyre for DejaVu Serif, Libertinus Math, New Computer Modern Math, TeX Gyre Termes/Pagella Math; without it formulas are set in Latin Modern Math (Typst: New Computer Modern Math). --no-auto mathfont (or font) stops it
UNICODE text the main font cannot draw (Arabic, Han, Greek with accents, rare symbols...) sets each run in an installed font for its script (see Other scripts), instead of printing boxes
MONOFONT the document contains code JetBrains Mono for code (Menlo or another installed monospace font if it isn't installed)
tablewidth a pipe table with plain --- separators and long lines picks the column widths that give the fewest lines within the text width (a column never narrower than its longest word), and gives a table that fits its natural width back; unequal dashes (`--
svg, remoteimages an SVG, or an image from the web, in a LaTeX build the SVG is converted to a PDF (once), the remote image is fetched and kept (--no-auto svg, --no-auto remoteimages)
typstdirect, htmldirect a .typ or .html file built by Typst / WeasyPrint or a browser, not through Pandoc (--no-auto typstdirect for the old route)
codewrap code blocks in a LaTeX build long lines wrap at the margin (--no-code-wrap or --no-auto codewrap for the old behaviour)
YAML / TEX / LUA project files found attaches metadata.yaml, preamble.tex, <name>.lua (see below)
citeproc @key / [@key, p. 90] citations (as Pandoc reads them: @key in a code span or an e-mail address is none) adds --citeproc, so citations and the reference list render from your bibliography: without any flag (--no-citeproc turns it off)
remotecsl csl: https://.../ieee.csl in the front matter or a metadata file fetches the style once and keeps it (again after 30 days), so a Pandoc built without HTTP support and a build with no network still work: offline it takes that copy, else a style of the same name in Pandoc's own csl/ folder, with a WARN, else Pandoc's default style with a WARN (pdfmd-options: {csl-fallback: error} stops instead)
crossref @fig:/@tbl: references or {#fig:...} labels adds the pandoc-crossref filter, ahead of citeproc
papersize pagesize: a4 (a common typo) converts it to Pandoc's real papersize:
texpackages \ce{H2O} (mhchem) or \SI{25}{\celsius}, \num, \qty (siunitx) in a LaTeX build whose preamble does not load the package loads it, last in the preamble, unless it is loaded already (your options win), the command is defined some other way, or the package is not installed
draftpictures a part (or section) built alone with seed-labels: draft the draft pass over the whole document does not draw TikZ pictures (pgfplots too), \chemfig, chemfig schemes or images: they step no counter. A page reference into what was left out prints ??; a picture that ends inside a macro makes pdfmd run the pass again with the pictures
priority bare pdfmd in a folder with several Markdown files picks one (see Several documents in a folder) instead of stopping with the list; --no-auto priority brings the list back
kit, kpsewhich a package's .sty beside the document (or in a kit) with no <package>-pdfmd.yaml next to it the helper file is read from the next place that has it: a selected kit that ships the package, then the installed copy TeX finds; --no-auto kit / --no-auto kpsewhich take one place off, a bare --no-auto both (a lone copy of the .sty then loses the package's front-matter keys, such as the experiment number)

-v explains each decision and prints the exact command it runs:

$ pdfmd lecture -v
CMD  pandoc lecture.md -o lecture.pdf --pdf-engine=lualatex -f gfm -V 'mainfont=STIX Two Text' -V geometry:margin=1in -V 'monofont=JetBrains Mono' --shift-heading-level-by=-1 --lua-filter .../pdfmd-tablewidth.lua
CMD  pandoc lecture.md -o lecture.pdf --pdf-engine=lualatex -f gfm -V 'mainfont=DejaVu Serif' ...
AUTO READER  lecture.md: no YAML front matter; reading as gfm
AUTO TITLE  lecture.md: promoted leading '# ' heading to Pandoc title metadata
AUTO MARGIN  lecture.md: no geometry/margin set; using geometry:margin=1in on LaTeX-family engines
AUTO MONOFONT  lecture.md: has code but no monofont set; using JetBrains Mono on LaTeX-family engines
OK    lecture.md > lecture.pdf  (lualatex)

The second CMD line is the font fallback at work: STIX Two Text was missing a glyph, so the document was rebuilt with DejaVu Serif; a WARN line before it names the glyphs (lecture.md: STIX Two Text lacks U+2265; rebuilt in DejaVu Serif (set mainfont: to keep your own)). (Temporary file paths are shortened here.)

When a PDF doesn't look the way the Markdown suggests, run -v first. The cause is usually one of these automatic decisions, and -v names it.

The closing line also names the engine that made the PDF. If an earlier one failed, the line says so, so a build that quietly fell back to Typst does not look like one that did not:

$ pdfmd paper
WARN  paper.md: lualatex failed (! Undefined control sequence.); trying xelatex...
WARN  paper.md: xelatex failed (! Undefined control sequence.); trying typst...
OK    paper.md > paper.pdf  in 41.3 s  (typst; lualatex, xelatex failed)

How long it took. The closing line says how long the build ran (in 41.3 s), also for a build that failed. -v adds a TIME line for every step (the engine, the history, the backup, ...) and what no step covers. The time is auto: 0.42 s under a second, 12.4 s under a minute, 3:07, 1:02:03 and from a day 1 d 02:03:04. time-format changes it: clock (03:07, hours never wrap into days: 30:00:00), seconds (187.0 s), words (3 min 7 s), or a template with {d} days, {h}/{hh} hours, {m}/{mm} minutes, {s}/{ss} seconds, {s1}/{s2} seconds with one or two decimals and {ms}: {h}:{mm}:{ss}, {s} s. A unit with a larger one in the template wraps (minutes at 60 beside {h}); the largest counts everything ({s} s is 187 s for 3 min 7 s). build-time: off (or --no-build-time) leaves the time off the line. Both settings are pdfmd-options keys, --setup rows and flags (--build-time / --no-build-time, --time-format STYLE); the history file (NAME.hst) records each build's seconds as took=.

The record of a build. Whatever the console showed, every build writes what happened to a plain-text file, so the one who debugs reads it instead of building again. It holds the command line, the versions of pdfmd, Pandoc and the engines, every command that ran, everything pdfmd printed (also the lines only -v shows on the console), what each engine answered, the steps and their times. A build that failed ends with full record: ~/.cache/pdfmd/builds/paper-1a2b3c4d/20261029-140533-failed.log; pdfmd --last-build paper.md prints the newest record of a document and --last-build --failed paper.md the newest failed one (a good build after a bad one does not erase it). The records sit in the cache, where cache: location says (global: ~/.cache/pdfmd/builds; document: .cache/pdfmd/builds beside the file, which then travels with the folder). build-log: on|failed|off (--build-log MODE), build-log-keep (the newest N stay, 5, and the newest failed one) and build-log-max-size (KB; a longer record keeps its start and its end; 512) are pdfmd-options keys and --setup rows. The home folder is written as ~ in the file, so a record can be pasted into a bug report. pdfmd --clear-cache removes them with the rest of the cache. A record that cannot be written never fails a build. Not yet in a record: the a build of a .qmd, a direct .typ/.html build and a report or book build, and the versions of the LaTeX packages.

When LaTeX itself stops, the warning also says where: Pandoc reports a line of the LaTeX it wrote, so pdfmd finds those words in your Markdown and prints near line 9 of paper.md: ...has \badmacro.

WeasyPrint does not typeset math itself. With MathJax in place, pdfmd --install math:mathjax (MathJax 4.1's TeX input and HTML output and the STIX Two math font, which goes with pdfmd's default body font: about 12 MB to download, about 2 MB on disk in ~/.local/share/pdfmd/mathjax, each package pinned and checked against its SHA-256; and node, when there is none on your PATH: node's own release from nodejs.org, about 30 MB, checked the same way, into pdfmd's tools folder), a Markdown document built with -e weasyprint (or one that falls back to it) has its math typeset by MathJax before WeasyPrint prints it: fractions, roots, matrices, accents, limits, stretchy arrows and braces, \ce reactions, in the STIX Two Math, sized to the body font's x-height when the body is not STIX Two Text. (MathJax's other math fonts, New Computer Modern, TeX Gyre Termes and Pagella and DejaVu, are not offered yet: their tall delimiters built from pieces came out broken in WeasyPrint.) WeasyPrint draws some of MathJax's output a little off (an accent three points over its letter, a fraction's denominator too low, a bar that ran past its letter); pdfmd's own style rules put that right. A formula MathJax cannot read goes to KaTeX when pdfmd --install math:katex has put it in place (a NOTE says how many), and a formula neither reads (a macro from your LaTeX preamble, say) stays text, with one WARN for all of them. KaTeX alone, without MathJax, does what it did: --no-auto mathjax asks for that. Without either, the math is Pandoc's plain math, simple formulas in Unicode (αᵢ ≤ β) and the others as their TeX, with one WARN; Typst or a LaTeX engine typesets them. A math option of your own (--mathml, --math-method=...) is kept, and --doctor says which of them you get.

A link to an anchor the document does not have ([see 13.5](#135-your-own-defaults) quoted from another document) is only a warning for LaTeX and HTML, but Typst stops on it. For Typst, PDF or .typ, such a link becomes its text, and one WARN names the anchors.

--strict (or pdfmd-options: {strict: true} in the document; --no-strict overrides it) makes that, and every other warning, an error for scripts and CI: a file whose build printed a WARN line, or about which Pandoc warned (an image or citation it could not find, a LaTeX reference left undefined), is reported FAIL with the first warnings listed and the exit code is 1. The output file is kept. In a batch only that file fails.

Other scripts

examples/scripts.md: Kazakh, Greek, Arabic, Hebrew, Chinese, Japanese, Korean and Hindi text, and emoji, in one PDF

(That is examples/scripts.md, built with no configuration.)

A document that mixes scripts needs more than one font. With lualatex or xelatex, pdfmd reads the character tables of your installed fonts, finds the characters the main font cannot draw and sets each run of them in a font that can: Arabic and Hebrew right to left, Chinese/Japanese/Korean in the flavour the text (kana, hangul) or lang: points to, punctuation and marks in the font of the text beside them. Characters that no installed font has are named in a warning, never dropped or swapped for something else. -v shows which font each script got.

How much changes is up to you (pdfmd-options: {fallback: MODE}, --fallback MODE, or the config file's options:):

fallback: What happens to a character the main font lacks
word (default) the whole word goes in one fallback font that draws all of it, so no word mixes two fonts; your main font stays for every other word, even if the document names DejaVu Serif
char just that character is set in another installed font
document the installed font that draws most of the document replaces the main font (the old retry-with-DejaVu idea, chosen by coverage)
off (or false) nothing: what Pandoc and TeX do without pdfmd (a missing glyph vanishes or prints a box, and they warn)
box no fallback; each missing character is drawn as a black box, so it cannot be overlooked
error no fallback; the build stops, naming the missing characters

pdfmd --check-fonts doc.md shows what would happen without building: the main font (and why it was changed, if it was), which scripts get which fallback font, the characters no installed font draws and the --install fonts:... that would fix it; it exits 1 when something can't be drawn.

Code is held to the same standard: a character the monofont lacks (an arrow, a CJK comment) in inline code or a code block is set in a fallback font under LaTeX (a code block that needs one is typeset as plain verbatim, without syntax highlighting), and through Typst's codefont list or a CSS font stack on Typst and WeasyPrint. Math is left to its own font.

missing: warn | box | error (--missing) says what happens to a character that no installed font draws, after the fallback (box and error as a fallback mode imply it). char, word and document also turn emoji into pictures under LaTeX; off, box and error leave them alone.

Emoji are set as pictures with lualatex and xelatex (which cannot draw colour fonts): each emoji, flag, skin tone, family or keycap is looked up in the colour emoji font (pdfmd --install emoji, a system Noto Color Emoji, or else Apple Color Emoji on a Mac), its picture taken out of the font file and included at the height of the text, in inline code and code blocks too. Apple's font is read through its glyph names (the same way for single emoji, skin tones, flags, keycaps and the joined emoji it has a glyph for, such as a woman scientist); the few it composes in tables pdfmd does not read (a family of four, say) are reported as undrawable (install Noto for those). The pictures are kept in pdfmd's cache folder; a .tex written with --to latex points there.

The same fallback works with Typst (-e typst: #text(font: ...) runs, and the fonts folder handed to Typst) and WeasyPrint (styled spans). WeasyPrint draws Noto Color Emoji's bitmaps badly, so emoji are left to the system's own emoji font there; Typst draws them well.

Getting fonts. Anything installed on the machine is used. For the rest, pdfmd --install fonts lists what pdfmd can fetch and pdfmd --install fonts:arabic,cjk-sc (or fonts:core, fonts:scripts, fonts:all; also fonts:ja, fonts:kazakh...) fetches it into pdfmd's own folder (~/.local/share/pdfmd/fonts, %LOCALAPPDATA%\pdfmd\fonts; no admin rights, nothing system-wide). Every file is checked against a checksum pinned in pdfmd before it is kept, and each package keeps its licence beside it (all SIL OFL, except DejaVu's own free licence). The list: STIX Two (pdfmd's default main font), JetBrains Mono (its default code font), Noto Serif, Amiri and Noto Naskh Arabic, Hebrew, Armenian, Georgian, the Indic and Southeast Asian scripts, Syriac, Thaana, Cherokee, Oriya, Mongolian, Coptic, Gothic, Runic, Tifinagh, N'Ko, Canadian syllabics, Urdu Nastaliq (used for lang: ur), sans-serif Indic faces, symbols, Noto Serif for Chinese (simplified/traditional), Japanese and Korean (one weight each, bold is synthesised) and the Noto Sans CJK ones (cjk-sans), DejaVu, and colour emoji. When a document needs a script no installed font has, the warning names the package to install. pdfmd --uninstall fonts:NAME removes some.

It stays out of the way of a document that has set up its own fonts per script (ucharclasses, \newfontfamily, xeCJK, \babelfont, CJKmainfont, mainfontfallback): pdfmd-options: {unicode: true} adds pdfmd's anyway, and --no-auto unicode turns it off.

Switching defaults off

pdfmd lecture --no-auto                  # everything off: close to plain pandoc
pdfmd lecture --no-auto margin mainfont  # only these (kinds: reader title margin mainfont monofont tablewidth
                                           #  unicode lookup yaml tex lua ...; `pdfmd --help` lists them)

Three states, not two (v3.29). Each kind is on, off or, for the three that have a file to name, explicit-only: pdfmd looks for no lua filter, preamble or metadata file beside the document, but uses the ones the document names (pdfmd-options: {lua-filter: x.lua}, preamble:, yaml:, -y). off is what --no-auto always was: it also ignores a file the document names. A setting says it with no-auto: [lua] (off; true: everything off) or auto: {lua: explicit-only, margin: on} (an auto entry wins over no-auto in the same layer), in the document's pdfmd-options:, in a folder's .pdfmd/config.yaml, or in the config. The layers, strongest first: the command line (--no-auto, which only switches off), the document, the folders (the nearest first), the config; the first layer that names a kind decides it (until 3.29.30 the layers were added up, so a kind switched off anywhere stayed off: a folder can now switch back on what the config switched off, auto: {margin: on}). What finds code is the exception: for lua and preamble (and files and tex, which cover them) a folder only ever tightens. Its on is ignored with a WARN, and the strictest state of the config's and of every folder's stands; only the document's own pdfmd-options (or the command line) can loosen what your config switched off. So auto: {lua: explicit-only} in the config keeps a folder you did not write from running its own filter while you can still name one. explicit-only for metadata looks for no metadata file beside the document; the folders' .pdfmd/metadata.yaml and your global metadata.yaml still apply (off drops them all, as it always did). A metadata file's own no-auto is not read (it would have to be read to know whether metadata files are read). pdfmd --setup has the rows Automatic behaviour to switch off and Look for nothing, use only what the document names, and in a folder's setup Switch back on.

Passing options to Pandoc

Any option pdfmd doesn't recognise is passed straight to Pandoc:

pdfmd lecture --toc --number-sections
pdfmd lecture -V fontsize=12pt

Settings inside the document

Or put settings in the document itself, so nobody has to remember the flag:

---
title: Lab report
pdfmd-options:
  no-auto: [margin, monofont]
  pdf-engine: tex        # only TeX engines; never fall back to HTML ones
---

flavour: (also spelt flavor: or reader:: one setting) fixes the reader of a Markdown file: a flavour word (pandoc, gfm, commonmark; see Flavours) or a Pandoc reader (markdown, gfm+smart, commonmark_x, ...); auto is the default choice described under What it does automatically. It is --from for one document, or for a whole folder or all your documents when it sits in metadata.yaml or the config file's options:.

pdf-engine: takes an engine name (lualatex, typst, ...) or a family: tex, typst, html or office. A family limits the fallback chain to that kind of engine. For example, a document full of chemical structures can require TeX, and a plain memo can skip the slow LaTeX run. -e on the command line always wins, and a bare -e means "try every engine".

paragraph-spacing: is the space between paragraphs, added to the line spacing: a length (6pt, 1em, 2mm; pt, mm, cm, in, em), 0 for none, or auto, the default, each engine's own (LaTeX's half line, Typst's 0.55em more than a line; none with indent: true, in Typst too since 3.33.58). It is for LaTeX and Typst, the same gap in both; --paragraph-spacing 6pt sets it for one run. The HTML engines (WeasyPrint, Calibre, a browser) and --to html take it from a style sheet (p { margin: ... }).

linestretch: and fontsize: give Typst the line spacing LaTeX has (since 3.33.73): lines linestretch x the LaTeX class's line distance apart (12, 13.6 and 14.5 pt at 10, 11 and 12 pt; 1.2 x any other size), in any font, so a page holds as many lines in both. Pandoc's own Typst template puts them closer (23.5 pt at linestretch: 2, 12 pt, where LaTeX has 28.9).

Your own defaults: the config file

pdfmd --init-config writes a commented ~/.config/pdfmd/config.yaml ($XDG_CONFIG_HOME; %APPDATA%\pdfmd on Windows), pdfmd --show-config says where it is and what it sets. Its options: block takes the same keys as a document's pdfmd-options: and applies to every document, below anything the document or its metadata files say (the command line beats all of them); translit: is the romanization packs of the name lookup. PDFMD_CONFIG=other.yaml uses another file, PDFMD_CONFIG= (empty) none.

Settings for a folder (v3.29): a .pdfmd/config.yaml in a document's folder, or in any folder above it, has the same options: for everything under it, and a .pdfmd/metadata.yaml the metadata (author, language, paper, fonts ...: what the global metadata.yaml of --setup holds). The order, strongest first: the command line, the document's pdfmd-options, its metadata files, the folder settings (the nearest folder first, then the ones above it), your global config.yaml and metadata.yaml, pdfmd's own defaults. pdfmd --setup FOLDER (. for this one) writes the two files from the same numbered screen as the global setup: it offers the metadata rows and the options a build reads from a folder, leaves out what only the global setup reaches (the o key lists them: the page header and footer, the HTML, Markdown and text output tables, the editor ...) and the two path rows (bibliography, csl), adds a row Also use the settings of the folders above (inherit), and removes the files, and the folder, when everything is back to the default. A row the folder has no value for shows where its value comes from, 9 (global) or 3 (folder ~/proj), or (default). A name that is plain or fancy is the screen, not a folder: write ./plain. inherit: false in a folder's file stops the walk upward there. A folder cannot set what belongs to this computer (translit, setup-ui, disabled): they are ignored with a WARN. A mapping such as stamp: or backup: in a folder's file stays off until it says enabled: true, as in the global file. A build lists the folder files it used on its FOUND line (--verbose: AUTO SETTINGS). A folder's options cannot name code (a Lua filter or a preamble is read from the document's own front matter only, and a folder's office: {profile: ...}, a Lua file, is ignored with a WARN), a .pdfmd/config.yaml owned by another user (a shared /tmp) is not read, and a path in it (css: house.css) is relative to the document, as everywhere, not to the folder file (Pandoc takes a path in the folder's metadata.yaml from the .pdfmd folder, so the setup does not offer those). An attached source carries the options: of the folder settings a build used (the nearest layer winning, flattened into one mapping, without kit and kit-stack: what a kit wrote is in the source itself), and pdfmd --restore writes them to .pdfmd/config.yaml of the restored folder; a .pdfmd/config.yaml that is already there stays as it is and a NOTE lists what the build used besides. The folders' .pdfmd/metadata.yaml files travel as the metadata files they are. Such a manifest is attachment format 3 (every other stays 2, so an older pdfmd still reads it).

translit: [greek, hangul]
options:
  fallback: word
  pdf-engine: lualatex

pdfmd --setup (or pdfmd --config) changes the same defaults from a menu, without editing the files: a numbered list (nothing to install, works over ssh), each setting with its choices and what they mean. It covers what a document would otherwise say in pdfmd-options: or its front matter, for every document:

  • global metadata (a metadata.yaml beside the config file, below anything a document's own folder says): author, language, paper size, font size, page geometry, main and code font, line spacing, coloured links, table of contents, and for citations the bibliography file, the CSL style and linked citations; The global metadata can be switched off (--no-global-metadata, pdfmd-options: {global-metadata: false}, or the Use the global metadata row; --global-metadata turns it back on for one run) and replaced by a preset: another file, presets/NAME.yaml beside the config file. --preset lab (or lab.yaml, or a path), pdfmd-options: {preset: lab} in a document or a folder's metadata, or the Which set of global metadata row chooses it; --preset none takes no global metadata, default is metadata.yaml. pdfmd --setup --preset lab edits that preset's rows (it makes the file), and pdfmd --presets lists them and marks the one a build here would use. The command line wins, then the document, then the folder's metadata, then the config.
  • characters a font lacks or none draws, fonts for other scripts, romanization packs;
  • the citation engine; output (the PDF engine, the default output format, defaults for .typ and .html files); HTML (full page, one file, style sheet, math method); tables (the rows, columns, cut and delimiter of .csv tables, pipe-table widths); code (wrapping, line numbers); Word and OpenDocument (LaTeX in them, reference numbers, the template);
  • plain Markdown, text and ASCII output (--to gfm, txt, ascii: scripts, math, title block, the source carried at the end);
  • PDF finishing (header, footer, bookmarks, attached links, a hybrid PDF); the build history (the "Compiled with..." stamp and its details); backups (their folder, names and how many are kept); the source inside the PDF (attaching the source, its files and its LaTeX packages, comment stripping);
  • the cache (reference numbers, plots, a part built alone); input (the Markdown reader, reading .txt as Markdown, raw pieces);
  • checks and limits (--strict, the size budgets, the codes --check leaves out), and which of pdfmd's automatic behaviours (--no-auto kinds) are off.

Every flag and every pdfmd-options key is either one of these rows or left out on purpose: o lists the rest (commands, what is about one run or one document, trust, what pdfmd writes for itself) with the reason, and a test keeps that complete. Choosing 0 (for a text, -) gives a setting its built-in default back; s saves, q quits.

Switching whole features off. d in --setup (or disabled: [history, tables] in config.yaml) switches off features you never use: modes, source, tables, history, code, raw, markdown, fonts, builtin, office, cache (the topics of --help; output, setup and debug stay on). Their options leave --help, shell completion (regenerate it with --completion) and the setup screen, and the command line refuses them; pdfmd --disabled --history doc uses one for a single run and pdfmd --disabled --help all shows them. A document's own pdfmd-options still apply, so a document from someone else builds the same. In the full-screen setup, r resets a row (it used to be d). Choosing no markup or page-break families stores an explicit empty list, disabling those families; reset to the flavour default with - in the plain list editor or r on the full-screen row. The files are rewritten without your comments (the old one is kept as .bak). The same keys can be written by hand under options: of config.yaml. A full-screen version is optional: pdfmd --install tui (prompt_toolkit, about 1 MB), then pdfmd --setup fancy, or set "This setup screen" to fancy to make it the one pdfmd --setup opens. The first time pdfmd finishes a build in a terminal with no config file yet, it prints one TIP line about --setup (never in a pipe, in CI, or from the Python API; PDFMD_NO_PROMPT=1 silences it).

The decisions pdfmd remembers for you (the Lua filters it trusts, an offer you dismissed) live in the same folder, not in ~/.cache/pdfmd, so cleaning the cache (--clear-cache) cannot lose them; files from an older version are moved there the first time they are needed.

Project files

pdfmd looks next to the document, and in a metadata/ folder beside it, for:

File Used as
metadata.yaml shared Pandoc metadata (fonts, bibliography, CSL, ...)
<name>.yaml per-document metadata, layered on top of metadata.yaml
report.yaml / book.yaml metadata for -r builds
preamble.tex, latex-preamble.tex, <name>-preamble.tex, preamble-*.tex LaTeX added to the header, generic files first
<name>.lua a Pandoc Lua filter for that document

Only these names are picked up automatically. An unrelated .lua or .tex file lying in the folder never changes a render. When several YAML files could apply and none is clearly meant, pdfmd stops and asks you to choose with -y FILE, rather than guessing. -y alone turns discovery off.

When two metadata files set the same key, the later one wins, as in Pandoc: <name>.yaml over metadata.yaml, report.yaml over metadata.yaml, and the second of -y base.yaml -y extra.yaml. This holds for Pandoc's keys and for pdfmd's own (pdfmd-options:, margin:), key by key: the earlier file still gives whatever the later one leaves out. The document's own front matter beats every file. --metadata-precedence first (or metadata-precedence: first in pdfmd --setup, under Input) turns this round so that the first file wins. Until 3.27.22, pdfmd's own keys followed that first rule while Pandoc's followed the later file.

Adding to a value instead of replacing it (3.31.40). The layers, lowest first, are: the global metadata.yaml (pdfmd --setup), a kit's defaults, the .pdfmd/metadata.yaml of the folders above the document (the farthest first), the document folder's own metadata files, the document's front matter. A key set higher up replaces the value below it, lists too. To add instead, write the key with a plus: author+: [B. Partner] is the value the layers below hold, then B. Partner. It works in any layer (a course folder's author+ adds to the global author), a single value counts as a list of one, nothing below means just the names written, and author: and author+: in one place are taken in that order. The command line is the top layer: -M author+=Charlie (also --metadata author+:Charlie) appends, while -M author=Charlie replaces. The setting pdfmd-options: {metadata-append: [author]} (a name or a list; the document, a metadata file, .pdfmd/config.yaml or pdfmd --setup) makes the document's own author: add as well, which suits a course that rotates its lab partners. Only a plain value or a list can be added to (a mapping stops with a message), and header-includes is left out because pdfmd lifts it into header files itself. Pandoc has no such rule, so pdfmd merges the layers and lib/pdfmd_lua/metadata_append.lua, which runs before every other filter, puts the finished value in. The built-in renderer (no Pandoc) reads the layers as Pandoc does, without adding. An attached or assembled source holds the finished value and metadata-append: [], so a restored report rebuilds with the same names, once.

A header-includes: stays when a preamble file joins it: the document's own, or else the one of the metadata file that wins (above). The same holds for the header files pdfmd adds itself, such as the code-wrapping header of a document with code. A metadata file's lines go before preamble.tex, and the document's own go after everything. An --include-in-header you pass yourself replaces them all, as it does in Pandoc. The global metadata.yaml that pdfmd --setup writes is the lowest of all metadata files.

With a metadata/ folder, a report's own folder can hold nothing but report.md and report.pdf. Relative paths inside the metadata (such as bibliography: refs.bib) resolve from metadata/, and image paths in the document still resolve from the document's own folder.

A Lua filter can run any command, so pdfmd tells you about the ones it picks up by itself. The first build with a <name>.lua (or nulabreport.lua) it has not run before prints one NOTE, and so does the first build after the file changed; the filter runs all the same, and there is never a prompt, so a filter you edit every day, -j, -w, CI and the Python API are not held up. pdfmd --trust-lua ~/work/reports marks a folder you work in (or one filter, as it is now): nothing under it is noted again. Only --strict skips a new or edited filter outside such a folder. pdfmd remembers each filter's path and SHA-256 in known-lua.txt beside its config. This is a notice, not a lock: it does not protect the first build of a folder you have just downloaded, so read its .lua first.

One shared metadata file can also be symlinked into many folders. A relative bibliography: refs.bib inside it then finds the refs.bib next to the file's real copy, so you never need an absolute path.

Kits

A kit is a named, versioned unit for a whole document pipeline: a LaTeX package, its Lua filter, default metadata, the citation style and the library files it needs. A kit is a folder that holds a YAML file named .kit (or NAME.kit); pdfmd names no package and no course, the file is the data.

kit-format: 1                  # required; a larger number stops with "needs a newer pdfmd"
name: nulabreport              # defaults to the folder's name
version: 1.30.0                # optional: left out, the version the package's `\ProvidesPackage` line announces
package: nulabreport           # the LaTeX package the kit ships (NAME.sty beside the file)
default-mode: genchem          # the mode used when none is asked for
modes:                         # one named variant of the kit
  qca:
    aliases: [220, "chem 220"] # other names, matched ignoring case, - _ . and spaces
    options: [qca]             # package options
    defaults: qca.yaml         # a metadata file, relative to the kit's folder
    metadata: {course: "CHEM 220L"}   # default front-matter keys of this mode
latex: ['\input{chemicals}']   # lines written after the package line
lua: [nulabreport.lua]         # Lua filters the kit adds to the build
defaults: metadata.yaml        # metadata file(s) every mode gets
latex-keys: {experiment: LabExperiment}   # the blocks of `<package>-pdfmd.yaml`, same meaning
carry: [nulabreport.sty, assets/]         # what an attached source or a bundle takes along
no-auto: [margin]              # pdfmd defaults the kit takes over (v3.32.8), see below
markup-off: [landscape]        # kinds of universal markup the kit switches off (v3.33.68), see below

Every path stays inside the kit's folder. settings:, requires: and options: are reserved for later; any other unknown key is a warning, not a stop. Several faults are listed together.

A kit that sets its own page (v3.32.8). A class has its own margins, and pdfmd's automatic margin (geometry:margin=...) would clash with them. no-auto: [margin] in the .kit switches that default off for documents that use the kit, where a kit that only wanted this had to write geometry: " " into its metadata. It may name margin, mainfont, monofont, mathfont, font (the three fonts) and tablewidth; any other kind is a warning, because a kit is chosen after pdfmd has looked for files, so it cannot take that search over. The kit's switch is weaker than the command line, the document and the folders' files, which can turn a kind back on (pdfmd-options: {auto: {margin: on}}), and stronger than the config file's. An older pdfmd ignores the key with a warning. pdfmd --kits NAME shows it.

A kit that does a markup's job itself (v3.33.68). markup-off: [landscape] in the .kit takes those kinds of universal markup (the kinds markup: names) out of the list for documents that use the kit, so a house style with its own landscape pages need not list every other kind. It may name any kind of markup: (callouts, toc, center, highlight, underline, keep, landscape, appendix, space; a list or one string with commas); any other word, comments among them (that is show-comments), is a warning naming the kit file. Like no-auto:, the kit's switch is weaker than --markup and --no-markup, the document, its own metadata files and the folders' files: a markup: set there is used as it is. It is stronger than the config file's, a preset's, the global metadata's, the kit's own metadata and the flavour's default list. An older pdfmd ignores the key with a warning. pdfmd --kits NAME shows it, --verbose says what it took out.

Finding a kit. By name, ignoring case and - _ . and spaces (Nu-Lab-Report is nulabreport): the folder's name, the NAME.kit file's name or the name: it declares. The places, in order: a kits/ folder beside the document and above it; the kits: of the config file (a folder that is a kit or one that holds kits; - {path: ~/kits/x, aliases: {qca: [analytical]}} adds mode names without editing the kit; the Folders of kits row of --setup); a kits/ folder beside the config file; then the folder of NAME.sty that kpsewhich finds, whose answer is remembered in kits.json of the cache folder (checked by the .kit's size and time; a miss is never remembered). Two different kits of one name in one place stop the search and name both; between places the order decides. --no-auto kit takes the searching off (the config's kits: and a path stay), --no-auto kpsewhich the TeX tree only. pdfmd --kits lists the kits, pdfmd --kits NAME-OR-PATH shows one in full and checks it. Searching is not selecting: a document that only says \usepackage{nulabreport} is not switched to the kit.

Choosing a kit. pdfmd-options: {kit: nulabreport:qca} in a document, a metadata file, a folder's .pdfmd/ files, a preset or the config's options:, or --kit nulabreport:qca for one run. The same choice can be written nulabreport, nulabreport[qca] (quote it in a shell), "[qca]nulabreport" (quote it in YAML), {nulabreport: qca}, or a list for the order; a value with /, \ or a leading ~ or . is a path to the kit's folder or .kit file; none (or off, no, false) means no kit. A mode is matched by name or alias (nulabreport:220). The layers stack, lowest first: the config, a preset, the folders' files (.pdfmd/), the document. The same kit named higher up changes only the keys it names (the mode); different kits are added in order; none drops what is below. A folder's kit: names a kit (searched in kits: of the config, the config folder's kits/ and the TeX tree), never a path and never the project's kits/ folder.

--kit NAME builds with a kit, pdfmd --kits NAME describes it (and builds nothing); with --verbose a kit that --kit chose is named on one line with its file and that command (KIT apa7 1.0 mode stu (chosen in the command line) from ~/kits/apa7/.kit; `pdfmd --kits apa7` describes it, v3.33.72); a normal run says nothing more. --kit replaces every kit: setting; give it several times to stack them; --kit none (or a bare --kit before another option) uses no kit. kit-stack (pdfmd-options, --kit-stack, a --setup row) makes --kit stack on the layers instead: all, or a list of global, preset, folder, document; so --kit-stack all --kit nulabreport:orgchem changes only the mode for one run.

A chosen kit's defaults, metadata and its mode's are metadata files between the global metadata and the folders' own, so a folder tunes the kit and the document wins; its lua filters run; its folder is on Pandoc's resource path, so its csl: and bibliography: are found by name; pdfmd --verbose prints what it chose.

The preamble a kit writes. For LaTeX output the kit's lines (\usepackage[<mode options>]{package}, then its latex: lines and its mode's) are one header file that comes before every other: your preamble.tex, header-includes: and --include-in-header files follow it, so a setting that needs the package finds it loaded. If your text says what the kit says, pdfmd settles it in the build copy (your files are never edited): the same \usepackage or \input line is dropped; extra options ([qca,draft] where the kit loads [qca]) are merged into the kit's line, because LaTeX stops with "Option clash" on two different lines; an option of another mode of the same kit is a contradiction, where a preamble.tex found in the folder yields to the kit with a warning (KIT-YIELD) and header-includes:, --include-in-header or a named preamble stop the build naming both sides; anything else stays after the kit's lines. Only a whole line is rewritten: the package inside a list or behind a macro stops the build and names the line. --verbose shows the quiet outcomes (KIT-SAME, KIT-MERGE).

A kit needs no install. A chosen kit's folder is put on TEXINPUTS and BIBINPUTS of the LaTeX run (after the folder itself, before TeX's own path; the folders its carry: names too, searched recursively, so a logo is found by its bare name), so its .sty and the library it \inputs are found without linking anything into the TeX tree. Tectonic reads no TEXINPUTS: with -e tectonic (v3.32.6) each of those folders, and every folder under a carry: one, goes to it as --pdf-engine-opt=-Zsearch-path=DIR (the option names one folder, not a tree), so the class a kit ships is the one that is loaded, not the copy in Tectonic's own bundle. A kit is then one folder: clone it, name it under kits: of the config (or --kit PATH), and select it.

Getting a kit. A kit is a folder, so a repository that holds one (a .kit or NAME.kit file in its root, like a package's own repository) or many (one per folder, like a collection) is a source of kits, and pdfmd --install kit:SOURCE takes them from any of them (v3.32.7):

pdfmd --install kit:owner/repo                 # every kit of a GitHub repository (its default branch)
pdfmd --install kit:owner/repo@v1.2#apa7       # one kit of it, at a tag, a branch or a commit
pdfmd --install kit:https://host/x/y.git       # any git URL (https or ssh); @REF and #NAME work the same
pdfmd --install kit:~/work/kits                # a folder on this machine (copied as it is)
pdfmd --install kit:apa7                       # no slash: a kit of that name in the repositories of `kit-sources`
pdfmd --install kit                            # lists what is installed; pdfmd --uninstall kit:apa7 removes one

The repository is fetched as a snapshot (a GitHub repository as its tarball over https, so no git is needed; any other URL with git clone --depth 1, https, ssh and local transports only) into a temporary folder and searched: a folder with a kit file is a kit, the root first, then every folder below it down to kit-search-depth levels (hidden folders are skipped), and what is inside a kit belongs to it. Each kit found is checked, then copied whole to kits/NAME/ beside the config file (the folder kit discovery already reads) with .pdfmd-install.json saying where it came from (pdfmd --kits NAME shows it); installing it again updates it. A kit that is there and was not installed by pdfmd is never replaced, and one from another source needs --yes. Nothing is run at install, not a script of the repository nor a kit's Lua filter; the archive is data (no name may leave the folder, links and special files are dropped) and capped by kit-install-max-mb (200) and kit-install-max-files (5000). A kit's Lua filter and its latex: lines are code that runs when a document selects the kit, so the install says which kits carry one: read it first, as with any filter. These settings go under options: in the config file: kit-search-depth (3), kit-install-max-mb, kit-install-max-files and kit-sources (a list of owner/repo or URLs, the repositories a bare name is looked for in; the author's collection by default).

A kit travels with the source. --attach-source, --keep-source and --bundle hold what the kit wrote (--embed-metadata, its preamble only): its preamble goes into the source first (with what your own LaTeX said twice settled in it, as in the build), its defaults and Lua filter go in as the metadata and filter files they already are, and the files its carry: list names are stored beside the document: a named file keeps its path, a named folder is carried by its files, which land beside the document (TeX looks there first, so a package that finds its logo by a bare name finds it again). A file the document's folder already holds is left to the folder, and a kit whose parts are over bundle-max-mb (100) is not carried, with a warning. The manifest records each kit (name, version, mode, package, the parts); pdfmd --restore says so and writes the parts back. A restored report needs no kit: the source says kit: none in its own pdfmd-options (it replaces a kit: chosen anywhere below it, in the document, a folder or the config), so a rebuild uses the stored copies and neither fails on a machine without the kit nor applies the kit twice on one that has it; delete that line to choose a kit again. A kit's Lua filter is better not carried as a file: a <package>.lua beside a document is applied by name, and the filter the build ran is already in the source.

Build stamps, history and snapshots

All are off by default.

  • --stamp keeps a BUILD NOTES HTML comment at the end of the .md up to date. It records when the document was compiled and with which pdfmd version, plus the versions of any LaTeX packages you name with --stamp-packages. The comment is invisible in the PDF. --stamp-mode history also keeps a list of past compiles.
  • --backup saves a timestamped copy of the source into .backups/ (a hidden folder; an existing backup/ is kept using, and pdfmd says where the first copy went) after every successful build. A copy is skipped when nothing changed, and keep: 30 limits how many are kept. With pdfs: 5 the built PDF is copied beside it too (report_20260924-091500.pdf, the same stamp), because a rebuilt source cannot give an old PDF back: the newest five are kept, pdfs: -1 keeps every one, and pdfs: 0 (the default) none; pdfmd --setup has the row.

Both can be turned on for a whole folder from metadata.yaml:

pdfmd-options:
  stamp: true
  backup: { dir: .backups, keep: 30 }

History in a file instead of in the document. --stamp-store file (or stamp: {store: file}; history: is the same setting under another name, 3.30.24, so history: {store: file, partial: true} works as well) writes each compile to NAME.hst in the backup folder, and the document itself is not edited by a build. The file is one line per entry, newest first, so it reads in any editor, two of them join with cat, and pdfmd --merge-history a.hst b.hst -o all.hst keeps every entry once:

# pdfmd history of report.md -- one entry per line, newest first: WHEN | KIND | TEXT [| key=value ...]
2026-10-09 11:22:01 | compiled | with nulabreport v1.27.7, pdfmd v3.25.7 | sha=3fa91c0d4b21 out=report.pdf

Draft builds in the history (3.30.23). A build of some parts or sections (pdfmd @60, report#discussion) is not the report, so it never writes the BUILD NOTES line. With stamp: {partial: true} (or --stamp-partial; --no-stamp-partial turns it off over a document that set it; pdfmd --setup has a row) and the history in a file, it leaves a partial entry instead, saying which parts it built and which of them changed:

2026-10-25 00:58:25 | partial | parts 50-calculations, 60-discussion; changed: 50-calculations; with nulabreport v1.28.20, pdfmd v3.30.23 | parts=50-calculations,60-discussion changed=50-calculations shas=50-calculations:8fec630b,60-discussion:78a4c87e out=report.calculations+discussion.pdf

"Changed" compares each part's text with the last entry that saw it (a full build in parts mode records every part's shas, a partial one the parts it built); a part no entry has seen is first seen, not changed. It is off unless set, because it adds lines to a history that held full builds only; a project that wants it (nulabreport does) sets it in its metadata file. With the history in BUILD NOTES (store: notes) nothing is written for a partial build.

pdfmd --history-to-file report.md moves the whole BUILD NOTES block that pdfmd owns into that file (the compiles as entries, anything written by hand inside it as one note entry), and --history-to-notes builds the block again (both back the document up first, --dry-run shows). A document may carry several BUILD NOTES blocks (yours, an AI agent's): pdfmd writes only into the one that already holds its own lines, or the only one there is, or starts one of its own when there are several and none is its; the rest is never touched, and a line pdfmd: ignore in a block keeps pdfmd out of it. The file travels with the document: --embed-metadata folds it in (hst), --unpack writes it out, and --attach-source stores it in the PDF for --restore.

Going back. pdfmd --history report.md lists every version it can find, newest first: the backups (in .backups, and in an older backup/, backups/... beside the document, whatever naming each file has), the git commits that changed the file (the repository may be a folder above), and the compiles recorded in its history. In a terminal the list is navigable: a number shows what differs, d 3 the diff, r 3 restores. Without one, --history-diff and --history-restore take a number, a backup's timestamp (any start of it: 20261003), a file name, a commit hash, latest, or previous (the newest that is not the file as it is). A restore backs the file up first, so nothing is ever lost; it keeps the build notes and history of the file as it is now, and adds a Restored version ... entry (stamp: {restored-note: false} or --no-restored-note leave it out; --dry-run shows what would change). pdfmd --init-backups report.md turns all of this on for a document: backups and a history file, set in its front matter under an AUTO GENERATED comment (--global for every document), and a first copy.

Separately, when pypdf is installed, every PDF built by pdfmd gets two hidden metadata keys, PdfmdVersions and PdfmdBuildDate, which you can read with pdfinfo -meta. Turn this off with --no-stamp-pdf-metadata.

Reference

  • docs/recipes.md is the task-by-task manual: fifteen chapters of "how do I..." with the commands (the basics, a house style, long documents, tables, code and images, raw HTML/LaTeX/Typst, other inputs and outputs, the PDF that carries its source, history, fonts and scripts, speed, the editor and VS Code, and what to do when something looks wrong), plus an option reference by task.
  • pdfmd --help lists every flag.
  • The docstring at the top of pdfmd.py is the full reference for each behaviour and its edge cases.
  • CHANGELOG.md records what changed in each version and why.

License

MIT

lib/pdfmd_mhchem/ is a Python port of Martin Hensel's mhchemParser (the parser of mhchem's \ce and \pu that MathJax and KaTeX use) and keeps its Apache License 2.0.

About

One command from Markdown to a good-looking PDF: a Pandoc wrapper with smart defaults, project-file discovery and a multi-engine fallback chain

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages