Skip to content
CastPDF

HTML to PDF fonts not working: why, and how to fix it

Fonts in HTML to PDF usually fail because the font is not installed on the server, its file cannot be fetched, or the PDF prints before it loads. Use an installed family, link a public font stylesheet, point @font-face at a public https URL, or embed the file as a data: URL. Our HTML to PDF API waits for fonts.

  • Updated October 2026
  • 100 free PDFs a month

The problem: the PDF uses a different font

Your quote template uses Inter. It looks crisp in the browser on your laptop. Then the server renders it, and the PDF arrives in a plain serif face. Headings wrap in new places, a two-page quote becomes three, and the table columns no longer line up.

Other versions of the same bug are worse. A customer name in Japanese prints as a row of empty boxes. An Arabic address comes out as unconnected letters. The thumbs-up emoji in a thank-you note simply vanishes.

Missing fonts are one of the top complaints from teams that run their own headless Chrome. The good news is that the causes are few and easy to test for, one at a time.

Why fonts fall back on a server

When a browser cannot use the font you asked for, it does not fail. It picks the next family in your font-family list, or a system default. So the PDF still renders, just in the wrong face. These are the usual reasons:

  • The font is not installed in the container. Your laptop has Arial, Helvetica and dozens of other families. A slim Linux server image may have almost none. A name like Segoe UI in your CSS points to nothing there.
  • The file cannot be fetched. The @font-face URL points to localhost, an internal address, or a page behind a login. The renderer cannot reach it, so the font never arrives.
  • The font host refuses the request. Browsers load web fonts with CORS rules. If the server holding the file does not allow other sites to use it, the browser discards the font.
  • A loading race. The page is printed the moment the HTML is parsed, before the font file finishes downloading. The first render uses the fallback, and that is the one that ends up in the PDF.
  • A missing script or weight. The font loads, but it has no glyphs for Chinese or emoji, or you only loaded the regular weight. The browser then borrows glyphs from elsewhere, or fakes the bold.

The fix, step by step

  1. Find the font your CSS asks for. Look at the font-family rules on body and your headings. Note every family name and weight the design uses.
  2. Choose how to load it. Use an installed family if one fits. Otherwise link a public font stylesheet, declare @font-face with a public https URL, or embed the file as a data: URL.
  3. Load every weight you use. Request 400 and 700 if the design has regular and bold text. One weight alone makes the browser imitate the others.
  4. End the stack with an installed font. Add a family that is always present, such as "Noto Sans", before the generic sans-serif. A fallback then looks deliberate, not random.
  5. Render and read the report. Ask for "response": "url" and check blocked_resources in the JSON. An empty list means no font request was refused.
  6. Confirm the fonts in the PDF. Open the file and check the font list in your viewer, or run pdffonts from Poppler on the command line.

The quickest way to load a web font is a stylesheet link from a public font service. This is the Inter quote header, with installed families behind it:

