Markdown to PDF is a two-step pipeline. First you turn Markdown into HTML. Then you turn that HTML into a PDF. The default output is ugly. Cramped margins. Headings stranded at the bottom of a page. Code blocks that run off the right edge and get clipped. Nobody ships that.

The fix is CSS. Not just any CSS, but print CSS, also called paged-media CSS. The renderer that draws your PDF reads the same stylesheet a browser would, plus a handful of print-only rules that control page size, margins, headers, footers, and breaks. Learn those rules and your PDFs go from "auto-generated" to "designed."

This post is about markdown pdf css: the real rules you write to style markdown pdf output. The pipeline is simple. A converter like marked or markdown-it turns your .md into an HTML string. Then a renderer turns the HTML into a PDF: Puppeteer driving headless Chromium, a paged-media engine like WeasyPrint or Prince, or an API. If you want the full breakdown of CLI versus library versus API, read Convert Markdown to PDF: CLI vs Library vs API. Here we focus on the styling layer.

Almost every rule below works across Chromium-based renderers (Puppeteer, headless Chrome, and APIs built on them). Where a feature is renderer-specific, like page numbers, I will say so and show both options so you can pick based on what you run.

1. Page setup with @page

The first thing to fix is the page itself. The @page at-rule controls the physical sheet: its size and its margins. This is where you set A4 or Letter, and where you reserve the white space around your content.

page-setup.css

/* A4 with even margins on all sides */

@page {

size: A4;

margin: 20mm;

}

/* Letter, landscape, uneven margins */

@page {

size: letter landscape;

margin: 18mm 25mm 22mm 25mm; /* top right bottom left */

}

A reasonable question: why not just put padding on body and skip @page? Because body padding does not understand pages. It applies once, at the start of your content, and never again. Page two through page twenty get no top margin from it. Content runs straight to the paper edge. The @page margin, on the other hand, is reserved on every sheet. That margin box is also where running headers, footers, and page numbers live in paged-media engines. Body padding cannot give you any of that.

So the rule is: use @page { margin } for the space around the page, and use body spacing only for fine adjustments inside that area. Set size explicitly too. If you leave it out, Chromium uses the page size passed to page.pdf(), and the two can quietly disagree.

2. Running headers, footers, and page numbers

Page numbers are the feature people ask for first and the one with the biggest renderer split. Be honest with yourself about which renderer you use, because the answer is different for each.

Chromium and Puppeteer: header and footer templates

Chromium's page.pdf() does not read CSS page-margin boxes. It draws headers and footers from two HTML templates you pass as options: headerTemplate and footerTemplate. Inside those templates you get special classes that Chromium fills in: pageNumber, totalPages, title, date, and url.

puppeteer-header-footer.js

await page.pdf({

format: 'A4',

printBackground: true,

displayHeaderFooter: true,

// margin must leave room for the templates

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

headerTemplate: `

  <div style="font-size:9px; width:100%; padding:0 18mm;

      color:#666; text-align:left;">

    <span class="title"></span>

  </div>`,

footerTemplate: `

  <div style="font-size:9px; width:100%; padding:0 18mm;

      color:#666; text-align:center;">

    Page <span class="pageNumber"></span>

    of <span class="totalPages"></span>

  </div>`

})

Two things bite people here. The default template font size is tiny, so set font-size explicitly or you get nothing visible. And the templates render inside the page margin, so your margin.top and margin.bottom must be large enough to hold them. If the header is invisible, your top margin is too small. There is no padding inside the margin box, so add horizontal padding in the template itself to line the header up with your body text.

Paged-media engines: counter(page) in margin boxes

WeasyPrint, Prince, and other true paged-media engines do this in pure CSS. They support page-margin boxes like @top-center and @bottom-center, and the counter(page) and counter(pages) functions. This is cleaner because the styling lives in the same stylesheet as everything else.

paged-media-page-numbers.css

