Guides

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.

M Mikel Rougstone · 31 August 2026 · 4 min read
Web fonts in generated PDFs: why they fall back, and how to make them load

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 usesMetric-compatible Linux fallback
Arial / HelveticaLiberation Sans, Arimo
Times New RomanLiberation Serif, Tinos
Courier NewLiberation Mono, Cousine
CalibriCarlito
CambriaCaladea

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.ready before rendering
  • A metric-compatible fallback you have actually looked at
  • A licence that permits embedding
  • pdffonts on 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.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.