quote-header.html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
<style>
  body { font-family: Inter, "Noto Sans", sans-serif; font-size: 10.5pt; }
  h1 { font-weight: 700; letter-spacing: -0.01em; }
  .quote-meta { font-weight: 600; color: #475569; }
</style>
<h1>Quote Q-3307</h1>
<p class="quote-meta">Valid until 31 October 2026</p>

Then check what the renderer did. The "response": "url" field makes the API return JSON instead of the PDF itself, so you can read the report:

quote-request.json
{
  "html": "<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\"><h1>Quote Q-3307</h1><p class=\"quote-meta\">Valid until 31 October 2026</p>",
  "css": "body { font-family: Inter, \"Noto Sans\", sans-serif; } .quote-meta { font-weight: 600; }",
  "format": "A4",
  "response": "url"
}
curl with a resource report
curl https://api.castpdf.com/v1/pdf \
  -H "Authorization: Bearer $CASTPDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d @quote-request.json \
  --output quote-response.json

jq '.blocked_resources' quote-response.json

Four ways to load a font that work

1. Use a family that is already installed

Nothing loads faster than a font that is already there. These families are installed on the CastPDF renderer, so you can name them in CSS without any link or file:

Font families installed on the CastPDF renderer
FamilyCoversGood for
Noto Sans, Noto Serif and Noto for other scriptsLatin, Greek, Cyrillic, Arabic and many moreMultilingual documents and safe fallbacks
Noto Sans CJK, Noto Serif CJK (such as Noto Sans CJK SC)Chinese, Japanese and KoreanNames and addresses in CJK scripts
Noto Color EmojiEmoji in colourNotes, reactions and labels with emoji
Liberation Sans, Serif and MonoLatin, same widths as Arial, Times New Roman and Courier NewDesigns written for those classic fonts
DejaVu Sans, Serif and Sans MonoLatin plus a wide symbol rangeCode samples, symbols and plain reports

Liberation deserves a closer look. Its letters take the same width as Arial, Times New Roman and Courier New, so text wraps the same way. If your design was made for Arial, list "Liberation Sans" right after it, and line breaks match what you saw on your desktop.

2. Link a public font stylesheet

A <link> to Google Fonts, as in the quote example above, works as it does on a website. Ask only for the weights you need. Each extra weight is one more file to download before printing.

3. Declare @font-face with a public https URL

For a brand font, host the files on a public CDN or storage bucket and declare them yourself. Use one rule per weight, and serve the file with a CORS header that allows other origins:

brand-fonts.css
@font-face {
  font-family: "Fjordline";
  src: url("https://static.example.com/fonts/fjordline-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
}
@font-face {
  font-family: "Fjordline";
  src: url("https://static.example.com/fonts/fjordline-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
}
body { font-family: Fjordline, "Noto Sans", sans-serif; }

4. Embed the font as a data: URL

A data: URL puts the font file inside your CSS, encoded as base64. No network request is made, so nothing can block it or time out. The cost is size: base64 adds about a third to the file, and it counts toward the rendered HTML limit of 5 MB. Subset the font to the characters you need if it is large.

Encode a font for a data: URL
base64 -w 0 fjordline-regular.woff2 > fjordline-regular.b64
printf '@font-face { font-family: "Fjordline"; src: url("data:font/woff2;base64,%s") format("woff2"); }\n' \
  "$(cat fjordline-regular.b64)" > fjordline-embedded.css

Other scripts, emoji and self-hosted Chrome

Arabic, Chinese and emoji in one document

List the script fonts in your stack, in order. The browser walks the list for each character, so Latin text uses the first family and other scripts find their glyphs further down. Set dir="rtl" on Arabic blocks so the text runs right to left.

multilingual-address.html
<style>
  .address {
    font-family: "Noto Sans", "Noto Sans Arabic", "Noto Sans CJK SC", "Noto Color Emoji", sans-serif;
  }
</style>
<div class="address">
  <p>Delivered to: Lena Ortiz</p>
  <p dir="rtl">شارع الملك فهد، الرياض</p>
  <p>上海市浦东新区世纪大道 100 号</p>
  <p>Thank you for your order 🎉</p>
</div>

If you run Chrome yourself

With Puppeteer or Playwright on your own server, you install fonts into the image and wait for them in code. On Debian or Ubuntu images, the system packages cover the same families listed above:

Dockerfile RUN step (Debian or Ubuntu)
apt-get update && apt-get install -y --no-install-recommends \
  fonts-noto fonts-noto-cjk fonts-noto-color-emoji \
  fonts-liberation fonts-dejavu-core \
  && rm -rf /var/lib/apt/lists/*

Then make the script wait until web fonts are ready before it prints. Fonts used inside Puppeteer header and footer templates are a separate case, covered in the Puppeteer header and footer guide.

wait-for-fonts.mjs (Puppeteer)
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/quotes/Q-3307', { waitUntil: 'networkidle0' });
await page.evaluateHandle('document.fonts.ready');
await page.pdf({ path: 'quote.pdf', format: 'A4', printBackground: true });
await browser.close();

How CastPDF handles it

The CastPDF renderer ships with the Noto, Noto CJK, Noto Color Emoji, Liberation and DejaVu families. For anything else, it fetches the font like any web page would, then waits for fonts to finish loading before it prints. You do not need a wait step in your own code.

The renderer only fetches public http and https addresses. A font URL on localhost, on a private network such as 10.x.x.x or 192.168.x.x, or a public name that resolves to one, is refused. The PDF still renders with the fallback font, and the response reports the refusal.

With a binary response, the x-blocked-resources header gives the count. With "response": "url", the blocked_resources array lists up to 10 URLs with their reasons. Failures at the network level appear there too, such as a DNS error or a request slower than 10 seconds.

Response with a refused font
{
  "id": "6a2e1f0c-93b4-4d7e-8c15-2f9a0b6d4e71",
  "status": "succeeded",
  "pages": 2,
  "bytes": 61844,
  "url": "https://api.castpdf.com/v1/files/6a2e1f0c-93b4-4d7e-8c15-2f9a0b6d4e71?exp=1791368102&sig=example",
  "expires_at": "2026-10-10T09:00:00.000Z",
  "blocked_resources": ["http://192.168.1.20/fonts/fjordline.woff2 (address 192.168.1.20:80)"]
}

A 404 is not in the report

If the font server answers with an HTTP error, such as 404 for a mistyped file name, the request did not fail at the network level. It is not listed in blocked_resources. Check the PDF itself while you build the template, so a wrong path does not slip through.

Fonts also change how much fits on a page, so fix them before you tune the page breaks, the repeating table headers or the page numbers. The print CSS reference has the full resource rules. If you are weighing a move away from running Chrome yourself, read CastPDF vs Puppeteer.

FAQ

Common questions

Why does my PDF use a different font on the server than on my laptop?

The font is installed on your laptop but not on the server, or the server cannot download it. The browser then uses the next family in your font stack. Load the font from a public URL or use an installed family.

Which fonts can I use in CastPDF without loading anything?

Noto Sans and Noto Serif with the Noto families for other scripts, Noto CJK, Noto Color Emoji, Liberation and DejaVu. Name them in your CSS and they work at once.

Can I use Google Fonts in an HTML to PDF API?

Yes. Add the stylesheet link to your HTML as you would on a website. CastPDF fetches it and waits for the fonts to load before printing.

What can I use instead of Arial on a Linux server?

Liberation Sans has the same character widths as Arial, so text wraps the same way. List it right after Arial in your font-family rule.

Why do Chinese or Japanese characters show as empty boxes in my PDF?

The font in use has no glyphs for those characters. Add a CJK family such as Noto Sans CJK SC to your font stack. It is installed on the CastPDF renderer.

Can I load a font from localhost or my internal network?

No. The renderer refuses private and loopback addresses, and reports the refused URL in the response. Host the font at a public https URL or embed it as a data URL.

Why is my bold text blurry or too wide in the PDF?

Only the regular weight was loaded, so the browser imitates bold by thickening the letters. Load the bold file as its own weight, for example 700.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.