@page {

size: A4;

margin: 22mm 18mm;

@bottom-center {

content: "Page " counter(page) " of " counter(pages);

font-size: 9pt;

color: #666;

}

@top-right {

content: "Quarterly Report";

font-size: 9pt;

color: #999;

}

}

The rule of thumb: if you run Puppeteer or any Chromium-based renderer (including most PDF APIs), use header and footer templates. If you run WeasyPrint or Prince, use margin boxes with counter(page). Pick one and do not mix them, or you will spend an afternoon wondering why @bottom-center renders nothing in Chrome. It renders nothing because Chrome does not support it.

3. Page breaks

This is where most Markdown PDFs fall apart. A heading lands alone at the bottom of a page while its paragraph starts on the next. A table splits a row in half. A code block breaks across pages mid-line. The modern break properties fix all of it: break-before, break-after, and break-inside. They replace the old page-break-* names, which still work but are deprecated.

page-breaks.css

/* Start every top-level section on a fresh page */

h1 {

break-before: page;

}

/* Never strand a heading at the bottom of a page */

h1, h2, h3, h4 {

break-after: avoid;

break-inside: avoid;

}

/* Keep table rows and figures whole */

tr, figure, blockquote {

break-inside: avoid;

}

/* Avoid widows and orphans in body text */

p {

orphans: 3;

widows: 3;

}

A few notes. break-after: avoid on a heading tells the renderer to keep the heading with whatever follows it, so the heading and its first paragraph stay together. break-inside: avoid stops an element from splitting across a page boundary; put it on table rows, figures, and small blocks you never want torn in two. The orphans and widows properties set the minimum number of lines a paragraph must leave at the bottom or top of a page before a break is allowed; three is a sensible default.

One honest caveat: break-inside: avoid is not magic. If an element is taller than a full page, the renderer has to break it somewhere, and it will. Use these rules to express intent, not to guarantee impossible layouts.

4. Code blocks

Code is the single most common thing to break in a Markdown PDF. A long line runs off the right edge and Chromium clips it. The block has no background because backgrounds do not print by default. Or a 60-line block splits across three pages with no thought. Here is the CSS that fixes all three.

code-blocks.css

pre {

font-family: 'JetBrains Mono', ui-monospace, monospace;

font-size: 9.5pt;

line-height: 1.5;

/* wrap long lines so nothing clips at the page edge */

white-space: pre-wrap;

word-break: break-word;

overflow-wrap: anywhere;

/* a printed background that actually prints */

background: #1e1e2e;

color: #cdd6f4;

print-color-adjust: exact;

-webkit-print-color-adjust: exact;

padding: 14px 16px;

border-radius: 6px;

/* prefer not to split short blocks */

break-inside: avoid;

}

code { font-family: inherit; }

The load-bearing line is white-space: pre-wrap. It keeps the leading whitespace that makes code readable but lets long lines wrap instead of overflowing. Without it, a 200-character line in a <pre> runs straight off the right margin and Chromium clips everything past the page edge. With overflow-wrap: anywhere added, even an unbroken string like a long URL or a base64 blob will fold rather than disappear.

The other essential pair is print-color-adjust: exact and the -webkit- prefix. By default renderers strip background colors to save ink. That is why your dark code block prints white. Setting exact forces the renderer to honor the background. In Puppeteer you also need printBackground: true in page.pdf(), which is the option-level equivalent. Set both. If a code block is genuinely longer than a page, drop the break-inside: avoid on those specific blocks and let it flow, because forcing a too-tall block to stay whole just pushes a huge gap onto the previous page.

Stop fighting the renderer.

Send your HTML and your CSS to PDFBase and get a styled PDF back. Real Chromium rendering, full paged-media support, no browser to babysit.

5. Typography and web fonts

Screen typography and print typography are not the same. On screen you think in pixels and your line length is whatever the viewport gives you. In print you think in points, your measure is fixed by the page, and the wrong base size makes a document feel cheap. Set type in pt for print. A 16px screen body becomes roughly 11pt to 12pt on paper.

