EmbedPDF

Custom fonts

The 14 standard PDF fonts only cover Latin text. To put CJK, Cyrillic, Arabic, an emoji, or your brand typeface into a FreeText annotation — or to fill in glyphs a document’s own fonts are missing — register a font with the engine and reference it by a key you choose.

This is a local-engine feature: engine.fonts exists on the engine you create with @embedpdf/engine. On the cloud engine it’s undefined by design — fallback fonts there are a server decision (see Fallback fonts).

Registered fonts are engine-global, not per-document: register once and every document opened on that engine can use them. They live for the engine’s lifetime (until you clear() them or destroy the engine).

Register a font#

Load the font bytes however you like (fetch, a bundler asset, a file input) and pass them to register with a stable key you’ll reference later.

import { localEngine } from '@embedpdf/engine';
 
const engine = localEngine();
 
const data = new Uint8Array(await (await fetch('/fonts/NotoSansSC-Regular.otf')).arrayBuffer());
 
await engine.fonts.register({
  key: 'noto-sc', // your stable id — reference this everywhere
  familyName: 'Noto Sans SC',
  data,
});

Only key and data are required. familyName, weight, and italic refine how the font is matched as a fallback; omit them and they’re inferred from the file.

register is idempotent — registering the same key again is a cheap no-op, so it’s safe to call on every page load without re-uploading the bytes.

The handle you get back carries what the engine resolved — the identity a saved document names the face by, and the licence:

const handle = await engine.fonts.register({ key: 'noto-sc', data });
handle.familyName; // 'Noto Sans SC' — as registered, else the font's own
handle.weight; // 400 (100..900)
handle.italic; // false
handle.embeddingPermission; // 'installable' | 'editable' | 'preview-and-print'
handle.editingAuthorized; // may new text be authored with it?
handle.instanced; // a static instance cut from a variable font?

Licences come from the font’s fsType and are enforced: a font whose licence forbids embedding (restricted, or bitmap-only) is refused at registration. A preview-and-print font renders text a document already contains and may be embedded, but does not author new text — a FreeText naming it fails and missing-glyph fallback skips it — until your application asserts that it holds a licence permitting editing:

await engine.fonts.authorizeEditing('brand-serif'); // handle.editingAuthorized → true

A variable font is pinned to one instance at registration (wght to the weight you gave, or its default), so what’s embedded is a plain static program; the handle says so with instanced: true. Fonts whose licence forbids subsetting are embedded whole.

Use it on a FreeText annotation#

A FreeText annotation’s fontFamily accepts either a standard font name or a font key you registered. Just pass the key:

const page = doc.page(pageObjectNumber);
 
await page.annotations.create({
  subtype: 'free-text',
  intent: 'free-text',
  rect: { left: 60, bottom: 600, right: 360, top: 660 },
  fontFamily: 'noto-sc', // ← your registered key
  fontSize: 18,
  textAlign: 'left',
  contents: '这是一个测试',
  color: { r: 0, g: 0, b: 0 },
});

When you download the document, the engine embeds only the glyph subset the annotation actually used — so a multi-megabyte CJK font adds just a few kilobytes per annotation, and the text renders anywhere.

Inside a rich text document a run names the face by family rather than by key — { text: '世界', style: { family: 'Noto Sans SC' } } — the way the PDF will; the engine resolves a registered family (or its key) either way. Reading back, the annotation’s fontFamily is your key whenever the font is registered on the engine doing the reading, so a saved document round-trips to the same picker entry.

Show the font in the browser too#

The engine embeds the font in the PDF; the DOM knows nothing about it. The live text editor and any vector rendering of the annotation set font-family to the key, so mount the same bytes as a @font-face under that name and the two agree pixel for pixel:

import { mountWebFont } from '@embedpdf/web';
 
await engine.fonts.register({ key: 'noto-sc', familyName: 'Noto Sans SC', data });
const unmount = await mountWebFont('noto-sc', data); // @font-face { font-family: 'noto-sc' }

mountWebFont is refcounted per document and idempotent per key; call the returned function to release the face. The full viewer does both steps for you from its annotations.fonts option — see Getting started.

Document font settings#

Two per-document switches shape the appearances the engine generates from then on. They are session state of the handle, never written into the file, and exist on the local engine only (doc.fonts is undefined elsewhere):

// How much of a registered font's program an appearance carries. 'default'
// subsets annotation text and embeds form-field text whole; 'subset' and
// 'full' apply to both. Programs already in the document are never re-embedded.
await doc.fonts?.setEmbeddingPolicy('full');
 
// Latin kerning and ligatures when shaping rich text. Off by default: Acrobat
// draws plain advance widths and no ligatures, and matching Acrobat wins.
await doc.fonts?.setTypographicFeatures(true);

The 14 standard font names (helvetica, courier, times-roman, …) are reserved. Don’t register a custom font under one of those keys, or fontFamily will resolve it as the standard font. Referencing a key you never registered throws — there’s no silent fall back to Helvetica.

Automatic fallback for missing glyphs#

Registering a font makes it available to name explicitly. If you also want it to fill in glyphs automatically — when a document’s own fonts (or a FreeText’s chosen font) don’t cover some characters — add it to the fallback chain:

await engine.fonts.register({ key: 'noto-sc', familyName: 'Noto Sans SC', data });
await engine.fonts.addFallback('noto-sc'); // also fills missing glyphs
 
// "Hello " draws in Helvetica; "世界" is filled from Noto automatically.
await page.annotations.create({
  subtype: 'free-text',
  intent: 'free-text',
  rect: { left: 60, bottom: 540, right: 360, top: 600 },
  fontFamily: 'helvetica',
  fontSize: 18,
  textAlign: 'left',
  contents: 'Hello 世界',
  color: { r: 0, g: 0, b: 0 },
});

register and addFallback are deliberately separate: registering exposes a font for explicit use, while addFallback also enrolls it for automatic substitution during page rendering and appearance generation. The chain is ordered — call addFallback for each font in the priority you want them tried.

Managing registered fonts#

engine.fonts.list(); // FontHandle[] — what's registered, in order
engine.fonts.clearFallbacks(); // drop the fallback chain; fonts stay registered
engine.fonts.clear(); // unregister every font and reset the chain

Each registered font is held in memory for the engine’s lifetime. A CJK face is several megabytes — register the few you actually need rather than a whole library, and reach for clear() if you swap font sets at runtime.

Cloud parity#

Code that targets both engines should feature-detect, since the cloud engine omits the service:

if (engine.fonts) {
  await engine.fonts.register({ key: 'noto-sc', familyName: 'Noto Sans SC', data });
}

On the cloud, the server already ships its own fallback fonts and applies them to every render and save — your client doesn’t need to (and can’t) configure them. See Fallback fonts.

Was this page helpful?

Your feedback goes directly to the documentation team.