StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: primitives

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: primitives

Introduction

This is the article in the PDF.js source walkthrough series about "PDF object primitives". It covers src/core/primitives.js (466 lines). The file parses no PDF syntax at all, yet it defines the minimal data model shared by Parser, XRef, Catalog and Evaluator.

Earlier articles passed through this layer at different heights: Parser cuts bytes into tokens and then assembles tokens into objects; XRef retrieves objects by object number; ChunkedStream and PDFWorkerStream deliver bytes into the Worker. But what an "object" actually looks like was only mentioned in passing. This article takes that foundation apart completely.

The file contains three kinds of things:

  • Value objects: Name, Cmd, Dict and Ref, corresponding to names, operators, dictionaries and indirect references in PDF;
  • Reference containers: RefSet and RefMap, keyed by the string form of a reference number, serving caching and cycle detection;
  • Global conventions: two Symbol sentinels, three interning caches, and four lightweight predicate functions.

In one sentence: it is PDF.js's object model layer, receiving tokenizer results from below and providing unified semantics for "lookup, dereference, merge, compare" above.

Version note: This article follows exactly the version of primitives.js in the current repository. Dict is backed by the private field #map, not the _map common in older versions; the reference cache is shared by Ref.get and Ref.fromString; and Dict.merge uses Map.prototype.getOrInsertComputed. Do not apply implementations from other versions you remember while reading.

Module Overview

primitives.js splits into seven blocks:

BlockKey membersResponsibility
SentinelsCIRCULAR_REF, EOFRepresent circular references and end of stream with unique Symbols
Global cachesCmdCache, NameCache, RefCache, clearPrimitiveCachesIntern objects with the same name to reduce repeated allocation
Names and commandsName, CmdWrap PDF names and content-stream operators
DictionaryDictPrivate Map, dereferencing, conditional writes, merging and cloning
Indirect referencesRefCompact string form of object number and generation
Reference sets and mapsRefSet, RefMapSets and caches keyed by reference strings
Type predicatesisName, isCmd, isDict, isRefsEqualConsolidate common instanceof checks and field comparisons

Where it sits in the whole chain:

StageModuleOutput
LexingLexerName, Cmd, numbers, strings, EOF
ParsingParserDict, arrays, Ref
AddressingXRef.fetchA parsed object or CIRCULAR_REF
LookupDict.get / getArrayA dereferenced value
DocumentCatalog / Page / EvaluatorPage tree, resources, operatorList

Two dimensions need to be distinguished here:

DimensionExamplePoints elsewhere
Direct valueName, Cmd, numbers, stringsNo, it is the value itself
Indirect referenceRefYes, XRef.fetch is needed to get the value
ContainerDict, arraysMay contain a Ref; the lookup method decides whether to dereference

The core design of primitives.js revolves around this table: when to keep a Ref, when to dereference, and when to merge several dictionaries into one.

Two Symbol Sentinels

The file starts by defining two constants:

const CIRCULAR_REF = Symbol('CIRCULAR_REF');
const EOF = Symbol('EOF');

They are not strings and not special Dicts, but sentinel values with a unique identity. Callers compare with ===, and the values cannot be confused with any legal PDF object.

CIRCULAR_REF is returned by XRef.fetch when it detects a circular reference. A corrupt PDF can make 12 0 R eventually point back at 12 0 R; if the parser kept recursing, the Worker would simply hang. XRef records the references currently being parsed in a RefSet and returns this Symbol when the same reference appears again, so the layer above sees a result that is "not an object" and stops expanding.

EOF is returned by Lexer.getObj when the input ends. Parser loops compare identity directly; for example the condition in array parsing is written as while (!isCmd(this.buf1, "]") && this.buf1 !== EOF), and the loop stops when it reads the sentinel.

If null or the empty string were used to signal the end here, they would collide with the legal null object and empty strings in PDF. Symbol has no such problem: it can be exported only by this module, and outside code cannot forge an equal value.

Design note: Errors and termination are both control signals and should not reuse business values. Using Symbol instead of a string avoids naming collisions and reduces a type check to one identity comparison; the price is that symbols are not serializable, so they can only live on the synchronous parsing path inside the Worker.

Three Caches and Cleanup

All three interning caches use prototype-free objects:

let CmdCache = Object.create(null);
let NameCache = Object.create(null);
let RefCache = Object.create(null);
 
function clearPrimitiveCaches() {
  CmdCache = Object.create(null);
  NameCache = Object.create(null);
  RefCache = Object.create(null);
}

Object.create(null) has no Object.prototype, so names such as "toString", "constructor" and "__proto__" cannot hit inherited properties. PDF names and operators come from file content and can absolutely be these strings; a plain object literal would produce false hits in cache lookups.

