StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: core_utils

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: core_utils

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:

DependencyUsed for
../shared/util.jsBaseException, assert, Util, warn
./primitives.jsDict, Name, Ref, RefSet
./base_stream.jsBaseStream (to tell whether action script is a stream)
../shared/css_utils.jsCONTROL_CHAR_REGEXP
./string_utils.jsstringToPDFString

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:

BlockKey exportsPurpose
ConstantsIDENTITY_MATRIX, RESOURCES_KEYS_OPERATOR_LIST, RESOURCES_KEYS_TEXT_CONTENT, PDF_VERSION_REGEXP, MAX_INT_32Identity matrix, resource key lists, version rule, 32-bit ceiling
ExceptionsMissingDataException, ParserEOFException, XRefEntryException, XRefParseExceptionFour distinguishable failures so callers can decide whether to fetch more data or recover
BytesarrayBuffersToBytes, fetchBinaryDataConcatenating multiple buffers, fetching wasm binaries
Dictionaries and comparisongetInheritableProperty, getParentToUpdate, deepCompareReading along the parent chain, locating a writable field, structural equality
Lookup tables and numberinggetLookupTableFactory, toRomanNumeralsLazy large tables, Roman page labels
Character and numeric validationisWhiteSpace, isBooleanArray, isNumberArrayByte-level whitespace test, array shape checks
Geometry lookuplookupMatrix, lookupRect, lookupNormalRectSafe reads with length checks and normalisation
Escaping and pathsparseXFAPath, escapePDFName, escapeStringXFA path parsing, name and string escaping on write
ActionscollectActions (with the internal _collectJS)Collecting JavaScript from Additional Actions and from A
XMLencodeToXmlString, codePointIter (with the internal XMLEntities)Entity encoding when writing XFA back
Fonts and CSSvalidateFontName, normalizeCSSFontFamily, validateCSSFontPutting font names from PDF safely into CSS
Values and metadatarecoverJsURL, numberToString, getNewAnnotationsMap, getModificationDate, getRotationMatrix, getSizeInBytesURL 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.js in the current repository. It has no legacy helpers such as isValidUrl or createPromiseCapability; getInheritableProperty takes a single object rather than positional arguments; and deepCompare, getModificationDate, getSizeInBytes, getNewAnnotationsMap and recoverJsURL all live in this file rather than being scattered across util.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:

ExceptionFields carriedWhere it is raisedAction taken upstream
MissingDataExceptionbegin, endChunkedStream.ensureByte, ensureRange; a network document reading a range not yet downloadedRecord the range, request it, then retry from scratch
ParserEOFExceptionmessageParser.getObj hits EOF inside an array or dictionary and is not in recovery modeTreat the object as truncated, skip it, do not overwrite an existing xref entry
XRefEntryExceptionmessageXRef.fetchUncompressed / fetchCompressed find a missing entry or an out-of-range indexConvert to XRefParseException, or trigger full-file recovery directly
XRefParseExceptionmessageXRef.parse fails to validate Root and PagesSwitch 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 wrapReason whitelist in shared/message_handler.js does not include them - it only knows AbortException, InvalidPDFException, PasswordException, ResponseException and UnknownErrorException. That is deliberate: MissingDataException and XRefParseException are control flow, not errors for callers to inspect. They must be handled before leaving the Worker, otherwise the main thread receives a vague UnknownErrorException.

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:

  1. Zero buffers returns a zero-length Uint8Array, not null. Callers can unconditionally read .length or take a subarray without an extra null check.
  2. 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 exactly bytes.buffer === buffer. Progressive loading most often has just one chunk, and skipping a full copy is well worth it.
  3. Several buffers sums byteLength for the total, allocates once, then writes each chunk with set(item, pos). Unlike repeated concat, 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 arrayBuffersToBytes returns 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; seeing Uint8Array returned, 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) calls xref.fetch when it meets a Ref, and dict.getArray(key) also dereferences every Ref inside the array. The caller receives the final value and never has to check for Ref itself.
  • Cycle protection keys on objId. objId is the Ref attached to a Dict when XRef retrieves 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 a Parent pointing straight back to an ancestor, and a RefSet cuts the walk immediately.
  • Dictionaries without objId are not added to visited. A directly constructed Dict (hand-assembled in a test, say) has no object number, and the loop condition only asks whether it is a Dict. In real files, nodes on a parent chain almost always have an objId.
  • stopWhenFound decides between "nearest wins" and "collect everything". It defaults to true, returning as soon as a value is found - which matches inheritance semantics: the closest declaration takes effect. Pass false and 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:

DimensiongetInheritablePropertygetParentToUpdate
GoalFind the value of a keyFind the field dictionary that should be modified
Starting pointAny dictionaryThe annotation dictionary plus its own Ref
Stop conditionThe key appears (or the chain ends)A dictionary containing T (or a parent that is not a Ref)
Cycle guardRecords the objId of visited dictionariesRecords visited Refs
Value accessget / getArray, automatic dereferencegetRaw("Parent"), accepting only a Ref
Return valueA value, an array of values, or undefinedA pair of dict and ref, both null or both the field hit
Typical callerPage.resources, field type, AAWidgetAnnotation.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. getParentToUpdate returns the pair dict and ref rather than a single dictionary precisely because the write-back needs ref as 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:

  1. Compare references first. The same object returns true immediately, avoiding pointless recursion into a subtree already known to be equal. Numbers, strings and null are also settled at this step by ===.
  2. Ref compares by value. isRefsEqual looks only at num and gen and never calls xref.fetch. This matters: a comparison should not trigger disk reads, otherwise a pure in-memory decision could throw MissingDataException and drag the caller into retry logic.
  3. Name compares as a string. Name.get caches, 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.
  4. Dict compares size first, then raw values key by key. It uses getRawEntries() and getRaw(key), again avoiding dereferencing. Equal size plus "every key in a has an equal value in b" 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: deepCompare only 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. Swap get for 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:

SegmentIndex rangeDigit coveredExample contents
Hundreds0 to 9Hundreds digit"", C, CC, CCC, CD, D, CM
Tens10 to 19Tens digit"", X, XX, XXX, XL, L, XC
Units20 to 29Units 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:

NumberThousandsHundredsTensUnitsResult
1emptyemptyemptyII
9emptyemptyemptyIXIX
40emptyemptyXLemptyXL
400emptyCDemptyemptyCD
2019MMemptyXIXMMXIX

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. getLookupTableFactory addresses time (deferring construction until first use), while ROMAN_NUMBER_MAP addresses 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 Float32Array or similar, recognised directly with ArrayBuffer.isView so 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;
}
FunctionExpected lengthNormalisesTypical fallback
lookupMatrix6noIDENTITY_MATRIX, null
lookupRect4noA rectangle from page width and height, [0, 0, 0, 0], null
lookupNormalRect4yesnull

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 /BBox is missing, null when 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:

  1. Control characters and whitespace: character codes below 0x21, covering NUL, TAB, LF, CR and space.
  2. Non-ASCII: character codes above 0x7e.
  3. 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 characterOutputReason
