You wrote your docs in Markdown. Now someone wants a PDF. Maybe it's a client deliverable that needs to look professional. Maybe it's a build step in your documentation pipeline. Maybe your product generates Markdown reports and users keep asking for "export to PDF."

Sounds simple. It's not. You'll run into ugly default styling, broken tables, code blocks that don't highlight, and page breaks that slice through headings. The gap between "I have a .md file" and "I have a good-looking .pdf file" is wider than you'd expect.

This guide covers five approaches—from the 30-second CLI command to a production API—with real code, real benchmarks, and an honest take on when each one falls apart.

The Markdown-to-PDF pipeline

Markdown .md source parse HTML + GFM extensions style Styled HTML + print CSS render PDF output file Every method follows this pipeline. They differ in how they handle each step.

Method 1: Pandoc (CLI)

Pandoc is the Swiss Army knife of document conversion. It's been the go-to for Markdown-to-PDF since before most of these other tools existed. One command, done.

terminal

# Install (macOS)

$ brew install pandoc

$ brew install --cask mactex-no-gui # LaTeX engine

# Basic conversion

$ pandoc README.md -o README.pdf

# With custom margins and font size

$ pandoc README.md -o README.pdf \

-V geometry:margin=1in \

-V fontsize=11pt \

--highlight-style=tango

# Using an HTML engine instead of LaTeX

$ pandoc README.md -o README.pdf \

--pdf-engine=weasyprint \

--css=custom.css

Pandoc is fast. A 10-page document converts in ~200ms. It handles GFM tables, task lists, and footnotes out of the box with --from gfm. Code highlighting is built in with a dozen themes.

The tradeoffs

  • LaTeX dependency. The default PDF engine is pdflatex, which means you need a full LaTeX installation—that's a 4GB+ download. In Docker, this makes your image enormous. You can use --pdf-engine=weasyprint or --pdf-engine=wkhtmltopdf to avoid LaTeX, but you lose some typographic niceties.
  • Styling is LaTeX, not CSS. If you want to customize the output, you're writing LaTeX templates, not CSS. Most web developers would rather debug a segfault. The --css flag only works with HTML-based engines like weasyprint.
  • No JavaScript. Pandoc converts Markdown directly—it doesn't render in a browser. Dynamic content, charts, or anything that needs JS won't work.
  • CLI-only. There's no native Node.js or Python library. You shell out to the binary, which means error handling is "parse stderr and hope for the best."

For one-off conversions and CI/CD pipelines where you control the environment, Pandoc is hard to beat. For production applications that need branded, styled output? You'll hit the wall fast.

Method 2: md-to-pdf (Node.js)

md-to-pdf is the most popular Node.js library for this job. Under the hood, it parses Markdown with marked, wraps it in HTML, applies CSS, and prints to PDF using Puppeteer. It abstracts the glue code so you don't have to write it yourself.

md-to-pdf-example.js

import { mdToPdf } from 'md-to-pdf'

// From a file

const pdf = await mdToPdf({ path: './README.md' })

fs.writeFileSync('README.pdf', pdf.content)

// From a string with custom CSS

const styled = await mdToPdf(

{ content: '# Hello\n\nSome **bold** markdown.' },

{

stylesheet: './custom.css',

pdf_options: {

format: 'A4',

margin: { top: '20mm', bottom: '20mm' },

printBackground: true

},

launch_options: { headless: 'new' }

}

)

The tradeoffs

  • Puppeteer under the hood. You inherit all of Puppeteer's deployment headaches—the 400MB Chromium binary, memory usage, cold starts. md-to-pdf just hides it behind a nicer API.
  • Limited GFM support. The library uses marked for parsing, which handles basic GFM tables. But footnotes, definition lists, and some extensions need extra configuration or don't work at all.
  • Code highlighting is manual. md-to-pdf doesn't include syntax highlighting by default. You need to add highlight.js via CSS and a custom stylesheet, and even then the results can be inconsistent across code blocks.
  • Page break control is CSS-only. If you want to prevent a heading from being orphaned at the bottom of a page, you're writing break-after: avoid rules in your stylesheet and praying Chromium respects them. (It mostly does. Mostly.)