Why objects instead of Map? All three caches sit on hot paths: every parsed name, every operator and every indirect reference performs a lookup. Object property access is usually lighter than Map.get, and the keys are strings anyway. The price is that only string keys can be stored, which is exactly what these three cases need.

clearPrimitiveCaches is called by clearGlobalCaches in cleanup_helper.js. Cleanup happens when a document is cleaned up; its purpose is not to invalidate old objects but to release the global interning tables, so that long-lived pages opening different documents do not keep accumulating strings and objects. The cache is an interning optimization, not semantic state; after a clear, reconstructing objects with the same names behaves the same.

Design note: Interning caches amplify the role of object identity, but code must not treat identity as the only truth. Both Name and Cmd keep the original string, and the predicate functions compare fields; clearing a cache only gives later objects a different instance and does not change value equality.

Name and Cmd

Name's Constructor and get

Name wraps a PDF name object, and the source is only a dozen lines:

class Name {
  constructor(name) {
    if ((typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) && typeof name !== 'string') {
      unreachable('Name: The "name" must be a string.');
    }
    this.name = name;
  }
 
  static get(name) {
    // eslint-disable-next-line no-restricted-syntax
    return (NameCache[name] ||= new Name(name));
  }
}

The constructor only performs type validation, and the validation is guarded by the PDFJSDev condition: a wrong type throws immediately in a test build, while a production build trusts the Parser. The name field stores the name text without the slash; for example /Type in PDF source is a Name in the object model whose name is "Type".

static get is the real entry point. The semantics of ||= are: read NameCache[name] first, and if the result is falsy, evaluate new Name(name) on the right and write it back; finally return that value. The cache holds objects, which are always truthy, so one string creates exactly one instance.

This has two consequences:

  1. Heavily repeated /Type, /Subtype and /Width share one Name object, reducing allocation and GC pressure;
  2. Callers can use Name.get("Page") to get a stable instance quickly without maintaining their own constant table.

But do not rely on object identity everywhere because of this. After clearPrimitiveCaches, the same name gets a new instance; semantic checks should use isName, or compare .name directly.

Cmd's Constructor and get

Cmd has almost the same structure as Name, except the field is called cmd:

class Cmd {
  constructor(cmd) {
    if ((typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) && typeof cmd !== 'string') {
      unreachable('Cmd: The "cmd" must be a string.');
    }
    this.cmd = cmd;
  }
 
  static get(cmd) {
    // eslint-disable-next-line no-restricted-syntax
    return (CmdCache[cmd] ||= new Cmd(cmd));
  }
}

Cmd represents an operator in a content stream, such as BT, ET, Tj, TJ or Do. Its difference from Name is not in implementation but in semantics and origin: Name comes from a name token with a slash, Cmd from a bare operator token. Parser and Evaluator keep them in different caches, so a "BT" with the same text does not leak between NameCache and CmdCache.

DimensionNameCmd
Fieldnamecmd
OriginPDF name object, for example /TypeContent-stream operator, for example BT
CacheNameCacheCmdCache
Typical checkisName(value, "Page")isCmd(value, "Tj")
Slash includedNo, the Parser has removed itNo, operators have no slash at all

Design note: Both Name and Cmd are strongly typed wrappers around a string. If plain strings were used, dictionary keys, content-stream operators and ordinary string values would mix together, and type checks could only be guessed from context. With a wrapper, instanceof, isName and isCmd fix the semantics, while the original string is still available for comparison and debugging.

Dict

Structure and Metadata

Dict is the object representation of a PDF dictionary, internally a private Map:

class Dict {
  __nonSerializable__ = nonSerializable; // Disable cloning of the Dict.
 
  #map = new Map();
 
  objId = null;
 
  suppressEncryption = false;
 
  xref;
 
  constructor(xref = null) {
    this.xref = xref;
  }
 
  assignXref(newXref) {
    this.xref = newXref;
  }
 
  get size() {
    return this.#map.size;
  }

Each field has a clear purpose:

FieldTypeRole
#mapMapKeys are PDF name strings; values are raw values or Refs
objIdstring or nullReference number written by XRef when caching the object, for example "12R"
xrefXRef or nullCross-reference table used for dereferencing
suppressEncryptionbooleanWhether to skip decryption when fetching values, used by the encryption dictionary itself
__nonSerializable__functionMarker for the cloning utility, preventing the whole dictionary from being treated as clonable data

The value of __nonSerializable__ is a function that returns itself:

const nonSerializable = () => nonSerializable; // Creating closure on some variable.

It is not a business field but a marker for cloning logic. Dict holds an xref and an internal Map; a deep copy is both expensive and likely to drag the whole cross-reference table along, and this field makes a generic cloner stop when it meets a dictionary.

objId is not the object number but the result of Ref.toString(). XRef.fetch fills it in when caching an object, and the resource, font and image caches use this string to identify "the same indirect object". An inline dictionary has no reference number, so objId stays null.

xref may be empty. When the Parser has just built a dictionary it may not know which xref it belongs to, and the trailer dictionary even gets it later through assignXref after XRef.parse. Without an xref, Dict.get does not dereference and returns the Ref unchanged.

#getValue

Every lookup method eventually goes through this private method:

