Get started
How to convert Markdown to PDF: the complete guide

How to convert Markdown to PDF: the complete guide

Convert Markdown to PDF five ways: Pandoc with LaTeX, marked plus Playwright, markdown-it plus Puppeteer, and a REST API. Covers GFM, math, mermaid, and page breaks.

benoitded17 min read

Markdown is the default writing format for documentation, README files, and notes, but you cannot email a .md file to a client. Converting Markdown to PDF gives you a portable, printable artifact that preserves the structure of the source. The right approach depends on whether you want a single CLI command, a styled documentation export, or a programmatic pipeline that turns user-submitted Markdown into branded PDFs at scale. This guide walks through five methods, with code that runs.

Which method should you pick?

The fastest answer: if you have a single file, run Pandoc. If you have a Node.js app generating PDFs from user content, render Markdown to HTML and pass it through Playwright or a PDF API. The five methods below trade install footprint, fidelity, and speed differently, so the right one depends on your environment.

MethodInstall sizeFidelitySpeedBest for
Pandoc + LaTeX~1 GB (TeX Live)Print-quality typesettingSlow (3-10s)Books, academic papers, one-offs
marked + Playwright~300 MB (Chromium)Full CSS3, web fontsFast (200-500ms)Node.js apps, custom branding
markdown-it + Puppeteer~300 MB (Chromium)Full CSS3, plugin ecosystemFast (200-500ms)Apps that need custom Markdown extensions
Pandoc + wkhtmltopdf~50 MBOutdated CSS, no flexboxFastLegacy systems only (deprecated)
PDF4.dev REST API0 (HTTP call)Full CSS3, managed ChromiumFast (under 300ms)Production, no infra to maintain

The two pipelines that actually matter today are Pandoc with LaTeX (when you want true typesetting) and any path that goes Markdown to HTML to Chromium (when you want web-style layouts and brand control).

Method 1: Pandoc with LaTeX (the academic path)

Pandoc is the universal document converter, and its LaTeX backend produces the most beautiful Markdown PDFs you will ever ship. The catch is the install: a full TeX distribution adds about a gigabyte to your machine. Use this path for books, papers, or any document where typography matters more than build time.

Install Pandoc and a TeX engine:

# macOS
brew install pandoc
brew install --cask mactex
 
# Ubuntu / Debian
apt install pandoc texlive-xetex texlive-fonts-recommended texlive-plain-generic

Convert a single file:

pandoc input.md -o output.pdf

Pandoc auto-selects an engine. Force XeLaTeX if your document uses Unicode characters or custom fonts:

pandoc input.md \
  --pdf-engine=xelatex \
  -V mainfont="Inter" \
  -V monofont="JetBrains Mono" \
  -V geometry:margin=1in \
  --toc \
  --number-sections \
  -o output.pdf

For polished output, use the Eisvogel template, a popular LaTeX template designed for technical documentation:

pandoc input.md \
  --from gfm \
  --template=eisvogel \
  --listings \
  -V titlepage=true \
  -V titlepage-color="0a0a0a" \
  -V titlepage-text-color="ffffff" \
  -V toc-own-page=true \
  -V book \
  --pdf-engine=xelatex \
  -o output.pdf

This produces a title page, a table of contents on its own page, syntax-highlighted code blocks via the listings LaTeX package, and book-style chapter breaks. It is the closest you can get to a published book from a single Markdown source.

LaTeX errors are notoriously hard to debug. If your build fails, run with --verbose and look for the first ! LaTeX Error line. Common culprits: Unicode characters in code blocks (use xelatex or lualatex, not pdflatex), missing fonts, and unbalanced math delimiters.

Method 2: marked + Playwright (the Node.js workhorse)

This is the path most modern apps take. marked is a small, fast Markdown parser that emits HTML. Playwright wraps that HTML in a headless Chromium tab and exports a PDF with full CSS3 support, custom fonts, and any layout you can build on a web page. Cold start is around 1 second; warm renders run in 200 to 500 milliseconds.

Install both:

npm install marked playwright
npx playwright install chromium

Build the converter:

import { marked } from "marked";
import { chromium } from "playwright";
import { readFileSync, writeFileSync } from "node:fs";
 
const css = `
  body {
    font-family: -apple-system, "Inter", system-ui, sans-serif;
    max-width: 720px;
    margin: 40px auto;
    color: #111827;
    line-height: 1.6;
  }
  h1 { font-size: 28px; border-bottom: 1px solid #e5e7eb; padding-bottom: 8px; }
  h2 { font-size: 22px; margin-top: 32px; }
  code {
    font-family: "JetBrains Mono", ui-monospace, monospace;
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.9em;
  }
  pre {
    background: #0a0a0a;
    color: #e5e7eb;
    padding: 16px;
    border-radius: 8px;
    overflow-x: auto;
  }
  pre code { background: none; color: inherit; padding: 0; }
  table { border-collapse: collapse; width: 100%; margin: 16px 0; }
  th, td { border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }
  blockquote {
    border-left: 4px solid #7c3aed;
    margin: 16px 0;
    padding: 4px 16px;
    color: #4b5563;
  }
`;
 
