DEV Community

rastersly
rastersly

Posted on Originally published at rasterly.dev

HTML to PDF that doesn't look broken: the CSS nobody documents

Disclosure up front: I build a render API (rasterly) that does HTML to PDF among other things. This post is the stuff I wish someone had written before I spent a week on it, not a pitch. The rules below work with any Chromium-based renderer.

You have an HTML invoice. It looks perfect in the browser. You render it to PDF and a table row is sliced in half across the page break, the brand color is gone, and there's a mystery 1cm margin you never set. Here's why, and the small set of CSS rules that actually fixes it.

The renderer matters more than the HTML

Two families of HTML-to-PDF exist, and they behave completely differently:

  • Chromium's printToPDF (Playwright/Puppeteer page.pdf(), and hosted APIs built on it). This is a real browser print engine. It honors @page, break-inside, print media queries, and web fonts.
  • Lightweight HTML-to-image/PDF libraries (wkhtmltopdf, many "html to pdf" npm packages). These use older or partial engines and quietly ignore half of the print CSS spec.

If your PDFs look wrong and you're on a lightweight library, that's usually the whole problem. Everything below assumes a Chromium-based renderer, because that's the one that respects these rules.

The four rules that fix 90% of broken PDFs

1. Backgrounds disappear unless you ask for them. Print defaults to no background graphics, to save ink. Colors, background images, and shaded table headers vanish. In Chromium you turn them back on at render time:

await page.pdf({ printBackground: true, format: "A4" });
Enter fullscreen mode Exit fullscreen mode

On a hosted API it's usually a print_background=true flag. This single setting is the most common "my PDF is missing the design" bug.

2. Control the page box with @page, not element margins. The paper size, orientation, and the physical margin around the printable area are set with the @page rule, in real units:

@page {
  size: A4;
  margin: 18mm 16mm;
}
Enter fullscreen mode Exit fullscreen mode

Trying to fake page margins with body { padding } fights the engine and drifts across pages. Set the page box once with @page and let content flow inside it.

3. Stop rows and cards from splitting across pages. The ugliest break bug is a table row or a card cut in half by a page boundary. break-inside: avoid keeps an element whole:

tr, .card, .line-item { break-inside: avoid; }
thead { display: table-header-group; } /* repeat table headers on every page */
Enter fullscreen mode Exit fullscreen mode

That table-header-group trick is the one people miss: it makes the table header reprint at the top of every page instead of appearing only on page one.

4. Fonts: embed or fall back visibly. A PDF renders on a server with no system fonts, so anything you don't load explicitly falls back to a default and your careful typography is gone. Load web fonts and, for print, prefer font-display: swap so text never renders invisibly while a font is pending.

A print stylesheet, in one block

Most document PDFs need exactly this:

@page { size: A4; margin: 18mm 16mm; }

@media print {
  body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  tr, .line-item, .card { break-inside: avoid; }
  thead { display: table-header-group; }
  .page-break { break-before: page; }
  a::after { content: ""; } /* don't print the raw URL after every link */
}
Enter fullscreen mode Exit fullscreen mode

print-color-adjust: exact is the CSS-side partner to printBackground — some engines want the flag, some want the CSS, so set both and stop guessing.

The full round trip

Here's the whole thing end to end against a hosted renderer (swap the host/header for whichever you use):

curl -X POST "https://api.rasterly.dev/v1/pdf" \
  -H "X-Api-Key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<style>@page{size:A4;margin:18mm}tr{break-inside:avoid}thead{display:table-header-group}</style><h1>Invoice INV-42</h1><table>...</table>",
    "format": "A4",
    "print_background": true
  }' \
  -o invoice.pdf
Enter fullscreen mode Exit fullscreen mode

The same call with Playwright directly is page.setContent(html) then page.pdf({ printBackground: true, format: "A4" }). Hosted or self-run, the CSS is identical — that's the point of this post.

How to tell if it's your CSS or your renderer

Render the same HTML two ways: once through your current tool, once through headless Chromium (page.pdf). If Chromium gets the backgrounds, page breaks, and fonts right and your tool doesn't, the tool is the problem, not your markup. If both get it wrong, it's the four rules above.

I put a live HTML-to-PDF playground on my site if you want to throw a real invoice at it: rasterly.dev/use/pdf-generation-api. But the rules travel — paste that print stylesheet into whatever you already use and most "broken PDF" tickets go away.

Happy to dig into specific break or font issues in the comments.

Top comments (0)