StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: obj_bin_transform_display

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: obj_bin_transform_display

Introduction

The previous core/obj_bin_transform_core.js article covered the write side: how the worker compiles fonts, patterns, and glyph paths into an ArrayBuffer. This article follows those bytes onto the main thread through the 441-line src/display/obj_bin_transform_display.js.

We will not revisit why structured cloning drops prototypes or repeat the five compile functions. The real question is how the read side interprets the same byte protocol: how it locates strings, unpacks three-state booleans, skips optional regions, chooses between copies and views, and releases large font data after a DOM loading attempt.

This file is not a one-shot deserialize(buffer) -> object operation. It exports five lightweight wrappers that retain the original buffer and read it on demand through getters or getIR(). Understanding it requires three perspectives: field layout, read timing, and buffer ownership.

Module Overview

Not all five exported classes are instantiated directly in api.js. FontInfo, PatternInfo, and FontPathInfo correspond to three kinds of commonobj; CssFontInfo and SystemFontInfo are created by the nested-info getters on FontInfo.

ClassInputPublic resultMain consumers
FontInfo{ buffer, extra }Font metrics, names, data, and nested infoFontFaceObject, FontLoader
CssFontInfoStandalone CSS info bufferfontFamily, fontWeight, italicAngleCreating FontFace or @font-face
SystemFontInfoStandalone system-font info bufferFallback flag, CSS, source, and styleFontLoader.loadSystemFont, canvas.js
PatternInfoPattern bufferAxial, radial, or Mesh IR arraycommonObjs, then the painting path
FontPathInfoGlyph-path bufferFloat16Array or Float32Array viewmakePathFromDrawOPS

The shared schema still comes from src/shared/obj_bin_transform_utils.js. The read side does not define a separate field order:

class CSS_FONT_INFO {
  static strings = ['fontFamily', 'fontWeight', 'italicAngle'];
}
 
class SYSTEM_FONT_INFO {
  static strings = ['css', 'loadedName', 'baseFontName', 'src'];
}
 
class FONT_INFO {
  static bools = [
    'black',
    'bold',
    'disableFontFace',
    'fontExtraProperties',
    'isInvalidPDFjsFont',
    'isType3Font',
    'italic',
    'missingFile',
    'remeasure',
    'vertical',
  ];
 
  static numbers = ['ascent', 'defaultWidth', 'descent'];
  static strings = ['fallbackName', 'loadedName', 'mimetype', 'name'];
 
  static OFFSET_NUMBERS = Math.ceil((this.bools.length * 2) / 8);
  static OFFSET_BBOX = this.OFFSET_NUMBERS + this.numbers.length * 8;
  static OFFSET_FONT_MATRIX = this.OFFSET_BBOX + 1 + 2 * 4;
  static OFFSET_DEFAULT_VMETRICS = this.OFFSET_FONT_MATRIX + 1 + 8 * 6;
  static OFFSET_STRINGS = this.OFFSET_DEFAULT_VMETRICS + 1 + 2 * 3;
}

The ten booleans occupy three bytes, giving these fixed-region boundaries:

RegionStart offsetLengthRead strategy
Booleans032 bits per field
Three Float64 values324DataView default big-endian
bbox279One-byte length + four little-endian Int16 values
fontMatrix3649One-byte length + six little-endian Float64 values
defaultVMetrics857One-byte length + three little-endian Int16 values
String region92VariableTotal length followed by four length-prefixed strings

InfoUtils.decoder itself uses shadow to cache one TextDecoder, but that does not mean property values are cached. As shown below, string getters scan and invoke this shared decoder again on every access.

Method-Level Source and Analysis

commonobj: Three Binary Branches, Three Delivery Models

When api.js receives a message, the Font branch retains a FontInfo wrapper, the Pattern branch immediately reconstructs IR, and the FontPath branch places a path wrapper in commonObjs:

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();
      }
      this.commonObjs.resolve(id, font);
    });
  break;
case "FontPath":
  this.commonObjs.resolve(id, new FontPathInfo(exportedData));
  break;
case "Pattern":
  const pattern = new PatternInfo(exportedData);
  this.commonObjs.resolve(id, pattern.getIR());
  break;

