Web fonts in generated PDFs: why they fall back, and how to make them load
The PDF shows Times where the page showed your brand font. Causes — timing, CORS, formats, licensing — and the fix for each.
A PDF that renders in the wrong font is the most common quality complaint in document generation. The page looked right in the browser; the file looks like 1998. There are six causes, and they are all fixable once you know which one you have.
1. Timing — the most common cause
The renderer starts the PDF the moment you ask. If the font is still downloading, the text is laid out in the fallback, and that is what gets printed. Nothing errors; the file is simply wrong.
In your own Playwright or Puppeteer code:
await page.goto(url, { waitUntil: 'networkidle0' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4' });
document.fonts.ready is the important line. Network idle alone can fire before the font engine has finished parsing the file.
Through an API, this should be the default. PDFGeny waits for the network to settle before rendering; for pages that draw late you can also pass "wait_for": ".ready" or a millisecond value.
2. CORS
Fonts are subject to cross-origin rules — stricter ones than images. A font served from a CDN without Access-Control-Allow-Origin loads fine on the page that hosts it and fails when the renderer fetches your HTML from a different origin.
The symptom is distinctive: the font works when you open the page yourself, and fails only in generated PDFs. Fixes, in order of reliability:
- Serve fonts from the same origin as the HTML
- Add
Access-Control-Allow-Origin: *on the font host - Embed the font as a data URI (below)
3. Relative URLs in inline HTML
When you POST raw HTML rather than a URL, there is no base document, so /fonts/brand.woff2 has nothing to resolve against. Use absolute URLs, or embed:
@font-face {
font-family: "Brand";
src: url(data:font/woff2;base64,d09GMgABAAAAA…) format("woff2");
font-weight: 400;
font-display: block;
}
For one or two weights the payload is acceptable — a subset WOFF2 of Latin characters is typically 15–30 KB — and the render becomes fully self-contained. No network, no CORS, no timing.
font-display: block matters here: swap explicitly tells the browser to paint fallback text first, which is exactly what you do not want in a document.
4. Format
WOFF2 is supported by every modern renderer. The old .eot and SVG font formats are not. A stylesheet that lists them first still resolves if WOFF2 follows; one that lists only them falls back silently. Ship WOFF2 with WOFF as a second option and nothing else.
5. System fonts that do not exist on the server
If your CSS says font-family: "Segoe UI", sans-serif and the renderer runs on Linux, there is no Segoe UI. The fallback is whatever fontconfig picks — often DejaVu Sans, which is wider than most interface fonts and will reflow your layout.
Either ship the font as a web font, or choose a fallback deliberately:
| If your design uses | Metric-compatible Linux fallback |
|---|---|
| Arial / Helvetica | Liberation Sans, Arimo |
| Times New Roman | Liberation Serif, Tinos |
| Courier New | Liberation Mono, Cousine |
| Calibri | Carlito |
| Cambria | Caladea |
Metric-compatible means the character widths match, so line breaks land in the same places.
6. Licensing
Most commercial font licences distinguish web use from document embedding. Generating a PDF embeds a subset of the font in the file, which is a different grant from serving it on a website. Check the licence before shipping thousands of documents containing someone's typeface.
Open fonts sidestep the question entirely: Inter, IBM Plex, Source Sans, Public Sans and the Liberation family are all licensed for embedding.
Verifying what actually got embedded
Do not trust the visual check — a close fallback can look almost right. Ask the file:
pdffonts invoice.pdf
name type emb sub uni
------------------------------------ ----------------- --- --- ---
ABCDEF+Inter-Regular TrueType yes yes yes
ABCDEF+Inter-Bold TrueType yes yes yes
emb yes means embedded; sub yes means subsetted, which is what you want for size. If you see DejaVuSans where you expected your brand font, you have one of the six problems above.
The checklist
- WOFF2 (plus WOFF), nothing older
- Absolute URLs or data URIs — never relative, in inline HTML
- Same origin, or CORS headers on the font host
- Wait for
document.fonts.readybefore rendering - A metric-compatible fallback you have actually looked at
- A licence that permits embedding
pdffontson a sample before you ship
Fonts are also the reason a document can look different between the browser's print dialog and a headless renderer — the two do not necessarily have the same font set. That and four other differences are covered in why the PDF differs from Print to PDF.