  #getValue(isAsync, key1, key2) {
    let value = this.#map.get(key1);
    if (value === undefined && key2 !== undefined) {
      if (
        (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
        key2.length < key1.length
      ) {
        unreachable("Dict.#getValue: Expected keys to be ordered by length.");
      }
      value = this.#map.get(key2);
    }
    if (value instanceof Ref && this.xref) {
      return isAsync
        ? this.xref.fetchAsync(value, this.suppressEncryption)
        : this.xref.fetch(value, this.suppressEncryption);
    }
    return value;
  }

It does three things:

  1. Two-key fallback. It checks key1 first and only checks key2 when the result is strictly undefined. PDF has a historical habit of using abbreviated and full keys together, such as "W" and "Width", or "F" and "Filter"; callers pass the short key first, and the source uses a test assertion to enforce the parameter order.
  2. Dereference on demand. If the value is a Ref and the dictionary holds an xref, the synchronous path calls xref.fetch and the asynchronous path calls xref.fetchAsync. The result may be a Dict, an array, a stream or a primitive value.
  3. Return as is. If the value is not a Ref, or the dictionary has no xref, the raw value is returned directly. Inline dictionaries and independently constructed dictionaries are therefore never forced into dereferencing.

suppressEncryption is passed all the way to fetch. The encryption dictionary cannot go through decryption again while parsing itself or it would recurse; this flag exists for that special path.

The key2.length < key1.length assertion is not a business rule but a guard against callers writing the full key first, which would make the short key permanently unreachable. Production builds remove this check and leave only the two Map.get calls.

Design note: #getValue combines "table lookup" and "dereference" into one action but keeps one explicit switch: whether an xref exists. That lets the same Dict class represent both "a dictionary bound to a document" and "a temporary dictionary in the middle of parsing". A temporary dictionary never secretly triggers a network read; only a bound dictionary goes through the full fetch chain.

get, getAsync and getArray

The three public lookup methods share the private method above:

  // Automatically dereferences Ref objects.
  get(key1, key2) {
    return this.#getValue(/* isAsync = */ false, key1, key2);
  }
 
  // Same as get(), but returns a promise and uses fetchIfRefAsync().
  async getAsync(key1, key2) {
    return this.#getValue(/* isAsync = */ true, key1, key2);
  }
 
  // Same as get(), but dereferences all elements if the result is an Array.
  getArray(key1, key2) {
    let value = this.#getValue(/* isAsync = */ false, key1, key2);
 
    if (Array.isArray(value)) {
      value = value.slice(); // Ensure that we don't modify the Dict data.
      for (let i = 0, ii = value.length; i < ii; i++) {
        if (value[i] instanceof Ref && this.xref) {
          value[i] = this.xref.fetch(value[i], this.suppressEncryption);
        }
      }
    }
    return value;
  }

get is the default synchronous entry point. It can throw MissingDataException: when the object has not yet arrived in a network chunk, xref.fetch throws it, PDFManager.ensure catches it, requests the range and retries. Synchronous therefore does not mean "no data loading"; it means "the caller is already inside a retryable parsing context".

getAsync is the asynchronous version. In async mode #getValue returns the promise from fetchAsync, and the async function flattens it into the final plain value. It suits asynchronous business code that is not wrapped in ensure and may trigger network loading.

getArray dereferences one level only:

  • It first dereferences the dictionary field itself through #getValue;
  • If the result is an array, it copies it with slice() first, so the cached array is not modified in place;
  • It then checks each element and dereferences only top-level Refs.

It does not recurse into nested arrays and does not dereference dictionary fields inside the array. If an element is itself a Dict, the Refs inside that dictionary remain, and its own get must handle them. This boundary matters: infinite recursive dereferencing would cause an uncontrollable amount of reading on a large resource tree.

MethodSync or asyncDereferences fieldDereferences array elementsCopies arrayTypical scenario
getSyncYesNot applicableNoReading an ordinary field in a parsing context
getAsyncAsyncYesNot applicableNoAsync flows that must wait for network data
getArraySyncYesOne level onlyYesArrays such as /Widths and /Decode
getRawSyncNoNoNoKeeping a Ref or forwarding the raw value

getRaw and Raw Traversal

Not every caller wants dereferencing. The getRaw family keeps the raw values the Parser put in:

  // No dereferencing.
  getRaw(key) {
    return this.#map.get(key);
  }
 
  getKeys() {
    return this.#map.keys();
  }
 
  // No dereferencing.
  getRawValues() {
    return this.#map.values();
  }
 