typography.css

/* Load a print font; bundle the file, do not rely on a CDN */

@font-face {

font-family: 'Source Serif';

src: url('/fonts/source-serif.woff2') format('woff2');

font-weight: 400 700;

font-display: block;

}

body {

font-family: 'Source Serif', Georgia, serif;

font-size: 11pt;

line-height: 1.55;

color: #1a1a1a;

}

h1 { font-size: 22pt; line-height: 1.15; }

h2 { font-size: 16pt; }

h3 { font-size: 13pt; }

/* Optional: print the URL after each link */

a[href^="http"]::after {

content: " (" attr(href) ")";

font-size: 8pt;

color: #666;

}

The @font-face rule is where most "my font did not load" bugs come from, so set font-display: block and host the font file yourself rather than pointing at a Google Fonts URL. More on the timing trap in the gotchas. The link-URL trick is genuinely useful for print: a reader holding a paper copy cannot click a link, so printing the destination next to it preserves the information. Scope it to a[href^="http"] so you do not litter every anchor, and skip it entirely for on-screen PDFs where the links are still clickable.

6. Tables and images

Markdown tables render as plain HTML <table> elements with no styling at all. Out of the box they are cramped, borderless, and when a table runs past one page the reader loses the header row. Two CSS rules fix the worst of it. Make the table full width, and tell the renderer to repeat <thead> on every page.

tables-images.css

table {

width: 100%;

border-collapse: collapse;

font-size: 10pt;

}

/* Repeat the header row on every page the table spans */

thead { display: table-header-group; }

tfoot { display: table-footer-group; }

th, td {

border: 1px solid #ddd;

padding: 6px 10px;

text-align: left;

}

th {

background: #f4f4f5;

print-color-adjust: exact;

-webkit-print-color-adjust: exact;

}

/* Never let an image overflow the printable width */

img {

max-width: 100%;

height: auto;

break-inside: avoid;

}

The magic line is thead { display: table-header-group }. It tells the renderer that the header row is a header group, so the renderer repeats it at the top of every page the table flows onto. Without it, page two of a long table is a wall of numbers with no labels. The matching tfoot { display: table-footer-group } does the same for a footer row, handy for running totals. The header background still needs print-color-adjust: exact or it prints white like every other background.

For images, max-width: 100% with height: auto is non-negotiable. A Markdown image is whatever pixel size the source file happens to be, and a wide screenshot will run off the page without it. Add break-inside: avoid so an image is not sliced across a page boundary.

7. A cover page

For reports and ebooks you usually want a title page that stands alone, with the real content starting on a fresh sheet behind it. Markdown has no concept of a cover, so you inject a small wrapper around the top of your HTML and style it. The key is a forced break after the cover so nothing else shares its page.

cover-page.css

/* Wrap your title HTML in <section class="cover"> */

.cover {

height: 100vh;

display: flex;

flex-direction: column;

justify-content: center;

align-items: center;

text-align: center;

/* the whole next section starts on a new page */

break-after: page;

}

.cover h1 { font-size: 34pt; margin-bottom: 8pt; }