The exportedData sent by Font.exportData() is not a bare buffer. It is an object containing buffer, charProcOperatorList, and extra. The latter two are not compiled into the binary data; the FontInfo constructor destructures only buffer and extra, while api.js passes the other two values to FontFaceObject.

fontLoader.bind(font) reaches finally whether it succeeds or enters fallback. Unless fontExtraProperties is enabled, it calls clearData() so the embedded font program does not remain in memory after the loading attempt. Data is retained when extra properties are enabled for inspection.

FontFaceObject is the stable outer object and forwards most property accesses to #fontData. The behavior of FontInfo getters therefore affects font loading and later rendering directly; these getters do not run only once during initialization.

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;
}

Notice the repeated accesses to this.cssFontInfo. Because the underlying getter does not cache, every access copies the nested buffer and creates a wrapper again; there is no cached css object here. By contrast, each data access creates a new Uint8Array view over the same underlying buffer until clearData() replaces the internal buffer.

The system-font path ultimately consumes loadedName, src, and style, using baseFontName in a warning if loading fails. canvas.js also reads systemFontInfo?.css as the typeface for a canvas font. All these fields come from the decoders below, not from prototype properties on a worker-side class instance.

CssFontInfo: Sequential String Scanning from Offset 0

CSS info consists of three consecutive entries containing a big-endian Uint32 byte length followed by UTF-8 bytes, with no total-length header. fontFamily, fontWeight, and italicAngle correspond to indexes 0, 1, and 2 respectively.

class CssFontInfo {
  #buffer;
  #view;
 
  constructor(buffer) {
    this.#buffer = buffer;
    this.#view = new DataView(buffer);
  }
 
