Blog/Guides

HTML to PDF custom fonts

Guides··5 min read

html is a UTF-8 JSON string, not Base64 of the document. The font file may still be a data URI inside CSS. Provide exactly one of html, url, markdown, or templateId. filename, if set, must end in .pdf. A published templateId can hold the same @font-face CSS; this post uses inline html.

Webfonts

A webfont is a @font-face whose src is https. Google Fonts, a CDN, or your own static host all work if Chromium can fetch them. Link a stylesheet, or write the @font-face block yourself. The host must be public. Private, loopback, and metadata hosts are rejected on the url source (url_not_allowed). The same reachability rule applies in practice to font files: if Chromium cannot get the file, you get the fallback face in the PDF.

url mode on POST /v1/pdf fetches a public page and prints it. cookies (name, value, optional domain / path / url) and extraHTTPHeaders apply to that page fetch. They are not a font-upload API and they do not open RFC1918 hosts. Prefer html when the document is private: ship the markup and the @font-face rules together.

Default waitUntil is networkidle0 (also load, domcontentloaded, networkidle2). That is a Chromium lifecycle, not a font API. It often covers a webfont request, and it does not guarantee it. There is no document.fonts.ready option. Do not invent waitForFunction. Use the documented waits only: waitForSelector as a CSS selector or { selector, timeout, visible }; waitForTimeout as extra milliseconds after the other waits, max 30000; timeout as the Chromium render budget, max 60000. Jobs that fail to render return render_failed and are not billed.

font-display on the @font-face rule is CSS, not a RelayPDF field. block keeps glyphs invisible until the face is ready, which is safer for a print snapshot than swap. That is still a race if the file never arrives. For invoices and letterheads, prefer the base64 path below.

Base64 font-face

Embed the font in CSS so print has no extra GET. Encode the woff2 (or ttf/otf) as base64 and put it in src: url(data:font/woff2;base64,…). Point font-family at that name on body or the branded selectors. Chromium embeds the used face in the PDF. The HTML payload is larger. There is nothing to wait for besides parse and layout.

Subset the file to the weights and glyphs you print. A full CJK woff2 in every request is a bad idea; a Latin invoice subset is not. Do not send a zip of fonts or a multipart upload. The only source is the JSON body. printBackground defaults to true, so CSS backgrounds that sit under branded type still print. format defaults to letter.

Request

Node @relaypdf/sdk. Field names in options stay camelCase (printBackground, waitUntil), same as JSON and Python.

import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({ apiKey: process.env.RELAYPDF_API_KEY });
const html = `<!doctype html><html><head><style>
@font-face { font-family: BrandSans;
  src: url(data:font/woff2;base64,BASE64_WOFF2) format('woff2');
font-weight: 400; font-style: normal; font-display: block;
}
body { font-family: BrandSans, sans-serif; }
</style></head><body>
<h1>Invoice #1042</h1><p>Total: $1,200.00</p>
</body></html>`;
const pdf = await client.pdf.fromHtml(html, {
  filename: "invoice-1042.pdf",
  options: {
    format: "letter",
    printBackground: true,
    waitUntil: "networkidle0",
    waitForTimeout: 250,
    timeout: 30000,
  },
});
await pdf.save("invoice-1042.pdf");

Same job as curl. html remains a JSON string.

curl -X POST https://api.relaypdf.com/v1/pdf
  -H "Authorization: Bearer pdf_live_..."
  -H "Content-Type: application/json"
  -d '{
    "html": "<style>@font-face{font-family:BrandSans;src:url(https://cdn.example.com/brand.woff2) format(\"woff2\");font-display:block}body{font-family:BrandSans,sans-serif}</style><h1>Invoice #1042</h1>",
    "filename": "invoice-1042.pdf",
    "options": {
      "format": "letter",
      "printBackground": true,
      "waitUntil": "networkidle0",
      "waitForTimeout": 250,
      "timeout": 30000
    }
  }'
  --output invoice-1042.pdf

CJK and RTL

This is not a second product. Chromium prints Unicode if the loaded face has glyphs. Latin webfonts often omit CJK. If the invoice mixes English and Japanese, put a CJK-capable family in the font-family stack after the brand face, or embed a subset that covers the code points you emit. Missing glyphs become tofu or a fallback you did not brand.

For Arabic, Hebrew, and other RTL copy, set dir="rtl" on html or the block, and keep the font that has those glyphs. CSS direction and unicode-bidi are ordinary HTML. There is no rtl option on POST /v1/pdf. headerTemplate and footerTemplate are Chromium margin HTML (pageNumber, totalPages, date, title). If those chrome fragments need the same face, include the @font-face CSS there too; they do not inherit the page document.

What is not an API

AskDocumented answer
Upload a .ttfNone. Put @font-face in html or the template
google_fonts_urlNot a field. Link the stylesheet in HTML
document.fonts.readyNot an option. Use waitUntil / waitForTimeout
waitForFunctionNot documented
Private font host on urlurl_not_allowed for private/loopback/metadata
Query-string API keyRejected. Bearer pdf_live_… only

Print options that still apply

format: letter, A4, legal, tabloid, or a named Chromium format. landscape boolean. preferCSSPageSize honors @page size only. scale 0.1–2. margin as CSS lengths. pageRanges such as 1-3,5. width / height for a custom page. response is binary (default), url (JSON with a public GET /v1/files/:id for 24 hours), or async (202, poll GET /v1/jobs/:id). callbackUrl must be https. Debit is $0.015 on a successful HTML/URL/Markdown/template PDF. Failed jobs, 429, and 402 are not billed.

Errors

Failures return { error: { code, message } }. invalid_request for mixed sources, a filename that is not .pdf, or an options value outside range. url_not_allowed for a private url. render_failed when the render budget expires. payment_required on an empty wallet. rate_limited with Retry-After. A silent fallback font is not an error: the job can succeed and still look wrong. Check the PDF, not only the status.

Ship it

Ship @font-face in the HTML. Use a public webfont plus waitUntil networkidle0 (and waitForTimeout if the file is slow), or embed a base64 face and drop the extra GET. Cover CJK and RTL with fonts that have the glyphs and with dir on the markup. Do not wait for a font-upload endpoint. CTA: https://relaypdf.com/html-to-pdf. Docs: https://relaypdf.com/docs/pdf and https://relaypdf.com/docs/options. Sibling wait post: https://relaypdf.com/blog/html-to-pdf-wait-for-javascript.

Ready to generate?

One API for HTML, Markdown, URLs, and Office. REST, SDK, CLI, or MCP.