.cover .subtitle { font-size: 13pt; color: #666; }

Use 100vh rather than a fixed height so the cover fills exactly one page regardless of page size. The break-after: page guarantees the content behind it starts clean. In a paged-media engine you can go further and suppress the page number on the cover with a named page: .cover { page: blank } plus @page blank { @bottom-center { content: none } }. In Chromium you handle that in the header and footer templates instead, since you control what those templates print per page.

Putting it together

Collect everything above into one stylesheet, call it print.css, and apply it during conversion. The point is that you write the CSS once and the renderer consumes it. Here it is two ways. First with Puppeteer, where you inject the stylesheet after loading the converted HTML.

md-to-pdf-puppeteer.js

import puppeteer from 'puppeteer'

import { marked } from 'marked'

import { readFile } from 'fs/promises'

const md = await readFile('report.md', 'utf8')

const body = marked(md) // Markdown to HTML

const browser = await puppeteer.launch()

const page = await browser.newPage()

await page.setContent(body, { waitUntil: 'networkidle0' })

await page.addStyleTag({ path: 'print.css' })

await page.evaluateHandle('document.fonts.ready') // wait for fonts

await page.pdf({

path: 'report.pdf',

format: 'A4',

printBackground: true // honor backgrounds

})

await browser.close()

That works, and if you only convert a handful of documents on your own machine it is fine. At scale you inherit the Chromium operations problem: the 400MB binary, the memory per instance, the cold starts, the zombie processes. The other way to apply the same stylesheet is to hand the HTML and CSS to an API and let someone else run the browser.

With the PDFBase API you send the rendered HTML plus your CSS in one call and get a styled PDF back. Same Chromium rendering, same paged-media rules, none of the infrastructure.

md-to-pdf-pdfbase.js

import PDFBase from 'pdfbase'

import { marked } from 'marked'

import { readFile } from 'fs/promises'

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

const body = marked(await readFile('report.md', 'utf8'))

const css = await readFile('print.css', 'utf8')

const pdf = await client.pdfs.create({

html: `<style>${css}</style>${body}`,

format: 'A4',

printBackground: true,

output: 'url'

})

console.log(pdf.data.url) // styled PDF, signed URL

Inlining the CSS in a <style> tag keeps everything self-contained so the renderer never has to fetch an external stylesheet. The same rules apply: set printBackground: true so your code-block and table backgrounds survive. If you would rather not write a line of code first, the free Markdown to PDF tool lets you convert and style Markdown with the free tool right in the browser, and the PDFBase docs cover header and footer options, templates, and batch conversion.

Gotchas

The same handful of problems trip up almost everyone. Keep this list next to you when a PDF comes out wrong.

  • Backgrounds print white. Renderers strip background colors by default. Add print-color-adjust: exact and -webkit-print-color-adjust: exact to any element with a background, and pass printBackground: true to page.pdf(). Miss either one and your dark code blocks come out blank.
  • Fonts do not load in time. The renderer can snapshot the page before a web font has downloaded, so you get the fallback font instead. Wait for document.fonts.ready before calling page.pdf(), set font-display: block, and self-host the font file rather than depending on a CDN that might be slow or blocked.
  • Margins fight headers. In Chromium the header and footer templates render inside the page margin. If your margin.top is smaller than the header height, the header is clipped or invisible. Reserve enough margin for them, and remember the templates have no padding of their own.
  • @page rules ignored. Chromium's page.pdf() reads size and margin from its own options, not always from your @page CSS. If the CSS seems ignored, set the size and margins in the page.pdf() options instead, or switch to a paged-media engine that honors @page fully.
  • Page numbers render nothing. Almost always a renderer mismatch: counter(page) in an @bottom-center box does nothing in Chromium, and header and footer templates do nothing in WeasyPrint. Match the technique to the engine.
  • Code still clips. If a long line runs off the page even with wrapping, you forgot white-space: pre-wrap on <pre>, or an unbreakable token needs overflow-wrap: anywhere to fold.

Wrapping up

Styling a Markdown PDF is not hard once you know the pieces. Set the page with @page. Add headers and numbers the way your renderer wants them. Control breaks so headings, rows, and code stay whole. Make backgrounds print. Set type in points and load your font before you render. Repeat table headers and constrain images. Add a cover if the document deserves one.

Save those rules in one print.css and reuse it across every document. The CSS is portable: the only thing that changes is whether you feed it to Puppeteer, to a paged-media engine, or to an API. For the full pipeline decision, including when a CLI or library beats an API, read Convert Markdown to PDF: CLI vs Library vs API.

If you want to skip the browser ops entirely, grab 100 free PDFBase credits with no credit card, send your HTML and CSS, and get a styled PDF back. The docs cover headers, footers, templates, and batch conversion.