ScreenshotNeo

BlogHTML to image & PDF

Web Fonts in Generated PDFs

Learn why custom fonts fall back in generated PDFs, how Puppeteer waits for fonts, and how to check embedding and licensing before sharing.

By the ScreenshotNeo team1 October 20269 min read

Short answer: A browser can place a web font in a generated PDF when the font resource loads, the declared family, weight and style match the CSS, and the renderer supports that face in print output. Puppeteer’s page.pdf() uses print CSS and, by default, waits for fonts to load. A fallback font appears when the resource fails, the CSS descriptors do not match, the browser cannot print that face, or the document is captured before font loading finishes. A successful render also does not prove that your font license permits PDF distribution.

What happens to a web font during PDF generation?

CSS @font-face declares a family and one or more font sources. The source may be remote or local; MDN recommends WOFF2 as an efficient, broadly supported web-delivery format. The browser downloads the face, matches it to the requested font-family, font-weight and font-style, lays out the page, and the PDF engine records font data when its print pipeline supports that operation.

When any part of that chain fails, the browser can use the next family in the CSS stack. The PDF may look correct on one machine and use a different face on another if the font is not available or printable in that environment. Adobe documents this fallback behavior for unsupported web-font printing; the exact result depends on the browser, operating system and PDF engine.

Minimal CSS that defines a printable web font

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @font-face {
      font-family: "Report Sans";
      src: url("/fonts/report-sans-regular.woff2") format("woff2");
      font-weight: 400;
      font-style: normal;
      font-display: block;
    }

    @font-face {
      font-family: "Report Sans";
      src: url("/fonts/report-sans-bold.woff2") format("woff2");
      font-weight: 700;
      font-style: normal;
      font-display: block;
    }

    body {
      font-family: "Report Sans", Arial, sans-serif;
    }
  </style>
</head>
<body>
  <h1>Quarterly report</h1>
  <p>This paragraph should use the licensed web font.</p>
</body>
</html>

Use separate @font-face rules for each weight and style you actually use. A request for font-weight: 600 does not reliably select a file declared only as 400. Keep the family name identical, and verify the URL in the browser’s network log.

Generate a PDF with Puppeteer

Puppeteer’s PDF guide says that page.pdf() waits for fonts by default. The API exposes the waitForFonts option, which waits for document.fonts.ready. Keep that option enabled unless your own lifecycle explicitly performs the same wait. Puppeteer generates print-media output by default, so include any required @media print rules.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto("http://127.0.0.1:3000/report.html", {
  waitUntil: "networkidle0"
});

// Explicit readiness check is useful when your application changes the
// default lifecycle or when you want a clear failure point.
await page.evaluate(async () => {
  await document.fonts.ready;
  if (document.fonts.status !== "loaded") {
    throw new Error(`Font loading status: ${document.fonts.status}`);
  }
});

await page.pdf({
  path: "report.pdf",
  format: "A4",
  printBackground: true,
  preferCSSPageSize: true,
  waitForFonts: true
});

await browser.close();

Run the page from an HTTP server so relative font URLs resolve consistently. A simple local server is enough:

python3 -m http.server 3000 --directory public
node generate-pdf.js

See the Puppeteer PDF generation guide and the Page.pdf() API reference for version-specific options.

Puppeteer PDF options that affect font output

Option Why it matters
waitForFonts Waits for document.fonts.ready. Keep it true unless you perform an equivalent wait.
printBackground Prints background colors and images. It does not embed fonts, but missing backgrounds can make a fallback face more noticeable.
preferCSSPageSize Uses @page dimensions when true, reducing layout changes between screen and PDF.
format, landscape, margin Change available line width and therefore wrapping, pagination and apparent font metrics.
pageRanges Limits output to selected pages after layout and font shaping occur.
scale Scales the rendered page; it does not repair a missing or substituted font.
timeout Controls PDF-generation timeout in supported Puppeteer versions. Slow font downloads should be addressed at the network or hosting layer too.

Check the API reference for the Puppeteer version pinned by your project. Do not assume an option added in one release exists in another.

How to wait for fonts before Puppeteer generates a PDF

  1. Navigate with a lifecycle that waits for your page’s other required resources.
  2. Await document.fonts.ready.
  3. Check document.fonts.check() for the exact family, weight and style you use.
  4. Call page.pdf() with waitForFonts: true.
await page.evaluate(async () => {
  await document.fonts.ready;
  const checks = [
    ["400 16px Report Sans", "Report Sans"],
    ["700 32px Report Sans", "Report Sans"]
  ];
  for (const [descriptor, family] of checks) {
    if (!document.fonts.check(descriptor, "The quick brown fox 0123456789")) {
      throw new Error(`Font is not usable: ${descriptor} (${family})`);
    }
  }
});

document.fonts.check() tells you whether the browser considers a face usable; it is not a legal or cross-viewer embedding audit.

Why is my custom font not showing in my generated PDF?

1. The font request failed

Open DevTools or capture request failures in Puppeteer. A 404, blocked cross-origin request, expired signed URL, certificate error or authentication failure leaves the browser with the fallback stack.

page.on("requestfailed", request => {
  const url = request.url();
  if (/\.woff2?(\?|$)|\.otf(\?|$)|\.ttf(\?|$)/i.test(url)) {
    console.error("Font request failed", url, request.failure());
  }
});

2. The family or face descriptor does not match

CSS asking for font-style: italic cannot use a face declared only as normal without a substitution decision. Declare every required weight and style, and use the exact family string.

3. Capture happens before the font is ready

Keep waitForFonts enabled and explicitly await document.fonts.ready when your application has client-side routing, delayed stylesheets or custom capture code.

4. Print CSS changes the result