  getRawEntries() {
    return this.#map.entries();
  }

getRaw accepts only one key and has no two-key fallback; it returns the value from the Map directly, which may be a Ref or any PDF object. getRawValues and getRawEntries are likewise lazy iterators, and traversal never triggers xref.fetch.

These methods are irreplaceable where "reference identity must be preserved". For example, XRef needs to know whether a field is a direct object or an indirect reference; Dict.merge needs to collect raw values first and decide later how to merge; and writing a PDF back must preserve the original Ref structure. If get were misused in those places, references would be expanded early, losing structure and possibly triggering unnecessary network reads.

set and the setIf Family

Write interfaces fall into two groups: strict writes and conditional writes.

  set(key, value) {
    if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
      if (typeof key !== "string") {
        unreachable('Dict.set: The "key" must be a string.');
      } else if (value === undefined) {
        unreachable('Dict.set: The "value" cannot be undefined.');
      }
    }
    this.#map.set(key, value);
  }
 
  setIfNotExists(key, value) {
    if (!this.has(key)) {
      this.set(key, value);
    }
  }
 
  setIfNumber(key, value) {
    if (typeof value === "number") {
      this.set(key, value);
    }
  }
 
  setIfArray(key, value) {
    if (Array.isArray(value) || ArrayBuffer.isView(value)) {
      this.set(key, value);
    }
  }
 
  setIfDefined(key, value) {
    if (value !== undefined && value !== null) {
      this.set(key, value);
    }
  }
 
  setIfName(key, value) {
    if (typeof value === "string") {
      this.set(key, Name.get(value));
    } else if (value instanceof Name) {
      this.set(key, value);
    }
  }
 
  setIfDict(key, value) {
    if (value instanceof Dict) {
      this.set(key, value);
    }
  }

set requires the key to be a string and the value not to be undefined in a test build. PDF dictionary keys are name strings by nature, and values can be any object, but "no value" should not be expressed with undefined; the correct expression for a missing field is a key that does not exist. Production builds drop the assertions and leave one Map.set.

setIfNotExists is the "first come, first kept" tool used during merging. It checks has first and does not overwrite an existing value. Dict.merge relies on exactly this when merging sub-dictionaries, so the first dictionary wins.

The other setIf methods are tolerant writes: they write only when the value matches the expected type and silently skip otherwise. They mainly serve optional fields in the parser and the document model:

MethodAccepted valueTypical use
setIfNumbernumberOptional numeric fields, keeping strings out
setIfArrayA real array or an ArrayBuffer viewArray fields; typed arrays are accepted too
setIfDefinedNeither undefined nor nullWrite only when there is a value, filtering empties
setIfNameA string or a NameStrings are converted into interned Names automatically
setIfDictA Dict instanceOptional sub-dictionaries

setIfArray is easy to misread: it accepts not only arrays for which Array.isArray is true, but also typed arrays according to ArrayBuffer.isView, such as Uint8Array. That lets binary arrays such as image pixels and font tables hang directly on a dictionary.

setIfName calls Name.get on a string, guaranteeing that what is written is still a Name rather than a bare string. This detail prevents the type drift of "the same field is sometimes a Name and sometimes a string".

Design note: Conditional writes put the "is this field legal" decision at the write site instead of scattering it across every caller. PDF files frequently contain type errors, missing fields and empty values; the parser should not fail the whole document because one optional field is broken. The setIf family provides local degradation: bad fields are ignored and the rest of the structure keeps building.

has, Symbol.iterator and empty

has checks only whether a key exists and does not look at the value:

  has(key) {
    return this.#map.has(key);
  }
 
  *[Symbol.iterator]() {
    for (const [key, value] of this.#map) {
      yield [
        key,
        value instanceof Ref && this.xref
          ? this.xref.fetch(value, this.suppressEncryption)
          : value,
      ];
    }
  }

The iterator dereferences automatically. The value obtained by for (const [key, value] of dict) has already gone through xref.fetch, consistent with get; this is entirely different from getRawEntries(), which keeps Refs and suits scenarios that need the raw structure.

Because the iterator is a synchronous generator, it cannot use fetchAsync. Traversing a dictionary that contains objects not yet downloaded may throw MissingDataException; the caller still needs ensure or must load the required range first. This limitation is shared with get and is not specific to the iterator.

The design of the empty-dictionary singleton is more unusual:

  static get empty() {
    const emptyDict = new Dict(null);
 
    emptyDict.set = (key, value) => {
      unreachable("Should not call `set` on the empty dictionary.");
    };
    return shadow(this, "empty", emptyDict);
  }

On the first access to Dict.empty, it creates a dictionary whose xref is null and overrides the instance-level set with a function that throws; shadow then replaces the getter on the class with this instance, so later accesses hit the original object directly. shadow also makes the property non-writable, so the singleton cannot be replaced.

Dict.empty is the safe return value for "no fields", and Dict.merge returns it when there is nothing to merge. It can still be read with get, checked with has and iterated, but any write throws immediately. This is safer than returning an ordinary empty dictionary: a caller that mistakenly thinks it can add fields is exposed at once instead of quietly polluting a globally shared object.

Design note: empty turns the immutability convention into runtime behavior through "singleton plus write protection". An ordinary empty object can only be guarded by comments, and a mistaken write would pollute every caller that holds it; here set becomes a failure point, so the error appears at the first write and never affects shared state.

merge

Dict.merge merges several dictionaries by priority and supports shallow merging and sub-dictionary merging:

  static merge({ xref, dictArray, mergeSubDicts = false }) {
    const mergedDict = new Dict(xref),
      properties = new Map();
 
    for (const dict of dictArray) {
      if (!(dict instanceof Dict)) {
        continue;
      }
      for (const [key, value] of dict.getRawEntries()) {
        const property = properties.getOrInsertComputed(key, makeArr);
 
        if (property.length && !(mergeSubDicts && value instanceof Dict)) {
          // Ignore additional entries, if either:
          //  - This is a "shallow" merge, where only the first element matters.
          //  - The value is *not* a `Dict`, since other types cannot be merged.
          continue;
        }
        property.push(value);
      }
    }
    for (const [name, values] of properties) {
      if (values.length === 1 || !(values[0] instanceof Dict)) {
        mergedDict.set(name, values[0]);
        continue;
      }
      const subDict = new Dict(xref);
 
      for (const dict of values) {
        for (const [key, value] of dict.getRawEntries()) {
          subDict.setIfNotExists(key, value);
        }
      }
      if (subDict.size > 0) {
        mergedDict.set(name, subDict);
      }
    }
    properties.clear();
 
    return mergedDict.size > 0 ? mergedDict : Dict.empty;
  }

It works in two passes.

The first pass walks each dictionary in dictArray order, uses getRawEntries() to get raw key-value pairs, and does not dereference early. properties is a temporary index from field name to an array of values; getOrInsertComputed(key, makeArr) creates an empty array when the key is absent and then pushes the current value into it.

The condition that actually decides whether to keep collecting is property.length && !(mergeSubDicts && value instanceof Dict).

  • By default mergeSubDicts is false: as soon as the field already has a value, same-named fields in later dictionaries are ignored, which is the first dictionary wins shallow merge.
  • With mergeSubDicts set to true, if the current value is also a Dict, collection continues and is left for sub-dictionary merging in the second pass; if the current value is not a Dict, it is still skipped.
  • Note that the test looks at "the current value", not "the first collected value". The first dictionary therefore decides whether this field eventually takes the sub-dictionary merge path.

The second pass produces the result field by field:

  • If there is only one value, or the first value is not a Dict, the first value is written directly. PDF arrays, names, numbers and strings cannot be merged, so later values are meaningless.
  • If the first value is a Dict and several values were collected, a new subDict is created, each sub-dictionary is traversed in collection order, and values are written with setIfNotExists. The first still wins, but different keys from different sub-dictionaries are combined.
  • A merged sub-dictionary that ends up empty is not written into the result, avoiding hollow fields.
  • If there are no fields at all, Dict.empty is returned.

One clarification is needed: the "arrays" in properties are only an intermediate container of the merge algorithm, not PDF arrays. PDF array values are treated as a non-mergeable type during merging, and only the first one is kept.

ScenariomergeSubDictsResult
A has X and B also has XfalseA's X is kept
The Y of both A and B is a dictionaryfalseA's Y is kept; sub-keys are not merged
The Y of both A and B is a dictionarytrueThe new dictionary holds all sub-keys of A and B, with A winning conflicts
A's Y is a dictionary and B's Y is an arraytrueA's Y is kept; the array does not participate in merging
dictArray contains only non-dictionariesEitherDict.empty is returned

Merging goes one level deep only. Nested dictionaries inside a sub-dictionary remain raw values and are not expanded recursively; this is consistent with setIfNotExists comparing only top-level keys. For structures such as /Resources and /Group, one level is usually enough, and recursive merging would change the object graph.

Design note: merge replaces "write while traversing" with "collect first, decide later". The first pass only records which values each field has seen; the second pass then knows whether the field is a single value, a non-mergeable value or several sub-dictionaries. This two-stage structure lets shallow merging and sub-dictionary merging share one collection path, with the condition appearing only in "whether to keep pushing".

clone and delete

  clone() {
    const dict = new Dict(this.xref);
    for (const [key, value] of this.#map) {
      dict.set(key, value);
    }
    return dict;
  }
 
  delete(key) {
    this.#map.delete(key);
  }

clone is a shallow clone: the new dictionary shares the same xref and copies raw key-value pairs one by one, without copying metadata such as objId and suppressEncryption and without recursively copying arrays or sub-dictionaries. The copied dictionary can add and remove top-level fields independently, but nested objects are still shared. A caller that wants to modify a sub-dictionary must clone that level itself.

delete removes a key directly and returns nothing. It does not check whether the key exists and triggers no dereferencing.

objId, xref and assignXref

These three members often appear together, but their responsibilities differ:

MemberWritten byPurpose
objIdXRef.fetchGives resource, font and image caches a stable string identity
xrefThe constructor or assignXrefDecides whether get can dereference
suppressEncryptionEncryption parsing codePrevents the encryption dictionary from decrypting again when fetching its own values

XRef.fetch writes objId when caching an object, with the value coming from xrefEntry.objId = ref.toString(). For objects that contain a stream, objId is written both on the outer dictionary and on dict. Catalog and PartialEvaluator use this string to avoid processing the same indirect object twice.

assignXref exists for "construct first, bind later". The trailer dictionary is first built by the Parser, when xref may be empty; after XRef.parse has built itself, it calls trailerDict.assignXref(this), and only from then on can the dictionary dereference fields such as /Root and /Encrypt. Calling get before binding returns Refs unchanged.

suppressEncryption defaults to false. When the encryption dictionary parses structures such as /CF and /StmF, it sets the flag to true so that fetch does not enter the decryption flow again and recurse forever. It only affects the argument passed to fetch during dereferencing and does not change the values in the dictionary.

Ref

Construction and toString

Ref represents a PDF indirect reference such as 12 0 R:

class Ref {
  #str;
 
  constructor(str, num, gen) {
    this.#str = str;
    this.num = num;
    this.gen = gen;
  }
 
  toString() {
    // This function is hot, so we make the string as compact as possible.
    // |this.gen| is almost always zero, so we treat that case specially.
    return this.#str;
  }

num is the object number and gen is the generation. #str is the compact string assembled in advance, and toString() returns it directly without any formatting. This method is on a hot path: RefSet, RefMap, objId, logging and error messages all call it; concatenating a template string each time would create a large number of short-lived strings.

When the generation is 0 the string is "12R", with the trailing 0 omitted; only a non-zero generation produces "12R4". The comment says "the generation is almost always zero", so the most common case is optimized specially.

There are no spaces here and no PDF source-text form such as "0 R". A Ref in the object model is a normalized compact representation, and the Parser is responsible for turning three tokens into one Ref.

fromString

Ref.fromString parses a string back into a Ref and reuses the cache:

  static fromString(str) {
    const ref = RefCache[str];
    if (ref) {
      return ref;
    }
    const m = /^(\d+)R(\d*)$/.exec(str);
    if (!m || m[1] === "0") {
      return null;
    }
    const num = parseInt(m[1], 10),
      gen = !m[2] ? 0 : parseInt(m[2], 10);
    // eslint-disable-next-line no-restricted-syntax
    return (RefCache[str] = new Ref(str, num, gen));
  }

The regular expression accepts only the compact form "digits + R + optional digits":

  • "12R" parses as object number 12, generation 0;
  • "12R4" parses as object number 12, generation 4;
  • "12 R", "12 0 R" and "R12" do not match and return null;
  • An object number of 0 returns null; the source treats object 0 as an invalid reference.

On success it writes back to RefCache[str], with the key being exactly the input string. So fromString suits the canonical keys stored in RefMap, and is not suitable for direct calls with user input or PDF source text containing spaces.

get

References should normally be constructed through Ref.get:

  static get(num, gen) {
    const str = gen === 0 ? `${num}R` : `${num}R${gen}`;
    // eslint-disable-next-line no-restricted-syntax
    return (RefCache[str] ||= new Ref(str, num, gen));
  }

It builds the canonical string from num and gen, then looks up the cache with ||=. With generation 0 it builds "12R", otherwise "12R4", matching the format of fromString. Therefore Ref.get(12, 0) and Ref.fromString("12R") hit the same cache entry.

CallCanonical stringResult
Ref.get(12, 0)"12R"Object number 12, generation 0
Ref.get(12, 4)"12R4"Object number 12, generation 4
Ref.fromString("12R")"12R"The same instance as the first row
Ref.fromString("12 R")Nonenull
Ref.fromString("0R")Nonenull

Ref's fields are public, but callers should treat it as an immutable value: do not modify num or gen. If the string key and the fields disagree, lookups in RefSet and RefMap break. To get a new reference, construct it with Ref.get instead of changing fields on the existing object.

Design note: Ref compresses "object number plus generation" into a cached string, which lowers memory use and lets Set and Map use value keys directly. toString is on a hot path because references appear extremely often in cycle detection, object caching and logging; moving the formatting cost forward to construction leaves only a string hash for every later comparison and index lookup.

RefSet

RefSet is a reference set that internally stores strings rather than Ref objects:

// The reference is identified by number and generation.
// This structure stores only one instance of the reference.
class RefSet {
  #set = new Set();
 