async function markdownToPdf(mdPath: string, pdfPath: string) {
  const markdown = readFileSync(mdPath, "utf-8");
  const body = await marked.parse(markdown, { gfm: true, breaks: false });
 
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <style>${css}</style>
</head>
<body>${body}</body>
</html>`;
 
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle" });
 
  const pdf = await page.pdf({
    format: "A4",
    margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
    printBackground: true,
  });
 
  writeFileSync(pdfPath, pdf);
  await browser.close();
}
 
await markdownToPdf("README.md", "README.pdf");

The gfm: true flag turns on GitHub Flavored Markdown extensions (tables, strikethrough, task lists, autolinks), which match what you see on github.com when you preview a .md file. printBackground: true is non-negotiable: without it, every background color in your CSS is dropped from the PDF.

Method 3: markdown-it + Puppeteer (the plugin-friendly path)

markdown-it parses Markdown into an explicit token stream, which makes it the right pick when you need custom syntax. Want to render :::warning blocks as colored boxes, or expand @username mentions to profile links? Write a markdown-it plugin and wire it into the pipeline. Puppeteer is functionally equivalent to Playwright for the PDF step.

npm install markdown-it markdown-it-anchor markdown-it-toc-done-right \
  markdown-it-katex markdown-it-task-lists puppeteer
import MarkdownIt from "markdown-it";
import anchor from "markdown-it-anchor";
import toc from "markdown-it-toc-done-right";
import katex from "markdown-it-katex";
import taskLists from "markdown-it-task-lists";
import puppeteer from "puppeteer";
import { readFileSync, writeFileSync } from "node:fs";
 
const md = MarkdownIt({ html: true, linkify: true, typographer: true })
  .use(anchor, { permalink: anchor.permalink.headerLink() })
  .use(toc, { level: [2, 3] })
  .use(katex)
  .use(taskLists);
 
async function convert(mdPath, pdfPath) {
  const source = readFileSync(mdPath, "utf-8");
  // Insert a [[toc]] marker at the top to render the table of contents
  const body = md.render(`[[toc]]\n\n${source}`);
 
  const html = `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
  <style>
    body { font-family: Inter, sans-serif; max-width: 720px; margin: 40px auto; }
    .task-list-item { list-style: none; }
    .task-list-item input { margin-right: 8px; }
  </style>
</head>
<body>${body}</body>
</html>`;
 
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle0" });
  await page.pdf({ path: pdfPath, format: "A4", printBackground: true });
  await browser.close();
}
 
convert("doc.md", "doc.pdf");

The markdown-it-katex plugin renders LaTeX math expressions (more on that in the math section). markdown-it-task-lists turns - [x] and - [ ] into checkbox items. markdown-it-toc-done-right walks the heading tree and emits a nested table of contents wherever you place [[toc]] in the source.

Method 4: Pandoc + wkhtmltopdf (deprecated, avoid)

wkhtmltopdf used to be the standard way to convert HTML to PDF on the command line. It is no longer maintained: the project was archived in 2023 and ships with a fork of Qt 4.8 from 2012. It cannot render flexbox, grid, custom properties, or modern font features. We mention it only because old tutorials still recommend it. Do not start a new project with wkhtmltopdf. Use one of the headless Chromium paths above, or Pandoc with LaTeX, or WeasyPrint if you need a Python-only solution.

Method 5: PDF4.dev REST API (no infrastructure)

If you do not want to install Chromium, manage browser pools, run apt updates for system fonts, or pay for memory-fat dynos that sit idle 99% of the time, route the same Markdown-to-HTML output through a managed PDF API. Render Markdown locally with marked, then POST the HTML to the API and stream the PDF back.

import { marked } from "marked";
import { readFileSync, writeFileSync } from "node:fs";
 
async function markdownToPdfApi(mdPath: string, pdfPath: string) {
  const markdown = readFileSync(mdPath, "utf-8");
  const body = await marked.parse(markdown, { gfm: true });
 
  const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
    <style>
      body { font-family: Inter, sans-serif; max-width: 720px; margin: 40px auto; color: #111827; line-height: 1.6; }
      pre { background: #0a0a0a; color: #e5e7eb; padding: 16px; border-radius: 8px; }
      code { font-family: "JetBrains Mono", monospace; }
    </style>
  </head><body>${body}</body></html>`;
 
  const res = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      format: {
        preset: "a4",
        margins: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
      },
    }),
  });
 
  if (!res.ok) throw new Error(`Render failed: ${res.status}`);
  writeFileSync(pdfPath, Buffer.from(await res.arrayBuffer()));
}
 
