Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use CSS @font-face in the HTML produced by Handlebars, make the font bytes reachable to Chromium (or embed them as Base64), apply the matching family, and create the PDF only after the page has loaded its styles and fonts. Handlebars only substitutes data into an HTML string; it does not load fonts. Puppeteer’s page.pdf() uses the browser’s print rendering and, in current releases, waits for document.fonts.ready by default.

The complete rendering flow

A reliable pipeline has four separate stages:

  1. Compile the Handlebars template with your data.
  2. Return a complete HTML document containing the font declaration and the elements that use it.
  3. Give Chromium a URL or data URL from which it can retrieve the font bytes.
  4. Generate the PDF after the page’s styles and content are present.

A server-side file path such as /app/fonts/ReportSans.woff2 is not automatically a browser URL. Use an absolute HTTP(S) URL, a suitable local URL exposed to the page, or a data: URL containing the font bytes.

Working Handlebars and Puppeteer example

The following pattern compiles a template, sets it as the page content, makes the font available from a web address, and writes an A4 PDF. Replace the asset URL and data with values appropriate for your deployment, and confirm that your font license permits this use.

const puppeteer = require('puppeteer');
const Handlebars = require('handlebars');

const templateSource = `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @font-face {
      font-family: 'ReportSans';
      src: url('https://assets.example.com/fonts/report-sans.woff2') format('woff2');
      font-weight: 400;
      font-style: normal;
      font-display: block;
    }

    @font-face {
      font-family: 'ReportSans';
      src: url('https://assets.example.com/fonts/report-sans-bold.woff2') format('woff2');
      font-weight: 700;
      font-style: normal;
      font-display: block;
    }

    body {
      font-family: 'ReportSans', sans-serif;
      font-weight: 400;
    }

    h1, strong { font-weight: 700; }
  </style>
</head>
<body>
  <h1>{{title}}</h1>
  <p>{{body}}</p>
</body>
</html>`;

(async () => {
  const browser = await puppeteer.launch();
  try {
    const page = await browser.newPage();
    const html = Handlebars.compile(templateSource)({
      title: 'Quarterly report',
      body: 'Prepared for the finance team.'
    });

    await page.setContent(html);
    await page.pdf({
      path: 'report.pdf',
      format: 'A4',
      printBackground: true,
      waitForFonts: true
    });
  } finally {
    await browser.close();
  }
})();

The URL in this sample is illustrative. In production, it must resolve from the Chromium process that renders the page. The font-family, font-weight, and font-style values in the rule must match the values requested by your document. If a paragraph asks for weight 700 but only a 400 file is declared, the browser may synthesize a bold face or fall back to another font.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choosing how to supply the font

Method Best fit Trade-off
Remote URL in @font-face The font is already hosted and the rendering environment has network access Validate the URL, access controls, TLS, and origin policy from the Chromium runtime
Base64 data: URL A self-contained HTML document or restricted network environment Increases HTML size and must comply with the font license
page.addStyleTag() The page already exists and CSS is assembled programmatically Inject it before capture and target the intended page
Installed system font A controlled container or VM where the same font is always installed Environment-specific; a different image can silently change the result

Embedding a Base64 font

Read the font file in Node.js, convert it to Base64, and interpolate the result into the style block. This avoids a network fetch but can make every generated HTML string large.

const fs = require('node:fs');
const font64 = fs.readFileSync('./fonts/report-sans.woff2').toString('base64');
const template = `<style>
@font-face {
  font-family: 'ReportSans';
  src: url(data:font/woff2;base64,${font64}) format('woff2');
  font-weight: 400;
  font-style: normal;
}
body { font-family: 'ReportSans', sans-serif; }
</style>`;

Use the MIME type that matches the file format, and do not embed a font when redistribution is prohibited by its license.

Injecting CSS after navigation

await page.setContent(html);
await page.addStyleTag({
  content: `
    @font-face {
      font-family: 'ReportSans';
      src: url('https://assets.example.com/fonts/report-sans.woff2') format('woff2');
      font-weight: 400;
      font-style: normal;
    }
    body { font-family: 'ReportSans', sans-serif; }
  `
});
await page.pdf({ path: 'report.pdf', waitForFonts: true });

Injection is useful when a shared template is already loaded, but it does not solve an unreachable URL. The browser still has to fetch the bytes.

Waiting for fonts before creating the PDF

Current Puppeteer PDF options include waitForFonts, whose default is true; it waits for document.fonts.ready. Keeping the option explicit makes the intent clear:

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.pdf({
  path: 'report.pdf',
  format: 'A4',
  waitForFonts: true
});

For a diagnostic or a render sequence with additional asynchronous work, you can make the wait visible yourself:

await page.evaluate(() => document.fonts.ready);
await page.pdf({ path: 'report.pdf', waitForFonts: true });

The explicit wait is not normally a required workaround. If the page is running in the background, activate it with page.bringToFront() before waiting. Also wait for your own application work—such as data-driven DOM updates—before asking the browser to create the PDF.