  constructor(parent = null) {
    if (parent) {
      if (
        (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
        !(parent instanceof RefSet)
      ) {
        unreachable('RefSet: Invalid "parent" value.');
      }
      for (const refStr of parent) {
        this.#set.add(refStr);
      }
    }
  }
 
  has(ref) {
    if (
      (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
      !(ref instanceof Ref) &&
      typeof ref !== 'string'
    ) {
      unreachable('RefSet: Invalid "ref" value in has.');
    }
    return this.#set.has(ref.toString());
  }
 
  put(ref) {
    if (
      (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
      !(ref instanceof Ref) &&
      typeof ref !== 'string'
    ) {
      unreachable('RefSet: Invalid "ref" value in put.');
    }
    this.#set.add(ref.toString());
  }
 
  remove(ref) {
    this.#set.delete(ref.toString());
  }
 
  [Symbol.iterator]() {
    return this.#set.keys();
  }
 
  clear() {
    this.#set.clear();
  }
}

has, put and remove all call ref.toString() first, so the argument can be a Ref or a string. A string must match the compact format of Ref.toString(); passing "12 0 R" does not hit "12R". Test builds reject other types, while production builds call toString() directly, so a wrong type can produce an unexpected key.

The constructor's parent copies an existing set. It walks the parent and adds each string to the new set; this is a snapshot copy, not a shared view. Later additions or removals in the parent do not affect the child, and vice versa.

The iterator returns the key iterator of the internal Set directly, so ref in for (const ref of refSet) is a string such as "12R", not a Ref. A caller that needs the object number must call Ref.fromString(ref) itself. This choice matches the internal storage and avoids repeatedly constructing wrapper objects during traversal.

XRef uses RefSet to record references being parsed and detect cycles; Evaluator uses RefSet to record Type3 font references and avoid processing them twice. Both want "a set of reference numbers" without extra values, so Set is enough.

Design note: RefSet does not store Ref objects internally; it stores the results of toString() uniformly. Ref's cache may be cleared, and the same reference may exist as different instances; string keys make the set depend only on the value semantics of "object number plus generation", unaffected by object identity. The parent copy also lets callers create a subset without destroying the original set.

RefMap

RefMap is a mapping from references to values:

class RefMap {
  #map = new Map();
 
  get size() {
    return this.#map.size;
  }
 
  get(ref) {
    return this.#map.get(ref.toString());
  }
 
  has(ref) {
    return this.#map.has(ref.toString());
  }
 
  put(ref, obj) {
    this.#map.set(ref.toString(), obj);
  }
 
  putAlias(ref, aliasRef) {
    this.#map.set(ref.toString(), this.get(aliasRef));
  }
 
  getOrPutComputed(ref, callback) {
    const map = this.#map,
      refStr = ref.toString();
 
    if (!map.has(refStr)) {
      map.set(refStr, callback(ref));
    }
    return map.get(refStr);
  }
 
  [Symbol.iterator]() {
    return this.#map.values();
  }
 
  clear() {
    this.#map.clear();
  }
 
  *values() {
    yield* this.#map.values();
  }
 
  *items() {
    for (const [ref, value] of this.#map) {
      yield [Ref.fromString(ref), value];
    }
  }
 
  *keys() {
    for (const ref of this.#map.keys()) {
      yield Ref.fromString(ref);
    }
  }
}

The basic methods resemble Map, but keys are normalized through ref.toString(). get, has and put all accept a Ref; a string also works because strings have toString() too, but it must already be in canonical form.

putAlias performs an immediate copy:

this.#map.set(ref.toString(), this.get(aliasRef));

It reads the current value of aliasRef first and then writes it under the key ref. After the write the two keys are unrelated; modifying the object behind aliasRef later or putting a new value does not automatically reflect on ref. If aliasRef has no value yet, this writes undefined, and no value is filled in automatically later. The word "alias" in the name describes the semantics at call time, not a persistent two-way binding.

getOrPutComputed means "return if present, otherwise compute and store". It computes refStr once to avoid formatting repeatedly in has, set and get; if the key is absent, it calls callback(ref) to compute and store the value; finally it returns map.get(refStr). Later calls with the same key do not run the callback again, and even a callback result of undefined is treated as an existing value.

Iteration behaves differently from a native Map:

MethodProducesUse
Default iterationValues onlyfor (const value of map)
values()Values onlyExplicitly stating that only values matter
items()[Ref, value] pairsWhen both reference number and value are needed
keys()RefsWhen traversing reference numbers

items and keys use Ref.fromString to restore the internal strings to Refs. If a string is not in canonical form, fromString may return null; but normal write paths all go through Ref.toString(), so this does not happen.

fontCache in PartialEvaluator uses RefMap. When the same font is referenced by different pages, putAlias can make a new reference point directly at a font object that has already been parsed; getOrPutComputed can implement "parse the first time, reuse afterwards".

Design note: Both RefMap and RefSet compress references into string keys, but their iteration contracts differ: a set needs only membership, so iterating strings is enough; a map must restore keys to Refs, so it additionally provides items() and keys(). The default iteration yields values because the vast majority of callers care only about the cached object, not the reference number.

The Four Predicate Functions

The end of the file contains four lightweight predicate functions:

function isName(v, name) {
  return v instanceof Name && (name === undefined || v.name === name);
}
 
function isCmd(v, cmd) {
  return v instanceof Cmd && (cmd === undefined || v.cmd === cmd);
}
 
function isDict(v, type) {
  return v instanceof Dict && (type === undefined || isName(v.get('Type'), type));
}
 
function isRefsEqual(v1, v2) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(
      v1 instanceof Ref && v2 instanceof Ref,
      'isRefsEqual: Both parameters should be `Ref`s.',
    );
  }
  return v1.num === v2.num && v1.gen === v2.gen;
}

They standardize the "type plus optional value" style of check:

FunctionFirst parameterSecond parameterCompared
isNameNameOptional name stringinstanceof and v.name
isCmdCmdOptional operator stringinstanceof and v.cmd
isDictDictOptional type nameinstanceof and the Type field
isRefsEqualRefAnother Refnum and gen

isDict's second parameter is not a dictionary subtype string but the value of the Type field, for example isDict(node, "Page"). It is equivalent to confirming that node is a Dict, then reading node.get("Type"), and finally comparing against "Page" with isName. get dereferences automatically, so the check is still correct even when /Type is an indirect reference; but this also means isDict may trigger an xref.fetch, so it is not suitable on paths that must not load data at all.

isRefsEqual compares only num and gen, not strings and not object identity. Even if two Refs come from different caches and have different #str values, for example because of an abnormal format, they are considered the same reference as long as the object number and generation match. This is exactly PDF's reference semantics: a reference is uniquely determined by those two numbers.

Test builds assert that both parameters are Refs; production builds omit the assertions and read the fields directly. Passing other types yields surprises such as undefined === undefined, so callers must still guarantee the types.

Design note: The predicate functions are not syntactic sugar; they centralize business rules such as "what counts as a Page dictionary" and "what counts as the same reference" in one place. Callers no longer need to remember that the Type field must be dereferenced, or that reference equality only looks at two numbers; when a rule changes, only these four small functions need to change.

Conclusion

primitives.js is PDF.js's object model foundation: it defines the four value types, name, operator, dictionary and indirect reference, plus the caches, dereferencing, merging and set utilities around them. Every object the Parser produces, every entry XRef caches and every field Catalog reads ultimately lands on these classes.

Where It Sits in the Chain

StageModuleInputOutput
LexingLexerPDF bytesName, Cmd, numbers, strings, EOF
ParsingParserToken sequenceDict, arrays, Ref
AddressingXRefRefA parsed object or CIRCULAR_REF
LookupDict.get / getArrayField nameA dereferenced value
Document and evaluationCatalog / EvaluatorDictionaries and referencesPage tree, resources, operatorList

In one sentence: the Lexer produces typed tokens, the Parser packs them into Dicts and Refs, XRef retrieves objects by reference, and Dict hides the retrieval process inside a single get.

Five Design Principles

