StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 12: Font Translation, from FontFile3 to FontFace

October 28, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 12: Font Translation, from FontFile3 to FontFace

Preface

In Part 11, the four Tf calls in the demo first page became four setFont plus two dependency operators, while the worker emitted two commonobj messages:

handler.send("commonobj", ["g_d0_f1", "Font", fontData], transfers);

Two questions were left open:

  • What exactly is fontData, and why can it ride the transfer list?
  • What happens to the font behind g_d0_f1 on the worker before the browser can use it?

Browsers only understand containers like OpenType, TrueType, or WOFF, but the demo PDF embeds a Type1C (CFF) font program. The translation work in between is the subject of this article. We follow the demo's two real fonts from the name /T1_0 all the way to new FontFace on the main thread.

The Two Font Dossiers

Start from the PDF objects and list the data this article uses. The demo document is the PDF 32000-1:2008 specification itself; the first page's resource dictionary:

<< /ExtGState << /GS0 13 0 R >>
   /Font << /T1_0 14 0 R /T1_1 15 0 R >>
   /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >>

14 0 R (/T1_0, body text) and 15 0 R (/T1_1, bold) have nearly identical shapes:

FieldT1_0 (14 0 R)T1_1 (15 0 R)
SubtypeType1Type1
BaseFontDDPEFM+HelveticaDDPEIM+Helvetica-Bold
EncodingWinAnsiEncodingWinAnsiEncoding
FirstChar / LastChar32 / 16932 / 151
Widths138 numbers120 numbers
ToUnicodenonenone
FontDescriptor452 0 R453 0 R

The six-letter-plus-sign prefix in DDPEFM+ marks a font subset: the embedded program contains only the glyphs the document uses. The corresponding FontDescriptors:

Field452 (Helvetica)453 (Helvetica-Bold)
Flags32262176
FontBBox[-166 -225 1000 931][-170 -228 1003 962]
Ascent / Descent718 / -207718 / -207
CapHeight / XHeight718 / 523718 / 532
StemV88140
CharSet55 glyph names40 glyph names
FontFile31306 0 R (Type1C, 3723 compressed → 4323 bytes decoded)1307 0 R (Type1C, 2642 compressed → 3121 bytes decoded)

Flags is a bitmask: 32 is Nonsymbolic; 262176 = 32 + 262144, i.e. Nonsymbolic | ForceBold.

Two key observations:

  1. The font program arrives as FontFile3 with Subtype Type1C, i.e. CFF (Compact Font Format), which browsers cannot load directly;
  2. Neither dictionary has a ToUnicode CMap, so the text's unicode values must be derived from encoding glyph names.

Step 1: loadFont Checks the Cache Before Translating

The entry point is handleSetFont, which hands the resource name T1_0 to loadFont. The first section of loadFont is all caching and fallback logic:

let fontRef;
if (font) {
  if (font instanceof Ref) {
    fontRef = font;
  }
} else {
  // Loading by name: take the raw reference from the resource dictionary
  const fontRes = resources.get("Font");
  if (fontRes) {
    fontRef = fontRes.getRaw(fontName); // T1_0 → Ref "14 0 R"
  }
}
if (fontRef) {
  if (this.type3FontRefs?.has(fontRef)) {
    return errorFont();
  }
  if (this.fontCache.has(fontRef)) {
    return this.fontCache.get(fontRef); // same ref reuses the promise
  }
  try {
    font = this.xref.fetchIfRef(fontRef);
  } catch (ex) {
    warn(`loadFont - lookup failed: "${ex}".`);
  }
}
 
if (!(font instanceof Dict)) {
  if (!this.options.ignoreErrors && !this.parsingType3Font) {
    return errorFont(); // an error font with loadedName "g_font_error"
  }
  // In ignore-errors mode, fall back to a built-in fallback font dictionary
  font = fallbackFontDict || PartialEvaluator.fallbackFontDict;
}

fontCache is keyed by Ref and stores promises. The demo content stream uses T1_0 twice and T1_1 twice, yet each font is translated only once — this cache is why. Its other role appears in Part 8's cleanup: the cache is walked to unload fonts and prevent memory leaks.

preEvaluateFont then does a lightweight pre-parse without touching the font program itself.

Step 2: preEvaluateFont Computes a Fingerprint

preEvaluateFont reads only dictionaries and returns:

{
  descriptor,   // the FontDescriptor dictionary (452 0 R)
  dict,         // the font dictionary itself (DescendantFonts for Type0)
  baseDict,
  composite,    // Type0 composite font? false for the demo
  type: "Type1",
  firstChar: 32,
  lastChar: 169,
  toUnicode,    // undefined for the demo
  hash,         // MurmurHash3_64 fingerprint
}

The hash enables cross-page deduplication: fonts with different object numbers but identical content are translated only once. The fingerprint mixes the fields that affect rendering without decoding the font program:

hash.update(encoding);            // "WinAnsiEncoding"
hash.update(`${firstChar}-${lastChar}`); // "32-169" — the width-table range
hash.update(toUnicode bytes);     // absent for the demo, skipped
hash.update(widthsBuf.join());    // "278,0,0,..." the width array
// composite fonts additionally mix in the W array and CIDToGIDMap
// Type3 fonts additionally mix in CharProcs keys

The demo's two fonts differ in encoding range and widths, so they get distinct fingerprints and separate fontAliases entries:

const fontAliases = (descriptor.fontAliases ||= Object.create(null));
if (fontAliases[hash]) {
  // Same-content font already translated: create an alias, return the cache
  ...
} else {
  fontAliases[hash] = { fontID: this.idFactory.createFontId() };
}
fontID = fontAliases[hash].fontID; // "f1" / "f2"

Note that the fingerprint lives on the descriptor dictionary's fontAliases field, and that dictionary object survives in the document-level cache, so identical fonts on later pages hit the alias too.

Step 3: fontID, loadedName, and Cache Registration

With the fontID in hand, loadFont builds the cross-thread unique name:

font.loadedName = `${this.idFactory.getDocId()}_${fontID}`;
// g_d0_f1 / g_d0_f2

Then it immediately stores a promise in fontCache and starts translation:

if (fontRefIsRef) {
  this.fontCache.put(fontRef, promise);
} else {
  font.cacheKey = `cacheKey_${fontID}`;
  this.fontCache.put(font.cacheKey, promise);
}
 
this.translateFont(preEvaluatedFont)
  .then(async translatedFont => {
    const translated = new TranslatedFont({
      loadedName: font.loadedName,
      font: translatedFont,
      dict: font,
    });
    if (translatedFont.isType3Font) {
      await translated.loadType3Data(this, resources, task, seenRefs);
    }
    resolve(translated);
  })
  .catch(reason => {
    // Translation failure does not throw; resolve an ErrorFont, rendering continues
    resolve(new TranslatedFont({ loadedName, font: new ErrorFont(...), dict }));
  });
return promise;

Two design points stand out:

  • Store the promise before translating: when the content stream mentions the same font again, it gets the same promise, eliminating duplicate concurrent translations;
  • Translation failure never aborts the page: the catch resolves an ErrorFont whose glyphs are all boxes, while the rest of the page renders normally.

The demo fonts are not Type3, so loadType3Data never fires.

Step 4: translateFont Extracts the Font Program

The demo fonts carry a FontDescriptor, so translateFont takes the main branch (the base-14 branch for descriptor-less fonts is not involved here). First it locates the font file stream:

for (const n of ["FontFile", "FontFile2", "FontFile3"]) {
  fontFile = descriptor.get(n);
  if (fontFile) {
    fontFileN = n;
    break;
  }
}

The demo hits FontFile3, i.e. 1306 0 R / 1307 0 R, whose stream dictionary says /Subtype /Type1C and /Filter /FlateDecode. If the stream were asynchronous (as with streamed transport of large documents), await fontFile.asyncGetBytes() would convert it to a synchronous stream first.

Then it collects metrics from the dictionary and descriptor:

const properties = {
  type: "Type1",
  name: "DDPEFM+Helvetica", // FontName matches BaseFont
  subtype: "Type1C",        // from the font stream dictionary
  file: fontFile,           // 4323 bytes of decoded CFF program
  fontFileN: "FontFile3",
  fontMatrix,               // absent in the dictionary → FONT_IDENTITY_MATRIX
  firstChar: 32, lastChar: 169,
  toUnicode: undefined,
  bbox: [-166, -225, 1000, 931],
  ascent: 718, descent: -207,
  xHeight: 523, capHeight: 718,
  flags: 32, italicAngle: 0,
  ...
};

Next, extractDataStructures resolves the encoding (WinAnsiEncoding is a built-in named encoding mapping straight to glyph-name tables), and extractWidths fills properties.widths with the 138 entries indexed by FirstChar + i. Because fontMatrix is the identity matrix, adjustWidths returns immediately.

Step 5: The Font Constructor Sniffs the Real Type and Repacks CFF as OpenType

const font = new Font(fontName.name, fontFile, newProperties, this.options);

The Font constructor does not trust the dictionary declaration; it sniffs the file header first:

[type, subtype] = getFontFileType(file, properties);
// isTrueTypeFile / isOpenTypeFile / isType1File / isCFFFile check magic bytes in turn
// demo: CFF magic (0x01 0x00) matches → type "Type1", subtype "Type1C"

The demo's declaration matches the sniffed result, so not even an info log is emitted. Then it dispatches by type:

case "Type1":
  this.mimetype = "font/opentype";
 
  const cff =
    subtype === "Type1C" || subtype === "CIDFontType0C"
      ? new CFFFont(file, properties)   // the demo takes this branch
      : new Type1Font(name, file, properties);
 
  adjustWidths(properties);
 
  // Wrap the CFF data inside an OTF font file
  data = this.convert(name, cff, properties);
  break;

Inside CFFFont, a CFFParser parses the CFF INDEXes, charset, and charstrings, and a CFFCompiler recompiles a "clean" CFF binary. convert() then hand-builds a standard OpenType container around it:

const builder = new OpenTypeFileBuilder(...);
builder.addTable("cmap", ...);  // platform 3/1 character-to-glyph mapping
builder.addTable("CFF ", cff.data); // the recompiled CFF
builder.addTable("OS/2", createOS2Table(..., properties)); // carries fsSelection bold/italic bits
builder.addTable("head", ...);
builder.addTable("hhea", ...);  // caretSlopeRun = tan(italicAngle) * xHeight
builder.addTable("hmtx", ...);  // per-glyph widths from charstring widths
builder.addTable("maxp", ...);  // numGlyphs
builder.addTable("name", createNameTable(fontName));
builder.addTable("post", createPostTable(properties));
return builder.toArray(); // Uint8Array, a fully loadable OTF

This is the core of "font translation": repacking the PDF-embedded CFF program into an OpenType the browser's FontFace API can load. Along the way the glyph mapping is built: getGlyphMapping produces charCode → glyphId (WinAnsi encoding plus charset), and adjustMapping moves glyphs into the private use area (PUA) when there is no ToUnicode, keeping both fillText and text copying correct — for example \251 (169) first maps through WinAnsi to the glyph name copyright, then to the actual glyph in the CFF, whose width is exactly the 737 at the end of the Widths array, matching Glyph { unicode: "©", width: 737 } from Part 11.

The two demo fonts diverge here:

Propertyg_d0_f1g_d0_f2
flags32 (Nonsymbolic)262176 (Nonsymbolic | ForceBold)
OS/2 fsSelectionregular bitsForceBold influences the bold bits
fallbackNamesans-serifsans-serif
bold / italic / blackunsetunset

The last row is easy to misunderstand: bold, italic, black, and remeasure are derived from the name with regexes only on the no-embedded-file, system-font fallback path (fallbackToSystemFont). The demo fonts have embedded programs and never take that path, so these keys are simply absent from the exported data. Weight information is carried by the flags baked into the OS/2 table, and on the canvas the two fonts are separate font-familys (their loadedNames), so no CSS weight is needed to tell them apart.

Step 6: exportData Compiles the Font into a Single Binary Buffer

Once translated, the font object must cross the thread boundary. postMessage-ing a plain object holding tens of KB of font data would trigger a structured-clone deep copy, so PDF.js compiles it into a single ArrayBuffer first:

exportData() {
  return {
    buffer: compileFontInfo(this.#getExportData(EXPORT_DATA_PROPERTIES)),
    charProcOperatorList: this.charProcOperatorList, // Type3 only
    extra: this.fontExtraProperties ? ... : undefined,
  };
}

EXPORT_DATA_PROPERTIES is a fixed list of 22 keys:

[
  "ascent", "bbox", "black", "bold", "cssFontInfo", "data",
  "defaultVMetrics", "defaultWidth", "descent", "disableFontFace",
  "fallbackName", "fontExtraProperties", "fontMatrix", "isInvalidPDFjsFont",
  "isType3Font", "italic", "loadedName", "mimetype", "missingFile",
  "name", "remeasure", "systemFontInfo", "vertical",
]

compileFontInfo packs these properties into one ArrayBuffer with a compact typed layout: booleans at two bits each, numbers as Float64, fixed-length arrays for bbox/fontMatrix, a pooled string region, and the font binary embedded verbatim. Keys that were never set (like the demo's bold) are encoded as "absent" and take no space.

Step 7: TranslatedFont.send — Once and 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);
}

The #sent flag guarantees each font is sent once — this is why four Tf calls in the demo produce only two commonobj messages (a second, independent gate alongside Part 11's dependency deduplication). The buffer goes into the transfer list, ownership moves to the main thread, and the worker keeps no copy.

The worker's job ends here. Timing-wise, this commonobj races the operatorList chunk — exactly the race described in Part 11.

Step 8: Main Thread — FontInfo Decoding, Then FontFace Mounting

The main thread's commonobj handler on PDFWorker receives the message:

messageHandler.on("commonobj", ([id, type, exportedData]) => {
  if (this.commonObjs.has(id)) {
    return null;
  }
  switch (type) {
    case "Font":
      const fontData = new FontInfo(exportedData);
      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(); // release the font binary right after mounting
          }
          this.commonObjs.resolve(id, font);
        });
      break;