await markdownToPdfApi("README.md", "README.pdf");

The API holds a warm Chromium pool, so cold start is removed entirely. PDFs come back in under 300 ms for typical documents.

Which Markdown flavors do these methods support?

Markdown is not one language. CommonMark is the strict spec, GFM (GitHub Flavored Markdown) adds tables and task lists, Obsidian adds wikilinks and embeds, MDX adds JSX. Pick a method that supports the features you actually use.

FeatureCommonMarkGFMObsidianMDX
TablesNoYesYesYes
Task lists - [x]NoYesYesYes
StrikethroughNoYesYesYes
FootnotesNoYes (extension)YesYes
Math $...$NoNo (rendered as text on github.com)YesYes (with plugin)
Wikilinks [[Page]]NoNoYesNo
Mermaid diagramsNoYes (rendered on github.com)YesYes
Embedded JSXNoNoNoYes
Frontmatter (YAML)NoNoYesYes

Pandoc supports all of CommonMark and GFM out of the box, plus footnotes, math, and YAML frontmatter when you pass --from gfm+yaml_metadata_block+tex_math_dollars. marked supports CommonMark and GFM. markdown-it supports CommonMark and reaches the rest through plugins.

How do I render LaTeX math in a Markdown PDF?

Use KaTeX for the HTML pipeline and --pdf-engine=xelatex for Pandoc. KaTeX is faster than MathJax and small enough to ship inline. Write inline math with single dollar signs and display math with double dollars:

The Pythagorean theorem states that $a^2 + b^2 = c^2$.
 
For continuous functions, the integral is:
 
$$\int_{a}^{b} f(x) \, dx = F(b) - F(a)$$

In the markdown-it pipeline, markdown-it-katex already handles this. In the marked pipeline, install marked-katex-extension:

import { marked } from "marked";
import markedKatex from "marked-katex-extension";
 
marked.use(markedKatex({ throwOnError: false }));
 
const html = await marked.parse(source);
// Remember to load the KaTeX CSS in your HTML wrapper:
// <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">

For Pandoc, math just works as long as the engine is LaTeX-based: pandoc input.md --pdf-engine=xelatex -o out.pdf.

How do I include mermaid diagrams?

Mermaid diagrams in Markdown look like fenced code blocks tagged mermaid. None of the parsers above render them directly: you need a pre-processing step that walks the AST, renders each mermaid block to SVG, and substitutes the SVG back into the HTML before the PDF step.

