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
- Why
- Install
- Usage
- One document
- Several documents in a folder:
priority - Keep the old file:
--no-rewrite - Check a document before you build it
- Crop a PDF to what it draws
- A small editor
- In VS Code
- A size budget for images and output
- Start from a template
- One section
- Other output formats
- Plain Markdown:
--to gfm - Plain text:
--to txt - Calculations inside the text:
{=},{ans}and::: calc - Read it in the terminal:
--viewand--to ansi - ASCII only:
--to ascii - Word and OpenDocument output
- Stopping part-way
- The PDF carries its own source
- PDF to Markdown
- Bookmarks, header and footer, linked files
- Use it from Python
- A local server for the web UI and for your own tools:
--serveand--api - Other command names:
mdpdf,md2pdf,inkmd,batchmdandpan-tec - Slides
- A whole folder
- A book or report from several files
- One long document in several files
- Tables straight from a CSV file
- One style sheet for the page:
--css,pdfmd.css,css-scope - A cover:
cover - Code: wrapping and line numbers
- Images: SVG and remote ones
- Diagrams written as code
- Editable in LibreOffice, and one file that carries its data
- HTML, LaTeX, Typst and Word pieces inside Markdown
- Flavours:
flavour - Not just Markdown
- What it does automatically
- Project files
- Kits
- Build stamps, history and snapshots
- Reference
- License
$ 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.
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.
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 ametadata.yamlbeside 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.texor<name>.luabeside the document (or in ametadata/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
lualatexfails 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
Глюкоза.mdwhen you typepdfmd 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 --historylists backups, commits and compiles,--history-restoreputs a version back (see Build stamps, history and snapshots). - It shows what it did. Every automatic decision prints an
AUTOline,--verboseshows the exact Pandoc command, and every default can be switched off.
pipx install pdfmd-cliThat 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 --setupPip 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 installingOn 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.mdThe 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_BINnames a folder for an install somewhere else, andpdfmd --doctorsays where it found one. Windows: the installers of Pandoc, Typst, Tesseract, LibreOffice, Graphviz and Poppler, andnpm install -gfor Mermaid, often leave their folder offPATH. pdfmd looks in the usual install folders (and the registry's App Paths) and uses what it finds, andpdfmd --doctorsays so. For an install somewhere else, name it inpdfmd --setup(section "Tool locations") or in the config'soptions::pandoc-path,typst-path,chrome-path,tesseract-path,soffice-path,dot-path,mmdc-path(a program or its folder) andpoppler-path(a folder). LibreOffice time limit: every LibreOffice run (Office files, spreadsheets, SVG pictures) may takesoffice-timeoutseconds, 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.0never runs LibreOffice (it counts as not installed), a negative number (-1) removes the limit. It is apdfmd-optionskey (one document), a config key and a row ofpdfmd --setup("Checks and limits"). Diagram time limit: every run ofmmdc,dotord2(and of thersvg-convertorinkscapethat turns their SVG into a PDF or PNG) may takediagram-timeoutseconds, 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 (--strictmakes 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).0draws no diagram, a negative number (-1) removes the limit. It is apdfmd-optionskey (one document), a config key and a row ofpdfmd --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 # ArchOn 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.TypstEvery 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 ~/.bashrcmacOS'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, ason(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 asmissing. No engine found (no record, a PDF of another program): ason.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.styor.cls; Typst: anerror: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 sThe 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.
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.
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.fishThe 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-cliOn 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^2andx_ias super/subscripts,\frac{a}{b}asa/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 aspip 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) andpdfmd --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(alsocortex, 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(orcore) is--install fullbelow.pan-tec(see "Other command names") runs pdfmd on it.pdfmd --install fullleaves the fallback behind: it installs Pandoc (the real binary from PyPI'spypandoc_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_binaryand delete thetypstfile.
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).
Every command below can be run from inside examples/.
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 NOTEA 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).
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 equalA 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.
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 saysEvery 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.
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.
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 picturesThe 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 PTsets the room around the ink (default 2);--crop-ignore-bottom PTleaves 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 Nkeeps 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 plainpdfis an ordinary build, with its stamp and backup, unless--cropcuts it) and once for the pictures, which are cropped and drawn together. Each result sits beside the others;-ois then a folder. A document cannot yet ask for this itself (default-outputtakes one format). - Targets.
--to pdf(the default),png,jpg(jpeg),tiff(tif),eps,psorsvg;-o x.pngpicks 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) andps(vector, all pages in one file); SVG bypdftocairo(poppler) or elsemutool(MuPDF).--to png|svgwithout--cropdraws 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 (
tiffandpskeep all pages in one file):NAME.pngbeside the document for a one-page result, a folderNAME/with001.png,002.png... for several (with a NOTE);--crop-page 1gives the plain name.-o a.pngwith several pages gives the foldera/.--no-page-folder(orpdfmd-options: {page-folder: false}, or the--setuprow) writesNAME-001.png... beside the document instead. - Names. A Markdown source gives
NAME.pdf|png|svgbeside it (-d: in the current folder); a PDF source givesNAME-crop.pdf, so the source is never overwritten unless-onames it. The intermediate PDF of a Markdown source is built with your other options (-e,-y...) but with no build stamp or backup. --cropwith--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-bon a folder (pdfmd notes -b --crop -o out/ -j 4: its*.md,--recursivefor subfolders;-jfiles at a time).-rmakes one result of a report folder, named after the folder;-pdraws a picture per slide.
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 PDFpdfmd --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 saveA 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.
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.
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 builtpdfmd-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.
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.
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).
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 fragmentpdfmd 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.** textabove 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;htmlkeeps<sub>,dropwrites the plain text,asciialways writes_2,^(q+z)); math stays$..$and$$..$$(--gfm-math fencedwrites```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
WARNcounts 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.
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 runNothing 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.
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.
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} ppmprints 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 italicAn 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 concentrationThe 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,3sfIt 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 constantsOn 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.
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 formatWhat 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.
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.
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.dotxThe 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.\ceis 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,\pagerefand 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\labelin 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(Word cannot show a PDF) are converted the same way. Pictures are cached by content, so a rebuild draws only what changed (pdfmd --clear-cacheempties it). A fragment that will not compile is named in a warning and left out; the rest of the document is built. Needspdftocairo(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 itsmetadata/folder or in~/.config/pdfmd/, oroffice: {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 optionalpandoc = function(doc, helpers)hook may restructure the whole document. The helpers includeinlines(tex)/blocks(tex)(LaTeX read with the filter's own math,\ceand\siapplied),read_file(name)(a file the document's\inputwould 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 (
-vlists 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}}).
--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 itAn 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 flagNamed 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-containedpdfmd-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.
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.svgbecomesfig.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}fromtexmf-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 allthe whole folder except output and housekeeping. Data whose file name a macro builds cannot be found: useall. A Markdown output (--keep-source) keeps the same files, but has no PDF to take pictures from: they come with--bundle. --restoreand--unpackstay short. They count what they write by folder;--verboselists every file, and--restore --listalways does.- Your own LaTeX packages (a house style in
texmf-home) are recorded with their version, and--restoretells you what is missing;--bundle-packagesstores them too: the style file, the<package>-pdfmd.yamlbeside 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 withattach-csla 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
.texfiles a document reads and its CSV data are blocks of the attachedpdfmd-source.md, after the text (.texfirst, 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--restoreputs 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 aneol: crlfline and comes back byte for byte.pdfmd --edit report.pdfwrites 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 sharednulab.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 withwas 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,\urland verbatim environments are never touched, and a%that closes a line keeps a bare%, because it swallows the line break. Withstrip-comments: false(ormetadatakept) the attached source gives back your metadata files and the document's front matter byte for byte, comments and quoting included;--restoreof such a PDF is a full source copy. The data files of.csvblocks (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--restoredoes 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: allkeeps it whole, andnocite: '@*'keeps every entry. - The citation style is not attached unless you ask (
--attach-csl,attach-csl: true,--embed-metadata cslfor 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--restoresays that a build needs a style of that name (Pandoc's owncsl/folder, or beside the document); a house style that exists nowhere else should travel, so setattach-csl: truefor 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
--listbefore sharing. --restorenever 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/--reportbuilds attach their chapters too. A section build (doc#section) attaches nothing.
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-imagesA 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 belowScanned 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.
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 letterA 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.
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.
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.
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 renderermd2pdf 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.
pdfmd slides -p # Beamer slides; each heading starts a slideSpeaker 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.
:::$ 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.
$ 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).
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 tooThe 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/.
pdfmd-options:
cache: {aux: true} # or: pdfmd report --cachekeeps 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 documentputs 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).
Measured values:
::: {.csv file="data.csv"}
:::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):::: csvwith the lines inside builds the same table as::: {.csv}.file=,align=and the other attributes need the braces.::: csvfileand::: csv-xare other classes.--extract-inline-csvmoves a bare block's data to a file (the block then has braces, for thefile=),--expand-tablesturns it into a pipe table, and--checkreads it as it reads the braced one. - To show CSV lines as text in a document that has
.csvdivs, switch the table off:pdfmd-options: {csv: off}(the short form ofcsv: {enabled: false}; a mapping such ascsv: {rows: 30}leaves the tables on),--no-csvfor a run (--csvturns them back on), or the row inpdfmd --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}, orTable: Caption), orcaption="Caption"inside the braces, with the identifier as#tbl:idthere 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 bothfile=and data inside the block, the file is used. - The delimiter is
delimiter=";"or its name (semicolon,tab,pipe,space,colon,comma), orpdfmd-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"(oralign="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, sayseparator="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 byopenpyxl(xlsx and xlsm: the values the file stores, so a cell shown as1.50is1.5, a date is2026-01-02), else bypandas, else by LibreOffice (the sheet by number); none of them is aWARNthat 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-tablesreads 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"orcsv: {cut: head}keeps the first ones only).rows=allorcols=20raise 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=offon the div (or--csv-note off, orcsv: {note: off}) leaves the note out, the…row still marks the cut, andnote=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, orpdfmd-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, wherepdfmd --setupsets 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.
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 wordsEvery key is also a --setup row, at the config and folder levels.
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'smarkdownreader reads the+rule,gfmdoes 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: textright above the table becomes its caption (several lines are joined; any case), and#+NAME: tbl:idits identifier; a plain name is dropped. This works above any pipe table, one that needed no repair too (| a | b |ora | bwith its:-|-:row).#+ATTR_*:lines above are taken::align lcrsets 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'smarkdownreader, so a file with no front matter that has a#+CAPTION:line is read with it instead ofgfm. |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), offAlso a --setup row, at the config and folder levels.
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&TandR&Dare 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
<<,>>,^^orvvneeds 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, guillemetBoth are also --setup rows.
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 longtblrBoth 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 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.
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.
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.
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.
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.
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.
```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.
--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.
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 sofficepdfmd report --raw # everything for every family
pdfmd report --raw-for html=tex,typst --raw-for tex=html # or per family, on the command lineA 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,  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.
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), offAlso 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.
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, inobsidianandpdfmd). Obsidian hides them in its reading view, and so does pdfmd, in every output:A %%a note%% bprintsA 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 thepdfmdflavour, outside a vault, it stays text (a lone%%can be TeX or plain text).pdfmd-options: {unclosed-comment: hide}orkeep(--unclosed-comment, also a row inpdfmd --setup) chooses either way. A later%%closes the earlier one, and an<!-- HTML comment -->hides the same way. Withshow-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@keyinside one does not switch the reader or turn on citations. -
Typography, figures, bare links and emoji (v3.32.23, in
obsidianandpdfmd). 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.orgis a link) and+emoji(:smile:). Since 3.32.4 a file without front matter had read as gfm with none of these;flavour: gfmorpandocgives the strict readers back. -
pdfmd's own syntax on (v3.32.24, in
obsidianandpdfmd, for a Markdown file).markupis on for callouts,toc,center,highlight,underline,keep,landscapeandappendix(> [!note],[TOC],<center>,==text==,<u>text</u>,::: keep,::: landscape,\appendix), andpagebreakfor every family, with no setting.spacestays off (a lone<br>is often just a blank line), and so dolinebreakandraw: name them (markup: [space],linebreak: true,raw: true) to turn them on. Amarkup:orpagebreak:setting of any kind,falseincluded, replaces the flavour's list;--markupand--no-markupdecide 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 theobsidianflavour inside a vault pdfmd does what that vault does: it readsstrictLineBreaksfrom.obsidian/app.json(no key: Obsidian's default, a break). Every other flavour,pdfmdincluded, keeps the Markdown rule, so a source wrapped at 80 columns stays one paragraph.pdfmd-options: {single-newline: hard}(orsoft; also a row inpdfmd --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_breaksdoes it. -
Links between notes:
[[Note]](v3.32.26, inobsidianandpdfmd). A printed document has no other notes to open, so a wikilink is written as the text Obsidian shows for it:[[Note|an alias]]isan alias,[[Note]]isNote,[[Note#Heading]]isNote > Headingand[[#Heading]], a link inside the note, isHeading;[[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, inobsidianandpdfmd). 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.|300is the width in pixels,|300x200a 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 is0.75pt (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;nonekeeps 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 (attachmentFolderPathof.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|nonenarrows 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 ofpdfmd --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.
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).
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.
(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.
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.
Any option pdfmd doesn't recognise is passed straight to Pandoc:
pdfmd lecture --toc --number-sections
pdfmd lecture -V fontsize=12ptOr 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).
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: lualatexpdfmd --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.yamlbeside 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-metadataturns it back on for one run) and replaced by a preset: another file,presets/NAME.yamlbeside the config file.--preset lab(orlab.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 nonetakes no global metadata,defaultismetadata.yaml.pdfmd --setup --preset labedits that preset's rows (it makes the file), andpdfmd --presetslists 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
.typand.htmlfiles); HTML (full page, one file, style sheet, math method); tables (the rows, columns, cut and delimiter of.csvtables, 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
.txtas Markdown, raw pieces); - checks and limits (
--strict, the size budgets, the codes--checkleaves out), and which of pdfmd's automatic behaviours (--no-autokinds) 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.
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.
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 belowEvery 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 oneThe 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.
All are off by default.
--stampkeeps aBUILD NOTESHTML comment at the end of the.mdup to date. It records when the document was compiled and with whichpdfmdversion, plus the versions of any LaTeX packages you name with--stamp-packages. The comment is invisible in the PDF.--stamp-mode historyalso keeps a list of past compiles.--backupsaves a timestamped copy of the source into.backups/(a hidden folder; an existingbackup/is kept using, and pdfmd says where the first copy went) after every successful build. A copy is skipped when nothing changed, andkeep: 30limits how many are kept. Withpdfs: 5the 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: -1keeps every one, andpdfs: 0(the default) none;pdfmd --setuphas 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.
docs/recipes.mdis 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 --helplists every flag.- The docstring at the top of
pdfmd.pyis the full reference for each behaviour and its edge cases. CHANGELOG.mdrecords what changed in each version and why.
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.