md-to-pdf is a solid choice when you need CSS control and you're already running Node.js. But you're really running Puppeteer with extra steps.

Method 3: Python (markdown + WeasyPrint)

Python has a clean two-step approach: use the markdown library to parse .md to HTML, then use WeasyPrint to render that HTML to PDF. No headless browser needed—WeasyPrint has its own CSS rendering engine.

md_to_pdf.py

import markdown

from weasyprint import HTML

# Parse Markdown to HTML with extensions

md_text = open('README.md').read()

html = markdown.markdown(md_text, extensions=[

'tables', 'fenced_code', 'codehilite', 'toc'

])

# Wrap in a full HTML document with CSS

styled = f"""

<!DOCTYPE html>

<html><head><style>

  body {{ font-family: sans-serif; max-width: 700px;

    margin: 0 auto; padding: 40px; font-size: 11pt; }}

  code {{ background: #f4f4f4; padding: 2px 6px;

    border-radius: 3px; font-size: 0.9em; }}

  pre {{ background: #f8f8f8; padding: 16px;

    border-radius: 6px; overflow-x: auto; }}

  table {{ border-collapse: collapse; width: 100%; }}

  th, td {{ border: 1px solid #ddd; padding: 8px; }}

</style></head><body>

{html}

</body></html>"""

# Render to PDF

HTML(string=styled).write_pdf('README.pdf')

The tradeoffs

  • No JavaScript rendering. WeasyPrint implements CSS layout from scratch—it's not a browser. This is good for speed and memory, but means no JS, no Flexbox (partial support), and no CSS Grid.
  • System dependencies. WeasyPrint requires cairo, Pango, and GDK-PixBuf. On macOS that's a brew install; on Alpine Linux it's a fight with musl libc that you'll lose the first two times.
  • Code highlighting requires Pygments. The codehilite extension works but you need to generate separate CSS for the color theme and include it in your template. It's not hard, but it's another step.
  • Two-step assembly required. You're wiring together the parser and the renderer yourself, including the HTML template with CSS. No library bundles this for you cleanly in Python.

For Python shops that need Markdown-to-PDF without a headless browser, this combo is solid. WeasyPrint's CSS support is surprisingly good for print layouts—it handles @page rules, page margins, running headers and footers, which is more than Puppeteer can say.

The CSS problem nobody warns you about

Most Markdown-to-PDF tools produce output that looks like a 2003 web page printed from Internet Explorer. The reason: they apply screen CSS, not print CSS.

Print CSS is a different world. You need @media print rules to hide navigation, adjust margins, and control backgrounds. You need break-inside: avoid on code blocks and tables so they don't split across pages. You need orphans and widows properties to prevent single-line paragraphs from dangling at the top or bottom of a page.

And here's the painful part: code syntax highlighting in print is broken in most tools. Pandoc handles it natively. Puppeteer-based tools need you to include highlight.js CSS and hope the colors work on white backgrounds. WeasyPrint needs Pygments CSS. Every tool has a different story, and none of them "just work."

Method 4: Marked + Puppeteer (DIY)

If you want full control, build the pipeline yourself: parse Markdown with marked, apply your own CSS, and use Puppeteer to print to PDF. More glue code, but you own every step.

marked-puppeteer.js

import { marked } from 'marked'

import puppeteer from 'puppeteer'

import hljs from 'highlight.js'

import fs from 'fs'

// Configure marked with GFM + syntax highlighting

marked.setOptions({

gfm: true,

breaks: true,

highlight(code, lang) {

if (lang && hljs.getLanguage(lang))

return hljs.highlight(code, { language: lang }).value

return code

}

})

const markdown = fs.readFileSync('./doc.md', 'utf-8')

const body = marked(markdown)

