← shotpdf

Why your HTML-to-PDF library mangles your invoices

26 August 2026

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.

The symptom

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.

Why it happens

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.

The three options, honestly

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:

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.

Fixes that need no vendor at all

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.

Get your backgrounds back

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.

Set the page size and margins deliberately

@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.

Stop breaks landing in the wrong place

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.

Repeat the table header on every page

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.

Wait for web fonts before you print

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.

Test with print styles actually applied

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.

Where shotpdf fits

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.