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

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.
| Class | Input | Public result | Main consumers |
|---|---|---|---|
FontInfo | { buffer, extra } | Font metrics, names, data, and nested info | FontFaceObject, FontLoader |
CssFontInfo | Standalone CSS info buffer | fontFamily, fontWeight, italicAngle | Creating FontFace or @font-face |
SystemFontInfo | Standalone system-font info buffer | Fallback flag, CSS, source, and style | FontLoader.loadSystemFont, canvas.js |
PatternInfo | Pattern buffer | Axial, radial, or Mesh IR array | commonObjs, then the painting path |
FontPathInfo | Glyph-path buffer | Float16Array or Float32Array view | makePathFromDrawOPS |
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:
| Region | Start offset | Length | Read strategy |
|---|---|---|---|
| Booleans | 0 | 3 | 2 bits per field |
Three Float64 values | 3 | 24 | DataView default big-endian |
bbox | 27 | 9 | One-byte length + four little-endian Int16 values |
fontMatrix | 36 | 49 | One-byte length + six little-endian Float64 values |
defaultVMetrics | 85 | 7 | One-byte length + three little-endian Int16 values |
| String region | 92 | Variable | Total 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));
}| Region | Location | Meaning |
|---|---|---|
| Flag | 0 | Nonzero means guessFallback === true |
| Total string length | 1..4 | Big-endian byte count of all four length prefixes and contents |
| String contents | 5 | css, loadedName, baseFontName, src |
| Style block | 5 + stringsLength | style.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 value | 2-bit form | Getter result |
|---|---|---|
undefined | 00 | undefined |
false | 01 | false |
true | 10 | true |
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;
}| Getter | Length-marker offset | Expected length | Element read | Bytes per item |
|---|---|---|---|---|
bbox | 27 | 4 | getInt16(..., true) | 2 |
fontMatrix | 36 | 6 | getFloat64(..., true) | 8 |
defaultVMetrics | 85 | 3 | getInt16(..., 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:
| Region | Length-prefix location | Content start |
|---|---|---|
| Four font strings | Total S at 92 | 96 |
systemFontInfo | 96 + S | 100 + S |
cssFontInfo | 100 + S + Ls | 104 + S + Ls |
data | 104 + S + Ls + Lc | 108 + 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:
- A new
datagetter reads length 0 and returnsundefined. - Strings, booleans, numbers, arrays, and nested-info length fields are preserved and remain readable.
- The internal
#bufferis physically shortened tooffset + 4; the old large buffer can be collected if nothing else references it. - 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:
| Offset | Field | Read result |
|---|---|---|
| 0 | KIND | 1 axial, 2 radial, 3 mesh |
| 1 | HAS_BBOX | Whether four Float32 values are present |
| 2 | HAS_BACKGROUND | Whether a three-byte background is present |
| 3 | SHADING_TYPE | Mesh shadingType |
| 4 | N_COORD | Number of coordinate pairs |
| 8 | N_COLOR | Number of color entries with four-byte strides |
| 12 | N_STOP | Number of gradient stops |
| 16 | N_FIGURES | Core 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.
| Access | Work on every call | Copies data? | Caches result? |
|---|---|---|---|
| Booleans, numbers | Reads the current bytes again | No | No |
| Regular strings | Relocates and UTF-8 decodes again | Produces a decoded string | No |
Arrays such as bbox | Rereads elements and creates an array | Yes, an ordinary array | No |
data | Relocates and creates a view | No | No |
| CSS/System info | Recopies the nested region and creates a wrapper | Yes | No |
PatternInfo.getIR() | Rebuilds stops, bbox, bounds, and IR | Partially | No |
FontPathInfo.path | Creates a view of the same type | No | No |
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
| Data | Read API | Byte order |
|---|---|---|
| String, region, and data lengths | getUint32(offset) | Default big-endian |
Three numbers including ascent | getFloat64(offset) | Default big-endian |
bbox, defaultVMetrics | getInt16(offset, true) | Little-endian |
fontMatrix | getFloat64(offset, true) | Little-endian |
| Pattern counts, stops, bbox | getUint32/getFloat32(..., true) | Little-endian |
Pattern coords | Float32Array | Native platform order |
| Every FontPath element | Float16Array / Float32Array | Native 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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026