HTML to PDF with custom fonts

Your template uses a brand font or a Google Font, it looks right in the browser, and the PDF comes out in Times New Roman (on Linux: Liberation Serif). Chromium printed before the font arrived, or it could never reach the font file. We measured each cause with Chromium 156 and checked which fonts were embedded with pdffonts.

What actually breaks

SetupFont in the PDF
Google Fonts <link>, setContent(html, {waitUntil: 'load'})Correct
Same, with waitUntil: 'domcontentloaded' or 'commit'
printed before the stylesheet and font loaded
Fallback serif
@font-face with a relative url(fonts/Brand.woff2) in setContent
the page is about:blank, so there is no base URL
Fallback serif
@font-face with url(file:///abs/path/Brand.woff2) in setContent
about:blank may not load file:// resources
Fallback serif
The same relative URL, but the HTML opened with page.goto('file:///…/page.html')Correct
Font inlined as a base64 data: URLCorrect

None of the failures print a warning: the PDF renders fine, just in the wrong font. Always check with pdffonts invoice.pdf (poppler-utils), which lists every embedded font.

Fix 1: inline the font, then wait for it

A base64 data: URL makes the HTML self-contained: no file paths, no base URL, no network. Then wait for document.fonts.ready before printing. With Playwright (Puppeteer is the same apart from the import):

import { chromium } from 'playwright';
import { readFileSync, writeFileSync } from 'node:fs';

// Inline the font so the page needs no file or network access.
const font = readFileSync('Brand.woff2').toString('base64');
const html = `<html><head><style>
  @font-face { font-family: "Brand"; src: url(data:font/woff2;base64,${font}) format("woff2"); }
  h1 { font-family: "Brand", serif; font-size: 40px; }
</style></head><body><h1>Invoice #1042</h1><p>Thanks for your order.</p></body></html>`;

const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready); // wait for every web font
writeFileSync('invoice.pdf', await page.pdf({ format: 'A4', printBackground: true }));
await browser.close();
console.log('invoice.pdf written');

Base64 adds about a third to the font size (a 34 KB woff2 becomes 45 KB of HTML), so subset large fonts or use woff2 rather than ttf. If you would rather keep fonts as files, write the HTML to disk and open it with page.goto('file://…'), so relative URLs resolve.

Fix 2: Google Fonts

A normal <link href="https://fonts.googleapis.com/css2?family=…" rel="stylesheet"> works as long as you wait for the load event (Playwright and Puppeteer's default) and the machine can reach fonts.gstatic.com. In locked-down containers with no outbound network, inline the font instead (fix 1). @import url(…) inside a <style> tag also worked in our tests.

With the Shotmint API

The renderer can't see your disk, so file paths and relative URLs can't work. Use base64 for your own fonts and a <link> for Google Fonts. Since 20 October 2026, the API waits up to 5 seconds for stylesheets and web fonts before printing, even with wait_until=domcontentloaded (before that fix, our own API printed the fallback font in that case too). Node 18+ (save as invoice.mjs, set SHOTMINT_KEY, put your font next to it as Brand.woff2):

import { readFileSync, writeFileSync } from 'node:fs';

const font = readFileSync('Brand.woff2').toString('base64');
const html = `<html><head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
  @font-face { font-family: "Brand"; src: url(data:font/woff2;base64,${font}) format("woff2"); }
  h1 { font-family: "Brand", serif; font-size: 40px; }
  body { font-family: "Inter", sans-serif; }
</style></head><body><h1>Invoice #1042</h1><p>Thanks for your order.</p></body></html>`;

const res = await fetch('https://shotmint.creasework.com/v1/pdf', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ html, format: 'A4' }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
writeFileSync('invoice.pdf', Buffer.from(await res.arrayBuffer()));
console.log('invoice.pdf written, credits left:', res.headers.get('x-credits-remaining'));

Python with requests:

import base64, os, requests

font = base64.b64encode(open("Brand.woff2", "rb").read()).decode()
html = f"""<html><head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
  @font-face {{ font-family: "Brand"; src: url(data:font/woff2;base64,{font}) format("woff2"); }}
  h1 {{ font-family: "Brand", serif; font-size: 40px; }}
  body {{ font-family: "Inter", sans-serif; }}
</style></head><body><h1>Invoice #1042</h1><p>Thanks for your order.</p></body></html>"""

res = requests.post(
    "https://shotmint.creasework.com/v1/pdf",
    headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
    json={"html": html, "format": "A4"},
)
res.raise_for_status()
open("invoice.pdf", "wb").write(res.content)
print("invoice.pdf written, credits left:", res.headers.get("x-credits-remaining"))

Both scripts were run against the live API: a 1-page A4 PDF with the brand font and Inter embedded (checked with pdffonts), 1 credit each.

Emoji and non-Latin text

Characters your font lacks fall back to fonts installed on the rendering machine. On our renderers, a heading with emoji, Japanese, Chinese, Korean and Arabic printed with Noto Color Emoji, WenQuanYi Zen Hei and DejaVu Sans. On your own server, run pdffonts on a test PDF: if a script is missing there, install a font that covers it (on Debian/Ubuntu, fonts-noto-color-emoji and fonts-noto-cjk), or inline a web font that covers it.

Font checklist

Related: HTML to PDF with Tailwind, page numbers and footers, HTML to PDF invoices, HTML to PDF in Node.js, free HTML to PDF converter.

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons