PDF.js Source Walkthrough: font_loader
October 27, 2026 · StreamPDF Team

Preface
The previous core/fonts.js article ended at the worker boundary: the PDF font dictionary, embedded font program, encodings, and metrics had been normalized into a Font, then exported through exportData(). obj_bin_transform_display.js reinterprets the cross-thread bytes as FontInfo, CssFontInfo, SystemFontInfo, and FontPathInfo.
Font bytes reaching the main thread does not mean Canvas can already use them. The browser still needs font registration, load completion, missing-font handling, and, for some painting modes, standalone glyph outlines. src/display/font_loader.js (599 lines) is the adaptation layer between "the data has been translated" and "the browser is ready."
In one sentence: FontLoader manages the relationship between fonts and the DOM, while FontFaceObject manages the relationship between worker font data and Canvas rendering. The former deals with FontFace, document.fonts, stylesheets, and legacy-browser detection; the latter deals with metrics, state, Path2D, and resource release.
Version note: This article follows the current repository source exactly. The file exports only
FontLoaderandFontFaceObject; the modern path uses the Font Loading API, while the legacy path still contains@font-face, a test font, and polling. Do not readattachedas "loaded successfully," and do not interpret the 30 test-font polls as automatic retries of a failed document font.
Module Overview
The file has two cooperating tracks:
| Area | Key members | Responsibility |
|---|---|---|
| Loader state | #systemFonts, nativeFontFaces, #styleSheet | Track system fonts, native FontFace objects, and the rule container |
| DOM registration | addNativeFontFace, removeNativeFontFace, insertRule | Operate on document.fonts and CSS stylesheets |
| Resource cleanup | clear | Remove fonts, reset deduplication state, and detach stylesheets |
| System fonts | loadSystemFont | Load the system-font substitute selected by the worker |
| Unified entry point | bind | Choose a path from attached, missing-file state, and supported capabilities |
| Capability checks | isFontLoadingAPISupported, isSyncFontLoadingSupported | Distinguish modern, synchronous, and legacy asynchronous loading |
| Legacy async detection | _queueLoadingCallback, _loadTestFont, _prepareFontLoadEvent | Queue completion callbacks and infer load completion through a test font |
| Font object | FontFaceObject constructor and the two create* methods | Wrap FontInfo and create a FontFace or @font-face |
| Built-in glyph rendering | getPathGenerator | Compile and cache FontPathInfo as Path2D |
| Rendering data interface | Proxy getters, the disableFontFace setter, and clearData | Expose state and metrics to Canvas and release large objects |
Connected to the previous two articles, the full pipeline is:
// Worker: Font.exportData()
// → TranslatedFont.send(): commonobj [loadedName, "Font", fontData]
// Main thread: new FontInfo(exportedData)
// → new FontFaceObject(fontData, ...)
// → FontLoader.bind(font)
// → FontFace + document.fonts (modern path)
// → @font-face + test-font polling (legacy path)
// → commonObjs.resolve(loadedName, font)
// CanvasGraphics: commonObjs.get(fontRefName)
// → ctx.font / fillText / strokeText
// → font.getPathGenerator(...) → Path2D (built-in outlines)Two groups of names must not be confused. loadedName is the loading name PDF.js generates for a document font; it also serves as the commonObjs object id and, usually, the CSS font name. name comes from the PDF font itself. cssFontInfo.fontFamily may also originate in the PDF document, so it must be serialized before entering CSS rather than treated like the internally generated loadedName.
From Worker Export to Main-Thread Objects
TranslatedFont.send: Send the Font Body Once
On the worker side, TranslatedFont uses #sent to ensure that the same font body is sent only once:
send(handler) {
if (this.#sent) {
return;
}
this.#sent = true;
const fontData = this.font.exportData(),
transfers = fontData.buffer ? [fontData.buffer] : null;
handler.send("commonobj", [this.loadedName, "Font", fontData], transfers);
}fontData.buffer is placed in the transfer list to avoid copying the entire font. The message's loadedName is the main-thread object id, "Font" selects the commonobj route, and the third item contains the binary buffer, the Type3 charProcOperatorList, and optional extra data.
This connects directly to exportData() in core/fonts.js: the worker has already repaired or wrapped the font and calculated encodings and metrics. The main thread no longer interprets TrueType or CFF tables; it consumes a stable exported result.
FontInfo: Read on Demand Instead of Expanding a Large Object
The FontInfo constructor stores an ArrayBuffer and a DataView and expands optional extra fields onto the instance, while the core exported fields remain lazy. Boolean fields occupy two bits each, while numbers, arrays, and strings have fixed layouts. cssFontInfo and systemFontInfo are reconstructed from their sections only when their getters are accessed. The most important members for font loading are data and clearData():
get data() {
const { offset, length } = this.#getDataOffsets();
return length === 0
? undefined
: new Uint8Array(this.#buffer, offset + 4, length);
}
clearData() {
const { offset, length } = this.#getDataOffsets();
if (length === 0) {
return;
}
this.#view.setUint32(offset, 0);
this.#buffer = new Uint8Array(this.#buffer, 0, offset + 4).slice().buffer;
this.#view = new DataView(this.#buffer);
}#getDataOffsets() skips the string, system-font-information, and CSS-font-information sections in order, then locates the font-program length. data is only a Uint8Array view over the original buffer; a zero length returns undefined, so "no embeddable font" and "data already cleared" look the same through this getter.
clearData() first writes zero to the data length, then copies and retains only the prefix before the data block. Identity, metrics, CSS information, and system-font information remain readable, while a font program potentially several megabytes in size no longer stays resident. Repeated calls are safe because the second call sees a zero length.
systemFontInfo exposes css, loadedName, baseFontName, src, and style; cssFontInfo exposes fontFamily, fontWeight, and italicAngle. The former describes the system-font substitute and how Canvas should use it in CSS, while the latter supplies the family and glyph style needed to construct native font rules.
The commonobj Font Branch: Publish Only After Loading Settles
api.js creates one commonObjs = new PDFObjects() and one fontLoader = new FontLoader(...) on WorkerTransport. When a font message arrives, the essential code is:
case "Font":
if ("error" in exportedData) {
const exportedError = exportedData.error;
warn(`Error during font loading: ${exportedError}`);
this.commonObjs.resolve(id, exportedError);
break;
}
const fontData = new FontInfo(exportedData);
const inspectFont =
this._params.pdfBug && globalThis.FontInspector?.enabled
? (font, url) => globalThis.FontInspector.fontAdded(font, url)
: null;
const font = new FontFaceObject(
fontData,
inspectFont,
exportedData.charProcOperatorList,
exportedData.extra
);
this.fontLoader
.bind(font)
.catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
.finally(() => {
if (!font.fontExtraProperties) {
font.clearData();
}
this.commonObjs.resolve(id, font);
});
break;If the worker exported an ErrorFont, there is no FontFaceObject; the error object is resolved directly to waiters. A normal font is wrapped first and then passed to bind(font).
The order matters:
bind()attempts attachment and waits for the selected loading flow.- If
bind()rejects,.catch()sendsFontFallbackto the worker. The worker also sets its font'sdisableFontFace = trueand attempts to resend every previously parsedFontPath. - On either success or failure,
.finally()callsfont.clearData()unlessfontExtraPropertiesis enabled. - Only then does
commonObjs.resolve(id, font)allow Canvas operations depending on the font to continue.
Thus resolve does not mean "the browser font is definitely usable." It means "the loading flow has settled, and rendering can continue according to the final state on the font object." If native loading failed, the main-thread object has already switched to disableFontFace in bind's catch block. System-font loading failure follows a separate path that does not reject, as explained below.
PDFObjects.get(id) throws if the object has not resolved, whereas get(id, callback) with a callback waits for its promise. commonObjs.resolve is both a data-publication point and a rendering-dependency synchronization point, so the order of clearData and resolve is not an incidental memory optimization.
FontLoader: Managing the DOM Font Lifecycle
Constructor: Document, Registries, and the Legacy Queue
class FontLoader {
#systemFonts = new Set();
#styleSheet = null;
constructor({ ownerDocument = globalThis.document, styleElement = null }) {
this._document = ownerDocument;
this.nativeFontFaces = new Set();
this.styleElement =
typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING') ? styleElement : null;
if (typeof PDFJSDev === 'undefined' || !PDFJSDev.test('MOZCENTRAL')) {
this.loadingRequests = [];
this.loadTestFontId = 0;
}
}
}ownerDocument defaults to the current document but can refer to a print iframe's document; every later font and style operation must target it. styleElement is a test injection point and is forced to null in non-testing builds. nativeFontFaces tracks native objects registered through this loader, while #systemFonts tracks only system font names that loaded successfully.
loadingRequests and loadTestFontId belong only to the non-MOZCENTRAL legacy asynchronous path: the former stores the completion-request queue, and the latter helps generate unique test-font ids. The modern Font Loading API path uses neither.
addNativeFontFace and removeNativeFontFace
addNativeFontFace(nativeFontFace) {
this.nativeFontFaces.add(nativeFontFace);
this._document.fonts.add(nativeFontFace);
}
removeNativeFontFace(nativeFontFace) {
this.nativeFontFaces.delete(nativeFontFace);
this._document.fonts.delete(nativeFontFace);
}The two sets must stay synchronized: PDF.js's own Set supports later bulk cleanup, while the browser's FontFaceSet determines whether Canvas can resolve the family. Calling new FontFace(...) alone does not register it; the face must enter the target document's document.fonts.
When system-font loading fails, removeNativeFontFace removes the newly added failed object from both sets. The ordinary embedded-font failure path does not immediately remove the face here; it switches to built-in outline rendering through disableFontFace, and clear() can still remove the registered object later.
insertRule and #getStyleSheet: Prefer Constructable Stylesheets
The legacy path must insert @font-face. Its rule container is selected according to capabilities and test configuration rather than hard-coded to a <style> element:
insertRule(rule) {
const styleSheet = this.#getStyleSheet();
styleSheet.insertRule(rule, styleSheet.cssRules.length);
}
#getStyleSheet() {
if (this.#styleSheet) {
return this.#styleSheet;
}
const StyleSheet =
this._document.defaultView?.CSSStyleSheet || globalThis.CSSStyleSheet;
if (!this.styleElement && StyleSheet) {
const { adoptedStyleSheets } = this._document;
if (adoptedStyleSheets) {
const styleSheet = new StyleSheet();
adoptedStyleSheets.push(styleSheet);
return (this.#styleSheet = styleSheet);
}
}
if (!this.styleElement) {
this.styleElement = this._document.createElement("style");
this._document.documentElement
.getElementsByTagName("head")[0]
.append(this.styleElement);
}
return (this.#styleSheet = this.styleElement.sheet);
}A constructable stylesheet is preferred because it is not blocked by CSP inline-style checks. The CSSStyleSheet constructor comes preferentially from this._document.defaultView, which matters across windows: a print iframe's document can adopt only a stylesheet created in that same window, not an arbitrary constructor from the top-level window.
When there is no test styleElement, the constructor exists, and adoptedStyleSheets is available, a new sheet is pushed into the document array and cached in #styleSheet. Otherwise, a <style> is appended to the target document's <head>, and its .sheet is used. Both routes expose the same insertRule(rule, cssRules.length) interface to insertRule.
The #styleSheet cache avoids rebuilding a container for every rule. Rules themselves are appended without deduplication or merging at this level. Font deduplication belongs to attached and #systemFonts above it.
clear: Remove Fonts and Rule Containers Together
clear() {
for (const nativeFontFace of this.nativeFontFaces) {
this._document.fonts.delete(nativeFontFace);
}
this.nativeFontFaces.clear();
this.#systemFonts.clear();
if (this.#styleSheet) {
const { adoptedStyleSheets } = this._document;
if (adoptedStyleSheets?.includes(this.#styleSheet)) {
this._document.adoptedStyleSheets = adoptedStyleSheets.filter(
styleSheet => styleSheet !== this.#styleSheet
);
}
this.#styleSheet = null;
}
if (this.styleElement) {
this.styleElement.remove();
this.styleElement = null;
}
}Cleanup has three layers. First, every native FontFace is deleted from document.fonts, then PDF.js's registry Set is cleared. Second, successful system-font records are cleared so a later lifecycle can load them again. Third, styles are handled: if the cached sheet is in adoptedStyleSheets, a filtered new array removes its adoption before the cache is reset; if a <style> was used, it is then removed from the DOM.
Both the constructable-stylesheet and <style> fallback paths are closed here, and an injected test style is also removed and reset. clear() does not reset loadTestFontId or explicitly drain loadingRequests; it cleans DOM registrations, successful-load deduplication state, and style resources. It should not be described as an asynchronous-queue cancellation mechanism that does not exist in the source.
System Fonts: Deduplication and Failure in loadSystemFont
When the worker has no usable embedded font but selects a system-font substitute, font.systemFontInfo is non-null. Instead of registering PDF data, the loader uses the substitution information to load a system resource:
async loadSystemFont({ systemFontInfo: info, disableFontFace, _inspectFont }) {
if (!info || this.#systemFonts.has(info.loadedName)) {
return;
}
assert(
!disableFontFace,
"loadSystemFont shouldn't be called when `disableFontFace` is set."
);
if (this.isFontLoadingAPISupported) {
const { loadedName, src, style } = info;
const fontFace = new FontFace(loadedName, src, style);
this.addNativeFontFace(fontFace);
try {
await fontFace.load();
this.#systemFonts.add(loadedName);
_inspectFont?.(info);
} catch {
warn(
`Cannot load system font: ${info.baseFontName}, installing it could help to improve PDF rendering.`
);
this.removeNativeFontFace(fontFace);
}
return;
}
unreachable("Not implemented: loadSystemFont without the Font Loading API.");
}The deduplication key is info.loadedName, and it enters #systemFonts only after await fontFace.load() succeeds. It therefore means "this system font loaded successfully," not "loading was attempted," and it is not an in-flight promise table. Repeated calls on the same FontFaceObject are also stopped earlier by bind's attached check.
This path explicitly calls fontFace.load(), unlike the embedded-font path below, which waits on nativeFontFace.loaded. On success, _inspectFont?.(info) gives the inspector the system-font information. On failure, the loader only warns that installing the font may improve rendering and removes the just-registered FontFace.
The system-font catch does not rethrow. Consequently, bind() completes normally, api.js does not send FontFallback for this failure, and commonObjs still resolves the original font object. Canvas can subsequently build a CSS font stack from systemFontInfo.css; this path attempts to load the substitute and falls back on failure without pretending that loading succeeded.
If the environment has no Font Loading API at all, the system-font path calls unreachable: the legacy @font-face probe serves ordinary font rules, and there is no separate legacy system-font implementation.
bind: The Unified Branch Before Fonts Enter the DOM
Early Returns, attached, and System-Font Priority
async bind(font) {
if (font.attached || (font.missingFile && !font.systemFontInfo)) {
return;
}
font.attached = true;
if (font.systemFontInfo) {
await this.loadSystemFont(font);
return;
}
if (this.isFontLoadingAPISupported) {
const nativeFontFace = font.createNativeFontFace();
if (nativeFontFace) {
this.addNativeFontFace(nativeFontFace);
try {
await nativeFontFace.loaded;
} catch (ex) {
warn(`Failed to load font '${nativeFontFace.family}': '${ex}'.`);
font.disableFontFace = true;
throw ex;
}
}
return;
}
const rule = font.createFontFaceRule();
if (rule) {
this.insertRule(rule);
if (this.isSyncFontLoadingSupported) {
return;
}
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: async font loading");
}
await new Promise(resolve => {
const request = this._queueLoadingCallback(resolve);
this._prepareFontLoadEvent(font, request);
});
}
}The first condition contains two different kinds of skip: an already attached object is not attached again, while missingFile && !systemFontInfo means there is neither a file nor system substitution information to register. The second case returns before attached is set.
Every other path sets font.attached = true immediately, before any await. It is only a reentry guard meaning "attachment has been attempted," not a loaded, ready, or usable state. Even if nativeFontFace.loaded later rejects, the source does not reset this flag; this file does not use it to implement error retries.
The systemFontInfo branch precedes the ordinary data branch because a missing file can still have system substitution information. Only without such a substitute does the font enter the modern or legacy embedded-font path according to browser capabilities.
The Modern Path: Await loaded, Disable FontFace on Failure
createNativeFontFace() can return null, for example when data is absent or disableFontFace is already set. bind then returns without registering an empty rule. If creation succeeds, it calls addNativeFontFace and then awaits nativeFontFace.loaded rather than invoking load() again.
Failure does three things: it logs the family and exception, sets font.disableFontFace = true, and rethrows the original exception. Rethrowing lets the .catch() in api.js notify the worker to enter FontFallback; setting the flag makes later paintChar calls on the current main-thread object choose built-in Path2D rendering instead of the failed browser font.
Even here, attached === true does not imply successful loading. Subsequent painting is determined by disableFontFace, missingFile, system-font information, and the published glyph paths together.
The Legacy Path: Inserting a Rule Does Not Finish Async Loading
Without the Font Loading API, createFontFaceRule() creates a rule and the loader inserts it. If isSyncFontLoadingSupported is true, bind can return immediately. Otherwise, MOZCENTRAL builds explicitly throw "not implemented"; other environments create a promise, place its resolve function in the loading-request queue, and start test-font detection.
A null rule likewise skips detection. _prepareFontLoadEvent therefore does not run for every font; it is confined to the narrow path with a rule, no Font Loading API, and no synchronous loading support.
Two Capability Getters: Compute Support Once
get isFontLoadingAPISupported() {
const hasFonts = !!this._document?.fonts;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
return shadow(this, "isFontLoadingAPISupported", hasFonts && !this.styleElement);
}
return shadow(this, "isFontLoadingAPISupported", hasFonts);
}
get isSyncFontLoadingSupported() {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
return shadow(this, "isSyncFontLoadingSupported", true);
}
return shadow(
this,
"isSyncFontLoadingSupported",
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) &&
(isNodeJS || FeatureTest.platform.isFirefox)
);
}document.fonts is the foundation of the modern path. Testing additionally requires !this.styleElement, allowing tests that inject a style element to force the legacy fallback. shadow fixes the first computed result on the instance, so later reads do not repeat capability detection.
MOZCENTRAL treats the synchronous path as supported directly. In other non-CHROME builds, Node.js can pretend to support it, and Firefox also takes this branch. The source comment refers to behavior since Firefox 14. Here "synchronous" describes only the loading model used by this abstraction, not a universal guarantee for every browser or font source.
Legacy Async Loading: Queue, Test Font, and Pixel Polling
_queueLoadingCallback: Completion Can Reorder, Callbacks Cannot
_queueLoadingCallback(callback) {
function completeRequest() {
assert(!request.done, "completeRequest() cannot be called twice.");
request.done = true;
while (loadingRequests.length > 0 && loadingRequests[0].done) {
const otherRequest = loadingRequests.shift();
setTimeout(otherRequest.callback, 0);
}
}
const { loadingRequests } = this;
const request = { done: false, complete: completeRequest, callback };
loadingRequests.push(request);
return request;
}Each request stores done, complete, and the original callback. Detection may call complete only once; it marks that request done, then starts at the queue head and consecutively removes every completed request, scheduling each with setTimeout(callback, 0).
Suppose A, B, and C are queued in that order, but B finishes first: B only becomes done and cannot pass A. When A later completes, the loop removes A and B in order and schedules their callbacks; if C is also done, it is removed next. This guarantees completion callbacks are scheduled in queue order. It does not prove that font bytes downloaded in that order, nor does it add another success check.
The callback passed by bind is the promise's resolve. Queue order therefore determines the completion-publication order of these legacy bind() promises.
_loadTestFont: A Known CFF Font with One Period Glyph
get _loadTestFont() {
// MOZCENTRAL throws "Not implemented" here.
const testFont = atob(/* Full CFF test-font Base64 omitted in this excerpt. */);
return shadow(this, "_loadTestFont", testFont);
}The complete Base64 literal in the source is a CFF font with only a . glyph, and that glyph fills its entire width and height. atob produces a binary string, while shadow caches the decoded result so every probe does not decode the same constant again.
This is not a document font. It is a probe with a known shape and stable pixel result: if Canvas can measure the alpha it paints, the code infers that the target font queued before it has also loaded.
_prepareFontLoadEvent: Build the Probe, Then Watch One Pixel
The method begins with three binary-string helpers, int32, string32, and spliceString, then creates a 1×1 Canvas and the detection function:
const canvas = this._document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d');
let called = 0;
function isFontReady(name, callback) {
if (++called > 30) {
warn('Load test font never loaded.');
callback();
return;
}
ctx.font = '30px ' + name;
ctx.fillText('.', 0, 20);
const imageData = ctx.getImageData(0, 0, 1, 1);
if (imageData.data[3] > 0) {
callback();
return;
}
setTimeout(isFontReady.bind(null, name, callback));
}Each iteration draws one period at 30px in the test font and reads the alpha of the only pixel. Alpha greater than zero means the known glyph was painted, so the completion callback runs; otherwise, setTimeout schedules another check. The source comment says timer clamping gives the font roughly 100ms to load.
The precise meaning of ++called > 30 is at most 30 actual pixel probes; entry number 31 warns and completes directly. There is no rejection and no new request for the document font. The "30 polls" are only a bounded completion-detection window, not a claim that PDF.js retries a failed font 30 times.
Unique ids and the CFF Checksum: Avoid Chromium's Data-Hash Cache
const loadTestFontId = `lt${Date.now()}${this.loadTestFontId++}`;
let data = this._loadTestFont;
const COMMENT_OFFSET = 976;
data = spliceString(data, COMMENT_OFFSET, loadTestFontId.length, loadTestFontId);
const CFF_CHECKSUM_OFFSET = 16;
const XXXX_VALUE = 0x58585858;
let checksum = int32(data, CFF_CHECKSUM_OFFSET);
for (i = 0, ii = loadTestFontId.length - 3; i < ii; i += 4) {
checksum = (checksum - XXXX_VALUE + int32(loadTestFontId, i)) | 0;
}
if (i < loadTestFontId.length) {
checksum = (checksum - XXXX_VALUE + int32(loadTestFontId + 'XXX', i)) | 0;
}
data = spliceString(data, CFF_CHECKSUM_OFFSET, 4, string32(checksum));The id combines lt, the current millisecond timestamp, and an incrementing counter. Chromium may cache by a hash of the actual font data, so changing only the CSS family is insufficient: every test must also change the font bytes, or a new probe may look as though it loaded long ago.
The implementation writes the id into the comment area at offset 976, which is aligned to a four-byte boundary. The original content is effectively a run of X characters, so checksum correction subtracts 0x58585858 for each four-byte unit and adds the corresponding integer from the id. A final partial unit is padded with XXX for the calculation. The new checksum is then written back at offset 16.
This satisfies two constraints at once: the data is new to Chromium, and the CFF checksum remains correct for IE. It modifies a copy of the embedded test font, never the document font data exported from the PDF.
Insert the Probe Rule, Trigger Font Use, and Remove Temporary DOM
const url = `url(data:font/opentype;base64,${btoa(data)});`;
const rule = `@font-face {font-family:"${loadTestFontId}";src:${url}}`;
this.insertRule(rule);
const div = this._document.createElement('div');
div.style.visibility = 'hidden';
div.style.width = div.style.height = '10px';
div.style.position = 'absolute';
div.style.top = div.style.left = '0px';
for (const name of [font.loadedName, loadTestFontId]) {
const span = this._document.createElement('span');
span.textContent = 'Hi';
span.style.fontFamily = name;
div.append(span);
}
this._document.body.append(div);
isFontReady(loadTestFontId, () => {
div.remove();
request.complete();
});The test font is encoded as a data URL and inserted through @font-face. The hidden, absolutely positioned div contains two spans: one uses the target font.loadedName, and the other uses the test id. They make both families participate in page layout and font requests, while the probe Canvas observes only the test font.
When detection finishes or exceeds the polling limit, the div is removed before request.complete(). The temporary spans and div therefore do not remain in the document. Test rules stay in the loader's style container until a later clear() removes the entire sheet.
The fundamental assumption behind this hack is ordered font loading: a known test font is placed after the target font, so its availability is taken as evidence that the target is ready. It is a compatibility solution for older browsers without a font-completion event, not a universal step still required by the modern path.
FontFaceObject: Turning Font Data into a Rendering Interface
Constructor: Keep the Binary View and Optional Extensions
class FontFaceObject {
compiledGlyphs = Object.create(null);
#fontData;
constructor(translatedData, inspectFont = null, charProcOperatorList, extra) {
this.#fontData = translatedData;
this._inspectFont = inspectFont;
if (charProcOperatorList) {
this.charProcOperatorList = charProcOperatorList;
}
if (extra) {
Object.assign(this, extra);
}
}
}Every instance has a prototype-less compiledGlyphs dictionary for caching Path2D by character; #fontData privately stores the FontInfo read by every basic getter. _inspectFont is an optional debugging hook.
charProcOperatorList is attached only when present for Type3 character procedures, while extra is expanded through Object.assign. Testing builds also assert that disableFontFace and fontExtraProperties are booleans, ensuring that the cross-thread format has not omitted those fields.
createNativeFontFace: Factory for Modern Browser Registration
createNativeFontFace() {
if (!this.data || this.disableFontFace) {
return null;
}
let nativeFontFace;
if (!this.cssFontInfo) {
nativeFontFace = new FontFace(this.loadedName, this.data, {});
} else {
const css = { weight: this.cssFontInfo.fontWeight };
if (this.cssFontInfo.italicAngle) {
css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
}
nativeFontFace = new FontFace(
serializeFontFamily(this.cssFontInfo.fontFamily),
this.data,
css
);
}
this._inspectFont?.(this);
return nativeFontFace;
}With no font data or with FontFace disabled, the method returns null, sparing bind from creating an empty object. The ordinary path uses the internal loadedName as the family and passes data with empty descriptors.
When cssFontInfo exists, the family instead comes from its fontFamily, weight comes from fontWeight, and a non-empty italicAngle becomes oblique <angle>deg. The string returned by serializeFontFamily can be used directly as the FontFace family string.
Creation then triggers _inspectFont?.(this). The object has not yet been added to document.fonts; registration remains FontLoader.addNativeFontFace's responsibility. Factory creation and lifecycle operations stay separate.
createFontFaceRule: Factory for Legacy CSS Rules
createFontFaceRule() {
if (!this.data || this.disableFontFace) {
return null;
}
const url = `url(data:${this.mimetype};base64,${this.data.toBase64()});`;
let rule;
if (!this.cssFontInfo) {
rule = `@font-face {font-family:"${this.loadedName}";src:${url}}`;
} else {
let css = `font-weight: ${this.cssFontInfo.fontWeight};`;
if (this.cssFontInfo.italicAngle) {
css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
}
const fontFamily = serializeFontFamily(this.cssFontInfo.fontFamily);
rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;
}
this._inspectFont?.(this, url);
return rule;
}Like the modern factory, this method returns null when data is absent or FontFace is disabled. The legacy path must convert the bytes into a Base64 data URL and use the worker-exported mimetype; CSS information likewise adds weight and oblique style when present.
The debugging hook additionally receives url here because this path has materialized a font URL for inspection. The method only returns a string; which document receives it and when it is inserted remain FontLoader decisions.
cssFontInfo.fontFamily: Why Serialization Is Mandatory
function serializeFontFamily(fontFamily) {
if (isCSSString(fontFamily)) {
return fontFamily;
}
const escaped = fontFamily.replaceAll(/["\\\p{Cc}]/gu, char =>
char === `"` || char === '\\' ? `\\${char}` : `\\${char.codePointAt(0).toString(16)} `,
);
return `"${escaped}"`;
}fontFamily can originate in an untrusted PDF and cannot be concatenated directly into @font-face; quotes, backslashes, or control characters could otherwise break the rule boundary and inject arbitrary CSS. serializeFontFamily returns an already valid CSS string unchanged. Other values are emitted as quoted <string> values, with double quotes and backslashes escaped and control characters converted to hexadecimal CSS escapes.
Always using <string> also prevents values such as serif or inherit from being interpreted as generic families or CSS-wide keywords. A normal loadedName is an internally generated loading id, so the branch without cssFontInfo can use the existing direct format; a family originating in the document must cross this sanitization boundary.
getPathGenerator: Build and Cache Path2D on Demand
getPathGenerator(objs, character) {
if (this.compiledGlyphs[character] !== undefined) {
return this.compiledGlyphs[character];
}
const objId = this.loadedName + "_path_" + character;
let cmds;
try {
cmds = objs.get(objId);
} catch (ex) {
warn(`getPathGenerator - ignoring character: "${ex}".`);
}
const path = makePathFromDrawOPS(cmds?.path);
if (!this.fontExtraProperties) {
objs.delete(objId);
}
return (this.compiledGlyphs[character] = path);
}The object id exactly matches worker-side buildFontPaths: ${loadedName}_path_${fontChar}. When the main thread receives "FontPath", it resolves a FontPathInfo; that object's path getter returns a Float16Array or Float32Array of drawing operations according to platform support.
makePathFromDrawOPS creates a Path2D and translates moveTo, lineTo, curveTo, quadraticCurveTo, and closePath in sequence; missing input yields an empty path. getPathGenerator is therefore not a glyph parser. It is a lightweight converter between outlines already compiled by the worker and native Canvas paths.
The cache check uses !== undefined, so even an empty Path2D is a hit. If objs.get throws because the object has not been published, this method warns and continues with an empty path; it does not wait asynchronously or retry. Normal rendering relies on commonObjs dependency handling to make the path available.
After the first compilation, the original FontPathInfo is deleted from the object pool unless fontExtraProperties is enabled, leaving only the compiled Path2D. Extra-properties mode retains raw data for debugging at the cost of more memory. This mirrors the font body's clearData() policy: normal rendering keeps the minimum, while debugging retains more source information.
All Proxy Getters, the State Setter, and clearData
Apart from the path method, FontFaceObject is deliberately a thin proxy. This table covers every basic getter defined in the current file:
| Category | Getters | Consumer or meaning |
|---|---|---|
| Glyph and style state | black, bold, italic, vertical | Canvas font styling and vertical layout |
| Loading and fallback state | disableFontFace, missingFile, isInvalidPDFjsFont | Select browser-font or built-in fallback paths |
| Type and configuration | isType3Font, fontExtraProperties, remeasure | Type3 branching, debug retention, and remeasurement |
| Horizontal metrics | ascent, descent, defaultWidth | Text layout and default glyph width |
| Geometry | bbox, fontMatrix, defaultVMetrics | Bounding box, text matrix, and default vertical metrics |
| Identity and font program | fallbackName, loadedName, mimetype, name, data | CSS family, object id, data URL, original font name, and bytes |
| Optional loading information | cssFontInfo, systemFontInfo | CSS descriptors and system-font substitution |
Each getter directly returns this.#fontData.<property>; the main thread does not recalculate metrics or repeat worker font analysis. The only writable state is disableFontFace:
get disableFontFace() {
return this.#fontData.disableFontFace;
}
set disableFontFace(value) {
shadow(this, "disableFontFace", !!value);
}
clearData() {
this.#fontData.clearData();
}The setter uses shadow to write a boolean own property on the FontFaceObject instance, overriding the prototype getter/setter. After native-font failure, the current object immediately reads true without modifying FontInfo's binary layout, and later reads use that settled state directly.
clearData() simply delegates to FontInfo.clearData(). It releases font-program bytes; it does not clear compiledGlyphs or remove a registered FontFace. The latter belongs to FontLoader.clear(), and the two resource types must not be conflated.
How CanvasGraphics Consumes FontFaceObject
After commonObjs.resolve, CanvasGraphics.setFont can synchronously retrieve the object by fontRefName. It stores the object in current.font, reads its matrix and styles, and sets the browser's ctx.font:
const fontObj = this.commonObjs.get(fontRefName);
current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;
this.current.font = fontObj;
this.current.fontSize = size;
if (fontObj.isType3Font) {
return;
}
const name = fontObj.loadedName || 'sans-serif';
const typeface = fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;
// `black` / `bold` choose the weight, `italic` chooses the style.
this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;Type3 does not need ctx.font, because its glyphs are painted by character procedures. Other fonts prefer systemFontInfo.css; otherwise, they use loadedName plus fallbackName. black, bold, italic, and font size form the CSS font shorthand. Size is also clamped to a range the browser can handle, with transforms compensating for the difference.
paintChar calls the path interface when built-in outlines are needed:
if ((font.disableFontFace || isAddToPathSet || patternFill || patternStroke) && !font.missingFile) {
path = font.getPathGenerator(this.commonObjs, character);
}
if (path && (font.disableFontFace || patternFill || patternStroke)) {
// Translate/scale to the glyph position, then ctx.fill(path) / ctx.stroke(path).
}Disabled FontFace, adding text to a clipping path, and certain pattern fill/stroke modes require standalone outlines rather than fillText alone. A true missingFile does not take this embedded-font path. The disableFontFace = true set after native loading failure takes effect here, and the FontPath objects resent by the worker are consumed here.
FontFaceObject therefore provides two rendering representations: the family, style, and metrics of a browser font, and a Path2D that Canvas can directly fill or stroke. FontLoader makes the first representation available where possible, while the second underpins the built-in renderer and failure fallback.
Concrete Loading Timelines
The Modern Path for a Normal Embedded Font
// 1. Worker exports FontInfo bytes and sends "commonobj".
// 2. Main thread constructs FontInfo + FontFaceObject.
// 3. bind: attached = true.
// 4. createNativeFontFace → document.fonts.add.
// 5. Await nativeFontFace.loaded.
// 6. On success: finish bind.
// On failure: disableFontFace = true, throw, await FontFallback handling.
// 7. finally: clearData unless fontExtraProperties.
// 8. commonObjs.resolve(id, font).
// 9. CanvasGraphics consumes ctx.font and/or Path2D.This timeline has two independent notions of "complete": nativeFontFace.loaded reports the browser's native loading result, while commonObjs.resolve means PDF.js has published the font object to the renderer. On failure, the former rejects, but the latter still occurs after fallback handling and data cleanup.
Completion Conditions for the Other Branches
| Branch | What bind awaits | How failure appears | State before resolve |
|---|---|---|---|
Already attached | Nothing; returns immediately | Does not recheck the original result | Retains state from the first attempt |
missingFile without a system substitute | Nothing; returns immediately | There is no font to register | Canvas uses missing-font logic |
| System font | fontFace.load() | Warn and remove the FontFace without rethrowing | Finishes normally without FontFallback |
| Native embedded-font success | nativeFontFace.loaded | Not applicable | disableFontFace keeps its previous value |
| Native embedded-font failure | loaded rejects, then the API catch notifies the worker | Sets disableFontFace and enters FontFallback | Publishes the font after fallback settles |
| Legacy synchronous | Returns immediately after rule insertion | No asynchronous probe result | Treats the synchronous loading path as complete |
| Legacy asynchronous | Test-font alpha appears or the probe limit is exceeded | Limit expiry only warns and completes | Queue schedules resolve in insertion order |
The first and last rows are the easiest to misread: attached only prevents duplicate attachment, and legacy probe timeout does not imply success. The source does not reset attached, loop over bind, or implement a general error-retry mechanism in these locations.
Time Boundaries for clearData, cleanup, and destroy
Font-body bytes are normally released when each bind chain settles rather than waiting for document cleanup. They are retained only when fontExtraProperties is enabled. Raw glyph paths likewise are deleted after the first getPathGenerator according to the same switch.
PDFDocumentProxy.cleanup(keepLoadedFonts = false) forwards to startCleanup. The latter first awaits the worker's "Cleanup", then calls page.cleanup() for every page; any page still rendering causes an error. It then always calls commonObjs.clear(), but calls fontLoader.clear() only when !keepLoadedFonts:
this.commonObjs.clear();
if (!keepLoadedFonts) {
this.fontLoader.clear();
}keepLoadedFonts lets DOM fonts remain resident to avoid later reuse costs, but the source documentation explicitly warns about increased persistent memory. Pure XFA forces fonts to be retained at the PDFDocumentProxy.cleanup entry point.
WorkerTransport.destroy() first marks itself destroyed, waits for every page's _destroy() and the worker's "Terminate", then unconditionally runs commonObjs.clear() and fontLoader.clear() after Promise.all(waitOn) succeeds, before cleaning up resources such as the message handler. PDFDocumentLoadingTask.destroy() waits for and invokes the transport destruction flow.
The three cleanup actions therefore operate at different levels: font.clearData() releases one font's large byte block; fontLoader.clear() releases DOM registrations and styles; commonObjs.clear() releases the main-thread object pool. Document destruction settles the latter two together, while ordinary cleanup can choose to retain loaded fonts.
Design Takeaways
-
Separate attachment, loading, and renderability.
attachedonly says the attachment entry point ran; the native promise, system-font catch, legacy probe, anddisableFontFacejointly determine later rendering. One boolean is not forced to express the entire state machine. -
Finish fallback before publishing the object.
api.jsplacesclearData()andcommonObjs.resolve()infinally, so Canvas sees an object whose loading flow has settled rather than a mutable intermediate state. -
Separate creation from registration.
FontFaceObjectonly creates aFontFaceor rule;FontLoaderalone operates on the target document. Print iframes, test styles, CSP fallback, and unified cleanup belong to one class with an explicit document boundary. -
CSP and cross-window behavior are part of stylesheet design. Constructable stylesheets are not merely a nicer modern API; they avoid inline-style restrictions. Taking the constructor from
ownerDocument.defaultViewensures that the sheet can be adopted correctly. -
System-font deduplication records success, not pretense.
#systemFontsis written after successfulload(), while failure removes the registered object and returns normally. It neither calls failure success nor introduces an in-flight cache or retry framework absent from the source. -
Legacy compatibility localizes uncertainty. The test font, unique id, checksum, pixel alpha, 30 polls, and ordered queue are encapsulated in the non-modern path. Modern Canvas and
FontFaceObjectneed not know these hacks exist. -
Serialize untrusted strings at the CSS boundary.
loadedNameis an internal id, butcssFontInfo.fontFamilycan come from a PDF.serializeFontFamilyuses CSS strings and escaping to prevent rule injection rather than assuming font names are normally benign. -
Cache compiled results and drop large raw objects promptly.
Path2Dis cached per character, rawFontPathInfocan be deleted after use, and font bytes can be truncated after binding.fontExtraPropertiesmakes debug visibility versus memory use an explicit switch. -
Fallback is cross-thread cooperation, not a unilateral family swap. After native-font failure, the main thread sets
disableFontFaceand sendsFontFallback; the worker updates its own state and resends parsed glyph paths so Canvas can reliably switch to built-in outlines. -
Cleanup follows resource ownership.
FontFaceObjectdoes not manage the DOM,FontLoaderdoes not recalculate font metrics, andcommonObjsdoes not create CSS. Each releases what it owns, keeping cleanup and destroy combinations predictable.
Summary
font_loader.js sits between "the worker understands the font" and "the browser can paint it." FontInfo retains the compact export, FontFaceObject exposes rule creation, metrics, and path generation, and FontLoader consolidates the modern API, system fonts, legacy-browser hacks, and resource lifecycle within one document boundary.
| Question | The current source's answer |
|---|---|
| How does a font enter the browser? | The modern path registers FontFace; the legacy path inserts @font-face |
| How are duplicate system fonts avoided? | #systemFonts records successfully loaded loadedName values |
| How does legacy async loading detect completion? | A known CFF test font, one-pixel alpha, and at most 30 actual probes |
| How do multiple legacy requests settle? | Completion may reorder; draining from the queue head schedules callbacks in queue order |
| What happens after native-font failure? | disableFontFace = true, rethrow, FontFallback, and glyph paths |
| When is the font object given to Canvas? | After bind/fallback settles and clearData, through commonObjs.resolve |
| How is repeated glyph-path work avoided? | compiledGlyphs caches Path2D, and normal mode deletes raw path objects |
| How are resources released at document end? | Cleanup can retain DOM fonts; destroy always clears the loader and object pool |
The most important lesson is not a particular browser hack but the state boundaries: attachment is not success, resolve does not mean the native font is usable, and failure does not require rendering to crash. PDF.js uses explicit publication timing and built-in outline fallback to hide these differences behind CanvasGraphics's stable interface.
StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.
Related articles
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026