·5 min read

Markdown to PDF: converting docs and READMEs to PDF via an API

There's no direct "markdown to PDF" step in a rendering pipeline — you convert markdown to HTML first (with a library like marked or markdown-it), then send that HTML to a PDF-generation API as an html source. This gives you full control over fonts, page breaks, and syntax highlighting, which a naive markdown-to-PDF converter usually doesn't.

Why markdown doesn't go straight to PDF

Markdown is a content format, not a layout format. It has no concept of page size, margins, headers/footers, or how a code block should wrap at the bottom of a page. Every tool that claims to convert "markdown to PDF" is doing the same two-step conversion under the hood: markdown to HTML, then HTML to PDF using a real browser engine (usually Chromium) to lay it out and print it. Once you know that, the API version is simple — you just own both steps instead of a black box owning them for you.

Step 1: convert markdown to HTML

Pick a markdown parser for your language — marked or markdown-it in Node, markdown or mistune in Python, CommonMark implementations elsewhere. Wrap the parsed HTML in a template with print-friendly CSS: a serif or system font, sane line-height, page-break rules for headings and code blocks, and @page margins.

import { marked } from "marked";
import fs from "node:fs";

const markdown = fs.readFileSync("README.md", "utf-8");
const contentHtml = marked.parse(markdown);

const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
  body { font-family: -apple-system, "Segoe UI", sans-serif; line-height: 1.6; color: #1a1a1a; padding: 0 4mm; }
  h1, h2, h3 { break-after: avoid; }
  pre { background: #f4f4f5; padding: 12px 16px; border-radius: 6px; overflow-x: auto; break-inside: avoid; }
  code { font-family: ui-monospace, Menlo, monospace; font-size: 0.9em; }
  table { border-collapse: collapse; width: 100%; }
  th, td { border: 1px solid #ddd; padding: 6px 10px; }
  @page { margin: 20mm 15mm; }
</style>
</head>
<body>${contentHtml}</body>
</html>`;

This is the part most "markdown to PDF" CLI tools hide from you — and it's also the part where readability actually lives. If your README has fenced code blocks with a language hint, you can run them through a syntax highlighter (shiki or highlight.js) before wrapping, since Docweave's renderer is a real Chromium instance and will paint whatever CSS/HTML you hand it, same as a browser tab. If you haven't worked with an HTML-to-PDF pipeline before, the HTML-to-PDF API glossary entry covers the concept in more depth.

Step 2: send the HTML to the PDF API

Once you have a full HTML document, POST it to Docweave's `/generate-pdf` endpoint as an html source. Ask for application/pdf explicitly and read the response as raw bytes — treating it as text will corrupt the binary.

const res = await fetch("https://docweave.dev/api/v1/pdf", {
  method: "POST",
  headers: {
    "Authorization": "Bearer dw_live_your_key",
    "Content-Type": "application/json",
    "Accept": "application/pdf"
  },
  body: JSON.stringify({
    source: { type: "html", html },
    idempotencyKey: "readme-export-2026-07-16"
  })
});

if (!res.ok) {
  throw new Error(`PDF generation failed: ${res.status} ${await res.text()}`);
}

const buffer = Buffer.from(await res.arrayBuffer());
fs.writeFileSync("README.pdf", buffer);
console.log("Wrote README.pdf");

The idempotencyKey matters here more than it might for a one-off invoice: doc exports are often triggered from CI ("attach a PDF of the docs to every release"), and a retried CI step shouldn't burn a second billed render for the same content. Pin the key to something derived from the file's content hash or the release tag.

If you're working in a language other than Node, the shape of the request is identical — see the Node, Python, PHP, Ruby, Go, or .NET guides for language-specific request code.

When to use a stored template instead

If you're generating a PDF from the same markdown structure repeatedly with different data — a changelog every release, a weekly report, a contract from a boilerplate — it's worth pushing the wrapper HTML (fonts, header, footer, CSS) into a stored template and passing only the rendered markdown body and a few variables as JSON per request. That keeps your calling code from re-declaring the same <style> block every time and lets you update the look in one place. This is the same pattern used for reports and other recurring documents.

Using the MCP server instead of REST

If you're generating docs from inside an agent workflow — a coding agent that writes a README and wants a PDF copy for a release artifact, for example — skip the REST call and use the open-source MCP server directly:

npx @docweave/mcp

Once it's registered with your MCP-capable client, the agent calls a single tool with the same HTML you built in step one:

await generate_pdf({
  source: { type: "html", html },
  outputPath: "./dist/README.pdf"
});

This is the workflow described in more depth on the PDF generation for AI agents page and the MCP server glossary entry — the agent never has to shell out to a headless browser itself, which is normally the hard part of doing this inside a sandboxed agent runtime.

Gotchas specific to markdown

  • **Relative image paths break.** If your markdown references ./images/diagram.png, that path is relative to the markdown file on disk, not to anything the renderer can resolve. Convert images to absolute URLs or inline them as base64 data URIs before building the HTML.
  • **Front matter isn't content.** Strip YAML front matter (--- blocks) before parsing, or your PDF will open with a literal dump of title:, date:, etc. at the top.
  • **GFM tables and task lists need the right parser flags.** Plain CommonMark parsers won't render GitHub-flavored tables or - [ ] checkboxes — enable GFM mode explicitly (marked and markdown-it both support it) or they'll show up as raw text.
  • **Long code blocks need overflow-x and break-inside: avoid in your CSS**, or a long unwrapped line will get clipped at the page edge instead of scrolling (which doesn't exist on paper) or wrapping cleanly.

None of this is unique to Docweave — it's inherent to converting a flowing text format into a paginated one. Rough-cost planning for doc exports (README bundles, changelog PDFs on every tag) is easiest with the PDF cost calculator, and the full request/response reference lives in the docs.

FAQ

Can I send raw markdown directly to a PDF API without converting to HTML first?

Not to Docweave's html source, no — the API renders HTML/CSS through Chromium, so it needs markup, not markdown syntax. The conversion step is one function call (marked.parse() or equivalent) and takes a few milliseconds; skipping it just means the renderer would print the literal # and ** characters instead of headings and bold text.

Will code blocks in my markdown get syntax highlighting in the PDF?

Only if you add it yourself before sending the HTML. Run your markdown parser's output through a highlighter like Shiki or highlight.js (many integrate directly with marked/markdown-it as a plugin), which wraps tokens in <span> tags with color classes — plain CSS in your wrapper template then styles them. The renderer just paints whatever HTML/CSS it receives.

How do I handle a whole folder of markdown files, like a docs site?

Concatenate them into one HTML document with page breaks between sections (break-before: page in CSS on each top-level heading), or make one API call per file if you want separate PDFs. For a handful of files, looping over an array and making sequential requests with a shared idempotencyKey prefix works fine; for hundreds, batch them and watch your rate limits.

Is this different from GitHub's or a markdown editor's built-in PDF export?

Not conceptually — they're doing the same markdown-to-HTML-to-PDF pipeline. The difference is control: this way you own the CSS, fonts, and page rules, and it's callable from a script or CI job rather than a manual export button, which matters if you want a PDF regenerated on every commit or release.

Related

Generate your first PDF in minutes.

Get an API key