(backslash plus (Otherwise the literal ends early
Backslashtwo backslashesOtherwise it is taken as the escape introducer
Newline \nbackslash plus nKeeps 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:

InputOutput
foo.bar[12].oof[3].rabfoo 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: check parents first and return on a hit - the reference already appeared on the current path, and continuing would loop. Once the object is fetched, the Ref goes 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 S is JavaScript count. JS may be a stream (common for longer scripts) or a string. Both are first passed through stringToPDFString with escape sequences preserved, then stripped of NUL characters.
  • Next: use getRaw to get the raw value and recurse, so the next link may itself be a Ref and 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:

  1. AA is itself inheritable. A widget may omit AA while its field or page defines one. So getInheritableProperty is used with stopWhenFound: false, yielding the dictionaries from nearest to farthest.
  2. 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.set replaces rather than merges for the same key, so the final result matches "the nearest declaration wins".
  3. Event name mapping. eventType is an abbreviation table; annotations use AnnotationActionEventType, where E maps to Mouse Enter, X to Mouse Exit and K to Keystroke. Pages use PageActionEventType, which only has O and C. 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.

SourceKey formInheritedOverride rule
AAEvent abbreviationyesNearer overrides farther
AFixed ActionnoStandalone key, takes no part in event overrides
Next chainProduces no keyn/aAppended 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_api sandbox, 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: '&lt;',
  /* > */ 0x3e: '&gt;',
  /* & */ 0x26: '&amp;',
  /* " */ 0x22: '&quot;',
  /* ' */ 0x27: '&apos;',
};

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 (0x20 to 0x7e): only the five characters are replaced by named entities; letters, digits and common punctuation stay as they are, so pure ASCII text leaves buffer empty 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 &#x1D400; 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 &quot;, an ampersand becomes &amp;, a less-than sign becomes &lt;, the Greek letter Eta becomes &#x397;, and code point U+1D400 becomes &#x1D400;. 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_REGEXP is \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-ident tokens, 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.

InputOutput
Wingdings 3Wingdings3
MS Gothic 2 Bold 7MS Gothic2 Bold7
Times New RomanTimes New Roman
Extra spaces at the ends and in the middleEach run collapses to one space
Wingdings3Wingdings3

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;
}
FieldInputHandlingOutput
fontWeightNumber 100Converted to a string, found in the set"100"
fontWeightNumber 314Not in the set"400"
fontWeight"bold"Keyword found in the set"bold"
italicAngle2.718Between minus 90 and 90"2.718"
italicAngle-123Out of range"14"
italicAngle"91"Out of range"14"
italicAngleUnparseableparseFloat 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-family is 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 as escapePDFName escaping 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');
  }
}
AngleMatrixX directionY direction
90[0, 1, -1, 0, width, 0]Flipped from yTaken directly from x
180[-1, 0, 0, -1, width, height]x reversed and translatedy reversed and translated
270[0, -1, 1, 0, 0, height]Taken directly from yFlipped 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);
}
InputBits neededOutput
000
111
25581
25692
65536173

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. recoverJsURL turns script text into a link, numberToString turns a float into a PDF number, getModificationDate turns a time into a PDF date, getRotationMatrix turns an angle into a PDF matrix, getSizeInBytes turns an integer into a field width, and getNewAnnotationsMap turns 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];
ConstantValueWhere it is used
IDENTITY_MATRIX[1, 0, 0, 1, 0, 0]Default annotation appearance matrix, and the reference comparison for "is rotated"
PDF_VERSION_REGEXPOne major, one minor digitHeader version and Catalog /Version validation
MAX_INT_322147483647Checking 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.js so 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

StageModuleExports used
Byte supplyChunkedStream, NetworkPdfManagerarrayBuffersToBytes, MissingDataException
Syntax parsingLexer, ParserisWhiteSpace, ParserEOFException
Location and retrievalXRefMissingDataException, ParserEOFException, XRefEntryException, XRefParseException
Document modellingPDFDocument, CataloggetInheritableProperty, lookupNormalRect, collectActions, recoverJsURL, toRomanNumerals, PDF_VERSION_REGEXP, RESOURCES_KEYS_*, validateCSSFont
Content evaluationPartialEvaluatorisNumberArray, lookupMatrix, lookupRect, getLookupTableFactory, IDENTITY_MATRIX, fetchBinaryData, codePointIter
Annotation editingAnnotation, PDFEditorgetParentToUpdate, getNewAnnotationsMap, getRotationMatrix, getModificationDate, deepCompare, numberToString
Writing and savingWriterescapePDFName, escapeString, getSizeInBytes, parseXFAPath
XFA and XMLXMLParser, XFA modulesencodeToXmlString, 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

  1. Failures are classified by semantics, not by origin. MissingDataException means "wait for data", ParserEOFException means "object truncated", XRefEntryException means "index points wrong", XRefParseException means "the whole index is untrustworthy". The four correspond to four recovery strategies, so the caller needs one instanceof check and never parses an error message. Precisely because they are control flow, the wrapReason whitelist deliberately excludes them.
  2. The toolbox keeps leaf dependencies. core_utils depends only on shared/util, primitives, base_stream, css_utils and string_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 in XRef or PDFDocument.
  3. Reading and writing are two separate APIs. Inherited values are read with getInheritableProperty and written through getParentToUpdate; names are written with escapePDFName and read by Lexer. Each API answers one question, so a caller cannot pick up semantically wrong defaults by casual reuse.
  4. Defaults and validation are layered. lookupMatrix, lookupRect and lookupNormalRect only check shapes and leave defaults to the caller; validateCSSFont only does safety validation and fills gaps, returning false so the caller can skip. Utility functions never decide on the business's behalf what to do after something is broken.
  5. Hot paths do only the necessary work. The single-buffer fast path of arrayBuffersToBytes copies nothing, escapePDFName and encodeToXmlString return the original string when no replacement is needed, getInheritableProperty allocates 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