  #readString(index) {
    assert(index < CSS_FONT_INFO.strings.length, 'Invalid string index');
    const { decoder } = InfoUtils;
    let offset = 0;
    for (let i = 0; i < index; i++) {
      offset += this.#view.getUint32(offset) + 4;
    }
    const length = this.#view.getUint32(offset);
    return decoder.decode(new Uint8Array(this.#buffer, offset + 4, length));
  }
}

The three public getters simply call #readString(0), #readString(1), and #readString(2). To read entry index, the loop skips only the preceding strings. Each skipped entry adds 4 + byteLength to the offset; the target length is then read at its own prefix, and its UTF-8 view begins at offset + 4.

The length is a UTF-8 byte count, not the number of UTF-16 code units in a JavaScript string. getUint32(offset) has no second argument, so it uses the DataView default of big-endian, exactly matching core's setUint32(offset, length).

None of the three getters caches. Reading italicAngle skips the first two entries; reading it again repeats the scan, view creation, and decode. Only the TextDecoder instance is reused.

SystemFontInfo: Total Length at Offset 1 and Special style Lookup

System-font info adds a one-byte guessFallback before the strings and stores an unaligned Uint32 total string length at offset 1. DataView supports unaligned access, so offset 1 is not a mistake.

Regular strings start at offset 5 and retain the four-entry length-prefixed layout:

get guessFallback() {
  return this.#view.getUint8(0) !== 0;
}
 
#readString(index) {
  assert(index < SYSTEM_FONT_INFO.strings.length, "Invalid string index");
  const { decoder } = InfoUtils;
  let offset = 5;
  for (let i = 0; i < index; i++) {
    offset += this.#view.getUint32(offset) + 4;
  }
  const length = this.#view.getUint32(offset);
  return decoder.decode(new Uint8Array(this.#buffer, offset + 4, length));
}
RegionLocationMeaning
Flag0Nonzero means guessFallback === true
Total string length1..4Big-endian byte count of all four length prefixes and contents
String contents5css, loadedName, baseFontName, src
Style block5 + stringsLengthstyle.style, then style.weight

style does not reuse #readString, because it jumps directly via the total length instead of scanning the four regular strings:

get style() {
  const { decoder } = InfoUtils;
  let offset = 1;
  offset += 4 + this.#view.getUint32(offset);
  const styleLength = this.#view.getUint32(offset);
  const style = decoder.decode(
    new Uint8Array(this.#buffer, offset + 4, styleLength)
  );
  offset += 4 + styleLength;
  const weightLength = this.#view.getUint32(offset);
  const weight = decoder.decode(
    new Uint8Array(this.#buffer, offset + 4, weightLength)
  );
  return { style, weight };
}

Initially, offset = 1 points to the total string length. The expression offset += 4 + stringsLength adds both the four-byte total-length field and the string contents, landing exactly at 5 + stringsLength, where the style length prefix begins. This apparently unusual jump corresponds directly to core backpatching the total length at offset 1.

After reading style, the offset advances by 4 + styleLength to the weight length prefix. The getter finally returns a new { style, weight } object. All lengths still use the default big-endian order.

There is a read/write asymmetry that must be understood as implemented: core's if (info.style) permits omitting the style block, but display's style getter has no presence flag or null branch. It assumes the current system-font info includes a style, as normal font-substitution data does. Calling this getter on a manually constructed buffer without a style does not return null; it can throw on an out-of-bounds read. The protocol must not be silently reinterpreted as nullable style with safe read-side handling.

FontInfo: extra Outside the Buffer, Fields Decoded in Getters

The constructor retains the buffer and creates a DataView over all of it. If extra exists, its properties are copied onto the instance as ordinary own properties; these debugging or rendering extras play no part in the binary addressing below.

constructor({ buffer, extra }) {
  this.#buffer = buffer;
  this.#view = new DataView(buffer);
  if (extra) {
    Object.assign(this, extra);
  }
}

Construction does not eagerly read every font property or instantiate nested info. Apart from extra, fields are decoded only when their getters are accessed.

Three-State Booleans: 2 Bits per Field, Not One Byte per Boolean

#readBoolean(index) {
  assert(index < FONT_INFO.bools.length, "Invalid boolean index");
  const byteOffset = Math.floor(index / 4);
  const bitOffset = (index * 2) % 8;
  const value = (this.#view.getUint8(byteOffset) >> bitOffset) & 0x03;
  return value === 0x00 ? undefined : value === 0x02;
}

One byte holds four fields. Index 0 uses the least-significant two bits, index 1 uses the next two, and so on. byteOffset advances every four indexes, while bitOffset cycles through 0, 2, 4, and 6.

Written value2-bit formGetter result
undefined00undefined
false01false
true10true

Thus undefined and false remain distinct during transport. Current core never produces 11; display's ternary yields false for anything other than 00 and 10, and the source has no extra branch rejecting 11.

Field indexes are black, bold, disableFontFace, fontExtraProperties, isInvalidPDFjsFont, isType3Font, italic, missingFile, remeasure, and vertical, in that order. The first eight occupy bytes 0 and 1; the last two occupy the low four bits of byte 2.

Numbers and Fixed-Size Arrays: Fixed Offsets, Two Byte Orders

The three numbers are read directly by schema index, eight bytes each:

#readNumber(index) {
  assert(index < FONT_INFO.numbers.length, "Invalid number index");
  return this.#view.getFloat64(FONT_INFO.OFFSET_NUMBERS + index * 8);
}

ascent(0), defaultWidth(1), and descent(2) are at offsets 3, 11, and 19 respectively. getFloat64 receives no littleEndian argument and therefore reads big-endian; core's setFloat64(offset, value) likewise uses default big-endian.

Array reads, however, all pass true explicitly for little-endian:

#readArray(offset, arrLen, lookupName, increment) {
  const len = this.#view.getUint8(offset);
  if (len === 0) {
    return undefined;
  }
  assert(len === arrLen, "Invalid array length.");
  offset += 1;
  const arr = new Array(len);
  for (let i = 0; i < len; i++) {
    arr[i] = this.#view[lookupName](offset, true);
    offset += increment;
  }
  return arr;
}
GetterLength-marker offsetExpected lengthElement readBytes per item
bbox274getInt16(..., true)2
fontMatrix366getFloat64(..., true)8
defaultVMetrics853getInt16(..., true)2

A zero length means the field is absent and returns undefined; a nonzero value must equal the fixed length or the assertion fails. The writer reserves payload space even when a field is absent, so subsequent fixed offsets never move. Every successful read creates a new ordinary JavaScript Array, not a typed array, and does not cache it.

This is the clearest reason not to assume one byte order: within the same FontInfo, the three standalone numbers are big-endian, the six Float64 values in fontMatrix are little-endian, and integers in bbox and defaultVMetrics are explicitly little-endian too. Matching the writer does not make the whole buffer uniformly ordered.

String Region: Total Length for Skipping, Entry Lengths for Decoding

FONT_INFO.strings contains fallbackName, loadedName, mimetype, and name, in that order. Unlike CSS info, the whole string region starts with a total length at offset 92; the first string's own length prefix starts at offset 96.

#readString(index) {
  assert(index < FONT_INFO.strings.length, "Invalid string index");
  const { decoder } = InfoUtils;
  let offset = FONT_INFO.OFFSET_STRINGS + 4;
  for (let i = 0; i < index; i++) {
    offset += this.#view.getUint32(offset) + 4;
  }
  const length = this.#view.getUint32(offset);
  return decoder.decode(new Uint8Array(this.#buffer, offset + 4, length));
}

OFFSET_STRINGS + 4 does not skip one field too many: it skips exactly the region's total length, not the first string's length. Inside the loop, getUint32(offset) reads the length of the current string being skipped.

Let S be the total payload of the four strings, including every four-byte length prefix and UTF-8 content but excluding the total-length field at offset 92. The tail regions then line up as follows:

RegionLength-prefix locationContent start
Four font stringsTotal S at 9296
systemFontInfo96 + S100 + S
cssFontInfo100 + S + Ls104 + S + Ls
data104 + S + Ls + Lc108 + S + Ls + Lc

Here Ls and Lc are the actual byte lengths of the nested system and css buffers. Every length field is a big-endian Uint32, and zero means the corresponding nested info or data is absent.

data and Tail Lookup: Skip Sequentially, Then Build a Zero-Copy View

#getDataOffsets() ignores string contents and advances through three length-prefixed regions before reading the data length:

#getDataOffsets() {
  let offset = FONT_INFO.OFFSET_STRINGS;
  const stringsLength = this.#view.getUint32(offset);
  offset += 4 + stringsLength;
  const systemFontInfoLength = this.#view.getUint32(offset);
  offset += 4 + systemFontInfoLength;
  const cssFontInfoLength = this.#view.getUint32(offset);
  offset += 4 + cssFontInfoLength;
  const length = this.#view.getUint32(offset);
 
  return { offset, length };
}
 
get data() {
  const { offset, length } = this.#getDataOffsets();
  return length === 0
    ? undefined
    : new Uint8Array(this.#buffer, offset + 4, length);
}

The returned offset points to the data length field, not to the font bytes themselves, so data constructs its view at offset + 4. length === 0 covers both "data was originally absent" and "clearData has already run."

This getter does not copy the font program; it creates only a Uint8Array view. Repeated accesses repeat the lookup and create new views, all sharing the current #buffer. If an old view is retained externally, replacing FontInfo's #buffer later does not retarget that view.

clearData: Preserve the Prefix and Copy It into a Smaller Buffer

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);
}

First, the data length in the old buffer is set to 0. Next, a view covers bytes 0 through offset + 4, including the fixed header, strings, both nested info regions, and the four-byte data length, but excluding the data payload. .slice() copies this range, and its buffer becomes the new backing store.

After cleanup:

  1. A new data getter reads length 0 and returns undefined.
  2. Strings, booleans, numbers, arrays, and nested-info length fields are preserved and remain readable.
  3. The internal #buffer is physically shortened to offset + 4; the old large buffer can be collected if nothing else references it.
  4. A second call finds the same terminal length field, now 0, and returns without shrinking again or throwing. An originally absent data block takes the same branch, making the operation idempotent.

This is not secure erasure. Old data bytes are not zeroed individually, and a Uint8Array returned before cleanup still references the old buffer, can still expose its bytes, and can keep that memory alive. clearData() releases FontInfo's own claim on the large payload; it does not revoke views already handed out.

cssFontInfo / systemFontInfo: Why Child Buffers Are Copied

Both nested getters first locate their region sequentially. A zero length returns null; otherwise they allocate an exactly sized array and copy:

get cssFontInfo() {
  let offset = FONT_INFO.OFFSET_STRINGS;
  const stringsLength = this.#view.getUint32(offset);
  offset += 4 + stringsLength;
  const systemFontInfoLength = this.#view.getUint32(offset);
  offset += 4 + systemFontInfoLength;
  const cssFontInfoLength = this.#view.getUint32(offset);
  if (cssFontInfoLength === 0) {
    return null;
  }
  const cssFontInfoData = new Uint8Array(cssFontInfoLength);
  cssFontInfoData.set(
    new Uint8Array(this.#buffer, offset + 4, cssFontInfoLength)
  );
  return new CssFontInfo(cssFontInfoData.buffer);
}
 
get systemFontInfo() {
  let offset = FONT_INFO.OFFSET_STRINGS;
  const stringsLength = this.#view.getUint32(offset);
  offset += 4 + stringsLength;
  const systemFontInfoLength = this.#view.getUint32(offset);
  if (systemFontInfoLength === 0) {
    return null;
  }
  const systemFontInfoData = new Uint8Array(systemFontInfoLength);
  systemFontInfoData.set(
    new Uint8Array(this.#buffer, offset + 4, systemFontInfoLength)
  );
  return new SystemFontInfo(systemFontInfoData.buffer);
}

Copying first satisfies each constructor's coordinate system: CssFontInfo reads its first string at 0, while SystemFontInfo reads its flag at 0 and total length at 1. Passing only the parent buffer would leave the nested contents away from offset 0, and the current constructors have no byteOffset parameter for retaining just a view.

Copying also separates lifetimes. A retained nested wrapper owns only its small, exact buffer instead of keeping a parent buffer alive along with a font program that may occupy several megabytes. Existing nested wrappers are also unaffected when FontInfo.clearData() replaces the parent buffer.

This independence has a cost: neither getter caches, so every access copies and constructs a wrapper again. The source lets callers decide whether to retain a result rather than adding cache-invalidation rules to FontInfo.

PatternInfo: Decoding Five Payload Regions After a 20-Byte Header

The constructor keeps three views: buffer for typed arrays, view for explicit numeric reads, and data for one-byte flags. getIR() performs the read in one operation rather than splitting it across font-like property getters.

getIR() {
  const dataView = this.view;
  const kind = this.data[PATTERN_INFO.KIND];
  const hasBBox = !!this.data[PATTERN_INFO.HAS_BBOX];
  const hasBackground = !!this.data[PATTERN_INFO.HAS_BACKGROUND];
  const nCoord = dataView.getUint32(PATTERN_INFO.N_COORD, true);
  const nColor = dataView.getUint32(PATTERN_INFO.N_COLOR, true);
  const nStop = dataView.getUint32(PATTERN_INFO.N_STOP, true);
 
  let offset = 20;
  const coords = new Float32Array(this.buffer, offset, nCoord * 2);
  offset += nCoord * 8;
  const colors = new Uint8Array(this.buffer, offset, nColor * 4);
  offset += nColor * 4;
  const stops = [];
  for (let i = 0; i < nStop; ++i) {
    const p = dataView.getFloat32(offset, true);
    offset += 4;
    const rgb = dataView.getUint32(offset, true);
    offset += 4;
    stops.push([p, `#${rgb.toString(16).padStart(6, "0")}`]);
  }
  let bbox = null;
  if (hasBBox) {
    bbox = [];
    for (let i = 0; i < 4; ++i) {
      bbox.push(dataView.getFloat32(offset, true));
      offset += 4;
    }
  }
 
  let background = null;
  if (hasBackground) {
    background = new Uint8Array(this.buffer, offset, 3);
    offset += 3;
  }
  // The kind-specific return value follows.
}

The header is laid out as follows, with every multibyte count explicitly little-endian:

OffsetFieldRead result
0KIND1 axial, 2 radial, 3 mesh
1HAS_BBOXWhether four Float32 values are present
2HAS_BACKGROUNDWhether a three-byte background is present
3SHADING_TYPEMesh shadingType
4N_COORDNumber of coordinate pairs
8N_COLORNumber of color entries with four-byte strides
12N_STOPNumber of gradient stops
16N_FIGURESCore currently writes 0; display does not read it

Starting at 20, coords contains nCoord * 2 Float32 values, or eight bytes per coordinate pair; colors contains nColor * 4 raw bytes. Each stop contains a little-endian Float32 position and a little-endian Uint32 RGB value. The optional bbox contains four little-endian Float32 values, and the optional background contains three raw bytes.

Both coords and colors are zero-copy views over the original buffer. stops is a new ordinary array, with each color reconstructed as #rrggbb through toString(16).padStart(6, "0"); hexadecimal letters become lowercase, and positions are now single-precision values. A present bbox becomes a new ordinary array, while background is a three-byte view.

One easy-to-miss byte-order detail is that coords uses new Float32Array(...), not getFloat32(..., true). A typed array interprets bytes in the platform's native order, and core writes this region through a native Float32Array too. It is not an "explicit little-endian" field. Pattern counts, stops, and bbox are the explicitly little-endian fields.

The same trailing switch constructs the three kinds of return value:

if (kind === 1) {
  return [
    'RadialAxial',
    'axial',
    bbox,
    stops,
    Array.from(coords.slice(0, 2)),
    Array.from(coords.slice(2, 4)),
    null,
    null,
  ];
}
if (kind === 2) {
  return [
    'RadialAxial',
    'radial',
    bbox,
    stops,
    [coords[0], coords[1]],
    [coords[3], coords[4]],
    coords[2],
    coords[5],
  ];
}
if (kind === 3) {
  const shadingType = this.data[PATTERN_INFO.SHADING_TYPE];
  let bounds = null;
  if (coords.length > 0) {
    bounds = BBOX_INIT.slice();
    for (let i = 0, ii = coords.length; i < ii; i += 2) {
      Util.pointBoundingBox(coords[i], coords[i + 1], bounds);
    }
  }
  return ['Mesh', shadingType, coords, colors, nCoord, bounds, bbox, background];
}
throw new Error(`Unsupported pattern kind: ${kind}`);

An axial pattern restores four coordinates into two ordinary point arrays and leaves both radius positions null. A radial pattern reads the six-tuple [x0, y0, r0, x1, y1, r1] and reconstructs two centers and two radii. These small coordinate arrays are copied into ordinary arrays rather than exposing the backing typed array to gradient IR.

Mesh retains the large coords and colors views and places nCoord at IR index 4 as the vertex count. bounds at index 5 is not transmitted; it starts from a copy of [Infinity, Infinity, -Infinity, -Infinity] and accumulates minimum and maximum x/y values point by point, or remains null with no coordinates. bbox at index 6 is the optional declared bounding box stored in the buffer and must not be confused with recomputed bounds. Index 7 is the optional background.

getIR() itself does not cache its result. However, api.js's Pattern branch calls it only once and places the returned IR, not the PatternInfo wrapper, in commonObjs. Typed-array views inside the IR retain the underlying buffer, so dropping the wrapper reference does not invalidate their data.

FontPathInfo: No Field Header; Type Determined by Build and Runtime

A path buffer consists solely of the raw bytes of a flat typed array, with no element count, precision tag, or byte-order tag:

class FontPathInfo {
  #buffer;
 
  constructor(buffer) {
    this.#buffer = buffer;
  }
 
  get path() {
    if (
      (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) ||
      FeatureTest.isFloat16ArraySupported
    ) {
      return new Float16Array(this.#buffer);
    }
    return new Float32Array(this.#buffer);
  }
}

A MOZCENTRAL build or a runtime supporting Float16Array produces a half-precision view; otherwise the getter falls back to Float32Array. FeatureTest.isFloat16ArraySupported actually checks typeof Float16Array !== "undefined" and caches that capability through shadow.

This condition matches the one used by worker-side Commands.getPath() to choose the written array type. compileFontPathInfo performs only path.slice().buffer, with no 16/32-bit conversion; display does not manually decode half floats either, but selects the matching typed-array constructor.

Every path access creates a new view over the whole buffer without copying bytes or caching the view. FontFaceObject.getPathGenerator later passes cmds?.path to makePathFromDrawOPS; only the generated path function is then cached in compiledGlyphs. That is a separate cache layer and must not be mistaken for caching inside FontPathInfo.path.

As with Pattern's coords, there is no DataView byte-order argument here; the array interprets raw bytes in native platform order. The buffer's byte length must also suit the selected element width, a protocol precondition guaranteed by valid writer input.

Design Notes

1. Getters Decode on Demand; They Do Not Automatically Cache

The file is "lazy" mainly because construction does not materialize complete objects. It does not pair every property with memoization.

AccessWork on every callCopies data?Caches result?
Booleans, numbersReads the current bytes againNoNo
Regular stringsRelocates and UTF-8 decodes againProduces a decoded stringNo
Arrays such as bboxRereads elements and creates an arrayYes, an ordinary arrayNo
dataRelocates and creates a viewNoNo
CSS/System infoRecopies the nested region and creates a wrapperYesNo
PatternInfo.getIR()Rebuilds stops, bbox, bounds, and IRPartiallyNo
FontPathInfo.pathCreates a view of the same typeNoNo

What is cached is the shared TextDecoder and FeatureTest capability values, not these property results. commonObjs and compiledGlyphs also belong to outer object-management layers, not to these getters.

2. Byte Order Is a Per-Field Protocol, Not a File-Wide Switch

DataRead APIByte order
String, region, and data lengthsgetUint32(offset)Default big-endian
Three numbers including ascentgetFloat64(offset)Default big-endian
bbox, defaultVMetricsgetInt16(offset, true)Little-endian
fontMatrixgetFloat64(offset, true)Little-endian
Pattern counts, stops, bboxgetUint32/getFloat32(..., true)Little-endian
Pattern coordsFloat32ArrayNative platform order
Every FontPath elementFloat16Array / Float32ArrayNative platform order

DataView's littleEndian argument defaults to false, so omission means big-endian. One-byte flags have no byte-order issue. When reading or changing this protocol, compare every line with core's setter: one true says nothing about the rest of the file, and a typed array is not an explicitly little-endian read.

3. Length Fields Provide Both Navigation and Presence

The fixed header permits direct addressing of frequent small fields, while the variable tail skips entries by reading a length and advancing 4 + length. No absolute offset per string is required, and no UTF-8 content must be scanned for delimiters.

Zero length has context-specific return conventions: array length 0 yields undefined, nested-info length 0 yields null, and data length 0 yields undefined. These differences are part of the source API and should not be normalized merely for visual consistency.

4. Views Versus Copies Balance Cost and Ownership

Large font data, Pattern coordinate/color arrays, and FontPath all favor views to avoid another copy. Small axial/radial coordinates, stops, bbox, and ordinary array fields are materialized directly into structures convenient for painting code.

Copying CSS/System info may look like an exception, but it solves both zero-based child-buffer addressing and independent lifetime. clearData() copies too, but retains only the short prefix before data: one small copy creates the opportunity to release a large font program.

5. Releasing the Current Owner Does Not Release Every Existing Reference

After FontInfo replaces its private buffer, future getters use the new buffer; old views, old nested objects, and typed arrays already handed to other code are not rewritten when that private field changes. To determine whether memory use actually falls, keep tracking whether a FontFace, temporary data view, or debugging tool still retains the old data.

Summary

obj_bin_transform_display.js is the main-thread interpreter for core's binary protocol. FontInfo restores fonts from three bytes of bitfields, fixed numeric regions, and a length-prefixed tail; CssFontInfo and SystemFontInfo scan UTF-8 strings in independent child buffers; PatternInfo.getIR() turns a 20-byte header and five payload regions into three kinds of IR; and FontPathInfo selects a half- or single-precision view through shared environmental assumptions.

The implementation is more specific than "turn a buffer back into an object." Its four central constraints are exact field-order agreement with core, per-field distinction among big-endian, little-endian, and native typed-array order, on-demand getters that mostly do not cache, and memory ownership managed jointly through views, child-buffer copies, and clearData().

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