A template looks right in the editor, the PDF comes back, and the text is a little wider, a little rounder, and the line that used to fit now wraps. Nothing failed. There was no error, no warning and no log line. The PDF simply carries a different font from the one the template asked for.
It can happen with any HTML-to-PDF service that runs Chromium on a Linux server, ours included. We rendered four one-line templates with our render worker (Chromium 153, the image we run in production) and read back which fonts each PDF embedded.
| The template says | The PDF carries |
|---|---|
font-family: "Segoe UI", Helvetica, sans-serif | Liberation Sans |
font-family: Inter, sans-serif, nothing loaded | Liberation Sans |
font-family: Inter, sans-serif, Google Fonts <link> | Inter |
the same, text with ✓, ✗ and → | Inter, DejaVu Sans for ✓ and ✗, Liberation Sans for → |
A family name is a wish
font-family is a list of names, and the browser takes the first one it has. On a Windows laptop that is Segoe UI. A Linux server has no Segoe UI and no Helvetica, so Chromium goes down the list, arrives at sans-serif, and the system hands it Liberation Sans. That is not a bad font: it was made with Arial's widths, so a line set for Arial keeps its length. It is just not the font the template names.
The second row is the one that catches people. Inter looks like a font the template has. On a designer's machine it is installed, so the editor preview shows Inter and everybody is happy. The server does not have it, and nothing in the template tells Chromium where to get it. The PDF comes out in Liberation Sans, and the first two lines of the picture are the same line.
The fix is to load every font you name. Either a <link> to Google Fonts or another web font service in the template's head, or an @font-face rule with the file. For fonts that are yours — a licensed corporate typeface — the same holds: serve the files from somewhere the server can fetch, and give the header and footer the @font-face rule too, because Chromium prints them from a separate document that never sees the template's stylesheet.
Check the PDF, not the preview
The preview in your browser uses the fonts on your computer, so it cannot tell you what a server lacks. The PDF can. It lists every font it embeds:
-
pdffonts invoice.pdffrom poppler-utils prints one line per font. - In Acrobat or Acrobat Reader: File → Properties → Fonts.
- In Chrome's PDF viewer there is no list; open the file in one of the above.
Names come with a six-letter prefix like ABCDEF+Inter-Regular. That means the PDF carries a subset: only the characters the document uses, which keeps it small. When the list says LiberationSans, DejaVuSans or NotoSans and your template named something else, either a font was never loaded or some characters were not in it (more on that below).
What a Google font costs
The worker has to fetch a web font before it can print, and it waits for document.fonts.ready, so a font that has not arrived is never skipped. That fetch takes time. We started a fresh worker three times, each with an empty cache, printed a plain page once to warm Chromium up and three more times to measure it, then printed the Inter template once and three more times. The worker reports its own render time.
| Render time (median) | |
|---|---|
| Page without web fonts | 58 ms |
| Inter, first render, empty cache | 318 ms |
| Inter, every render after | 72 ms |
The first render fetched Google's stylesheet and the font files; the ones after found both in the worker's disk cache and cost 14 ms more than a page with no web font at all. In production that cache lives on a disk that outlasts restarts and deployments and keeps a file for a week, so the fetch happens once per server and week, not once per document. Organisation fonts come from our own storage and go through the same cache.
Those are the numbers of a laptop in Germany talking to Google, with nothing heavy running beside it; next to three busy builds the same runs took four to five times as long, in the same proportions. The point is the shape, not the milliseconds. A service without such a cache pays the first line on every render.
A checkmark from a third font
The last row is the subtle one. Inter was loaded, the words are Inter, and still the PDF embeds three fonts. The Inter files Google served for this request do not contain ✓ and ✗, so Chromium looked for each character in the fonts installed on the server and found them in DejaVu Sans; the arrow came from Liberation Sans. Look at the picture: the check mark is heavier than the letters next to it, the arrow sits lower.
Chromium decides this per character, silently, and the result depends on what the server has installed — our image ships DejaVu, Liberation and the Noto families, so nothing falls out as an empty box, but it may well be a different design. Three ways out:
- Pick a font that has the symbols you use, and load it.
- Load a second font just for them (Noto Sans Symbols 2, say) and name it after your text font:
font-family: Inter, "Noto Sans Symbols 2", sans-serif. - Draw status marks as inline SVG, which no font decides.
In short
- Name only fonts you load, and load every font you name.
- Read the font list of the PDF, not the look of the preview.
- The first render with a web font is slower; after that a cache should make it free.
- Symbols fall back one character at a time. If they matter, bring a font that has them.
The four PDFs are here to check for yourself: Segoe UI, Inter not loaded, Inter from Google Fonts, with symbols.

