Your invoicing code builds the invoice as HTML, because HTML and CSS are the only sane way to lay out line items, a logo, and a total that survives adding a discount row. It looks right in the browser. Then you run it through your PDF converter and email the result, and the customer gets something else.
You will recognise at least two of these.
The font is not your font. You loaded Inter over @font-face. The PDF is set
in something with different metrics, so every column width you tuned is now
wrong, and the total no longer lines up with the column it belongs under.
The layout collapsed. The header was display: flex with the logo left and
the invoice number right. In the PDF they are stacked, full width, in source
order — the flex container behaved like a plain block box. Same story with a
grid: tracks gone, items in a single column.
A page break went through a table row. Not between rows — through one. The description sits at the bottom of page one and its amount is at the top of page two. If you have a multi-page invoice, the table header appears once, on page one, and pages two onward are unlabelled columns of numbers.
The backgrounds are white. The zebra striping on the line items is gone. The dark bar behind "INVOICE" is gone. The coloured "PAID" badge is now black text on nothing.
None of these are bugs in your CSS. Your CSS is fine — the browser proves it every time you look at the page.
Two different things are being called "HTML to PDF", and they are not comparable.
A converter that is not a browser has to implement CSS itself. That is an enormous amount of work, so every such tool implements a subset, and the subset is chosen for documents rather than for applications. This is not a criticism — the projects say so themselves. WeasyPrint's own documentation describes its flexbox support as working "for simple use cases but is not deeply tested", and its grid support as working "for simple cases, but has some limitations", with an explicit list of what is unsupported. It also states plainly that there is "no user-interaction, no JavaScript, no live rendering". Read your tool's support page before you assume a property is honoured; the good ones tell you.
wkhtmltopdf is a different case. It is a real browser engine — QtWebKit — wrapped in a CLI. But it is a snapshot of one, and the repository was archived by its owner on 2 January 2023 and is read-only. An engine that stopped moving in the QtWebKit era predates the CSS you are writing today. Flexbox in that engine is not the flexbox in your browser.
The reason the browser gets it right is not that browsers are better software. It is that the browser is the thing your CSS was written against. You developed the invoice by looking at it in Chrome, and every judgement you made — that the column widths work, that the total lines up — was a judgement about what that engine does. Other complete implementations exist; the one that matters is the one you designed against. If you want the PDF to match what you see on screen, the PDF has to come out of an engine that renders that screen.
That is a real constraint, not a sales pitch — and it costs something, which is the next section.
1. A pure library in your own process.
WeasyPrint, a Java PDF library, whatever your stack has. It runs in-process. No subprocess, no network, no extra container, no browser to keep alive. It is the simplest thing that can possibly work and for a lot of documents it is the right answer.
The cost is that you are writing CSS against a partial implementation, and you find out which parts by hitting them. If your document is a statement — headings, paragraphs, one table, absolute positioning at most — this is fine and you should stop reading. If your document shares a stylesheet with your web app, you will keep discovering that a property you rely on is on the unsupported list.
2. Headless Chromium, run by you.
Correct output, because it is the engine. Playwright or Puppeteer, ten lines, and the render itself genuinely is ten lines.
The ten lines are not the work. The work is that you now operate a browser:
/dev/shm. Docker gives a container 64MB by default. Chromium uses shared
memory heavily and crashes with no useful error when it runs out. You need
--disable-dev-shm-usage, a larger shm_size, or both.finally, or
cookies and storage from one render leak into the next.If you already operate containers and are happy to own one more, this is a perfectly good answer, and at high volume it is the cheap one.
3. A rendering API.
Someone else runs that browser. You POST HTML, you get PDF bytes. No process to supervise, no memory dial to tune.
The costs are real and you should count them: it costs money per month, there is a network hop that your latency budget has to absorb, and your invoice pipeline now depends on somebody else's uptime. Check whether the vendor states what it runs on and what happens when it goes down, because plenty do not.
Most of the symptoms at the top are fixable in CSS. Do these first, whichever option you end up on — they are the difference between a browser-rendered PDF that looks right and one that looks nearly right.
Everything below is Chromium-oriented, because Chromium is what Puppeteer, Playwright and every Chrome-based rendering API drive. Where behaviour differs between engines I have said so.
Browsers do not print background colours and images by default. Two separate switches control this and you usually need both.
The CSS side is print-color-adjust: exact, which tells the engine not to
"optimise" the element for the output device. It is inherited, so setting it high
up covers the subtree:
@media print {
html {
-webkit-print-color-adjust: exact; /* Chromium and WebKit */
print-color-adjust: exact; /* standard */
}
}
Ship both names. Chromium has supported -webkit-print-color-adjust since
Chrome 17, but only added the unprefixed print-color-adjust in Chrome 136.
Safari took the prefix from 6 and the standard name from 15.4. Firefox never had
the -webkit- prefix — it shipped color-adjust in 48 and print-color-adjust
in 97. One rule with both names covers everything currently shipping. (Versions
from MDN's browser-compat-data for print-color-adjust.)
One gotcha that catches people: per MDN's compat notes, Chromium and WebKit do
not print the <body> element's own background, and setting
print-color-adjust on <body> applies it only to that element's descendants —
not to <body> itself. If you want a full-bleed colour, put it on a child
element that fills the page, not on <body>.
The other switch is the renderer's own "background graphics" option, which is the
same checkbox as in Chrome's print dialog. In the DevTools Protocol,
Page.printToPDF has printBackground, and it defaults to false. Puppeteer
and Playwright expose it as a printBackground / print_background argument.
If that is off, your CSS will not save you.
@page is the at-rule for this, and its size and margin descriptors are the
two parts with real support:
@page {
size: A4;
margin: 18mm 15mm;
}
@page :first {
margin-top: 30mm; /* room for a letterhead */
}
Two caveats worth knowing before you rely on it.
First, most of what the spec allows inside @page is not implemented anywhere.
MDN lists size, margin (and its longhands) and page-orientation as
supported, and then a long list — backgrounds, borders, padding, colour, font —
as specified but supported by no user agent. Do not try to draw a border on the
page box.
Second, and this is the one that wastes an afternoon: a headless renderer
generally ignores your @page { size: ... } unless you ask it not to.
Page.printToPDF takes explicit paperWidth / paperHeight (defaulting to
8.5×11 inches) and marginTop / marginBottom / marginLeft / marginRight
(defaulting to about 1cm each), plus a preferCSSPageSize flag that
defaults to false. Playwright's page.pdf() likewise defaults format to
Letter. So if you are driving Chromium yourself and your A4 stylesheet keeps
producing Letter pages, it is not your CSS — set the size on the call, or turn on
the "prefer CSS page size" option.
The margin at-rules (@top-center, @bottom-right and the other fourteen) are
how the spec wants you to do running headers and page numbers. Chromium has been
growing support here — generated content in page margin boxes landed in Chrome
131 — but this is newer ground than size and margin, so test it against the
exact engine version you render with rather than assuming.
break-inside: avoid is the property. The old page-break-inside is formally
defined as an alias of it, so both work and there is no reason to write both:
@media print {
tr,
.line-item,
figure,
.totals-block {
break-inside: avoid;
}
h2, h3 {
break-after: avoid; /* don't strand a heading at the page foot */
}
.terms {
break-before: page; /* start T&Cs on a fresh page */
}
}
break-inside has been widely available across browsers since January 2019.
The part that has historically not worked is exactly the part you care about:
breaks inside table rows. Chromium's old layout engine handled table
fragmentation poorly, which is where all those "Chrome ignores page-break on
tr" answers come from. That was rewritten. Per Chrome's own RenderingNG
documentation, LayoutNG block fragmentation shipped in Chrome 102, flex and
grid fragmentation in 103, table fragmentation in 106, and printing
support was completed in 108. If you are on a current Chromium,
break-inside: avoid on a <tr> is supported. If you are on an old pinned image
or a frozen engine, it is not, and the display: block workarounds you will find
on Stack Overflow are answers to that older world — they break your column
alignment, so do not reach for them before checking your version.
orphans and widows also help, for prose rather than tables:
@media print {
p { orphans: 3; widows: 3; }
}
Note the engine split: per MDN's compat data, Chromium has supported orphans
since Chrome 25 and Safari since 1.3, but Firefox has not implemented it. If
you render with Chromium this is useful; if you also support printing from
Firefox, treat it as an enhancement, not a guarantee.
Put your header row in a <thead> and your rows in a <tbody>. That is not a
style rule, it is the thing that makes header repetition possible at all:
<table>
<thead>
<tr><th>Description</th><th>Qty</th><th>Amount</th></tr>
</thead>
<tbody>
<tr><td>…</td><td>…</td><td>…</td></tr>
</tbody>
</table>
Chromium implemented header-group repetition to match Firefox, IE and Edge; the
commit that added it repeats the header group when it has break-inside: avoid,
and makes that the default style for thead when printing. So with a real
<thead> you generally get repetition for free, and you can make it explicit
with thead { break-inside: avoid; }.
What not to rely on: position: fixed as a running header. CSS 2.1 says "for
paged media, boxes with fixed positions are repeated on every page". Chromium
does not reliably do this — there is a long-standing open Chromium issue titled
"Fixed position boxes should repeat on each page for media=print". If you build
your invoice header out of a fixed-position div because it worked in Firefox, it
may appear only once in a Chromium-generated PDF. Use <thead> for table
headers, and page margin boxes or your renderer's header/footer template for
running furniture.
This is the highest-value fix on the list and the one most often missed, because the failure is a race and races look intermittent. The renderer captures the page before the font file has arrived, so the PDF is set in the fallback — and if the fallback has different metrics, every measurement in your layout shifts.
The browser gives you the exact signal you need. document.fonts.ready resolves
once the document has finished loading fonts and layout operations are
complete. Await it before you render:
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });
Two things about it that are easy to get wrong.
Fonts only load when they are used. document.fonts.ready tells you the
fonts the current document actually needs have loaded — not that every
@font-face you declared has been fetched. That is usually what you want. It
does mean that if a font is only used by an element you reveal later, waiting
early proves nothing.
font-display decides what happens if the font is slow. MDN's table: block
gives a short block period then an infinite swap period; swap gives an
extremely small block period then an infinite swap period; fallback gives an
extremely small block period and a short swap period; optional gives an
extremely small block period and no swap period. MDN does not put fixed
millisecond values on "short" and "extremely small" — they are user-agent
defined — so the practical point is directional: optional and fallback are
designed to give up on a slow font, which for a screen is polite and for an
invoice is a wrong-looking document. If the font matters, block plus awaiting
document.fonts.ready is the combination that gets you the font or a visible
failure, rather than a silent substitution.
The most reliable version of this, if you are POSTing self-contained HTML to
something, is to not depend on the network at all: inline the font as a
data: URI in your @font-face src. It makes the payload bigger and removes
the race entirely.
Do not judge your print CSS by looking at the page. Chrome DevTools will apply it
for you: open the Command Menu (Cmd/Ctrl + Shift + P), type "rendering",
choose Show Rendering, and under Emulate CSS media type select print.
You then get the normal Elements and Styles panels against your print stylesheet,
which beats generating a PDF and squinting at it.
One thing to be aware of when you automate: Playwright's page.pdf() renders
with print media by default — its docs tell you to call emulateMedia first
if you want screen media instead. So @media print blocks do apply to
programmatic PDFs, which is what you want, and also means a @media print rule
that hides navigation will hide it in your generated PDF too.
Full disclosure: I built one of the option-three services, so treat this section as an ad and the rest of the page as the article.
shotpdf is Chromium behind an HTTP endpoint. You POST HTML or a URL, you get PDF bytes back in the response — no job ID, no polling, no webhook:
curl -X POST https://shotpdf.p.rapidapi.com/pdf \
-H "Content-Type: application/json" \
-H "X-RapidAPI-Key: YOUR_KEY" \
-H "X-RapidAPI-Host: shotpdf.p.rapidapi.com" \
-d '{"html":"<h1>Invoice #1042</h1><p>Total: $430.00</p>",
"paper":"A4","margin":"10mm","print_background":true}' \
--output invoice.pdf
paper is A4, Letter or Legal; margin is applied to all four sides;
print_background maps to Chromium's background-graphics switch and defaults to
true, so the print-color-adjust rules above will hold. It renders with print
media, so your @media print block applies. There is a free tier of 50 renders a
month, which is enough to run a real invoice through it rather than just check
that it answers.
One limit worth stating here rather than making you find it. It sets paper
size and margins from those fields, so @page { size: … } is not the control
surface — use paper.
As for the font race described above: it waits for document.fonts.ready before
it prints, rather than printing at load. That wait is capped at three seconds
and it degrades to the fallback rather than to an error, so a font host that is
slow or down costs you the typeface and not the render. The cap is the honest
caveat: a font server that takes longer than three seconds will still leave you
with the fallback, and the measurement below is one page, one font, one host. If
the typeface is not negotiable, inline it as a data: URI and take the question
off the table entirely — that advice holds here as much as anywhere.
Worth saying how that was arrived at, because it is the whole point of this
article. Writing this piece prompted me to actually check, and the check found a
real bug. Rendering byte-identical HTML twenty times with no font wait, the
Google font it asks for was embedded in 14, 15 and 16 of 20 renders across
three runs — roughly a quarter of the PDFs came back 200, perfectly valid, and
in the wrong typeface, with nothing in the response to tell you which one you
got. It waited for load and printed before the font arrived — exactly the
failure described at the top of this page. With the wait in place the same test
is 20 out of 20, both on my machine and against the live service.
Read the pre-fix numbers as a local measurement rather than as this service's
miss rate. The race is load against the font fetch, so the distance between the
renderer and the font host moves it, and only the 20-out-of-20 half was run
against production. I mention the failure at all because a vendor telling you
their converter substitutes fonts is worth more than one telling you it never
did.
When to pick something else. If your document has no CSS you care about — plain text, a Markdown export, no custom font, no exact positioning — a library in your own process is fewer moving parts and has no network hop. If you are doing tens of thousands of renders a day, your own Chromium container is cheaper than any per-call API, this one included. And if your invoicing pipeline cannot tolerate a dependency without an SLA, buy an SLA from someone who sells one.
If none of that applies — you have HTML, you need a PDF that looks like the HTML, and you would rather not own a browser process — that is the case it is for.