const html = `<!DOCTYPE html>

<html><head><style>

  body { font-family: -apple-system, sans-serif;

    max-width: 800px; margin: 0 auto; padding: 40px;

    color: #1a1a1a; line-height: 1.7; }

  h1 { border-bottom: 2px solid #e5e5e5; padding-bottom: 8px; }

  pre { background: #f6f8fa; padding: 16px;

    border-radius: 6px; overflow-x: auto; }

  table { border-collapse: collapse; width: 100%; }

  th, td { padding: 10px 14px; border: 1px solid #e5e5e5;

    text-align: left; }

  @media print {

    pre, table { break-inside: avoid; }

    h1, h2, h3 { break-after: avoid; }

  }

</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: 'doc.pdf',

format: 'A4',

printBackground: true,

margin: { top: '25mm', bottom: '25mm', left: '20mm', right: '20mm' }

})

await browser.close()

This gives you complete control over every step. You pick the Markdown parser, write your own CSS (including print styles), and configure Puppeteer however you want. The HTML to PDF in Node.js guide covers the Puppeteer side in more detail.

The tradeoffs

  • It's a lot of glue code. You're wiring together three libraries (marked, highlight.js, Puppeteer), writing an HTML template, managing a CSS file, and handling Puppeteer lifecycle. For what's fundamentally "convert this file," it's a lot of moving parts.
  • ~2-3 second latency. Puppeteer needs to launch Chromium, load the HTML, render it, and print. Even with a warm browser pool, you're looking at 1-2 seconds per conversion. Compare that to Pandoc's ~200ms.
  • All of Puppeteer's operational baggage. Chromium binaries, memory management, Docker image bloat, Lambda layers. You're building PDF infrastructure whether you planned to or not.
  • GFM completeness depends on your parser. marked handles basic GFM. For footnotes, definition lists, math blocks, or custom containers, you need plugins or a different parser like markdown-it with extensions.

The DIY approach makes sense when you need pixel-perfect, branded PDF output and you're willing to maintain the pipeline. For most teams, it's more infrastructure than the feature justifies.

Skip the pipeline. Send Markdown, get PDF.

PDFBase handles Markdown parsing, CSS styling, GFM extensions, and code highlighting. One API call, zero infrastructure.

Method 5: PDFBase API

Instead of wiring together a parser, a stylesheet, and a rendering engine, you send Markdown to an API and get a PDF back. PDFBase accepts raw Markdown as input, handles GFM parsing, applies configurable CSS, does syntax highlighting, and renders via Chromium—all on managed infrastructure.

pdfbase-markdown.js

import PDFBase from 'pdfbase'

const client = new PDFBase('pk_live_...')

const pdf = await client.pdfs.create({

markdown: `

# Quarterly Report

## Revenue Summary

| Quarter | Revenue | Growth |

|---------|---------|--------|

| Q1 2026 | $1.2M | +18% |

| Q2 2026 | $1.5M | +25% |

## API Usage

\`\`\`javascript

const response = await fetch('/api/report', {

method: 'POST',

body: JSON.stringify({ quarter: 'Q2' })

})

\`\`\`

> Note: All figures are preliminary.

`,

css: `

  body { font-family: 'Inter', sans-serif; color: #1a1a1a; }

  h1 { color: #0f172a; border-bottom: 2px solid #e2e8f0; }

  table { border-collapse: collapse; width: 100%; }

  th { background: #f8fafc; }

  th, td { padding: 10px 14px; border: 1px solid #e2e8f0; }

`,

format: 'A4',

output: 'url'

})

console.log(pdf.data.url) // signed URL, 24h expiry

