← All guides

How to convert Markdown to PDF programmatically

Markdown is a great source format for reports, documentation, contracts and AI-generated content. Turning it into a PDF that looks professional takes two steps: convert Markdown to HTML, then lay out that HTML on pages. This guide covers the common ways to do both, with their trade-offs, then shows the one-request version with PDFOven.

Option 1: Pandoc

Pandoc is the Swiss Army knife of document conversion:

pandoc report.md -o report.pdf

By default Pandoc produces PDFs through LaTeX, so you need a TeX distribution installed (often more than a gigabyte for a full install). The output looks like a LaTeX paper, which is ideal for academic writing and awkward for branded documents. Styling means LaTeX templates, not CSS.

Pandoc can use an HTML-based engine instead, which lets you style with CSS:

pandoc report.md --pdf-engine=weasyprint --css=report.css -o report.pdf

That requires WeasyPrint and its system libraries, and inherits WeasyPrint's limits: no JavaScript, and less complete flexbox and grid support than a browser.

Option 2: marked plus a headless browser (Node)

For full control and modern CSS, convert with a Markdown parser and print with Chromium:

npm install marked puppeteer
import { marked } from "marked";
import puppeteer from "puppeteer";
import { readFile, writeFile } from "node:fs/promises";

const markdown = await readFile("report.md", "utf8");
const css = await readFile("report.css", "utf8");
const body = await marked.parse(markdown);

const html =
  '<!doctype html><html><head><meta charset="utf-8"><style>' + css + "</style></head>" +
  "<body>" + body + "</body></html>";

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle0" });
  await writeFile("report.pdf", await page.pdf({ format: "A4", printBackground: true, margin: { top: "0.75in", bottom: "0.75in", left: "0.75in", right: "0.75in" } }));
} finally {
  await browser.close();
}

marked supports GitHub-flavored Markdown (tables, task lists, strikethrough) by default. Note that it does not sanitize HTML: if the Markdown comes from users or an LLM, raw HTML inside it is passed through, so sanitize the output (for example with DOMPurify) before rendering it in a browser that can reach your network.

The trade-off is the usual one for headless Chromium: a large dependency, memory use, fonts to install, and serverless packaging work. Packages such as md-to-pdf wrap this same pipeline and save some code but not the operational cost.

In Python the equivalent is the markdown package plus WeasyPrint or Playwright.

Styling Markdown for print

Whatever renderer you choose, a small print stylesheet goes a long way:

@page { size: A4; margin: 0.75in; }
body { font-family: Georgia, serif; font-size: 11pt; line-height: 1.55; color: #222; }
h1, h2, h3 { font-family: Helvetica, Arial, sans-serif; break-after: avoid; }
h2 { margin-top: 1.6em; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
pre, blockquote, table, img { break-inside: avoid; }
pre { background: #f6f8fa; padding: 12px; border-radius: 4px; font-size: 9pt; white-space: pre-wrap; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
hr { break-after: page; border: 0; }

The last rule is a handy trick: a horizontal rule (---) in the Markdown becomes a page break.

The PDFOven way

PDFOven accepts Markdown directly. It converts it to HTML and prints it with Chromium, and you can pass your stylesheet alongside:

import { readFile, writeFile } from "node:fs/promises";

const res = await fetch("https://pdfoven.com/v1/pdf", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.PDFOVEN_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    markdown: await readFile("report.md", "utf8"),
    css: await readFile("report.css", "utf8"),
    options: {
      format: "A4",
      margin: "0.75in",
      print_background: true,
      footer_template:
        '<div style="font-size:8px;width:100%;text-align:center">' +
        '<span class="pageNumber"></span> / <span class="totalPages"></span></div>',
    },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(error.code + ": " + error.message);
}
await writeFile("report.pdf", Buffer.from(await res.arrayBuffer()));

From Python:

import os, requests

resp = requests.post(
    "https://pdfoven.com/v1/pdf",
    headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
    json={"markdown": open("report.md").read(), "options": {"format": "Letter"}},
    timeout=60,
)
resp.raise_for_status()
open("report.pdf", "wb").write(resp.content)

Or from a shell script, using jq to safely JSON-encode the file:

jq -Rs '{markdown: ., options: {format: "A4"}}' report.md \
  | curl -X POST https://pdfoven.com/v1/pdf \
      -H "Authorization: Bearer $PDFOVEN_KEY" \
      -H "Content-Type: application/json" \
      --data-binary @- -o report.pdf

Markdown from LLMs

Language models produce Markdown naturally, which makes this a convenient last step for AI-generated reports: ask the model for Markdown, send it straight to /v1/pdf. If you want agents to do this on their own, see generating PDFs from AI agents with MCP.

Preview images

The same body sent to POST /v1/image returns a PNG (or JPEG with ?type=jpeg) of the rendered document, which is useful for thumbnails. Set options.full_page to capture everything.

Limits and pricing

PDFOven's free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. Errors use { "error": { "code", "message" } }, with 429 quota_exceeded when the quota is used up. Rendered files are not stored. To try your Markdown and CSS interactively first, use the free Markdown to PDF tool.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key