page.pdf() emulates print media. A print stylesheet may override font-family, hide a component, change its width or apply a different weight. Inspect the page with print media emulation before generating the PDF.

await page.emulateMediaType("print");
console.log(await page.$eval("body", el => getComputedStyle(el).fontFamily));

5. The renderer cannot print that web font

Some browser and operating-system combinations fall back even after the resource loads. Test the actual Chromium build and target viewers. Puppeteer documentation does not guarantee identical behavior for every PDF engine or viewer.

6. The font file is malformed or unsupported

Try a valid WOFF2 file and confirm its declared tables and Unicode coverage with the font vendor’s tools. Missing glyphs can look like a family fallback even when the family itself loaded.

7. A local file policy blocks the font

Opening HTML as file:// can produce origin and relative-path problems. Serve the document over HTTP during generation and use absolute, reachable font URLs.

How do I embed a web font in a PDF?

There is no universal JavaScript switch that forces embedding. Make the font available to the browser, wait for it, and use a renderer that supports writing that face into PDF output. Then inspect the resulting PDF in the viewers and workflows where it will be distributed.

  1. Choose a font file and confirm it covers the characters and weights in the document.
  2. Declare it with @font-face and a stable URL.
  3. Verify the network response and computed style.
  4. Wait for document.fonts.ready.
  5. Generate with print CSS settings that match the intended page.
  6. Check the PDF’s font properties in your target PDF tools and viewers.

Font metadata can report embedding restrictions, but metadata alone is not license clearance. Adobe’s developer guidance says its embedding guidelines do not guarantee compliance with vendor agreements, and the PDF specification describes restrictions that can limit embedded fonts to viewing and printing.

Can I distribute a PDF with a web font?

That depends on the font’s actual license and the distribution you plan. Separate these questions:

  • Technical access: can the renderer fetch and use the file?
  • Embedding metadata: does the font program permit the type of embedding the PDF performs?
  • License rights: does the vendor permit distributing, printing, editing or publishing the resulting PDF?

Adobe Fonts says printing a page that uses its web fonts is allowed for personal use and directs PDF/EPS publishers to licensing terms. That is Adobe’s policy guidance, not a rule for every foundry, subscription or renderer. Read the license for the specific font and intended audience; obtain a separate license when required.

A practical pre-distribution checklist

  • Confirm every font URL returns the intended file without authentication surprises.
  • Check family, weight, style and Unicode coverage for the actual document.
  • Await document.fonts.ready and keep Puppeteer’s waitForFonts enabled.
  • Review print CSS, page size, margins, line wrapping and page breaks.
  • Open the PDF in the viewers used by recipients.
  • Inspect font properties and note any substituted or missing faces.
  • Review the font license for embedding, redistribution, editing and commercial use.
  • Keep the font license record with the build or release artifact.

Performance, reliability and cost considerations

Font downloads add network latency to the first PDF capture. Host only the faces and subsets you need, prefer WOFF2 for web delivery, and serve them with long-lived caching when the files are immutable. Waiting for readiness makes output deterministic but cannot fix an unreachable origin; set a sensible navigation and PDF timeout and fail clearly when a required face is absent.

Changing page width, scale or font weight can alter line breaks and page count. Treat those settings as part of the document’s reproducible build. Cache generated PDFs only when the source content, font files and print settings are unchanged.

Or skip the browser setup

ScreenshotNeo can generate a PDF from one API request, with options for paper size, margins, landscape mode and page ranges. Its capture flow accepts cookie and consent banners before removing more than 60 known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, blank pages, failed loads, timeouts and cache hits are not billed, and the response identifies the page verdict and billing status in headers. An MCP server provides capture_pdf, take_screenshot and get_page_info tools for Claude, Cursor and other MCP clients.

Use the ScreenshotNeo documentation for the current parameters and authentication details.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -d format=pdf \
  -o report.pdf
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={
        "access_key": "YOUR_API_KEY",
        "url": "https://stripe.com",
        "format": "pdf",
    },
    timeout=90,
)
r.raise_for_status()
open("report.pdf", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: "YOUR_API_KEY",
  url: "https://stripe.com",
  format: "pdf"
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await import("node:fs/promises").then(fs => fs.writeFile("report.pdf", data));

ScreenshotNeo includes 1,000 shots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Troubleshooting quick reference

Symptom Likely cause Fix
PDF uses Arial or Times Font request failed or face mismatch Inspect the request, URL, family, weight and style; await fonts.
Browser looks correct, PDF differs Print media CSS or unsupported print face Emulate print, inspect computed styles and test the target Chromium build.
Only bold text falls back No matching 700 face Add a 700 @font-face rule or use a weight you actually provide.
Some symbols are boxes Missing glyph coverage Use a font covering those code points or a deliberate fallback stack.
Intermittent output Delayed stylesheet/font or unstable network Serve assets reliably, await document.fonts.ready, and record failed requests.
Recipients cannot edit or print as expected Embedding restriction or license limitation Inspect PDF font properties and review the vendor license before distribution.

FAQ

Does Puppeteer always embed WOFF2 files?

No. WOFF2 is a good web-delivery format, but embedding depends on the browser’s PDF pipeline, font support and permissions.

Is document.fonts.ready enough?

It confirms the document’s font-loading set has settled. Also verify the exact family, weight, style and glyph coverage used by your document.

Should I convert every font to a system font?

No. A system font can simplify deployment, but it changes typography and still requires checking the target environment and license.

Can a PDF look right while using a fallback font?

Yes. Similar metrics can hide substitution. Inspect the PDF’s font properties instead of relying only on visual appearance.

Does a web-font subscription automatically cover shared PDFs?

No universal answer exists. Check the specific vendor terms for embedding and the planned distribution.

Sources