You send Markdown, optionally with custom CSS, and get back a finished PDF. Here's what happens behind the scenes:

  • Full GFM parsing. Tables, task lists, strikethrough, footnotes, autolinks—all supported out of the box. No extensions to configure, no plugins to install.
  • Automatic code highlighting. Fenced code blocks with language tags get syntax-highlighted automatically. Over 180 languages supported. The highlighting looks good in print, not just on screen.
  • Custom CSS. Pass a css parameter to override any styling. Want your company's brand colors and fonts? Done. The CSS is applied after the default stylesheet, so you only override what you need.
  • Print-optimized output. Page breaks, orphan/widow handling, table continuation headers—the default stylesheet handles the print CSS nightmares so you don't have to.
  • ~500ms response time. Slower than Pandoc's ~200ms (because it's rendering via Chromium over the network), but faster than running Puppeteer locally with cold starts. And you're not managing the infrastructure.

Honest limitations

  • Network dependency. Your app needs internet access to reach the API. For air-gapped environments or offline-first tools, Pandoc is the better choice.
  • Cost at scale. At very high volumes (100K+ PDFs/month), the per-PDF cost may exceed what you'd spend running your own Chromium cluster. For most teams, the engineering time saved more than justifies it.
  • Latency floor. The ~500ms includes network round-trip. If you need sub-100ms conversions, you need a local solution.

For the full API reference and more examples, see the PDFBase docs. Want to test it without writing code? Try the free Markdown to PDF tool.

Comparison

Here's every method side by side on the dimensions that actually matter.

Pandoc md-to-pdf Python Marked + Puppeteer PDFBase API
CSS Support LaTeX only (or partial via weasyprint) Full (Chromium) Good (no Flexbox/Grid) Full (Chromium) Full (Chromium)
GFM Tables Yes Basic Yes (with extension) Depends on parser Yes (full GFM)
Code Highlighting Built-in (12 themes) Manual (add highlight.js) Pygments (manual CSS) Manual (highlight.js) Automatic (180+ langs)
Speed (10-page doc) ~200ms ~2-3s ~800ms ~2-3s ~500ms
Setup Complexity Medium (LaTeX dep) Medium (Chromium) Medium (cairo/Pango) High (multi-library) Trivial (npm install)
Infrastructure Binary on PATH Chromium on server System libs Chromium on server None (remote)

Which Should You Choose?

Don't overthink this. Match your situation to the right tool:

Simple docs, CI/CD pipelines, no styling requirements

Use Pandoc. It's the fastest option, runs anywhere, and handles GFM well. The output won't win design awards, but if you just need readable PDFs from Markdown files, nothing beats a one-liner in your build script.

Branded/styled output, you want CSS control, Node.js project

Use md-to-pdf or the Marked + Puppeteer DIY approach. You get full CSS control and Chromium rendering. md-to-pdf is less code; DIY gives you more flexibility. Both require managing Chromium in your deployment.

Python codebase, no headless browser wanted

Use markdown + WeasyPrint. Lightweight, good print CSS support including @page rules, no browser dependency. You'll need to handle the HTML template yourself, but the CSS rendering is surprisingly capable.

Production app, automated pipeline, don't want infrastructure

Use a PDF API like PDFBase. Send Markdown, get a styled PDF with GFM support and syntax highlighting. No Chromium to manage, no CSS to debug, no system dependencies. Works from any language or runtime.

Air-gapped environment, offline-first, sub-100ms needed

Use Pandoc. It's the only option here that needs zero network access and runs in under 200ms. Accept the LaTeX dependency or use weasyprint as the PDF engine to keep the install smaller.

The Bottom Line

Converting Markdown to PDF sounds like it should be a solved problem. It's not—because the real challenge isn't parsing Markdown. It's making the output look good. CSS print styles, code highlighting, GFM tables, page breaks that don't slice through content—that's where every approach shows its cracks.

Pandoc is unbeatable for speed and simplicity when you don't care about styling. The Puppeteer-based approaches (md-to-pdf, DIY) give you full CSS control but saddle you with browser infrastructure. WeasyPrint is the sleeper pick for Python teams who need good print CSS without a browser.

And if you'd rather skip the plumbing entirely: grab 100 free PDFBase credits and try converting your Markdown in one API call. The docs have examples in Node.js, Python, Go, and curl.