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

Introduction
src/core/core_utils.js is not a processing stage; it is a shared toolbox.
The previous articles described a pipeline: how XRef locates objects, how Lexer and Parser cut bytes into objects, how PDFDocument organises dictionaries into pages, how PartialEvaluator turns a content stream into an operatorList. Those modules have strict upstream and downstream relations, and core_utils.js sits across all of them: the syntax layer uses its exception types and whitespace test, the document layer uses its inheritable-property walk, the annotation layer uses its matrices and dates, the writer layer uses its name escaping.
The file is 791 lines and exports 37 symbols. Only four of them are classes, and all four are exceptions; the rest are functions and constants, with no instance state, no cache objects and no mutable data kept across calls. That statelessness is not a style preference - it is the precondition for every module being able to reference it safely.
Its dependency direction is worth stating up front:
| Dependency | Used for |
|---|---|
../shared/util.js | BaseException, assert, Util, warn |
./primitives.js | Dict, Name, Ref, RefSet |
./base_stream.js | BaseStream (to tell whether action script is a stream) |
../shared/css_utils.js | CONTROL_CHAR_REGEXP |
./string_utils.js | stringToPDFString |
primitives.js, base_stream.js and string_utils.js never import core_utils.js back, so it is a leaf node in the dependency graph. Any module can import it without worrying about a cycle. Conversely, if getInheritableProperty lived in document.js, any use from xref.js would create a xref -> document -> xref cycle.
One sentence summary: this is where things live that several stages need but no stage owns - error classification, byte shuffling, inheritance chains, escaping, action collection, encoding and validation.
Module Overview
The 791 lines split into twelve blocks by function:
| Block | Key exports | Purpose |
|---|---|---|
| Constants | IDENTITY_MATRIX, RESOURCES_KEYS_OPERATOR_LIST, RESOURCES_KEYS_TEXT_CONTENT, PDF_VERSION_REGEXP, MAX_INT_32 | Identity matrix, resource key lists, version rule, 32-bit ceiling |
| Exceptions | MissingDataException, ParserEOFException, XRefEntryException, XRefParseException | Four distinguishable failures so callers can decide whether to fetch more data or recover |
| Bytes | arrayBuffersToBytes, fetchBinaryData | Concatenating multiple buffers, fetching wasm binaries |
| Dictionaries and comparison | getInheritableProperty, getParentToUpdate, deepCompare | Reading along the parent chain, locating a writable field, structural equality |
| Lookup tables and numbering | getLookupTableFactory, toRomanNumerals | Lazy large tables, Roman page labels |
| Character and numeric validation | isWhiteSpace, isBooleanArray, isNumberArray | Byte-level whitespace test, array shape checks |
| Geometry lookup | lookupMatrix, lookupRect, lookupNormalRect | Safe reads with length checks and normalisation |
| Escaping and paths | parseXFAPath, escapePDFName, escapeString | XFA path parsing, name and string escaping on write |
| Actions | collectActions (with the internal _collectJS) | Collecting JavaScript from Additional Actions and from A |
| XML | encodeToXmlString, codePointIter (with the internal XMLEntities) | Entity encoding when writing XFA back |
| Fonts and CSS | validateFontName, normalizeCSSFontFamily, validateCSSFont | Putting font names from PDF safely into CSS |
| Values and metadata | recoverJsURL, numberToString, getNewAnnotationsMap, getModificationDate, getRotationMatrix, getSizeInBytes | URL recovery, number serialisation, annotation grouping, dates, rotation matrices, byte widths |
Its position in the pipeline is not a single arrow: every layer references it. Lexer and Parser use the whitespace test and the EOF exception, XRef uses the four exceptions to pick a recovery strategy, Document and Catalog use inheritance and rectangle reads, PartialEvaluator uses the array checks and lookup factories, Annotation and the editor use the parent chain and matrices, Writer and the XFA modules use escaping and byte widths. The conclusion below turns this relation into a table.
Version note: This article follows exactly the version of
core_utils.jsin the current repository. It has no legacy helpers such asisValidUrlorcreatePromiseCapability;getInheritablePropertytakes a single object rather than positional arguments; anddeepCompare,getModificationDate,getSizeInBytes,getNewAnnotationsMapandrecoverJsURLall live in this file rather than being scattered acrossutil.js. Read the source, not memory.
Four Exceptions: Failures Need Classification Too
The four exceptions are defined at the very top of the file, in 25 lines:
class MissingDataException extends BaseException {
constructor(begin, end) {
super(`Missing data [${begin}, ${end})`, 'MissingDataException');
this.begin = begin;
this.end = end;
}
}
class ParserEOFException extends BaseException {
constructor(msg) {
super(msg, 'ParserEOFException');
}
}
class XRefEntryException extends BaseException {
constructor(msg) {
super(msg, 'XRefEntryException');
}
}
class XRefParseException extends BaseException {
constructor(msg) {
super(msg, 'XRefParseException');
}
}They all extend BaseException. BaseException is not in this file but in shared/util.js, where the prototype is given an Error instance and message and name are assigned directly:
const BaseException = (function BaseExceptionClosure() {
function BaseException(message, name) {
// ...
this.message = message;
this.name = name;
}
BaseException.prototype = new Error();
BaseException.constructor = BaseException;
return BaseException;
})();This idiom keeps exceptions recognisable by instanceof Error while turning name into a readable, serialisable string. name is not decoration here: wrapReason relies on it to rebuild an exception type from a plain object that crossed a Worker boundary.
The four exceptions map to four completely different failure semantics:
| Exception | Fields carried | Where it is raised | Action taken upstream |
|---|---|---|---|
MissingDataException | begin, end | ChunkedStream.ensureByte, ensureRange; a network document reading a range not yet downloaded | Record the range, request it, then retry from scratch |
ParserEOFException | message | Parser.getObj hits EOF inside an array or dictionary and is not in recovery mode | Treat the object as truncated, skip it, do not overwrite an existing xref entry |
XRefEntryException | message | XRef.fetchUncompressed / fetchCompressed find a missing entry or an out-of-range index | Convert to XRefParseException, or trigger full-file recovery directly |
XRefParseException | message | XRef.parse fails to validate Root and Pages | Switch to recoveryMode and reload the whole file |
The first row is the one most easily misread as an error. MissingDataException means "not downloaded yet", not "the file is broken". NetworkPdfManager.ensure treats it as an ordinary pause signal:
async ensure(obj, prop, args) {
try {
const value = obj[prop];
if (typeof value === "function") {
return await value.apply(obj, args);
}
return value;
} catch (ex) {
if (!(ex instanceof MissingDataException)) {
throw ex;
}
await this.requestRange(ex.begin, ex.end);
return this.ensure(obj, prop, args);
}
}Note the return this.ensure(obj, prop, args): no intermediate state is preserved, the whole property access simply runs again. Every downstream parse function must therefore be written reentrantly - it cannot assume that a previous run got halfway and left its results behind. begin and end form a half-open range precisely so that requestRange knows which slice to fetch.
ParserEOFException is a genuine structural problem, but it is only raised inside an object. parser.js has two clear raise sites: an array that never reaches ], and a dictionary that never reaches >>. By the time it reaches xref.indexObjects, it is downgraded to a warning:
parser.getObj();
updateEntries = true;
} catch (ex) {
if (ex instanceof ParserEOFException) {
warn(`indexObjects -- checking object (${token}): "${ex}".`);
} else {The meaning is: there really is an object header at this offset, but the object body is incomplete, so do not let it overwrite an existing entry. Recovery would rather keep the old record than mistake a truncated object for a new one.
XRefEntryException means "the index points somewhere wrong": an empty entry, a zero offset, an out-of-range object-stream index. When PDFDocument.checkFirstPage and checkLastPage catch it, they clear the page cache first and then throw XRefParseException:
try {
await this.getPage(0);
} catch (reason) {
if (reason instanceof XRefEntryException) {
this.#pagePromises.delete(0);
await this.cleanup();
throw new XRefParseException();
}
}cleanup() runs before the throw because a bad entry may already have pushed page objects into caches; without cleaning, the recovery retry would read an inconsistent state.
XRefParseException is the highest level of the four: the entire xref cannot be trusted. The Worker has exactly one response - download the whole file and parse it again from zero:
loadDocument(false).then(onSuccess, function (reason) {
ensureNotTerminated();
// Try again with recoveryMode == true
if (!(reason instanceof XRefParseException)) {
onFailure(reason);
return;
}
pdfManager.requestLoadedStream().then(function () {
ensureNotTerminated();
loadDocument(true).then(onSuccess, onFailure);
}, onFailure);
});Design point: all four exceptions stay inside the Worker. The
wrapReasonwhitelist inshared/message_handler.jsdoes not include them - it only knowsAbortException,InvalidPDFException,PasswordException,ResponseExceptionandUnknownErrorException. That is deliberate:MissingDataExceptionandXRefParseExceptionare control flow, not errors for callers to inspect. They must be handled before leaving the Worker, otherwise the main thread receives a vagueUnknownErrorException.
Joining Multiple Buffers
During progressive loading a file arrives in several ranges, each an independent ArrayBuffer. The parser, however, needs one contiguous run of bytes. arrayBuffersToBytes handles that:
function arrayBuffersToBytes(arr) {
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
for (const item of arr) {
assert(item instanceof ArrayBuffer, 'arrayBuffersToBytes - expected an ArrayBuffer.');
}
}
const length = arr.length;
if (length === 0) {
return new Uint8Array(0);
}
if (length === 1) {
return new Uint8Array(arr[0]);
}
let dataLength = 0;
for (let i = 0; i < length; i++) {
dataLength += arr[i].byteLength;
}
const data = new Uint8Array(dataLength);
let pos = 0;
for (let i = 0; i < length; i++) {
const item = new Uint8Array(arr[i]);
data.set(item, pos);
pos += item.byteLength;
}
return data;
}Each of the three branches has a purpose:
- Zero buffers returns a zero-length
Uint8Array, notnull. Callers can unconditionally read.lengthor take asubarraywithout an extra null check. - One buffer returns
new Uint8Array(buffer)directly. That is a view, not a copy: the new array shares memory with the original buffer. A unit test asserts exactlybytes.buffer === buffer. Progressive loading most often has just one chunk, and skipping a full copy is well worth it. - Several buffers sums
byteLengthfor the total, allocates once, then writes each chunk withset(item, pos). Unlike repeatedconcat, or pushing then joining, there is no intermediate array and no exponential growth.
The length is summed rather than computed as arr[0].byteLength * arr.length because chunk sizes can differ; the last chunk is usually shorter. Advancing pos by hand instead of using the return value of data.set (it has none) is the only slightly verbose part of the function.
The same file also contains one function that performs a network request:
async function fetchBinaryData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch file "${url}" with "${response.statusText}".`);
}
return response.bytes();
}It is the only side-effecting function in the toolbox and serves wasm decoders only (OpenJPEG and friends). Returning an ArrayBuffer rather than a Response means the caller can use it immediately; the response.ok check stops a 404 HTML page from being fed to wasm as binary and producing an obscure compile error.
Design point: the single-buffer fast path of
arrayBuffersToBytesreturns a view, so callers must not modify the result in place, or they corrupt the underlying buffer. Every caller in the source only reads. This zero-copy-but-shared contract has no type system to enforce it and survives only through naming and comments; seeingUint8Arrayreturned, do not assume an independent copy.
Parent Chain: Reading Values and Locating the Write Target Are Two Things
The getInheritableProperty Method
Both the PDF page tree and the AcroForm field tree allow property inheritance. The specification explicitly lists which keys are inheritable: pages have Resources, MediaBox, CropBox and Rotate; fields have FT, Ff, DA, Q and so on. If a child does not define them, walk up to Parent. getInheritableProperty implements that rule:
function getInheritableProperty({ dict, key, getArray = false, stopWhenFound = true }) {
let values;
const visited = new RefSet();
while (dict instanceof Dict && !(dict.objId && visited.has(dict.objId))) {
if (dict.objId) {
visited.put(dict.objId);
}
const value = getArray ? dict.getArray(key) : dict.get(key);
if (value !== undefined) {
if (stopWhenFound) {
return value;
}
(values ||= []).push(value);
}
dict = dict.get('Parent');
}
return values;
}A few details determine its behaviour:
- Reads dereference automatically.
dict.get(key)callsxref.fetchwhen it meets aRef, anddict.getArray(key)also dereferences everyRefinside the array. The caller receives the final value and never has to check forRefitself. - Cycle protection keys on
objId.objIdis theRefattached to aDictwhenXRefretrieves it. It is used instead of object identity because the same page dictionary may be wrapped in different instances along different paths while its object number and generation never change; at the same time, a broken file may have aParentpointing straight back to an ancestor, and aRefSetcuts the walk immediately. - Dictionaries without
objIdare not added tovisited. A directly constructedDict(hand-assembled in a test, say) has no object number, and the loop condition only asks whether it is aDict. In real files, nodes on a parent chain almost always have anobjId. stopWhenFounddecides between "nearest wins" and "collect everything". It defaults totrue, returning as soon as a value is found - which matches inheritance semantics: the closest declaration takes effect. Passfalseand the walk continues all the way up, pushing each level's value into an array ordered from nearest to farthest.values ||= []allocates lazily: an array is created only when a second value actually appears, so the common path (one level found) creates no extra object.
Why is "collect everything" needed? Because Resources is not a simple override. A page's own Resources may only hold Font while the parent's Resources holds XObject; rendering needs both. Page.#getInheritableProperty hands the array to Dict.merge for a shallow merge, which keeps only the first value for the same key, so the nearer value still wins while different keys can come from different levels. If that merging logic were pushed into getInheritableProperty, the function would no longer be a general tool but would be bound to page-resource semantics.
The getParentToUpdate Method
The second function solves a different problem: when changing a value, which dictionary should be changed. A widget annotation (a checkbox, for example) usually hangs under a field dictionary, and the field holds T (the field name); the value V belongs on the field, not on the widget:
function getParentToUpdate(dict, ref, xref) {
const visited = new RefSet();
const firstDict = dict;
const result = { dict: null, ref: null };
while (dict instanceof Dict && !visited.has(ref)) {
visited.put(ref);
if (dict.has('T')) {
break;
}
ref = dict.getRaw('Parent');
if (!(ref instanceof Ref)) {
return result;
}
dict = xref.fetch(ref);
}
if (dict instanceof Dict && dict !== firstDict) {
result.dict = dict;
result.ref = ref;
}
return result;
}It differs from the reading function in almost every dimension:
| Dimension | getInheritableProperty | getParentToUpdate |
|---|---|---|
| Goal | Find the value of a key | Find the field dictionary that should be modified |
| Starting point | Any dictionary | The annotation dictionary plus its own Ref |
| Stop condition | The key appears (or the chain ends) | A dictionary containing T (or a parent that is not a Ref) |
| Cycle guard | Records the objId of visited dictionaries | Records visited Refs |
| Value access | get / getArray, automatic dereference | getRaw("Parent"), accepting only a Ref |
| Return value | A value, an array of values, or undefined | A pair of dict and ref, both null or both the field hit |
| Typical caller | Page.resources, field type, AA | WidgetAnnotation.setValue |
getRaw("Parent") instead of get("Parent") is the key: here we need to know whether the parent is an indirect reference. If it is a directly embedded dictionary there is no independent Ref to write to, and the function gives up. The dict !== firstDict check makes "the annotation itself carries T" return an empty result, so the caller writes the value back onto the annotation instead of accidentally modifying an ancestor.
visited.has(ref) checks the incoming ref on the first iteration; visited.put(ref) happens afterwards. That way, even if Parent points back to itself, it is caught on the next round.
Design point: reading inheritance and writing inheritance cannot share one walk. On read, "the nearest value" is enough; on write, the owning object of the value must be known, otherwise the widget is modified instead of the field and AcroForm still cannot see the new value after saving.
getParentToUpdatereturns the pairdictandrefrather than a single dictionary precisely because the write-back needsrefas the key of the change table.
DeepCompare: Structural Equality, Not Reference Equality
Some scenarios need to decide whether two dictionaries have the same content. When merging the AcroForm default resources DR of several documents, for example, if the two DR values are identical there is no point copying the fonts and graphics states twice. deepCompare provides a recursive structural comparison:
function deepCompare(a, b) {
if (a === b) {
return true;
}
if (a instanceof Ref && b instanceof Ref) {
return isRefsEqual(a, b);
}
if (a instanceof Name && b instanceof Name) {
return a.name === b.name;
}
if (a instanceof Dict && b instanceof Dict) {
if (a.size !== b.size) {
return false;
}
for (const [key, value1] of a.getRawEntries()) {
const value2 = b.getRaw(key);
if (value2 === undefined || !deepCompare(value1, value2)) {
return false;
}
}
return true;
}The order of the recursion matters:
- Compare references first. The same object returns
trueimmediately, avoiding pointless recursion into a subtree already known to be equal. Numbers, strings andnullare also settled at this step by===. Refcompares by value.isRefsEquallooks only atnumandgenand never callsxref.fetch. This matters: a comparison should not trigger disk reads, otherwise a pure in-memory decision could throwMissingDataExceptionand drag the caller into retry logic.Namecompares as a string.Name.getcaches, so normally the same name is the same instance and step one already returned; but after a cache clear, or when objects come from different documents, the comparison has to fall back to strings.Dictcompares size first, then raw values key by key. It usesgetRawEntries()andgetRaw(key), again avoiding dereferencing. Equal size plus "every key inahas an equal value inb" implies a two-way one-to-one match, so no reverse traversal is needed.
Arrays are also compared by length first and then element by element; everything else falls through to the final return false. There is no type coercion: 1 and "1" are not equal, and neither are true and 1. One easily overlooked test case: clear the Ref cache and call Ref.get(10, 0) again - the two objects are distinct instances but deepCompare must still return true. That proves the comparison is about object number and generation, not object identity.
It also does not handle cyclic structures. If a PDF object graph points back at itself through Refs, the recursion stops at the Ref level and never expands; only two directly embedded dictionaries that contain each other would recurse forever, and the parser never produces that shape.
Design point:
deepCompareonly reads raw values and never triggers retrieval. That keeps equality a pure function: the same pair of inputs always yields the same result regardless of load progress. Swapgetfor a dereferencing variant and, while comparing a document that has not fully downloaded, the function would sometimes throw and sometimes return a value, leaving the caller no way to plan a retry.
Lazy Lookup Tables and Roman Numerals
getLookupTableFactory is only eleven lines, yet it is central to the startup cost of the whole font subsystem:
function getLookupTableFactory(initializer, useArray = false) {
let lookup;
return function () {
if (initializer) {
lookup = useArray ? [] : Object.create(null);
initializer(lookup);
initializer = null;
}
return lookup;
};
}It returns a zero-argument function. The first call creates the container, runs the initializer and then nulls out initializer; every later call returns the same object. The effect is "build the table only when needed, and only once".
Why is that necessary? In metrics.js every standard font has a width table, glyphlist.js maps thousands of glyph names to Unicode, and unicode.js holds all sorts of special symbol tables. Built at module top level, importing the font subsystem would pay the full construction cost even if the current page is all images and needs no glyph at all. A lazy factory defers the cost until the first real lookup.
The container uses Object.create(null) rather than an object literal for a concrete reason: table keys come from glyph and font names inside PDFs, which may be constructor, toString, __proto__ and the like. A normal object would hit the prototype chain and return a function instead of undefined, after which the calling logic gets the wrong type. A prototype-less object severs that path entirely.
The useArray branch is rarely used but does exist. getSymbolGlyphIdEncoding in fonts.js is built that way:
const getSymbolGlyphIdEncoding = getLookupTableFactory(t => {
let glyphId = 3;
for (const [firstCharCode, lastCharCode] of [
[0x20, 0x7e],
[0xa1, 0xfe],
]) {
for (let charCode = firstCharCode; charCode <= lastCharCode; charCode++) {
t[glyphId++] = SymbolSetEncoding[charCode];
}
}
}, /* useArray = */ true);Here the keys are glyph ids, increasing from 3 onwards, not strings. Array indexing beats hash lookup and keeps numeric order for free. Indices 0 to 2 are holes that look up to undefined, exactly matching the .notdef, .null and nonmarkingreturn glyphs that take no part in the mapping. In other words, useArray is not a casual "object or array, whatever"; it is the dedicated shape for "keys are small consecutive integers".
The second "lookup table" is Roman numerals:
// prettier-ignore
const ROMAN_NUMBER_MAP = [
"", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM",
"", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC",
"", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"
];
function toRomanNumerals(number, lowerCase = false) {
assert(Number.isInteger(number) && number > 0, 'The number should be a positive integer.');
const roman =
'M'.repeat((number / 1000) | 0) +
ROMAN_NUMBER_MAP[((number % 1000) / 100) | 0] +
ROMAN_NUMBER_MAP[10 + (((number % 100) / 10) | 0)] +
ROMAN_NUMBER_MAP[20 + (number % 10)];
return lowerCase ? roman.toLowerCase() : roman;
}The 30-entry table is split into three segments of ten, for hundreds, tens and units:
| Segment | Index range | Digit covered | Example contents |
|---|---|---|---|
| Hundreds | 0 to 9 | Hundreds digit | "", C, CC, CCC, CD, D, CM |
| Tens | 10 to 19 | Tens digit | "", X, XX, XXX, XL, L, XC |
| Units | 20 to 29 | Units digit | "", I, II, III, IV, V, IX |
So the lookups are 0 + hundreds, 10 + tens and 20 + units. Thousands have no table and simply repeat M. The | 0 is truncation, shorter than Math.floor and equivalent for positive numbers.
A few examples:
| Number | Thousands | Hundreds | Tens | Units | Result |
|---|---|---|---|---|---|
| 1 | empty | empty | empty | I | I |
| 9 | empty | empty | empty | IX | IX |
| 40 | empty | empty | XL | empty | XL |
| 400 | empty | CD | empty | empty | CD |
| 2019 | MM | empty | X | IX | MMXIX |
It is used only for page labels (the /S /R or /r of PageLabel), in Catalog's pageLabels:
case "R":
case "r":
currentLabel = toRomanNumerals(currentIndex, style === "r");
break;The assert rejects 0 and negative numbers because PDF page numbers start at 1; lowerCase corresponds to the lowercase style r. Above 3999 it still works, just emitting a longer run of M with no overline or subtractive notation - PDF page labels never needed that classical spelling anyway.
Design point: these two "lookup table" cases solve different problems.
getLookupTableFactoryaddresses time (deferring construction until first use), whileROMAN_NUMBER_MAPaddresses branches (collapsing the tens and units rules into a single array index). What they share is that neither does string concatenation or conditionals on a hot path.
Whitespace, Arrays and Six Numbers
isWhiteSpace is for byte streams, not for strings:
// Checks if ch is one of the following characters: SPACE, TAB, CR or LF.
function isWhiteSpace(ch) {
return ch === 0x20 || ch === 0x09 || ch === 0x0d || ch === 0x0a;
}The argument is a number: callers take a getByte() from a stream or index into a Uint8Array. It accepts exactly four values - space 0x20, horizontal tab 0x09, carriage return 0x0d and line feed 0x0a. Vertical tab 0x0b and form feed 0x0c are excluded, and a test explicitly asserts that 0x0b returns false. Lexer, the ASCII85 decoder and the Type1 font parser all depend on this precise set; writing /\s/ to save effort would treat 0x0b as whitespace too and shift token boundaries.
Next come two array shape checks:
function isBooleanArray(arr, len) {
return (
Array.isArray(arr) &&
(len === null || arr.length === len) &&
arr.every(x => typeof x === 'boolean')
);
}
function isNumberArray(arr, len) {
if (Array.isArray(arr)) {
return (len === null || arr.length === len) && arr.every(x => typeof x === 'number');
}
// This check allows us to have typed arrays but not the
// BigInt64Array/BigUint64Array types (their elements aren't "number").
return (
ArrayBuffer.isView(arr) &&
!(arr instanceof BigInt64Array || arr instanceof BigUint64Array) &&
(len === null || arr.length === len)
);
}isBooleanArray is used only for a pattern's /Extend: the specification demands two booleans. isNumberArray is more general and accepts two shapes:
- Plain arrays: content operator arguments are often arrays assembled by
Parser. - Typed arrays: some paths normalise arguments into a
Float32Arrayor similar, recognised directly withArrayBuffer.isViewso that no element-by-element copy into a plain array is needed.
The exclusion of BigInt64Array and BigUint64Array is explained in the comment: their element type is bigint, and mixing them with number throws a TypeError. ArrayBuffer.isView also returns true for a DataView, but both call sites in this file pass a concrete length (6 or 4) and a DataView has no length, so it is filtered out by the length check.
Passing null for len means "do not check the length", only the element types. The many isNumberArray(args, null) calls in evaluator.js use it that way: the argument count is already fixed by the switch branch, and all that matters here is that the elements are numbers.
Three value readers build on these checks:
function lookupMatrix(arr, fallback) {
return isNumberArray(arr, 6) ? arr : fallback;
}
function lookupRect(arr, fallback) {
return isNumberArray(arr, 4) ? arr : fallback;
}
function lookupNormalRect(arr, fallback) {
return isNumberArray(arr, 4) ? Util.normalizeRect(arr) : fallback;
}| Function | Expected length | Normalises | Typical fallback |
|---|---|---|---|
lookupMatrix | 6 | no | IDENTITY_MATRIX, null |
lookupRect | 4 | no | A rectangle from page width and height, [0, 0, 0, 0], null |
lookupNormalRect | 4 | yes | null |
A matrix is PDF's six-tuple a b c d e f; a rectangle is x1 y1 x2 y2. Wrong length or non-numeric elements return the fallback rather than throwing. That is the usual trade-off on a rendering path: one broken annotation is not worth failing a whole page over, so keep drawing with a safe default.
lookupNormalRect adds one step, Util.normalizeRect. PDF allows a rectangle's two corners in any order, so [100, 100, 0, 0] and [0, 0, 100, 100] describe the same area. Downstream width and height calculations assume x1 is not greater than x2 and y1 is not greater than y2, so this swaps into "top-left to bottom-right" consistently. normalizeRect clones with slice(0) first and then swaps as needed, so the input array is never modified.
Note that lookupRect does not clone: it returns the original array. What the caller receives may be an array already copied by Dict.getArray, or one shared by the parser; read-only use is fine, but sorting or rewriting it in place would corrupt the dictionary. All callers in the source only read.
Design point: these three functions separate shape validation from business defaults. Validation only answers "is this six numbers / four numbers"; the default is the caller's decision - the identity matrix when an appearance stream lacks
/Matrix, page dimensions when/BBoxis missing,nullwhen a structure-tree node has no/Rect. Hard-coding a default into the utility would force every call site to wrap it again.
Names, Strings and XFA Paths
PDF name objects have a character grammar of their own: whitespace and delimiters cannot appear literally, and # is the escape introducer. Writing a PDF means re-encoding names. escapePDFName does that:
function escapePDFName(str) {
const buffer = [];
let start = 0;
for (let i = 0, ii = str.length; i < ii; i++) {
const char = str.charCodeAt(i);
// Whitespace or delimiters aren't regular chars, so escape them.
if (
char < 0x21 ||
char > 0x7e ||
char === 0x23 /* # */ ||
char === 0x28 /* ( */ ||
char === 0x29 /* ) */ ||
char === 0x3c /* < */ ||
char === 0x3e /* > */ ||
char === 0x5b /* [ */ ||
char === 0x5d /* ] */ ||
char === 0x7b /* { */ ||
char === 0x7d /* } */ ||
char === 0x2f /* / */ ||
char === 0x25 /* % */
) {
if (start < i) {
buffer.push(str.substring(start, i));
}
buffer.push(`#${char.toString(16).padStart(2, '0')}`);
start = i + 1;
}
}
if (buffer.length === 0) {
return str;
}
if (start < str.length) {
buffer.push(str.substring(start));
}
return buffer.join('');
}The condition falls into three groups:
- Control characters and whitespace: character codes below
0x21, covering NUL, TAB, LF, CR and space. - Non-ASCII: character codes above
0x7e. - PDF delimiters and the escape character:
#,(,),<,>,[,],{,},/and%.
# must be escaped because it introduces #xx itself; left alone, a # inside a name would be read as the start of the next two hex digits. The backslash is not in the list because it is not special in a PDF name.
The output is # plus two lowercase hex digits, zero-padded with padStart(2, "0"). A lone # becomes #23, / becomes #2f, a tab followed by b becomes #09b, and a name starting with byte 0xfe becomes #fe plus the rest. Two performance details are hidden in the implementation: start records where the current unescaped run began, so a slice is pushed only when a special character is hit, and a single join runs at the end; if nothing needs escaping at all, buffer.length === 0 returns the original string and even the concatenation is skipped. That is the path for the vast majority of names.
String literals follow another set of rules. escapeString handles parentheses, backslash and newlines:
function escapeString(str) {
return str.replaceAll(/([()\\\n\r])/g, match => {
if (match === '\n') {
return '\\n';
} else if (match === '\r') {
return '\\r';
}
return `\\${match}`;
});
}| Original character | Output | Reason |
|---|---|---|
( | backslash plus ( | Otherwise the literal ends early |
| Backslash | two backslashes | Otherwise it is taken as the escape introducer |
Newline \n | backslash plus n | Keeps a real newline out of the literal, which parses more reliably |
The closing parenthesis and carriage return follow the same logic. Other control characters are not handled. In generated annotation appearance streams the text comes from form values and is usually printable; scenarios that genuinely need full PDF string encoding (encryption, document information) live elsewhere and do not go through this function.
The XFA path is a third string grammar:
function parseXFAPath(path) {
// Anchoring prevents retrying the match at every character.
const positionPattern = /^(.+)\[(\d+)\]$/;
return path.split('.').map(component => {
const m = component.match(positionPattern);
if (m) {
return { name: m[1], pos: parseInt(m[2], 10) };
}
return { name: component, pos: 0 };
});
}XFA separates levels with dots and marks the index of a repeated element with brackets, as in foo.bar[12].oof[3]. The function splits the whole path into name-plus-position pieces:
| Input | Output |
|---|---|
foo.bar[12].oof[3].rab | foo at 0, bar at 12, oof at 3, rab at 0 |
foo[].bar[1x] | Kept verbatim as names, position 0 |
foo[1][2] | Name foo[1], position 2 |
The ^ and $ anchors cannot be dropped. The source comment says it plainly: without anchors the regex retries at every character position, and .+ degrades to quadratic behaviour on a long name without brackets. A test builds a 200,000-character component and requires parsing to stay under a second; with anchors it is a single linear scan.
When the brackets are malformed or the index is not purely numeric, the function does not raise an error; it treats the whole component as a plain name with position zero. XFA data binding would rather lose one index level than fail an entire form because of one bad path.
It has two users: dataset_reader.js turns the ref of a dataset into a searchNode path, and writer.js reassembles node paths when saving.
Design point: the three escape and parse functions belong to three syntax layers - PDF names, PDF strings and XFA paths. None of them attempts to repair anything; each emits strictly according to its own grammar. A file that can be read back by the same library depends on writer and reader rules being exact mirrors; any opportunistic patching turns into different data after one round trip.
Action Trees: Collecting Scripts, Not Executing Them
The _collectJS Method
Annotations and pages can both carry Additional Actions (/AA), where the key is an event abbreviation and the value is an action dictionary. An action dictionary may be a single object, may be chained through /Next, or may be an array. _collectJS extracts every piece of JavaScript from that tree:
function _collectJS(entry, xref, list, parents) {
if (!entry) {
return;
}
let parent = null;
if (entry instanceof Ref) {
if (parents.has(entry)) {
// If we've already found entry then we've a cycle.
return;
}
parent = entry;
parents.put(parent);
entry = xref.fetch(entry);
}
if (Array.isArray(entry)) {
for (const element of entry) {
_collectJS(element, xref, list, parents);
}
} else if (entry instanceof Dict) {
if (isName(entry.get('S'), 'JavaScript')) {
const js = entry.get('JS');
let code;
if (js instanceof BaseStream) {
code = js.getString();
} else if (typeof js === 'string') {
code = js;
}
code &&= stringToPDFString(code, /* keepEscapeSequence = */ true).replaceAll('\x00', '');
if (code) {
list.push(code.trim());
}
}
_collectJS(entry.getRaw('Next'), xref, list, parents);
}
if (parent) {
parents.remove(parent);
}
}Ref: checkparentsfirst and return on a hit - the reference already appeared on the current path, and continuing would loop. Once the object is fetched, theRefgoes into the set and is removed when the call returns.- Array: recurse into each element; an action chain can use an array to express multiple actions.
- Dictionary: only actions whose
SisJavaScriptcount.JSmay be a stream (common for longer scripts) or a string. Both are first passed throughstringToPDFStringwith escape sequences preserved, then stripped of NUL characters. Next: usegetRawto get the raw value and recurse, so the next link may itself be aRefand gets the same cycle protection.
parents is populated and emptied within the same call, so it detects cycles on the current path rather than deduplicating globally. If the same action dictionary is referenced by two different events, it must appear in both event script lists; global deduplication would drop the script from the second event. The distinction is subtle but directly changes behaviour.
Both the NUL removal and trim exist to clean bytes that a file can store but a runtime cannot use. Acrobat is lenient about NUL inside scripts; the Function constructor in a browser may not be. Cleaning before handing the code upward keeps compatibility problems inside the core layer.
The collectActions Method
The outer collectActions decides which events are collected and which declaration wins:
function collectActions(xref, dict, eventType) {
const actions = new Map();
const additionalActionsDicts = getInheritableProperty({
dict,
key: "AA",
stopWhenFound: false,
});
if (additionalActionsDicts) {
for (let i = additionalActionsDicts.length - 1; i >= 0; i--) {
const additionalActions = additionalActionsDicts[i];
if (!(additionalActions instanceof Dict)) {
continue;
}
for (const [key, rawActionDict] of additionalActions.getRawEntries()) {
const action = eventType[key];
if (!action) {
continue;
}
const parents = new RefSet();
const list = [];
_collectJS(rawActionDict, xref, list, parents);
if (list.length > 0) {
actions.set(action, list);
}
}
}
}Three design points:
AAis itself inheritable. A widget may omitAAwhile its field or page defines one. SogetInheritablePropertyis used withstopWhenFound: false, yielding the dictionaries from nearest to farthest.- Iterate in reverse. Index 0 is the nearest dictionary and the last entry is the farthest ancestor. Walking backwards writes ancestor actions first and then lets nearer dictionaries overwrite them.
Map.setreplaces rather than merges for the same key, so the final result matches "the nearest declaration wins". - Event name mapping.
eventTypeis an abbreviation table; annotations useAnnotationActionEventType, whereEmaps toMouse Enter,XtoMouse ExitandKtoKeystroke. Pages usePageActionEventType, which only hasOandC. Keys missing from the table are skipped, so unknown events never enter the result.
A single A is handled last: if the dictionary has its own A, the same _collectJS runs over it again under the fixed key Action. A button's "click to open a link" is usually written there rather than in /AA, hence the separate pass; Action does not collide with event abbreviations. The function ends with actions.size ? actions : null, so one truthiness check tells the caller that there are no actions - simpler than testing size === 0, and it also stops an empty map from being passed further down.
| Source | Key form | Inherited | Override rule |
|---|---|---|---|
AA | Event abbreviation | yes | Nearer overrides farther |
A | Fixed Action | no | Standalone key, takes no part in event overrides |
Next chain | Produces no key | n/a | Appended to the list in order within one collection |
Design point: the core layer only collects script strings, it never executes them. Execution happens in the
scripting_apisandbox, which has the full Acrobat object model and timeout control. Separating "fetch the script" from "run the script" means that even when no runtime is available (Node, for instance), the JavaScript inside annotations can still be read, displayed and inspected by developer tools.
XML Encoding: The Entity Table Used When Writing XFA Back
At runtime XFA is rewritten into an XML data set. The core layer has no DOM, only string concatenation, so it needs an escape function. The entity table has just five entries:
const XMLEntities = {
/* < */ 0x3c: '<',
/* > */ 0x3e: '>',
/* & */ 0x26: '&',
/* " */ 0x22: '"',
/* ' */ 0x27: ''',
};The encodeToXmlString Method
The keys are numeric literals equivalent to the string keys "60", "62" and so on, and lookups go straight through XMLEntities[char]. Exactly five characters must be escaped in XML: the two angle brackets, the ampersand and the two quote characters.
function encodeToXmlString(str) {
const buffer = [];
let start = 0;
for (let i = 0, ii = str.length; i < ii; i++) {
const char = str.codePointAt(i);
if (0x20 <= char && char <= 0x7e) {
// ascii
const entity = XMLEntities[char];
if (entity) {
if (start < i) {
buffer.push(str.substring(start, i));
}
buffer.push(entity);
start = i + 1;
}
} else {
if (start < i) {
buffer.push(str.substring(start, i));
}
buffer.push(`&#x${char.toString(16).toUpperCase()};`);
if (char > 0xffff) {
// char is represented by two u16
i++;
}
start = i + 1;
}
}
if (buffer.length === 0) {
return str;
}
if (start < str.length) {
buffer.push(str.substring(start));
}
return buffer.join('');
}It scans by code point and splits into two paths:
- Printable ASCII (
0x20to0x7e): only the five characters are replaced by named entities; letters, digits and common punctuation stay as they are, so pure ASCII text leavesbufferempty after the loop and the function returns the original string. - All other characters: always emitted as a numeric entity
&#xHH;in uppercase hex. That covers newlines, control characters, non-ASCII letters and supplementary-plane characters.
For a supplementary-plane character, codePointAt returns a value above 0xffff, indicating it occupies two UTF-16 code units; after emitting one numeric entity, i is incremented an extra time to skip the low surrogate. This way U+1D400 produces a single 𝐀 rather than two orphaned surrogate entities.
codePointIter is the generator version of the same scan:
function* codePointIter(str) {
for (let i = 0, ii = str.length; i < ii; i++) {
const char = str.codePointAt(i);
if (char > 0xd7ff && (char < 0xe000 || char > 0xfffd)) {
// char is represented by two u16
i++;
}
yield char;
}
}The condition looks convoluted but is identifying code points that are "not a single BMP character": the surrogate range 0xd800 to 0xdfff, and every value above 0xfffd (supplementary-plane characters necessarily fall there). default_appearance.js uses it to measure text width code point by code point; iterating with a plain for index would count a supplementary-plane character as two unknown glyphs.
A few output examples: a double quote becomes ", an ampersand becomes &, a less-than sign becomes <, the Greek letter Eta becomes Η, and code point U+1D400 becomes 𝐀. Why not emit non-ASCII as-is? The XFA data set eventually goes to an XML parser, and the output encoding (UTF-8, UTF-16, ASCII) is not necessarily under this code's control. After full numeric encoding the result contains only ASCII, so any encoding can carry it safely and no declaration mismatch can produce mojibake.
Design point: the strategy is "named entities only for the required set, numeric for everything else". Named entities are readable but limited to five; numeric entities are verbose but universal. For XFA, which routinely contains labels in many languages, universality matters more than readability. The pure-ASCII fast path is kept as well, so most short strings allocate nothing extra.
Font Names and CSS: Keeping Untrusted Input Out of the Rendering Layer
The validateFontName Method
XFA fonts have to be rendered by the browser, and the core layer builds a CSS font description for them: fontFamily, fontWeight, italicAngle. Those values come from the PDF FontDescriptor and are untrusted input. Three functions perform validation, normalisation and default filling in that order.
validateFontName has two paths:
function validateFontName(fontFamily, mustWarn = false) {
const m = /^("|').*("|')$/.exec(fontFamily);
if (m && m[1] === m[2]) {
const re = new RegExp(`[^\\\\]${m[1]}`);
if (re.test(fontFamily.slice(1, -1))) {
if (mustWarn) {
warn(`FontFamily contains unescaped ${m[1]}: ${fontFamily}.`);
}
return false;
}
if (CONTROL_CHAR_REGEXP.test(fontFamily)) {
if (mustWarn) {
warn(`FontFamily contains control characters: ${fontFamily}.`);
}
return false;
}
} else {
for (const ident of fontFamily.split(/[ \t]+/)) {
if (/^(?:\d|-[\d-])/.test(ident) || !/^[\w\\-]+$/.test(ident)) {
if (mustWarn) {
warn(`FontFamily contains invalid <custom-ident>: ${fontFamily}.`);
}
return false;
}
}
}
return true;
}- Quoted: the value must start and end with the same quote character. After stripping them, the same quote may not appear inside unless preceded by a backslash - that would end the CSS string early. Control characters are rejected next:
CONTROL_CHAR_REGEXPis\p{Cc}, covering form feed, NUL, TAB and LF. CSS treats form feed as a newline too, which would also terminate the string. - Unquoted: the value is split on spaces or tabs into separate
custom-identtokens, each checked individually. A token may not start with a digit, may not start with a hyphen followed by a digit or another hyphen, and may only consist of word characters, backslashes and hyphens.
When validation fails, mustWarn decides whether to log. Reading XFA fonts passes true, because that is a good moment to tell the developer which PDF has a problematic font name; internal reuse does not want the console flooding.
The normalizeCSSFontFamily Method
normalizeCSSFontFamily rewrites the value before validation runs:
function normalizeCSSFontFamily(fontFamily) {
return fontFamily.replaceAll(/( +)(\d)?/g, (_, spaces, digit) => digit ?? ' ');
}The rule matches a run of spaces plus one optional digit immediately after it: if there is a digit, keep only the digit; if not, collapse the run to a single space.
| Input | Output |
|---|---|
Wingdings 3 | Wingdings3 |
MS Gothic 2 Bold 7 | MS Gothic2 Bold7 |
Times New Roman | Times New Roman |
| Extra spaces at the ends and in the middle | Each run collapses to one space |
Wingdings3 | Wingdings3 |
Why remove the space? A CSS custom-ident may not begin with a digit. Unquoted, Wingdings 3 splits into two identifiers whose second is 3, making the whole font-family declaration invalid. Without the space, Wingdings3 is a single legal identifier and the font name remains readable.
The regex puts the space run and the optional digit in one match instead of matching spaces and then looking ahead, to avoid backtracking. The source comment notes that allowing repeated backtracking over the spaces would make the replacement quadratic in the number of consecutive spaces. A test with a hundred thousand consecutive spaces guards against exactly that regression.
The validateCSSFont Method
Finally validateCSSFont fills in defaults and rewrites the passed object in place. The weight whitelist CSS_FONT_WEIGHT_VALUES holds the round hundreds from 100 to 1000 plus the four keywords normal, bold, bolder and lighter; the excerpt below omits the declaration of that set:
function validateCSSFont(cssFontInfo) {
const DEFAULT_CSS_FONT_OBLIQUE = '14';
const DEFAULT_CSS_FONT_WEIGHT = '400';
const { fontFamily, fontWeight, italicAngle } = cssFontInfo;
if (!validateFontName(fontFamily, true)) {
return false;
}
const weight = fontWeight ? fontWeight.toString() : '';
cssFontInfo.fontWeight = CSS_FONT_WEIGHT_VALUES.has(weight) ? weight : DEFAULT_CSS_FONT_WEIGHT;
const angle = parseFloat(italicAngle);
cssFontInfo.italicAngle =
isNaN(angle) || angle < -90 || angle > 90 ? DEFAULT_CSS_FONT_OBLIQUE : italicAngle.toString();
return true;
}| Field | Input | Handling | Output |
|---|---|---|---|
fontWeight | Number 100 | Converted to a string, found in the set | "100" |
fontWeight | Number 314 | Not in the set | "400" |
fontWeight | "bold" | Keyword found in the set | "bold" |
italicAngle | 2.718 | Between minus 90 and 90 | "2.718" |
italicAngle | -123 | Out of range | "14" |
italicAngle | "91" | Out of range | "14" |
italicAngle | Unparseable | parseFloat yields NaN | "14" |
Note that a passing italicAngle keeps its original string form, not a normalised number. Downstream concatenates it straight into CSS, so 2.718 becomes oblique 2.718deg. The default "14" is an empirical slant: without a usable ItalicAngle, 14deg looks close to a typical italic without being excessive. The default weight "400" is regular.
If fontFamily fails validation the whole function returns false, and the caller PDFDocument.loadXfaFonts simply continues past that font. The trade-off is rejection rather than repair. A name with quotes or control characters means the upstream data is already untrustworthy; substituting characters could produce a font name that looks normal but does not exist, still fall back to the default font and hide the problem.
Design point: validation happens before entering CSS, not as a fallback after rendering.
font-familyis concatenated into a style sheet where one unescaped quote can change the declarations that follow; discovering it after the browser fails to parse is too late. The core layer takes on output encoding here, which is the same kind of thinking asescapePDFNameescaping before writing.
Numbers, Dates, Matrices and Byte Widths
The recoverJsURL Method
Older PDFs often contain a kind of "pseudo link": the action type is JavaScript, but the script body is just one call that opens a URL. So that the link still works without executing any script, recoverJsURL uses a whitelist regex to recover the URL:
function recoverJsURL(str) {
const URL_OPEN_METHODS = ['app.launchURL', 'window.open', 'xfa.host.gotoURL'];
const regex = new RegExp(
'^\\s*(' +
URL_OPEN_METHODS.join('|').replaceAll('.', '\\.') +
')\\((?:\'|")([^\'"]*)(?:\'|")(?:,\\s*(\\w+)\\)|\\))',
'i',
);
const jsUrl = regex.exec(str);
if (jsUrl?.[2]) {
return {
url: jsUrl[2],
newWindow: jsUrl[1] === 'app.launchURL' && jsUrl[3] === 'true',
};
}
return null;
}The three capture groups are the method name, the URL and the second argument. Only app.launchURL(..., true) interprets that second argument as "open in a new window"; for window.open and xfa.host.gotoURL the result is false even when true is passed. Tests cover both cases, and also cover navigateToUrl(...), a method name outside the whitelist, which returns null.
The regex only accepts URLs wrapped in single or double quotes with no quotes inside; leading whitespace before the method name is allowed and matching is case-insensitive. It is not a general JavaScript parser and never will be: anything outside the whitelist returns null and is left to the sandbox or ignored. Catalog.parseDestDictionary tries this path first when S is JavaScript, and degrades a hit into an ordinary link action.
The numberToString Method
When writing content streams, numbers must be compact and must never be in exponential form. The rule in numberToString is simple:
function numberToString(value) {
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
assert(typeof value === 'number', `numberToString - the value (${value}) should be a number.`);
}
if (Number.isInteger(value)) {
return value.toString();
}
const roundedValue = Math.round(value * 100);
if (roundedValue % 100 === 0) {
return (roundedValue / 100).toString();
}
if (roundedValue % 10 === 0) {
return value.toFixed(1);
}
return value.toFixed(2);
}It multiplies by 100 and rounds, then uses "divisible by 100" to decide whether an integer is enough and "divisible by 10" to choose one or two decimals. That avoids floating-point noise such as 0.30000000000000004 while keeping output as a decimal fraction in the range content streams normally use. JavaScript's Number.toString() switches to scientific notation for very small or very large values (say 1e-7), and PDF content streams do not accept exponents; toFixed covers exactly the non-integer decimal path.
The getModificationDate Method
The PDF date string format is D:YYYYMMDDHHmmSS with an optional timezone offset. getModificationDate produces only the first part:
function getModificationDate(date = new Date()) {
if (!(date instanceof Date)) {
date = new Date(date);
}
const buffer = [
date.getUTCFullYear().toString(),
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
date.getUTCDate().toString().padStart(2, '0'),
date.getUTCHours().toString().padStart(2, '0'),
date.getUTCMinutes().toString().padStart(2, '0'),
date.getUTCSeconds().toString().padStart(2, '0'),
];
return buffer.join('');
}The year is not padded; every other field is padded to two digits. Everything is UTC rather than local time, so the same document saved in different timezones yields the same string. The test case 3141-06-09 02:06:53 UTC outputs 31410609020653.
The caller prepends the D: prefix itself, as when an annotation is saved:
dict.set('M', `D:${getModificationDate()}`);The function does not return the prefix because it is also useful for internal cases that need a bare timestamp outside CreationDate and ModDate. The parameter accepts a string or any value the Date constructor can parse, which makes it easy to restore from an existing timestamp.
The getRotationMatrix Method
Rotating an annotation means rotating its appearance stream coordinate system into the page coordinate system. getRotationMatrix handles only the three legal angles:
function getRotationMatrix(rotation, width, height) {
switch (rotation) {
case 90:
return [0, 1, -1, 0, width, 0];
case 180:
return [-1, 0, 0, -1, width, height];
case 270:
return [0, -1, 1, 0, 0, height];
default:
throw new Error('Invalid rotation');
}
}| Angle | Matrix | X direction | Y direction |
|---|---|---|---|
| 90 | [0, 1, -1, 0, width, 0] | Flipped from y | Taken directly from x |
| 180 | [-1, 0, 0, -1, width, height] | x reversed and translated | y reversed and translated |
| 270 | [0, -1, 1, 0, 0, height] | Taken directly from y | Flipped from x |
The matrix follows PDF conventions: x' = a*x + c*y + e and y' = b*x + d*y + f. At 90 degrees, x' = width - y and y' = x, so the original top-right corner (width, height) maps to the top-left (0, height) and content never escapes into negative coordinates. The 180 case only flips both axes, so it must translate by both width and height; the 270 case only needs the height translation.
An unsupported angle throws instead of returning the identity matrix. The rotation value passed in comes from an annotation dictionary or a user action, so it can only be 0, 90, 180 or 270; anything else means the upstream logic is already wrong, and a silent fallback would draw an appearance stream in the wrong orientation, which is harder to diagnose than a plain failure.
Its relationship with the IDENTITY_MATRIX constant deserves a separate look. Annotation.getRotationMatrix returns that shared constant directly when the angle is 0, so saving an appearance stream can decide "is a /Matrix needed" by reference comparison: the matrix is written into the dictionary only when rotationMatrix !== IDENTITY_MATRIX is true. Returning a fresh array every time would force an element-by-element comparison. The shared constant turns "is this the identity" into one pointer comparison and saves an allocation too. The cost is that callers must not modify the returned value - all current callers only read.
The getSizeInBytes Method
An xref stream has to choose field widths for offsets and generations. getSizeInBytes answers "how many bytes does this positive integer need":
function getSizeInBytes(x) {
// n bits are required for numbers up to 2^n - 1.
// So for a number x, we need ceil(log2(1 + x)) bits.
return Math.ceil(Math.ceil(Math.log2(1 + x)) / 8);
}| Input | Bits needed | Output |
|---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
255 | 8 | 1 |
256 | 9 | 2 |
65536 | 17 | 3 |
Returning 0 for 0 is deliberate: a field width of 0 means the column occupies no bytes and the reader applies the default the specification defines for it, so a column that is always zero can be dropped entirely. The 1 + x rather than plain x is because x itself must be representable: 255 needs 8 bits, and log2(255) rounds up from about 7.99 to 8 as well; but 256 with log2(256) = 8 would be miscounted as one byte, whereas log2(257) is about 8.006 and rounds up to 9, which is correct.
writer.js uses it to choose the /W array: the first column is fixed at one byte for the type, and the offset and generation columns narrow to their actual maximum values. In a document with tens of thousands of objects the offset usually fits in three bytes, which avoids pinning the whole xref stream at four bytes and saves a quarter of the table.
The getNewAnnotationsMap Method
Annotations created in the editor live in AnnotationStorage under keys with a fixed prefix. When rendering a page, the code needs to find quickly which new annotations belong to it:
function getNewAnnotationsMap(annotationStorage) {
if (!annotationStorage) {
return null;
}
const newAnnotationsByPage = new Map();
// The concept of page in a XFA is very different, so
// editing is just not implemented.
for (const [key, value] of annotationStorage) {
if (!key.startsWith(AnnotationEditorPrefix)) {
continue;
}
newAnnotationsByPage.getOrInsertComputed(value.pageIndex, makeArr).push(value);
}
return newAnnotationsByPage.size > 0 ? newAnnotationsByPage : null;
}AnnotationEditorPrefix is pdfjs_internal_editor_. The storage holds two kinds of key: the id of an existing annotation (whose value the user edited) and the internal id of a new editor object. Only the latter have to be "synthesised" into annotations when the operatorList is generated; the former go through the existing annotation parsing path. The prefix filter separates the two.
getOrInsertComputed(value.pageIndex, makeArr) atomically fetches an array or creates one. makeArr is just () => [], pulled out so that a fresh closure is not passed on every call. After grouping by page, Page.getOperatorList only needs map.get(this.pageIndex) instead of scanning the whole storage.
Returning null rather than an empty Map matches the convention in collectActions: a truthiness check is enough, and newAnnotsByPage?.get(...) short-circuits safely. XFA documents never call it, because XFA's notion of a page differs entirely from an AcroForm page and annotation editing was never implemented there.
Design point: these six functions look unrelated, but all of them convert an internal representation into an external protocol.
recoverJsURLturns script text into a link,numberToStringturns a float into a PDF number,getModificationDateturns a time into a PDF date,getRotationMatrixturns an angle into a PDF matrix,getSizeInBytesturns an integer into a field width, andgetNewAnnotationsMapturns flat storage into a per-page index. Their output must conform strictly to the target format, with no "probably good enough" intermediate form.
Three Constants and Two Resource Tables
The constants at the top of the file look scattered but form two groups. The first covers arithmetic and scale ceilings:
const PDF_VERSION_REGEXP = /^[1-9]\.\d$/;
const MAX_INT_32 = 2 ** 31 - 1;
const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];| Constant | Value | Where it is used |
|---|---|---|
IDENTITY_MATRIX | [1, 0, 0, 1, 0, 0] | Default annotation appearance matrix, and the reference comparison for "is rotated" |
PDF_VERSION_REGEXP | One major, one minor digit | Header version and Catalog /Version validation |
MAX_INT_32 | 2147483647 | Checking whether width * height * 4 exceeds a safe range before image resampling |
PDF_VERSION_REGEXP accepts only one-decimal forms from 1.0 to 9.9; 1.10 is rejected. Header parsing and Catalog version reading use the same regex, so "the version declared in the header" and "the version overridden by the catalog" follow one rule. An invalid version only warns and never blocks opening the document:
if (PDF_VERSION_REGEXP.test(version)) {
this.#version = version;
} else {
warn(`Invalid PDF header version: ${version}`);
}MAX_INT_32 is used in image_resizer.js: when width * height * 4 exceeds it, the RGBA buffer is outside the safe range, so it is downsampled before going to ImageDecoder. This ceiling is not JavaScript's array limit but a guard against allocating several gigabytes in one go.
The second group is the resource key lists:
const RESOURCES_KEYS_OPERATOR_LIST = [
'ColorSpace',
'ExtGState',
'Font',
'Pattern',
'Properties',
'Shading',
'XObject',
];
const RESOURCES_KEYS_TEXT_CONTENT = ['ExtGState', 'Font', 'Properties', 'XObject'];The two lists differ by three entries: ColorSpace, Pattern and Shading are needed for rendering only, not for text extraction. Rendering has to paint colours, patterns and shadings, so it needs all seven; pure text extraction cares only about glyph mapping and form XObjects, and colour spaces, patterns and shadings contribute nothing to the result. Page.loadResources prefetches with the appropriate list for each intent: the rendering path passes RESOURCES_KEYS_OPERATOR_LIST, the text path passes RESOURCES_KEYS_TEXT_CONTENT. Prefetching means doing the "parse the resource dictionary" step once up front, so that most references PartialEvaluator meets later are already cached. It is a hint, not a hard constraint: the evaluator still lazily loads missing resources according to the actual operators, so a file that hides a font under an unlisted key still renders, just without that one prefetch optimisation.
Design point: the resource keys are hoisted into constants instead of being written inline as arrays in
document.jsso that "what rendering needs" and "what text needs" become two declarations you can compare. The difference between the two arrays is exactly what text extraction can skip, and readers do not have to reverse-engineer it from the evaluator.
Conclusion
core_utils.js has no execution stage of its own. Its value is in gathering logic that every stage touches but no stage should own, and in staying stateless.
Where It Sits in the Overall Chain
| Stage | Module | Exports used |
|---|---|---|
| Byte supply | ChunkedStream, NetworkPdfManager | arrayBuffersToBytes, MissingDataException |
| Syntax parsing | Lexer, Parser | isWhiteSpace, ParserEOFException |
| Location and retrieval | XRef | MissingDataException, ParserEOFException, XRefEntryException, XRefParseException |
| Document modelling | PDFDocument, Catalog | getInheritableProperty, lookupNormalRect, collectActions, recoverJsURL, toRomanNumerals, PDF_VERSION_REGEXP, RESOURCES_KEYS_*, validateCSSFont |
| Content evaluation | PartialEvaluator | isNumberArray, lookupMatrix, lookupRect, getLookupTableFactory, IDENTITY_MATRIX, fetchBinaryData, codePointIter |
| Annotation editing | Annotation, PDFEditor | getParentToUpdate, getNewAnnotationsMap, getRotationMatrix, getModificationDate, deepCompare, numberToString |
| Writing and saving | Writer | escapePDFName, escapeString, getSizeInBytes, parseXFAPath |
| XFA and XML | XMLParser, XFA modules | encodeToXmlString, validateFontName, normalizeCSSFontFamily |
In one sentence: the syntax layer turns bytes into objects, the document layer turns objects into pages, and core_utils supplies the rules both must follow - what an object looks like, how errors are classified, and how output is encoded.
Five Design Principles
- Failures are classified by semantics, not by origin.
MissingDataExceptionmeans "wait for data",ParserEOFExceptionmeans "object truncated",XRefEntryExceptionmeans "index points wrong",XRefParseExceptionmeans "the whole index is untrustworthy". The four correspond to four recovery strategies, so the caller needs oneinstanceofcheck and never parses an error message. Precisely because they are control flow, thewrapReasonwhitelist deliberately excludes them. - The toolbox keeps leaf dependencies.
core_utilsdepends only onshared/util,primitives,base_stream,css_utilsandstring_utils, none of which depend on it. Every stage can reference it without a cycle; new utilities should respect the same boundary and never pull inXReforPDFDocument. - Reading and writing are two separate APIs. Inherited values are read with
getInheritablePropertyand written throughgetParentToUpdate; names are written withescapePDFNameand read byLexer. Each API answers one question, so a caller cannot pick up semantically wrong defaults by casual reuse. - Defaults and validation are layered.
lookupMatrix,lookupRectandlookupNormalRectonly check shapes and leave defaults to the caller;validateCSSFontonly does safety validation and fills gaps, returningfalseso the caller can skip. Utility functions never decide on the business's behalf what to do after something is broken. - Hot paths do only the necessary work. The single-buffer fast path of
arrayBuffersToBytescopies nothing,escapePDFNameandencodeToXmlStringreturn the original string when no replacement is needed,getInheritablePropertyallocates an array only when a second value appears, and the lookup factory defers table construction until first use. Individually these optimisations are tiny, but they sit on every document, every page and every name, and together they make the difference in startup time and memory footprint.
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 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: 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 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026