StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: font_loader

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: font_loader

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 FontLoader and FontFaceObject; the modern path uses the Font Loading API, while the legacy path still contains @font-face, a test font, and polling. Do not read attached as "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:

AreaKey membersResponsibility
Loader state#systemFonts, nativeFontFaces, #styleSheetTrack system fonts, native FontFace objects, and the rule container
DOM registrationaddNativeFontFace, removeNativeFontFace, insertRuleOperate on document.fonts and CSS stylesheets
Resource cleanupclearRemove fonts, reset deduplication state, and detach stylesheets
System fontsloadSystemFontLoad the system-font substitute selected by the worker
Unified entry pointbindChoose a path from attached, missing-file state, and supported capabilities
Capability checksisFontLoadingAPISupported, isSyncFontLoadingSupportedDistinguish modern, synchronous, and legacy asynchronous loading
Legacy async detection_queueLoadingCallback, _loadTestFont, _prepareFontLoadEventQueue completion callbacks and infer load completion through a test font
Font objectFontFaceObject constructor and the two create* methodsWrap FontInfo and create a FontFace or @font-face
Built-in glyph renderinggetPathGeneratorCompile and cache FontPathInfo as Path2D
Rendering data interfaceProxy getters, the disableFontFace setter, and clearDataExpose 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:

  1. bind() attempts attachment and waits for the selected loading flow.
  2. If bind() rejects, .catch() sends FontFallback to the worker. The worker also sets its font's disableFontFace = true and attempts to resend every previously parsed FontPath.
  3. On either success or failure, .finally() calls font.clearData() unless fontExtraProperties is enabled.
  4. 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:

CategoryGettersConsumer or meaning
Glyph and style stateblack, bold, italic, verticalCanvas font styling and vertical layout
Loading and fallback statedisableFontFace, missingFile, isInvalidPDFjsFontSelect browser-font or built-in fallback paths
Type and configurationisType3Font, fontExtraProperties, remeasureType3 branching, debug retention, and remeasurement
Horizontal metricsascent, descent, defaultWidthText layout and default glyph width
Geometrybbox, fontMatrix, defaultVMetricsBounding box, text matrix, and default vertical metrics
Identity and font programfallbackName, loadedName, mimetype, name, dataCSS family, object id, data URL, original font name, and bytes
Optional loading informationcssFontInfo, systemFontInfoCSS 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

BranchWhat bind awaitsHow failure appearsState before resolve
Already attachedNothing; returns immediatelyDoes not recheck the original resultRetains state from the first attempt
missingFile without a system substituteNothing; returns immediatelyThere is no font to registerCanvas uses missing-font logic
System fontfontFace.load()Warn and remove the FontFace without rethrowingFinishes normally without FontFallback
Native embedded-font successnativeFontFace.loadedNot applicabledisableFontFace keeps its previous value
Native embedded-font failureloaded rejects, then the API catch notifies the workerSets disableFontFace and enters FontFallbackPublishes the font after fallback settles
Legacy synchronousReturns immediately after rule insertionNo asynchronous probe resultTreats the synchronous loading path as complete
Legacy asynchronousTest-font alpha appears or the probe limit is exceededLimit expiry only warns and completesQueue 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

  1. Separate attachment, loading, and renderability. attached only says the attachment entry point ran; the native promise, system-font catch, legacy probe, and disableFontFace jointly determine later rendering. One boolean is not forced to express the entire state machine.

  2. Finish fallback before publishing the object. api.js places clearData() and commonObjs.resolve() in finally, so Canvas sees an object whose loading flow has settled rather than a mutable intermediate state.

  3. Separate creation from registration. FontFaceObject only creates a FontFace or rule; FontLoader alone operates on the target document. Print iframes, test styles, CSP fallback, and unified cleanup belong to one class with an explicit document boundary.

  4. 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.defaultView ensures that the sheet can be adopted correctly.

  5. System-font deduplication records success, not pretense. #systemFonts is written after successful load(), 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.

  6. 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 FontFaceObject need not know these hacks exist.

  7. Serialize untrusted strings at the CSS boundary. loadedName is an internal id, but cssFontInfo.fontFamily can come from a PDF. serializeFontFamily uses CSS strings and escaping to prevent rule injection rather than assuming font names are normally benign.

  8. Cache compiled results and drop large raw objects promptly. Path2D is cached per character, raw FontPathInfo can be deleted after use, and font bytes can be truncated after binding. fontExtraProperties makes debug visibility versus memory use an explicit switch.

  9. Fallback is cross-thread cooperation, not a unilateral family swap. After native-font failure, the main thread sets disableFontFace and sends FontFallback; the worker updates its own state and resends parsed glyph paths so Canvas can reliably switch to built-in outlines.

  10. Cleanup follows resource ownership. FontFaceObject does not manage the DOM, FontLoader does not recalculate font metrics, and commonObjs does 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.

QuestionThe 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