  1. Object model and syntax parsing are separated. primitives.js reads no bytes and recognizes no keywords; it only defines what a value looks like. The Parser handles syntax and XRef handles addressing. All three can be replaced and tested independently.
  2. Interning caches optimize allocation only and carry no semantics. NameCache, CmdCache and RefCache let repeated values share instances, but clearPrimitiveCaches can clear them at any time; checks and comparisons always fall back to field values.
  3. Dereferencing must be an explicit choice. get, getAsync, getArray, getRaw and the iterator each have a clear contract, with no magic that "recursively expands everything". Callers choose the parsing depth they need, avoiding accidental network reads and object-graph growth.
  4. Tolerant writes beat parse failures. The setIf family puts type checks for optional fields at the write site, so bad values are ignored instead of throwing; this degradation strategy is necessary for the large number of non-conforming PDFs in the real world.
  5. Reference identity is unified as a string. Ref.toString produces a compact key, and RefSet, RefMap and objId all use it for indexing; object instances can be rebuilt, and reference semantics are determined only by object number and generation.

With primitives.js understood, looking at the Dict construction in Parser, the cache in XRef.fetch and the /Pages traversal in Catalog makes it clear that they share one set of value semantics rather than building ad hoc objects of their own. The next article can return to parser.js and see how these primitives are recognized one by one from the byte stream.

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