npm install -D @mermaid-js/mermaid-cli
import { marked } from "marked";
import { mkdtemp, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
 
const exec = promisify(execFile);
 
const renderer = new marked.Renderer();
const baseCode = renderer.code.bind(renderer);
let mermaidIndex = 0;
const mermaidBlocks: { id: string; src: string }[] = [];
 
renderer.code = (code, lang) => {
  if (lang === "mermaid") {
    const id = `mermaid-${mermaidIndex++}`;
    mermaidBlocks.push({ id, src: code });
    return `<div data-mermaid="${id}"></div>`;
  }
  return baseCode(code, lang);
};
 
async function renderMermaid(src: string) {
  const dir = await mkdtemp(join(tmpdir(), "mmd-"));
  const input = join(dir, "in.mmd");
  const output = join(dir, "out.svg");
  await writeFile(input, src);
  await exec("mmdc", ["-i", input, "-o", output, "-b", "transparent"]);
  return readFile(output, "utf-8");
}
 
// ... after marked.parse, walk the html string and replace each
// data-mermaid placeholder with the actual SVG returned by renderMermaid()

For Pandoc, use the pandoc-mermaid filter: pandoc input.md --filter mermaid-filter -o out.pdf.

How do I add page breaks in Markdown?

Markdown has no native page break syntax, so every method requires an escape hatch. The cleanest options:

For the HTML pipeline, drop in a raw HTML element with a CSS rule that forces a page break:

First page content.
 
<div style="break-before: page;"></div>
 
Second page content.

For the Pandoc plus LaTeX pipeline, embed a raw LaTeX command:

First page content.
 
\newpage
 
Second page content.

Both renderers ignore the foreign syntax in non-PDF outputs (HTML preview, GitHub render), which keeps the source portable. Read the CSS print styles guide for the full set of break-before, break-after, and break-inside rules.

How do I generate a table of contents?

Pandoc has the cleanest answer: pandoc input.md --toc -o out.pdf. For an HTML pipeline, the markdown-it path uses markdown-it-toc-done-right, which inserts a nested <ul> wherever you place [[toc]] in the source. With marked, you walk the token list yourself:

import { marked } from "marked";
 
function buildToc(markdown) {
  const tokens = marked.lexer(markdown);
  const toc = tokens
    .filter((t) => t.type === "heading" && t.depth >= 2 && t.depth <= 3)
    .map((t) => {
      const slug = t.text.toLowerCase().replace(/[^a-z0-9]+/g, "-");
      const indent = "  ".repeat(t.depth - 2);
      return `${indent}- [${t.text}](#${slug})`;
    })
    .join("\n");
  return marked.parse(toc);
}

Prepend the result to the rendered HTML body, and you have a clickable TOC inside the PDF.

How do I handle frontmatter?

YAML frontmatter at the top of a Markdown file is metadata, not content. Strip it before parsing:

import matter from "gray-matter";
import { marked } from "marked";
 
const source = readFileSync("post.md", "utf-8");
const { data, content } = matter(source);
 
const body = await marked.parse(content);
const html = `<!DOCTYPE html><html><head>
  <title>${data.title ?? "Untitled"}</title>
</head><body>
  <h1>${data.title}</h1>
  <p class="meta">By ${data.author} on ${data.date}</p>
  ${body}
</body></html>`;

gray-matter parses the frontmatter into a JS object that you can inject into the HTML wrapper as the document title, author, date, or any other metadata you want printed at the top of the PDF.

Common gotchas

Background colors disappear: in Playwright and Puppeteer, you must pass printBackground: true to page.pdf(). Without it, every CSS background is dropped, which makes dark code blocks render as plain text on white.

Web fonts arrive late: when you load Google Fonts or any external font inside the HTML wrapper, use waitUntil: "networkidle" (Playwright) or "networkidle0" (Puppeteer) on setContent, then call await page.evaluateHandle("document.fonts.ready") before exporting the PDF. Otherwise the first render may use a fallback font.

Code block highlighting: marked and markdown-it both emit raw <pre> tags. To get syntax-highlighted code, plug in highlight.js or shiki in the renderer step, then load the matching CSS theme inside the HTML wrapper.

FAQ

What is the easiest way to convert Markdown to PDF?

For one-off files, Pandoc on the command line is the shortest path: pandoc input.md -o output.pdf. For automation inside a Node.js app, render Markdown to HTML with marked or markdown-it, then convert the resulting HTML with Playwright or a managed PDF API.

Does Pandoc require LaTeX?

By default, yes. Pandoc uses a LaTeX engine (pdflatex, xelatex, or lualatex) to produce PDFs, which means installing TeX Live or MacTeX. You can bypass LaTeX entirely by passing --pdf-engine=weasyprint (Python-based, smaller install) or by exporting to HTML first and converting with a headless browser.

Can I convert GitHub Flavored Markdown to PDF?

Yes. Pass --from gfm to Pandoc, or enable gfm: true in marked, or load the GFM preset in markdown-it. All three handle tables, strikethrough, task lists, and autolinks. Tables are the most common feature missing from CommonMark-only parsers, so always enable GFM if your source has any.

How do I render LaTeX math inside Markdown?

Inline math goes between single dollars ($a^2 + b^2 = c^2$), display math between double dollars ($$E = mc^2$$). KaTeX renders the math inside the HTML before the PDF step. Pandoc handles math natively when the output engine is LaTeX-based.

How do I add a page break in Markdown?

Markdown itself has no page break syntax. In a Pandoc to LaTeX pipeline, embed \newpage. In an HTML pipeline, insert a raw HTML element with style="break-before: page;". Both are ignored by Markdown previewers and trigger only in the PDF output.

Can I include mermaid diagrams in a Markdown PDF?

Yes. Pre-render the mermaid blocks to SVG with the official @mermaid-js/mermaid-cli package, then replace the fenced code blocks with the SVG inside the HTML before conversion. For Pandoc, use the pandoc-mermaid filter so the substitution runs inline during the build.

Why does my Markdown PDF look unstyled?

Default HTML output has no stylesheet, so Markdown converts to bare HTML that the browser renders with Times New Roman. Inject a CSS file with Pandoc using --css style.css --standalone, or include a <style> block in your HTML wrapper before passing it to Playwright.

How do I generate a table of contents from headings?

Pandoc supports --toc for automatic generation. In a JS pipeline, walk the parsed token list from marked or markdown-it, collect every h2 and h3, and prepend the result to the body as a nested list before conversion.

Wrapping up

Pick Pandoc with LaTeX when you want print-quality typesetting and you do not mind the install size. Pick marked or markdown-it with Playwright when you are inside a Node.js app and want full CSS3 plus custom branding. Skip wkhtmltopdf entirely. If you do not want to babysit Chromium, route the same HTML through PDF4.dev and keep your dyno cold.

Try the same Markdown-to-HTML output through our managed renderer:

Html To PdfTry it free

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.