Print CSS changes what appears in the PDF

page.pdf() renders with the print CSS media type. A font that appears correct in a screen preview can therefore be changed by an @media print rule, a print-only stylesheet, or a different font declaration later in the cascade.

@media print {
  body { font-family: 'ReportSans', sans-serif; }
  .screen-only { display: none; }
}

When diagnosing a mismatch, inspect the generated PDF path, not only the page in a normal screen-media preview. Use browser developer tools or evaluate the computed style on the element that is visibly wrong.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Debugging a font that does not appear

1. Inspect the compiled HTML

Log or save the exact string returned by Handlebars.compile(...). Confirm that the <style> element survived compilation, the family spelling is identical everywhere, and the target element actually receives that family. Handlebars performs substitution; it is not a font loader.

2. Verify reachability from Chromium

Open the font URL from the same runtime that launches Chromium. Check DNS, TLS, authentication, redirects, and response status. A path that exists on the Node.js host can still be unavailable to the browser. Switch to an absolute reachable URL or a Base64 data URL when appropriate.

3. Check the descriptors

  • Declare every weight and style you use.
  • Use the correct format string, such as format('woff2').
  • Keep a generic fallback such as sans-serif.
  • Look for a later selector that overrides font-family.

4. Check the network response

In a diagnostic run, listen for failed requests and inspect the response for the font URL. A 404, an authentication redirect, or a server that returns HTML instead of font bytes will leave the browser using a fallback. Fix the server response rather than adding more waits.

5. Check print-only rules

Remove or correct any print rule that replaces the family. Compare the computed style under print media and screen media if the two views differ.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

6. Wait after the final DOM and CSS change

Call document.fonts.ready only after Handlebars content, injected styles, and any client-side changes are complete. Calling it before adding the @font-face rule cannot wait for a font that did not yet exist.

7. Treat headers and footers separately

PDF header and footer values are separate template options. Do not assume a body font declaration automatically applies to those templates. Test them independently with the Puppeteer version deployed by your application.

Common symptoms, causes, and fixes

Symptom Likely cause Fix
Everything uses a system font The URL cannot be fetched or the family name does not match Check the compiled HTML and Chromium’s request; use an absolute URL or data URL
Regular text works but bold does not No matching 700 declaration or the file is mapped to the wrong weight Add the correct @font-face rule and apply font-weight: 700
Screen view is correct, PDF is not Print media rules change the family Inspect and correct @media print CSS
Intermittent fallback fonts Capture starts before asynchronous content or font CSS is complete Finish DOM work, await document.fonts.ready, and keep waitForFonts enabled
Body is correct but header/footer is not Those templates have separate rendering behavior Declare and verify their font styling independently
Base64 version is slow or oversized The font is embedded repeatedly in large HTML strings Use a reachable hosted asset when permitted, or cache the compiled CSS/data carefully
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance, reliability, and licensing considerations

  • Prefer WOFF2: it generally transfers less data than older web-font formats, provided your target Chromium build supports the file.
  • Cache immutable assets: a versioned font URL avoids repeated downloads while making updates explicit.
  • Limit the faces you declare: loading regular, bold, italic, and multiple language subsets increases work even when the document uses only one face.
  • Make failures visible: retain a generic fallback and log failed font requests so a missing custom face does not produce an unreadable PDF.
  • Keep the rendering image consistent: system-font fallback and Chromium versions can change line breaks, pagination, and widows/orphans.
  • Respect the license: hosting or embedding a font can have different redistribution terms from desktop use.

Or skip the browser setup

If your goal is to capture a rendered page rather than maintain a Puppeteer rendering service, ScreenshotNeo provides a website screenshot API and MCP server. A single request returns PNG, JPEG, WebP, or PDF; it can wait for a selector, delay, or network idle and supports custom CSS and JavaScript when your page needs final rendering adjustments.

For a direct capture, use the API documented at https://screenshotneo.com/docs/:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Before capture, ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the page verdict and billing status in X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

The Free plan includes 1,000 screenshots per month without a card. Paid plans start at $5 for 3,000 shots; all features are included on every plan. Create a free ScreenshotNeo account.

FAQ

Can Handlebars itself load a font?

No. It produces an HTML string. Chromium resolves the CSS and fetches or decodes the font when Puppeteer loads that string.

Is waitForFonts: true still needed if I call document.fonts.ready?

The PDF option already defaults to waiting. Keeping both can document the sequence during troubleshooting, but the explicit JavaScript wait is not inherently required twice.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Why does a local relative URL work in a browser but fail in a PDF job?

The browser you tested may have a different base URL and filesystem access. page.setContent() receives an HTML string, so make the font source resolvable from the Chromium page or embed it.

Can I use the same font for PDF headers and footers?

Headers and footers are separate PDF template options. Verify custom-font behavior for those templates in the exact Puppeteer and Chromium versions you deploy instead of assuming body CSS carries over.

Quick Recap

Bestseller No. 1
Bestseller No. 2

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.