Convert Markdown files to PDF from any terminal with one command:
md2pdf README.mdThe command shows a compact progress view, fills an existing table of contents marker block on a temporary copy, renders Mermaid diagrams, and writes a PDF next to the Markdown file unless another output directory is configured. Arguments may be single files or whole folders, and --merge combines them into one PDF.
Prerequisites:
- Node.js 22.12 or later
Install the command globally with npm:
npm install --global @chrtmnn/md2pdfThe md2pdf command is then available from any directory, on Windows, macOS and Linux.
The installation downloads a Chromium build for rendering, once. It comes from Puppeteer, a dependency of md-to-pdf and mermaid-cli, whose postinstall script stores it in ~/.cache/puppeteer (%USERPROFILE%\.cache\puppeteer on Windows). md2pdf itself has no install script, and once installed, conversions need no network access.
pnpm blocks dependency build scripts, so approve Puppeteer's download after installing with pnpm:
pnpm add --global @chrtmnn/md2pdf
pnpm approve-builds --globalIn a script, where nothing can answer the prompt, allow the download up front instead: pnpm add --global --allow-build=puppeteer @chrtmnn/md2pdf. A fresh machine also needs PNPM_HOME set and on the PATH (pnpm setup does that for interactive shells).
Running as root: Chromium does not start as root with its sandbox on, which is how md2pdf runs in containers, CI images and cloud development environments. md2pdf therefore starts Chromium with --no-sandbox when it runs as root, and --verbose says so. To do the same for another user whose sandbox cannot start (for example where unprivileged user namespaces are blocked), set MD2PDF_NO_SANDBOX=1. Everywhere else the sandbox stays on. Only convert documents you trust without it.
Releases are published from GitHub Actions with a provenance attestation; the package page on npmjs.com links each version to the commit and the workflow run that built it.
Upgrading from the PowerShell wrapper: earlier versions were run from a clone of this repository, installed with scripts\install.ps1. Remove that clone's bin folder from your user PATH (Edit environment variables for your account), then install the package as above.
Convert one Markdown file:
md2pdf README.mdConvert multiple files:
md2pdf README.md docs\usage.mdConvert every *.md file of a folder:
md2pdf docsInclude subfolders:
md2pdf -R docsCombine everything into a single PDF named handbook.pdf:
md2pdf -R --merge handbook docsCombine a folder and one extra file into a single PDF with one table of contents spanning all documents:
md2pdf --toc --merge handbook docs CHANGELOG.mdWrite PDFs to an output directory:
md2pdf -o pdf README.mdUpdate an existing TOC in the original Markdown file while converting:
md2pdf -u README.mdCreate a TOC on the temporary conversion copy even when the source file has no TOC markers:
md2pdf --toc README.mdShow help:
md2pdfShow output from the underlying conversion tools:
md2pdf --verbose README.mdAlso emit an HTML file next to the PDF for inspection:
md2pdf --html README.mdRender Mermaid diagrams as PNG instead of SVG:
md2pdf --png README.mdmd2pdf [-R] [--merge name] [-s name|path] [--css-var name=value] [-o output_dir] [--title text] [--html] [--toc | --no-toc] [-u] [--png] [--verbose] [--debug] [--keep-temp] [--temp-root dir | --temp-in-output] [files or folders...]
| option | description |
|---|---|
-R, --recursive |
Also expand subfolders of folder arguments. Skips node_modules, .git, and folders starting with a dot. |
--merge <name> |
Combine all resolved Markdown files into one PDF with this base name. The .pdf suffix is optional. |
-o, --output-dir <dir> |
Output directory for PDFs. Defaults to each Markdown file's directory, or to the common parent folder of all inputs with --merge. |
--title <text> |
Document title for the PDF metadata, instead of the first heading (or the --merge name). |
--html |
Also write a standalone HTML file next to the PDF using the same stylesheet. An existing HTML file that md2pdf did not generate is never overwritten (see Output Files). |
-s, --stylesheet <name|path> |
Stylesheet for the generated PDF: a path, or the name of a stylesheet in ~/.md2pdf (see Personal Stylesheets). -s default always means the bundled default.css. Without the option, ~/.md2pdf/default.css is used when it exists, otherwise the bundled stylesheet. Relative @import and url() references are resolved against the stylesheet's own folder. |
--css-var <name=value> |
Override a CSS custom property for this run. The leading -- is optional. Repeat for multiple variables. A name the stylesheet never reads with var() produces a warning, which usually means a typo. |
--toc |
Create or refresh a TOC on the temporary conversion copy, even without source TOC markers (see Table of Contents Markers). |
--no-toc |
Never create or refresh a TOC, not even for a file that carries TOC markers. |
-u, --write-toc |
Write the refreshed TOC back into the original Markdown file. Does not create a new source TOC; a file without a marker block gets a warning. Cannot be combined with --merge or --no-toc. |
--png |
Render Mermaid diagrams as PNG instead of SVG. Useful for PDF viewers or downstream tools that handle embedded SVG poorly. |
-v, --verbose |
Print output from doctoc, mermaid-cli, and md-to-pdf while they run, one line per step. |
--debug |
Shorthand for --html --keep-temp --verbose. |
--keep-temp |
Keep the temporary work directories and print their paths, including the one holding the stylesheet that was used. |
--temp-root <dir> |
Root directory for temporary work dirs. Defaults to the system temp directory. Cannot be combined with --temp-in-output. |
--temp-in-output |
Place the temporary work dir inside the output directory. Cannot be combined with --temp-root. |
-V, --version |
Print the version number. |
-h, --help |
Show help. |
Every previous option name still works, so existing scripts keep running: -f/--force-doctoc for --toc, --update-md-toc for --write-toc, -k for --keep-temp, -r for --temp-root, and -p for --temp-in-output. They are no longer listed in --help.
npm uninstall --global @chrtmnn/md2pdfWith pnpm, run pnpm remove --global @chrtmnn/md2pdf instead. The Chromium download stays in ~/.cache/puppeteer; delete that folder too if no other tool uses it.
A positional argument may be a Markdown file or a folder. A folder contributes the *.md files it contains, at the position where you named it, so md2pdf intro.md chapters appendix.md converts intro.md, then everything in chapters, then appendix.md.
- Only the
.mdextension is matched, upper or lower case (.md,.MD). Other Markdown extensions such as.markdownare not picked up. - Files inside one folder are converted in file name order. The comparison is a plain code-point comparison so the order is identical on every machine, which also means names starting with an upper-case letter come first (
README.mdbeforereadme.md). - Pass
-Rto include subfolders.node_modules,.git, and any folder whose name starts with a dot are skipped, and folder links (symlinks and junctions) are not followed, so a link pointing back at a parent folder cannot cause an endless loop. - Passing both a folder and a file inside it converts that file once, not twice.
- A folder without any
.mdfile produces a warning and is not counted as a failure. - Files you name directly follow the same extension rule:
md2pdf notes.txt, ormd2pdf *in a folder with other files, skips everything that is not.mdwith a warning, and each skipped file counts as a failure. Usemd2pdf .to convert all Markdown files of a folder.
Each PDF is named after its Markdown file and replaces an existing PDF of that name.
- The PDF is first written to a temporary file next to the target and then swapped in. If writing fails — the old PDF is open in a viewer that locks it, the disk is full — the previous PDF stays as it was.
- If two inputs would produce the same output, for example
md2pdf -R -o pdfs .over a tree with severalREADME.mdfiles, md2pdf lists them and stops before converting anything. Convert them in separate runs or into different-ofolders. - The
--debugHTML file carries a<meta name="generator" content="md2pdf">tag. An existing HTML file without that tag, such as a hand-writtenindex.htmlnext toindex.md, is not replaced; that document fails instead. HTML files written by md2pdf versions before this check lack the tag too, so delete them once.
--merge <name> combines every resolved Markdown file into a single PDF:
md2pdf -R --merge handbook docsThis writes handbook.pdf. The .pdf suffix is optional, so --merge handbook.pdf is equivalent. The name is a file name, not a path; use -o to choose the folder. It comes before the files, so a name ending in .md is rejected: --merge a.md b.md would otherwise use a.md as the name and merge only b.md. Without -o the PDF is written to the common parent folder of all inputs.
Merging happens on the Markdown, before rendering, and the normal conversion then runs once over the combined document. Two consequences are worth knowing:
- All other options still apply. In particular
--tocproduces one table of contents spanning every document, which is usually the main reason to merge in the first place. It is placed on its own page in front of the first document, and the documents' own tables of contents are left out of the merged PDF (the source files are not changed). - Each document starts on a new page. The page break is produced by the
.document-breakhelper in the default stylesheet. If you pass your own stylesheet with-s, add a matching rule or the documents will run together. To flatten the breaks, use--css-var document-break-before=auto.
Limitation: relative link targets are not rewritten when documents are merged, so a link written relative to a subfolder may not point anywhere useful in the merged PDF. Relative image targets are fine: each one is resolved against its own source document while the documents are concatenated, so two documents in different folders can both use
images/logo.png.md2pdfprints a warning whenever the merged inputs come from more than one folder.
Stylesheets you use for many documents can live in a personal config folder, so -s finds them by name from any directory:
- Windows:
%USERPROFILE%\.md2pdf - macOS/Linux:
~/.md2pdf
~/.md2pdf/
├── custom.css
└── letter.css
md2pdf -s custom.css report.md # uses ~/.md2pdf/custom.css
md2pdf -s letter invite.md # uses ~/.md2pdf/letter.css, ".css" is optional here-s looks in this order:
- The value as a path, relative to the folder you run
md2pdffrom. A matching file there always wins, and.cssis never added to it. ~/.md2pdf/<name>, then~/.md2pdf/<name>.css. This only applies to a plain name.-s ./custom.cssor-s themes/dark.cssnever looks in the config folder, and subfolders of the config folder are not searched.
If nothing matches, the error lists every location that was tried. Set the MD2PDF_CONFIG_DIR environment variable to use a different folder.
A default.css in the config folder is used whenever you pass no -s at all:
md2pdf report.md # uses ~/.md2pdf/default.css when it exists
md2pdf -s default report.md # uses the bundled default.css for this run
md2pdf -s default.css report.md # uses ~/.md2pdf/default.css explicitlyIt replaces the bundled stylesheet instead of adding to it, exactly like any other -s value, so everything the bundled file provides — the @page setup, the .page-break and .document-break helpers, and all the custom properties --css-var targets — has to come from your file. The simplest start is a copy:
Copy-Item "$(npm root --global)\@chrtmnn\md2pdf\dist\css\default.css" $HOME\.md2pdf\default.cssAfter installing with pnpm, use pnpm root --global in place of npm root --global. On macOS and Linux:
cp "$(npm root --global)/@chrtmnn/md2pdf/dist/css/default.css" ~/.md2pdf/default.cssdefault is reserved for the bundled stylesheet, so -s default never picks up ~/.md2pdf/default.css; use -s default.css (or its path) for that. --verbose prints which stylesheet a run uses and why.
A stylesheet in the config folder may @import other files and use url() for fonts and images. Relative references resolve against the folder of the file they appear in, including subfolders such as ~/.md2pdf/theme/. Remote imports such as web fonts may sit anywhere in the file: they are hoisted to the top of the effective stylesheet, keeping the layer(), supports() and media conditions of the whole import chain, because a browser honours an @import only before every other rule.
The default stylesheet exposes a .page-break helper that forces a page break before the element it is applied to. Insert an empty HTML element with the class wherever the next page should start — any of these work:
<i class="page-break"></i>
<span class="page-break"></span>
<div class="page-break"></div>The helper sets display: block internally, so inline elements work too. The marker is invisible in the rendered PDF and is ignored by Markdown viewers that do not honour the class.
The default stylesheet fills the six @page margin boxes from custom properties, so a running header or footer needs no change to the stylesheet itself — only --css-var:
| Variable | Default |
|---|---|
--page-top-left-content |
unset |
--page-top-center-content |
unset |
--page-top-right-content |
unset |
--page-bottom-left-content |
unset |
--page-bottom-center-content |
counter(page) "/" counter(pages) |
--page-bottom-right-content |
unset |
Every page therefore carries a centred page-of-total footer such as 2/7, and the remaining five boxes stay empty until they are given content.
md2pdf --css-var page-top-right-content='"Internal draft"' report.md
md2pdf --css-var page-bottom-center-content='"Page " counter(page)' report.md
md2pdf --css-var page-bottom-center-content=unset report.md # no page numbersEach value is a CSS content value, not plain text, which has two consequences:
- Literal text has to be quoted.
page-top-right-content=Draftis not a validcontentvalue, so the box renders empty and nothing warns about it — the variable is read by the stylesheet, so the unused-variable check stays quiet. Quote it as'"Draft"'in PowerShell and bash alike. counter(page)andcounter(pages)give the current page and the page total, and concatenate with quoted strings by writing them next to each other, as in the second example above.
Box content renders at 9pt in --color-muted, inside the page margin set by --page-margin-top and --page-margin-bottom. A custom stylesheet passed with -s replaces the bundled one entirely (see Personal Stylesheets), so it needs its own @page margin box rules for any of this to apply.
md2pdf builds a TOC automatically when the source Markdown contains a marker block. Add the following block once at the location where the TOC should appear:
<!-- toc START -->
<!-- toc END -->On the next conversion, the block is filled with the current heading structure (the headings after the block). The source file itself stays as it is unless you pass -u. Which tool generates the TOC — currently doctoc — is an implementation detail of md2pdf.
md2pdf also recognises the markers that doctoc writes, so documents prepared for doctoc keep working unchanged:
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- END doctoc generated TOC please keep comment here to allow auto update -->| marker | START | END |
|---|---|---|
md2pdf (recommended) |
<!-- toc START --> |
<!-- toc END --> |
| doctoc | <!-- START doctoc generated TOC … --> |
<!-- END doctoc generated TOC … --> |
Rules that apply to every marker:
- Only a marker block written on its own lines counts. The
md2pdfmarker must match exactly (case-sensitive; up to three spaces of indentation and trailing spaces are fine, nothing else), so markdown-toc's<!-- toc -->is not mistaken for it. Markers that merely appear as examples, inside a code block or in inline code like the ones above, are ignored and stay intact. - If a file has several marker blocks, only the first one is filled; the others are left as they are.
- If a file has a START marker without a matching END marker after it, the conversion of that file fails with an error and the file is left untouched. Otherwise the TOC would replace everything after the START marker.
- A block keeps the marker lines you wrote, and a title line you put between the START marker and the list is kept on every refresh. With the
md2pdfmarker the filled block contains only the title and the TOC; doctoc's own markers come with doctoc's "DON'T EDIT" note, as before.
If the source Markdown does not yet contain a marker block, pass --toc to create one on the temporary conversion copy. The new block uses the md2pdf marker, is placed directly before the first second-order (##) heading in the file and gets the default "Table of Contents" title of the TOC tool. A marker block you add yourself starts without a title.
To also write the refreshed TOC back into the original Markdown file (instead of only into the temporary conversion copy), combine -u with an existing marker block. Nothing outside the block is changed, and the write-back is refused if the TOC tool changed anything else. -u never adds a marker block to the source file: on a file without one it leaves the file unchanged and prints a warning. -u cannot be combined with --merge, because a merged run converts a temporary combined copy of the files, not the files themselves, nor with --no-toc, which switches the refresh off.
Mermaid code fences are rendered automatically during conversion.
Diagrams render to SVG by default. Pass --png to render them as PNG instead, at a 3x scale for print resolution. SVG is a good default because it stays crisp at any zoom level, but choose --png if the target PDF viewer or a downstream tool handles embedded SVG poorly.
For further information visit https://mermaid.js.org/intro/syntax-reference.html.
Markdown input:
```mermaid
flowchart LR
A[Markdown file] --> B[Table of Contents]
B --> C[Render diagrams]
C --> D[PDF output]
```Rendered preview:
flowchart LR
A[Markdown file] --> B[Table of Contents]
B --> C[Render diagrams]
C --> D[PDF output]