Font rendering is one of the most frustrating parts of PDF generation. You pick a clean sans-serif for your invoice template, generate a PDF, and what comes out looks like it was typeset in 1993. Or worse, the font is completely missing and the renderer fell back to something unrecognizable.
This happens because traditional PDF libraries handle fonts differently than browsers do, and getting them to use the font you actually want requires work that browsers solved years ago.
Why Fonts Are Painful in Traditional PDF Libraries
pdfmake: base64 embedding
pdfmake is a popular JavaScript library for generating PDFs client-side or in Node.js. It works well for simple documents, but fonts require you to embed them as base64-encoded strings in a vfs_fonts.js file. You have to download the font files, convert them, bundle them with your app, and maintain that bundle when you update font versions.
For a single font family with regular, bold, italic, and bold-italic variants, you are embedding four separate base64 blobs. A typical Google Font can be 100-300 KB per variant. Your bundle gets large fast, and the workflow is manual every time you want to change something.
WeasyPrint: system font hell
WeasyPrint renders HTML to PDF via Cairo and Pango, the Linux text rendering stack. This means fonts need to be installed at the system level. On your dev machine it works because you have fonts installed. In your Docker container or CI environment, you have whatever fonts the base image ships with, which is usually just a handful of generic fallbacks.
Making a specific Google Font work requires installing it in the container via fc-cache, mapping it correctly in fontconfig, and hoping the font name matches what your CSS specifies. When something breaks, the error messages are cryptic and the debugging cycle is slow.
Headless Chromium: missing fonts in minimal environments
Using Puppeteer or Playwright to generate PDFs is a reasonable approach, but minimal Docker images that ship Chromium often strip font packages to save space. You end up with PDFs where Latin characters render fine but other scripts (Arabic, Chinese, Cyrillic) are blank squares, or where a web font loaded from a CDN times out because your headless browser has no network access in the rendering environment.
The HTML Approach: Let the Browser Handle It
When you use a rendering pipeline built on a real browser engine with proper network access and font loading support, fonts just work the way they do on the web.
The simplest case: add a Google Fonts <link> tag to your HTML template. The browser fetches the font, caches it, and uses it during rendering. No embedding, no file conversion, no system configuration.
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body>
<h1>Invoice #2026-0142</h1>
<!-- ... -->
</body>
</html>
That is all it takes. The same <link> tag you use in a web app works in an HTML-to-PDF template. No configuration. No base64. No system packages.
Transactional.dev uses this approach. Templates are HTML with Handlebars variables, and Google Fonts load exactly like they do in a browser. You define your template once, pass your data via the API, and the PDF comes back with the right font.
Custom Fonts via @font-face
For fonts you host yourself, brand fonts, or commercial typefaces, use @font-face in your template CSS.
Option 1: reference a URL
If your font file is publicly accessible, reference it directly:
@font-face {
font-family: 'BrandFont';
src: url('https://assets.yourcompany.com/fonts/BrandFont-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'BrandFont';
src: url('https://assets.yourcompany.com/fonts/BrandFont-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
Option 2: inline base64
If your font cannot be served from a public URL, you can embed it directly in the CSS using a base64 data URI. This is especially useful for fonts under an NDA or behind authentication:
@font-face {
font-family: 'BrandFont';
src: url('data:font/woff2;base64,d09GMgABAAA...') format('woff2');
font-weight: 400;
font-style: normal;
}
Use woff2 format for the smallest file size. Avoid ttf in data URIs -- it is significantly larger.
Variable Fonts
Variable fonts are a good fit for generated documents. Instead of loading four separate files for regular, medium, semibold, and bold, you load one font file that covers the full weight range.
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
body { font-family: 'Inter', sans-serif; font-weight: 400; }
h1 { font-weight: 700; }
h2 { font-weight: 600; }
.label { font-weight: 500; }
This reduces the number of HTTP requests and gives you precise weight control without managing multiple font file variants.
Font Weight and Style Coverage
A common mistake is loading only one font weight and then using font-weight: bold in CSS. The browser will synthesize a bold variant by thickening the strokes algorithmically. The result looks slightly wrong compared to a properly-designed bold. In a PDF going to a client, that matters.
Always load every weight you use:
/* Load exactly what you need */
@font-face { font-family: 'Outfit'; font-weight: 400; src: url('/fonts/Outfit-Regular.woff2'); }
@font-face { font-family: 'Outfit'; font-weight: 600; src: url('/fonts/Outfit-SemiBold.woff2'); }
@font-face { font-family: 'Outfit'; font-weight: 700; src: url('/fonts/Outfit-Bold.woff2'); }
/* Never use a weight you did not explicitly load */
The same applies to italic. If you use font-style: italic anywhere, load the italic variant. Otherwise the browser synthesizes it by slanting the regular glyphs, which produces poor results for most typefaces.
Fallback Strategy
Even with a correctly loaded font, always define a fallback chain. If the font fails to load (network error, wrong URL, auth issue), your PDF still needs to be readable:
body {
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;
}
.monospace {
font-family: 'JetBrains Mono', 'Courier New', monospace;
}
Keep fallbacks in the same general category (sans-serif, serif, monospace) to avoid a completely broken layout if the primary font fails.
Generating the PDF via API
With your template using the right font stack, generating the PDF is a single API call:
curl -X POST https://api.transactional.dev/v1/generate \
-H 'x-api-token: YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"documentId": "your-document-uuid",
"variables": {
"customer": { "name": "Acme Corp" },
"invoice": { "number": "INV-2026-0142", "total": 1280.50 }
}
}'
The response returns a signed URL to the generated PDF:
{
"url": "https://files.transactional.dev/client/42/generated/.../invoice.pdf",
"documentId": "your-document-uuid"
}
The PDF will use whatever fonts your template specifies. No extra configuration needed on the API side.
Practical Tips
Preload your fonts. Add <link rel="preload"> tags for fonts used above the fold in your template. This tells the renderer to fetch the font earlier and reduces the chance of a flash of unstyled text (FOUT) being captured in the render snapshot.
<link rel="preload" href="https://fonts.gstatic.com/s/inter/v13/...woff2" as="font" type="font/woff2" crossorigin>
Use font-display: block. For PDF generation specifically, font-display: swap can cause the renderer to capture the document before the custom font loads. Use font-display: block to tell the browser to wait for the font before rendering:
@font-face {
font-family: 'Inter';
font-display: block;
src: url('...') format('woff2');
}
Subset your fonts. If your PDF only contains Latin characters, request a subset from Google Fonts to reduce load time:
https://fonts.googleapis.com/css2?family=Inter:wght@400;700&subset=latin
For self-hosted fonts, use a tool like pyftsubset or fonttools to strip unused glyph ranges before hosting.
Common Mistakes
Using font-weight: bold without loading the bold variant. The synthesized bold looks wrong. Load the actual bold file.
Referencing a font by the wrong family name. The name in font-family must match exactly what you specified in @font-face. Case-sensitive. A mismatch falls through to the system fallback silently.
Loading fonts over HTTP in a mixed-content environment. If your template is served over HTTPS, font URLs must also be HTTPS. HTTP font URLs will be blocked.
Not testing with the actual rendering environment. A font that looks correct in your local browser preview may behave differently in a headless rendering environment if network access is restricted or DNS resolution is slow. Always test with a real API call, not just a browser preview.
Embedding huge base64 TTF files. Use woff2. It is typically 30-40% smaller than woff and 60-70% smaller than TTF for the same font. Smaller font files mean faster rendering.
Conclusion
Font problems in generated PDFs almost always come down to the rendering environment, not the font itself. Traditional PDF libraries treat fonts as external resources you have to manage manually. When you use an HTML template rendered by a real browser engine, fonts work the same way they do on the web.
Add a Google Fonts <link> tag, or declare your own font via @font-face, and you get the font you specified in your PDF. No base64 bundles to maintain, no system packages to install in containers, no fighting with fontconfig.
If you want to skip the PDF rendering infrastructure entirely and just call an API, Transactional.dev lets you build HTML templates with full font support and generate PDFs via a single POST request. The free tier is enough to test your templates and see your fonts render correctly before committing to anything.