Three roles divide the work:

  • FontInfo: wraps the buffer and lazily decodes with a DataView following the compile-time layout — bold, ascent, data, and friends are getters that read from the binary on access;
  • FontFaceObject: a layer of delegating getters plus a compiledGlyphs cache (used by the Type3/fallback paths) and createNativeFontFace;
  • FontLoader.bind: actually mounts the font into the document.

The core of bind:

async bind(font) {
  if (font.attached || (font.missingFile && !font.systemFontInfo)) {
    return; // mount each font only once
  }
  font.attached = true;
 
  if (font.systemFontInfo) {
    await this.loadSystemFont(font); // system-font substitution path, not the demo
    return;
  }
 
  if (this.isFontLoadingAPISupported) {
    const nativeFontFace = font.createNativeFontFace();
    if (nativeFontFace) {
      this.addNativeFontFace(nativeFontFace); // document.fonts.add(...)
      try {
        await nativeFontFace.loaded;          // wait for the browser to truly load it
      } catch (ex) {
        font.disableFontFace = true;
        throw ex;
      }
    }
  }
  ...
}

For the demo, createNativeFontFace is a single line:

nativeFontFace = new FontFace(this.loadedName, this.data, {});
// new FontFace("g_d0_f1", <OpenType buffer>, {})

this.data is the OTF bytes packed on the worker; the mimetype (font/opentype) is not needed on this API path — it only appears in the legacy @font-face + data URL fallback as url(data:font/opentype;base64,...).

commonObjs.resolve(id, font) runs only after await nativeFontFace.loaded completes — in other words, the dependency unblocks at the moment the browser has truly loaded the font, not when the message arrives. Afterwards, the setFont in Part 10 retrieves exactly this FontFaceObject from commonObjs.get("g_d0_f1").

The finally clause calls clearData() to drop the font binary reference inside the FontFaceObject: the font now belongs to document.fonts, and keeping the buffer would only waste memory (large fonts can be megabytes). A later cleanup (Part 8) removes the FontFace from the document.

Step 9: Failure Fallback — FontFallback and Per-Glyph Paths

If bind fails (corrupt font, browser refusal), the catch sends FontFallback back to the worker. The worker's TranslatedFont.fallback responds:

fallback(handler, evaluatorOptions) {
  if (!this.font.data) {
    return;
  }
  // Font loading failed; fall back to the built-in renderer
  this.font.disableFontFace = true;
  // Resend path data for every glyph parsed so far
  PartialEvaluator.buildFontPaths(
    this.font,
    this.font.glyphCacheValues,
    handler,
    evaluatorOptions
  );
}

From then on, that font's text no longer goes through fillText; each glyph is drawn as a Path2D (cached by FontFaceObject.getPathGenerator on the main thread). This is the degraded form of the font pipeline: slower, but no content is lost. Both demo fonts load normally and never take this path.

Summary

Following the demo's two embedded fonts, this article walked the complete font pipeline from the Tf operator to the browser's FontFace:

  1. loadFont first checks fontCache by Ref; the same reference reuses the same promise;
  2. preEvaluateFont fingerprints encoding, character range, ToUnicode, and widths with MurmurHash3_64, deduplicating identical fonts across pages;
  3. fontID (f1/f2) plus the document prefix produce the cross-thread names g_d0_f1 / g_d0_f2;
  4. translateFont tries FontFile / FontFile2 / FontFile3 in order — the demo hits FontFile3 (Type1C) — and collects descriptor metrics into properties;
  5. the Font constructor sniffs the file magic to confirm the real type, recompiles the CFF via CFFParser + CFFCompiler, and convert() hand-builds an OpenType with nine tables: cmap, CFF, OS/2, head, hhea, hmtx, maxp, name, post;
  6. exportData compresses 22 properties into a single ArrayBuffer via compileFontInfo;
  7. TranslatedFont.send uses #sent to guarantee a single send, with the buffer transferred zero-copy;
  8. on the main thread, FontInfo lazily decodes the buffer, FontFaceObject wraps it, and FontLoader.bind creates a FontFace and awaits loaded before resolving the dependency;
  9. after mounting, clearData() immediately releases the binary; on failure, FontFallback degrades to per-glyph path rendering.

So the commonObjs entry behind a setFont operator is not a simple file hand-off, but the result of a full pipeline of format translation, deduplication, transfer, and mounting. And the demo first page can draw © and bold text correctly precisely because of unglamorous fields at both ends of this pipeline: the 737 width and the OS/2 bold bits.

With that, both inputs to the demo's render — the operatorList (Part 11) and fonts (this article) — are complete. Next we turn to rendering's other leg: the text layer getTextContent, where the worker evaluates the same content stream a second time to extract selectable text and positions.

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