StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive 8: PDFDocument

October 26, 2026 · StreamPDF Team

PDF.js Deep Dive 8: PDFDocument

Introduction

This is the eighth article in the PDF.js series. It covers the Page and PDFDocument classes in src/core/document.js.

It picks up where article 6 left off at PDFManager.initDocument — the xref (cross-reference table) has been parsed. This article looks at how PDFDocument builds the whole document model on top of that xref (page count, forms, signatures, fingerprints, and so on), and how the Page class wraps per-page data and supplies the content stream and Resources input that article 7's PartialEvaluator consumes.

document.js is roughly 2,400 lines and acts as the document data hub on the worker side: main-thread APIs such as PDFDocumentProxy.getPage(), getMetadata(), and getAnnotations() all end up here.

The LETTER_SIZE_MEDIABOX constant

const LETTER_SIZE_MEDIABOX = [0, 0, 612, 792];

The default MediaBox for US Letter paper (units are 1/72 inch, i.e. 8.5×11 inches). The PDF specification requires every page to have a /MediaBox (it may be inherited), but broken files can omit it or supply an empty rectangle — Page.mediaBox falls back to this constant so a page always has a legal display size.

The STARTXREF_SIGNATURE constant

const STARTXREF_SIGNATURE = new Uint8Array([
  0x73, 0x74, 0x61, 0x72, 0x74, 0x78, 0x72, 0x65, 0x66,
]); // "startxref"

The byte sequence of the startxref keyword. A PDF file ends with a startxref block that records the byte offset of the cross-reference table. PDFDocument.startXRef scans backwards from the end of the file for this signature to locate the start of the xref.

The ENDOBJ_SIGNATURE constant

const ENDOBJ_SIGNATURE = new Uint8Array([0x65, 0x6e, 0x64, 0x6f, 0x62, 0x6a]); // "endobj"

The byte sequence of the endobj keyword. For a linearized PDF (Fast Web View) the first object is the linearization dictionary, and the position right after its parameter section is the xref offset following the first page's content — the startXRef getter uses this signature to find "the end of the first object".

The find method

function find(stream, signature, limit = 1024, backwards = false) {
  const signatureLength = signature.length;
  const scanBytes = stream.peekBytes(limit); // peek at most limit bytes without moving pos
  const scanLength = scanBytes.length - signatureLength;
  if (scanLength <= 0) {
    return false;
  }
  if (backwards) {
    const signatureEnd = signatureLength - 1;
    let pos = scanBytes.length - 1;
    while (pos >= signatureEnd) {
      let j = 0;
      while (
        j < signatureLength &&
        scanBytes[pos - j] === signature[signatureEnd - j]
      ) {
        j++;
      }
      if (j >= signatureLength) {
        stream.pos += pos - signatureEnd; // hit: move stream.pos to the start of the signature
        return true;
      }
      pos--;
    }
  } else {
    let pos = 0;
    while (pos <= scanLength) {
      let j = 0;
      while (j < signatureLength && scanBytes[pos + j] === signature[j]) {
        j++;
      }
      if (j >= signatureLength) {
        stream.pos += pos;
        return true;
      }
      pos++;
    }
  }
  return false;
}

A byte-level signature scanner: it searches for a byte signature inside the first limit bytes of the stream, in either the forward direction (backwards=false) or the reverse direction. On a hit it advances stream.pos to the start of the signature; on a miss it returns false (pos is unchanged, because it peeked with peekBytes instead of reading).

Two typical uses:

  • Forward: checkHeader looks for %PDF- (PDF_HEADER_SIGNATURE) within the first 1024 bytes of the file, tolerating garbage bytes before the header.
  • Backward: startXRef walks backwards from the end of the file in 1024-byte steps looking for startxref, because the specification allows the signature to appear anywhere within the last 1024 bytes of the file.

Design note: find only does "locate the signature and advance the stream pointer", keeping signature matching decoupled from parsing — the caller does the parsing (reading the number, reading the version). This is the classic scanner-plus-parser split.

The Page class

Page is a wrapper around single-page data — it holds the page dictionary (pageDict), the page reference (ref), and the various document-level caches, and exposes everything page-level: content stream, Resources, page boxes, rotation, annotations, and so on. It is the product of PDFDocument.getPage() and the direct caller of article 7's PartialEvaluator.

The constructor method

constructor({
  pdfManager, xref, pageIndex, pageDict, ref,
  globalIdFactory, fontCache, builtInCMapCache, standardFontDataCache,
  globalColorSpaceCache, globalImageCache, systemFontCache,
  nonBlendModesSet, xfaFactory,
}) {
  this.pdfManager = pdfManager;
  this.pageIndex = pageIndex;
  this.pageDict = pageDict;
  this.xref = xref;
  this.ref = ref;
  this.fontCache = fontCache;
  /* ... remaining caches are stored as-is ... */
  this.evaluatorOptions = pdfManager.evaluatorOptions;
  this.xfaFactory = xfaFactory;
 
  const idCounters = { obj: 0 };
  this._localIdFactory = class extends globalIdFactory {
    static createObjId() {
      return `p${pageIndex}_${++idCounters.obj}`;
    }
    static getPageObjId() {
      return `p${ref.toString()}`;
    }
  };
}

Like article 7's PartialEvaluator constructor, this uses dependency injection: all document-level caches (fontCache, globalImageCache, and so on) are passed in from outside (by PDFDocument.getPage, sourced from Catalog), and Page only holds page-level state.

The interesting part is _localIdFactory — it subclasses globalIdFactory anonymously and overrides two static methods:

MethodFormat producedPurpose
createObjId()p${pageIndex}_${n}Unique ID for objects within the page (images and the like); the page-number prefix isolates them per page
getPageObjId()p${ref}ID of the page object itself, used by the main thread to manage and destroy objects per page

Inheritance guarantees that document-level methods such as createFontId() and getDocId() behave unchanged, while only the two page-level ones are customized.

The _createPartialEvaluator method

_createPartialEvaluator(handler, pageIndex = this.pageIndex) {
  return new PartialEvaluator({
    xref: this.xref,
    handler,
    pageIndex,
    idFactory: this._localIdFactory,
    fontCache: this.fontCache,
    builtInCMapCache: this.builtInCMapCache,
    /* ... remaining caches ... */
    options: this.evaluatorOptions,
  });
}

The factory method between Page and PartialEvaluator — it passes the xref, caches, and options held by Page straight through, with the single change that idFactory becomes the page-level _localIdFactory. This is the "page-level" branch described in article 7's constructor section.

Every place that needs to parse a content stream (getOperatorList, extractTextContent, save, getAnnotationsData, and so on) creates its evaluator through this method, guaranteeing "one independent evaluator instance per page, with caches shared globally".

The #getInheritableProperty method

#getInheritableProperty(key, getArray = false) {
  const value = getInheritableProperty({
    dict: this.pageDict,
    key,
    getArray,
    stopWhenFound: false, // collect every value along the inheritance chain
  });
  if (!Array.isArray(value)) {
    return value;
  }
  if (value.length === 1 || !(value[0] instanceof Dict)) {
    return value[0];
  }
  return Dict.merge({ xref: this.xref, dictArray: value });
}

The attribute inheritance mechanism of the PDF page tree. Keys such as /Resources, /MediaBox, /CropBox, and /Rotate may be defined on any ancestor node (/Pages); when a child page omits them, the value is inherited up the tree.

Here stopWhenFound: false collects values from every level of the chain (not just the nearest one), and then the array is handled in three cases:

  1. Not an array → return it directly (only one definition along the chain).
  2. Array of length 1, or the element is not a Dict → return the first one (the nearest level wins).
  3. Multiple Dicts → Dict.merge merges them, with lower-level keys overriding upper-level keys.

Case 3 covers the situation where /Resources is defined at several levels — for example, a parent node defines common fonts and a child page adds its own image resources, and both are usable after merging.

The get content method

get content() {
  return this.pageDict.getArray("Contents");
}

Reads the /Contents entry of the page dictionary. The value can be:

  • Stream: a single content stream (the common case);
  • Array: multiple content streams or references (the specification allows splitting a page's content into several streams);
  • undefined: an empty page.

Note that this is the "raw" read; the actual stream assembly happens in getContentStream.

The get resources method

get resources() {
  // The spec requires \Resources to exist (it may be empty), but some documents omit it;
  // in that case fall back to an empty dict.
  const resources = this.#getInheritableProperty("Resources");
  return shadow(
    this,
    "resources",
    resources instanceof Dict ? resources : Dict.empty
  );
}

Gets the page's /Resources dictionary (home of named resources such as fonts, XObjects, color spaces, and GStates). #getInheritableProperty gives it page-tree inheritance, and a missing entry degrades to Dict.empty. shadow freezes the result into an instance property so later accesses bypass the getter.

The getBoundingBox method

getBoundingBox(name) {
  if (this.xfaData) {
    return this.xfaData.bbox; // XFA page boxes are computed by the template
  }
  const box = lookupNormalRect(
    this.#getInheritableProperty(name, /* getArray = */ true),
    null
  );
  if (box) {
    if (box[2] - box[0] > 0 && box[3] - box[1] > 0) {
      return box;
    }
    warn(`Empty, or invalid, /${name} entry.`);
  }
  return null;
}

The shared logic for reading a page box (MediaBox/CropBox/BleedBox, etc.):

  1. XFA documents first use the bbox computed by xfaFactory;
  2. lookupNormalRect normalizes the array to [x1, y1, x2, y2] (requiring x1 < x2 and y1 < y2, swapping otherwise);
  3. Width and height must be positive; empty or invalid rectangles return null, leaving the fallback to the caller.

The get mediaBox method

get mediaBox() {
  // reset an invalid mediaBox to Letter size
  return shadow(
    this,
    "mediaBox",
    this.getBoundingBox("MediaBox") || LETTER_SIZE_MEDIABOX
  );
}

/MediaBox defines the physical media boundary of the page and is the root of the whole box system. When missing or invalid it falls back to LETTER_SIZE_MEDIABOX.

The get cropBox method

get cropBox() {
  // degrade an invalid cropBox to mediaBox
  return shadow(this, "cropBox", this.getBoundingBox("CropBox") || this.mediaBox);
}

/CropBox defines the visible (clipped) area of the page, and the specification states that its default value is the MediaBox — that is the fallback chain here.

The get userUnit method

get userUnit() {
  const obj = this.pageDict.get("UserUnit");
  return shadow(this, "userUnit", typeof obj === "number" && obj > 0 ? obj : 1.0);
}

/UserUnit (PDF 1.6+) scales user space units, defaulting to 1.0 (i.e. 1/72 inch). Raising it lets a page exceed the default maximum page size in PDF. Any non-positive number is treated as 1.0.

The get view method

get view() {
  // Spec (6th edition, p.963): crop/bleed/trim/art box usually should not extend beyond mediaBox;
  // when they do, the effective area shrinks to their intersection.
  const { cropBox, mediaBox } = this;
  if (cropBox !== mediaBox && !isArrayEqual(cropBox, mediaBox)) {
    const box = Util.intersect(cropBox, mediaBox);
    if (box && box[2] - box[0] > 0 && box[3] - box[1] > 0) {
      return shadow(this, "view", box);
    }
    warn("Empty /CropBox and /MediaBox intersection.");
  }
  return shadow(this, "view", mediaBox);
}

view is the box the main thread actually uses to compute the viewport. The full fallback chain:

CropBox (default = MediaBox) → intersect with MediaBox → if the intersection is empty, fall back to MediaBox

Util.intersect returns the intersection of two rectangles; isArrayEqual short-circuits to avoid a pointless intersection computation. This is a direct implementation of the PDF specification §10.10.1.

The get rotate method

get rotate() {
  let rotate = this.#getInheritableProperty("Rotate") || 0;
  // normalize: must be a multiple of 90 and fall in [0, 270]
  if (rotate % 90 !== 0) {
    rotate = 0;
  } else if (rotate >= 360) {
    rotate %= 360;
  } else if (rotate < 0) {
    // the spec does not cover negative numbers; interpret them as counter-clockwise
    rotate = ((rotate % 360) + 360) % 360;
  }
  return shadow(this, "rotate", rotate);
}

/Rotate is the page's clockwise rotation, and the specification only allows 0/90/180/270. The normalization here:

  • not a multiple of 90 → reset to zero (illegal value);
  • ≥360 → take the modulus;
  • negative → convert the "negative angle" into the equivalent positive angle with ((x % 360) + 360) % 360 (e.g. -90 → 270).

It is inheritable, so it goes through #getInheritableProperty.

The #onSubStreamError method

#onSubStreamError(reason, objId) {
  if (this.evaluatorOptions.ignoreErrors) {
    warn(`getContentStream - ignoring sub-stream (${objId}): "${reason}".`);
    return;
  }
  throw reason;
}

The callback for when one sub-stream of a content-stream array fails (passed to StreamsSequenceStream). In ignoreErrors mode it warns and skips that sub-stream; otherwise it throws. This keeps "one broken stream among several" from making the whole page unopenable.

The async getContentStream method

async getContentStream() {
  const content = await this.pdfManager.ensure(this, "content");
 
  if (content instanceof BaseStream && !content.isImageStream) {
    if (content.isAsync) {
      const bytes = await content.asyncGetBytes();
      if (bytes) {
        return new Stream(bytes, 0, bytes.length, content.dict);
      }
    }
    return content;
  }
  if (Array.isArray(content)) {
    const promises = [];
    for (let i = 0, ii = content.length; i < ii; i++) {
      const item = content[i];
      if (item instanceof BaseStream && item.isAsync) {
        promises.push(
          item.asyncGetBytes().then(bytes => {
            if (bytes) {
              content[i] = new Stream(bytes, 0, bytes.length, item.dict);
            }
          })
        );
      }
    }
    if (promises.length > 0) {
      await Promise.all(promises);
    }
    return new StreamsSequenceStream(content, this.#onSubStreamError.bind(this));
  }
  // no page content: substitute an empty stream
  return new NullStream();
}

Turns /Contents into a synchronously consumable Stream, in three shapes:

ShapeHandling
Single streamIf it is an async stream (isAsync, data has not fully arrived), call asyncGetBytes() first to pull all the data and re-wrap it as a synchronous Stream; image streams (isImageStream) are ignored
Array of streamsFirst replace every async stream in the array with a synchronous one (Promise.all fetches them concurrently), then use StreamsSequenceStream to concatenate the segments into one logical stream, with #onSubStreamError as the read-error callback
No contentReturn a NullStream (empty stream) so a blank page can still go through the parsing pipeline

Why must it become a synchronous stream: article 7's EvaluatorPreprocessor/Lexer is a lexer that reads bytes synchronously, so every "parse while downloading" async stream is materialized into an in-memory synchronous stream here. The incremental benefit of streaming lives at the xref/object level; at the content-stream level parsing is always complete.

The get xfaData method

get xfaData() {
  return shadow(
    this,
    "xfaData",
    this.xfaFactory ? { bbox: this.xfaFactory.getBoundingBox(this.pageIndex) } : null
  );
}

XFA (XML Forms Architecture) documents have no traditional /MediaBox; the page size is computed by the XFA template. When there is an xfaFactory it returns { bbox }, otherwise null. getBoundingBox uses this to decide between the XFA path and the traditional path.

The async #replaceIdByRef method

async #replaceIdByRef(annotations, deletedAnnotations, existingAnnotations) {
  const promises = [];
  for (const annotation of annotations) {
    if (!annotation.id) continue;
    const ref = Ref.fromString(annotation.id);
    if (!ref) {
      warn(`A non-linked annotation cannot be modified: ${annotation.id}`);
      continue;
    }
    if (annotation.deleted) {
      deletedAnnotations.put(ref); // mark for deletion
      if (annotation.popupRef) { /* delete the popup as well */ }
      continue;
    }
    if (annotation.popup?.deleted) { /* delete the popup on its own */ }
    existingAnnotations?.put(ref);
    annotation.ref = ref;
    promises.push(
      this.xref.fetchAsync(ref).then(obj => {
        if (obj instanceof Dict) {
          annotation.oldAnnotation = obj.clone(); // keep the old dict for incremental updates
        }
      }, () => warn(`Cannot fetch \`oldAnnotation\` for: ${ref}.`))
    );
    delete annotation.id;
  }
  await Promise.all(promises);
}

The preprocessing step of the annotation editor's save flow. Annotations sent from the main thread are identified by id (a reference in string form, such as "123R"); this converts them all into Ref objects and splits them by state into three groups:

  • Deletion (deleted): the reference goes into deletedAnnotations, and the associated Popup annotation is deleted too;
  • Modification: the reference goes into existingAnnotations, and a clone of the old dictionary is captured into annotation.oldAnnotation — saving then writes back only the changed fields;
  • Purely new annotations (which cannot be parsed as a Ref) are skipped with a warning.

The async saveNewAnnotations method

async saveNewAnnotations(handler, task, annotations, imagePromises, changes) {
  if (this.xfaFactory) {
    throw new Error("XFA: Cannot save new annotations.");
  }
  const partialEvaluator = this._createPartialEvaluator(handler);
 
  const deletedAnnotations = new RefSet();
  const existingAnnotations = new RefSet();
  await this.#replaceIdByRef(annotations, deletedAnnotations, existingAnnotations);
 
  const pageDict = this.pageDict;
  const annotationsArray = this.annotations.filter(
    a => !(a instanceof Ref && deletedAnnotations.has(a))
  );
  const newData = await AnnotationFactory.saveNewAnnotations(
    partialEvaluator, this.xref, task, annotations, imagePromises, changes
  );
 
  for (const { ref } of newData.annotations) {
    if (ref instanceof Ref && !existingAnnotations.has(ref)) {
      annotationsArray.push(ref); // attach the new annotation ref to the page
    }
  }
 
  const dict = pageDict.clone();
  dict.set("Annots", annotationsArray);
  changes.put(this.ref, { data: dict }); // the page dict itself is a change entry too
 
  for (const deletedRef of deletedAnnotations) {
    changes.put(deletedRef, { data: null }); // data: null means delete that object
  }
}

Saves annotations the user created in the editor (ink, text notes, stamps, and so on). The flow:

  1. #replaceIdByRef converts ids to refs and separates the deletion set from the existing set;
  2. AnnotationFactory.saveNewAnnotations generates an appearance stream for each new annotation and writes it into changes (the change set used later for incremental saving);
  3. Update the page dictionary's /Annots array — drop deleted entries and append only truly new ones (refToReplace entries are not added twice);
  4. Put the modified page dictionary into changes as well; deleted references are marked with data: null.

XFA documents do not support this operation and throw immediately.

The async save method

async save(handler, task, annotationStorage, changes) {
  const partialEvaluator = this._createPartialEvaluator(handler);
  const annotations = await this._parsedAnnotations;
 
  const promises = [];
  for (const annotation of annotations) {
    promises.push(
      annotation
        .save(partialEvaluator, task, annotationStorage, changes)
        .catch(reason => {
          warn(`save - ignoring annotation data during "${task.name}" task: "${reason}".`);
          return null;
        })
    );
  }
  return Promise.all(promises);
}

Saves modifications to existing annotations — mainly the values entered in interactive form (AcroForm) fields. It walks the parsed annotations, calling annotation.save on each to serialize the new values from annotationStorage back into PDF objects and register them in changes. A single failure only warns and does not affect the other annotations.

The async loadResources method

async loadResources(keys) {
  // TODO: add async `#getInheritableProperty` and remove this.
  await (this.#resourcesPromise ??= this.pdfManager.ensure(this, "resources"));
  await ObjectLoader.load(this.resources, keys, this.xref);
}

Preloads all indirect objects under the specified keys of the Resources dictionary. ObjectLoader.load walks the dictionary recursively, fetching every Ref beneath keys (such as ["Font", "XObject", "ExtGState", ...]) ahead of time with xref.fetchAsync and caching it, so that later parsing can read synchronously.

#resourcesPromise caches the ensure Promise, so each page ensures only once. There are two sets of key constants: RESOURCES_KEYS_OPERATOR_LIST (for rendering, including ColorSpace/Pattern/Shading, etc.) and RESOURCES_KEYS_TEXT_CONTENT (for text extraction, needing only a few dictionaries such as Font).

The async #getMergedResources method

async #getMergedResources(streamDict, keys) {
  // in rare cases the /Contents stream dict also carries /Resources, which takes priority
  // (see issue18894.pdf)
  const localResources = streamDict?.get("Resources");
  if (!(localResources instanceof Dict && localResources.size)) {
    return this.resources;
  }
  await ObjectLoader.load(localResources, keys, this.xref);
  return Dict.merge({
    xref: this.xref,
    dictArray: [localResources, this.resources],
    mergeSubDicts: true,
  });
}

Handles an edge case that is outside the specification but real: the content-stream dictionary carries its own /Resources. In that case it is merged with the page-level Resources, with localResources first (taking priority); mergeSubDicts: true means sub-dictionaries (such as the entries under Font) are merged recursively too. When absent, the page-level Resources are used directly.

The async getOperatorList method

async getOperatorList({
  handler, sink, task, intent, cacheKey,
  pageIndex = this.pageIndex,
  annotationStorage = null,
  modifiedIds = null,
}) {
  const contentStreamPromise = this.getContentStream();
  const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
  const partialEvaluator = this._createPartialEvaluator(handler, pageIndex);
 
  const newAnnotsByPage = !this.xfaFactory
    ? getNewAnnotationsMap(annotationStorage)
    : null;
  const newAnnots = newAnnotsByPage?.get(this.pageIndex);
  /* ... if there are new editor annotations: collect missing bitmaps, then
         AnnotationFactory.generateImages → AnnotationFactory.printNewAnnotations for their opList ... */
 
  const pageListPromise = Promise.all([
    contentStreamPromise,
    resourcesPromise,
  ]).then(async ([contentStream]) => {
    const resources = await this.#getMergedResources(
      contentStream.dict,
      RESOURCES_KEYS_OPERATOR_LIST
    );
    const opList = new OperatorList(intent, sink);
    handler.send("StartRenderPage", {
      transparency: partialEvaluator.hasBlendModes(resources, this.nonBlendModesSet),
      pageIndex,
      cacheKey,
    });
    await partialEvaluator.getOperatorList({
      stream: contentStream,
      task,
      resources,
      operatorList: opList,
    });
    return opList;
  });
 
  let [pageOpList, annotations, newAnnotations] = await Promise.all([
    pageListPromise,
    this._parsedAnnotations,
    newAnnotationsPromise,
  ]);
  /* ... merge the new annotations (refToReplace replaces the old ones) ... */
}

This is the core method of the Page class; the GetOperatorList message that article 3's _pumpOperatorList receives ultimately routes here. The whole thing is an assembly line of three concurrent branches:

  1. Page content stream: getContentStream and loadResources run concurrently; once the resources are merged an OperatorList is created and a StartRenderPage message is sent first (carrying the transparency pre-check — hasBlendModes detects whether blending modes exist, and the main thread uses that to decide whether to enable transparency compositing), then article 7's partialEvaluator.getOperatorList converts the byte stream into an operator list.
  2. Existing annotations: this._parsedAnnotations parses concurrently.
  3. New editor annotations (entries in annotationStorage prefixed with AnnotationEditorPrefix): after resolving bitmap dependencies (one annotation may reference an image inside another annotation), AnnotationFactory.printNewAnnotations generates their appearance.

Once the three branches converge, the annotation operator merge stage begins:

const renderForms = !!(intent & RenderingIntentFlag.ANNOTATIONS_FORMS),
  isEditing = !!(intent & RenderingIntentFlag.IS_EDITING),
  intentAny = !!(intent & RenderingIntentFlag.ANY),
  intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY),
  intentPrint = !!(intent & RenderingIntentFlag.PRINT);
 
for (const annotation of annotations) {
  if (
    intentAny ||
    (intentDisplay && annotation.mustBeViewed(...) && annotation.mustBeViewedWhenEditing(...)) ||
    (intentPrint && annotation.mustBePrinted(annotationStorage))
  ) {
    opListPromises.push(annotation.getOperatorList(partialEvaluator, task, intent, annotationStorage));
  }
}
const opLists = await Promise.all(opListPromises);
for (const { opList, separateForm, separateCanvas } of opLists) {
  pageOpList.addOpList(opList);
  form ||= separateForm;
  canvas ||= separateCanvas;
}
pageOpList.flush(true, { form, canvas });

intent is a bit flag (RenderingIntentFlag) that decides the purpose of the render:

FlagMeaning
DISPLAYOn-screen display — render only visible annotations
PRINTPrinting — render only printable annotations
ANYRender everything (thumbnails and similar)
ANNOTATIONS_FORMSRender form fields
ANNOTATIONS_DISABLESkip annotations entirely (return right after flush)
IS_EDITINGEditing mode — do not paint the appearance stream of the annotation being edited

Each annotation builds its own operatorList, which is appended to the page stream via addOpList; separateForm/separateCanvas are aggregated and sent to the main thread along with flush, telling it whether a separate canvas layer is needed for form/XFA annotations. The return value is only { length } — the operators themselves have already been streamed out through sink.

The async extractTextContent method

async extractTextContent({
  handler, task, includeMarkedContent, disableNormalization, sink, intersector = null,
}) {
  const contentStreamPromise = this.getContentStream();
  const resourcesPromise = this.loadResources(RESOURCES_KEYS_TEXT_CONTENT);
  const langPromise = this.pdfManager.ensureCatalog("lang");
 
  const [contentStream, , lang] = await Promise.all([...]);
  const resources = await this.#getMergedResources(
    contentStream.dict, RESOURCES_KEYS_TEXT_CONTENT
  );
  const partialEvaluator = this._createPartialEvaluator(handler);
 
  return partialEvaluator.getTextContent({
    stream: contentStream, task, resources,
    includeMarkedContent, disableNormalization, sink,
    viewBox: this.view, lang, intersector,
  });
}

The text extraction entry point, structured as a simplified getOperatorList: the same "content stream plus merged resources" preparation, but using RESOURCES_KEYS_TEXT_CONTENT (only a few resources such as fonts are needed), plus the document language lang. It ultimately delegates to article 7's PartialEvaluator.getTextContent, and the result streams back to the main thread through sink for the text layer and search. intersector is used for intersection tests between highlight annotations and text.

The async getStructTree method

async getStructTree() {
  const structTreeRoot = await this.pdfManager.ensureCatalog("structTreeRoot");
  if (!structTreeRoot) return null;
  // ensure structTree includes this page's annotations
  await this._parsedAnnotations;
  try {
    const structTree = await this.pdfManager.ensure(this, "_parseStructTree", [structTreeRoot]);
    return await this.pdfManager.ensure(structTree, "serializable");
  } catch (ex) {
    warn(`getStructTree: "${ex}".`);
    return null;
  }
}

Gets this page's logical structure tree (structure elements of a Tagged PDF, used for accessibility and screen readers). It first fetches the catalog-level structTreeRoot, then ensures the annotations are parsed (annotations are also leaves of the structure tree), and finally _parseStructTree parses it and serializes the result (serializable) into plain data that can be sent to the main thread. Any step failing returns null and does not affect rendering.

The _parseStructTree method

_parseStructTree(structTreeRoot) {
  const tree = new StructTreePage(structTreeRoot, this.pageDict);
  tree.parse(this.ref);
  return tree;
}

Synchronous structure-tree parsing: build a StructTreePage from the document-level structTreeRoot and this page's dictionary, then parse collects the structure elements belonging to this page according to ref (associated through /StructParent//StructParents).

The async getAnnotationsData method

async getAnnotationsData(handler, task, intent) {
  const annotations = await this._parsedAnnotations;
  if (annotations.length === 0) return annotations;
 
  const annotationsData = [], textContentPromises = [];
  let partialEvaluator;
  /* ... unpack the intent flags ... */
  const highlightedAnnotations = [];
 
  for (const annotation of annotations) {
    const isVisible = intentAny || (intentDisplay && annotation.viewable);
    if (isVisible || (intentPrint && annotation.printable)) {
      annotationsData.push(annotation.data); // serialized data
    }
    if (annotation.hasTextContent && isVisible) {
      // popup-like annotations need their content text extracted
      partialEvaluator ??= this._createPartialEvaluator(handler);
      textContentPromises.push(annotation.extractTextContent(partialEvaluator, task, [-Inf, -Inf, Inf, Inf]));
    } else if (annotation.overlaysTextContent && isVisible) {
      highlightedAnnotations.push(annotation);
    }
  }
 
  if (highlightedAnnotations.length > 0) {
    const intersector = new Intersector(highlightedAnnotations);
    textContentPromises.push(
      this.extractTextContent({ /* ..., */ intersector })
        .then(() => intersector.setText())
    );
  }
  await Promise.all(textContentPromises);
  return annotationsData;
}

The worker-side implementation of the main thread's getAnnotations(), returning the annotations' serializable data (annotation.data) instead of an operatorList. Two extra touches:

  • Annotations carrying text, such as Popup/FreeText: call annotation.extractTextContent to extract their content text for accessibility and copy;
  • Highlight annotations (Highlight/Underline and others overlaid on text): use Intersector to intersect the annotation rectangle with the page text and write the actual highlighted characters back into the annotation data (setText()) — that is why copying highlighted content yields the original text.

The get annotations method

get annotations() {
  const annots = this.#getInheritableProperty("Annots");
  return shadow(this, "annotations", Array.isArray(annots) ? annots : []);
}

Reads the /Annots array (the raw list of references and dictionaries). Note that it also takes the inheritable path — a few documents attach annotations to a parent node of the page tree. Non-arrays degrade to an empty array.

The get _parsedAnnotations method

get _parsedAnnotations() {
  const promise = this.pdfManager
    .ensure(this, "annotations")
    .then(async annots => {
      if (annots.length === 0) return annots;
 
      const [annotationGlobals, fieldObjects] = await Promise.all([
        this.pdfManager.ensureDoc("annotationGlobals"),
        this.pdfManager.ensureDoc("fieldObjects"),
      ]);
      if (!annotationGlobals) return [];
 
      const orphanFields = fieldObjects?.orphanFields;
      const annotationPromises = [];
      for (const annotationRef of annots) {
        annotationPromises.push(
          AnnotationFactory.create(
            this.xref, annotationRef, annotationGlobals,
            this._localIdFactory, false, orphanFields, null, this.ref
          ).catch(reason => {
            warn(`_parsedAnnotations: "${reason}".`);
            return null; // a single failure does not affect the rest
          })
        );
      }
 
      const sortedAnnotations = [];
      let popupAnnotations, widgetAnnotations;
      // Popup depends on its parent annotation existing in the display layer, so it must come last (fixes issue 11362)
      for (const annotation of await Promise.all(annotationPromises)) {
        if (!annotation) continue;
        if (annotation instanceof WidgetAnnotation) {
          (widgetAnnotations ||= []).push(annotation);
          continue;
        }
        if (annotation instanceof PopupAnnotation) {
          (popupAnnotations ||= []).push(annotation);
          continue;
        }
        sortedAnnotations.push(annotation);
      }
      if (widgetAnnotations) sortedAnnotations.push(...widgetAnnotations);
      if (popupAnnotations) sortedAnnotations.push(...popupAnnotations);
      return sortedAnnotations;
    });
 
  return shadow(this, "_parsedAnnotations", promise);
}

Parses the raw /Annots array into a list of annotation instances; it is the hub of the annotation system (getOperatorList, getAnnotationsData, save, and getStructTree all depend on it). Three key points:

  1. Document-level dependencies: first fetch annotationGlobals (annotation global data: default appearance, fonts, and so on) and fieldObjects (the form field tree) concurrently. orphanFields is used to put back orphan annotations whose parent field is in the Fields array but whose Widget has no Parent.
  2. Fault tolerance: if AnnotationFactory.create fails for one annotation it just returns null and does not drag down the page.
  3. Ordering: Widget → ordinary annotations → Popup. Widgets (form field controls) and Popups (pop-up windows) are deliberately pushed last — a Popup's display depends on its parent annotation already being registered in the display layer, and the wrong order leaves the popup without a host (issue 11362).

The whole getter returns a Promise frozen with shadow, guaranteeing annotations are parsed only once.

The get jsActions method

get jsActions() {
  const actions = collectActions(this.xref, this.pageDict, PageActionEventType);
  return shadow(this, "jsActions", actions);
}

Collects page-level JavaScript actions — the O (open page) and C (close page) event scripts in the /AA (Additional Actions) dictionary. PageActionEventType restricts it to those two. It is null when there are no actions.

The async collectAnnotationsByType method

async collectAnnotationsByType(handler, task, types, promises, annotationGlobals) {
  const { pageIndex } = this;
 
  if (Object.hasOwn(this, "_parsedAnnotations")) {
    // already parsed: filter from the cache by type
    const cachedAnnotations = await this._parsedAnnotations;
    for (const { data } of cachedAnnotations) {
      if (!types || types.has(data.annotationType)) {
        data.pageIndex = pageIndex;
        promises.push(Promise.resolve(data));
      }
    }
    return;
  }
 
  // not parsed: create lazily and filter by type at create time (avoid fully parsing every annotation)
  const annots = await this.pdfManager.ensure(this, "annotations");
  let partialEvaluator;
  for (const annotationRef of annots) {
    promises.push(
      AnnotationFactory.create(
        this.xref, annotationRef, annotationGlobals, this._localIdFactory,
        false, null, types, this.ref
      ).then(async annotation => {
        if (!annotation) return null;
        annotation.data.pageIndex = pageIndex;
        if (annotation.hasTextContent && annotation.viewable) {
          partialEvaluator ??= this._createPartialEvaluator(handler);
          await annotation.extractTextContent(/* ... */);
        }
        return annotation.data;
      })
      /* ... catch for fault tolerance ... */
    );
  }
}

Collects annotations in bulk by type (used by main-thread batch queries such as "export all highlights/stamps"). Two paths:

  • _parsedAnnotations already cached → filter by annotationType directly in memory;
  • not cached → pass the types set to the collectByType parameter of AnnotationFactory.create and filter at creation time, so annotations of non-target types are never fully parsed, saving a full parse.

This is a typical "lazy first, cache as a safety net" example: the same data, with the optimal retrieval path chosen based on timing.

The PDFDocument class

The source comment puts it plainly: The PDFDocument class holds all the (worker-thread) data of the PDF file. — it is the document data holder on the worker side: the xref, Catalog, page cache, and all document-level information such as forms, signatures, XFA, and fingerprints converge on this class. It is what new PDFDocument(pdfManager, stream) creates in article 6's PDFManager.initDocument.

The constructor method

constructor(pdfManager, stream) {
  /* test builds assert that stream is a BaseStream */
  if (stream.length <= 0) {
    throw new InvalidPDFException("The PDF file is empty, i.e. its size is zero bytes.");
  }
  this.pdfManager = pdfManager;
  this.stream = stream;
  this.xref = new XRef(stream, pdfManager);
 
  const idCounters = { font: 0 };
  this._globalIdFactory = class {
    static getDocId() {
      return `g_${pdfManager.docId}`;
    }
    static createFontId() {
      return `f${++idCounters.font}`;
    }
    static createObjId() {
      unreachable("Abstract method `createObjId` called.");
    }
    static getPageObjId() {
      unreachable("Abstract method `getPageObjId` called.");
    }
  };
}

Construction does three things:

  1. Defense: an empty file throws InvalidPDFException immediately;
  2. Build the xref: new XRef(stream, pdfManager) — note this only creates the object; the actual parsing happens in parse;
  3. Build the document-level ID factory _globalIdFactory: getDocId() returns g_${docId} and createFontId() returns an incrementing f${n}; createObjId/getPageObjId are abstract methods that call unreachable — page-level IDs must be supplied by Page._localIdFactory (which subclasses this class), which prevents "using a page-level ID in a global context" at the type level.

The parse method

parse(recoveryMode) {
  this.xref.parse(recoveryMode);
  this.catalog = new Catalog(this.pdfManager, this.xref);
}

The two steps covered in article 6: xref.parse parses the cross-reference table (when recoveryMode is true it enters damaged-file recovery mode, scanning the whole file for objects), then Catalog is built on top of the xref (the document catalog, entry point for the page tree, outlines, and metadata).

The get linearization method

get linearization() {
  let linearization = null;
  try {
    linearization = Linearization.create(this.stream);
  } catch (err) {
    if (err instanceof MissingDataException) {
      throw err; // data has not arrived during streaming; rethrow so the layer below keeps fetching
    }
    info(err); // other errors are only logged; treat the document as non-linearized
  }
  return shadow(this, "linearization", linearization);
}

Tries to read the linearization dictionary (Linearized PDF / Fast Web View). A linearized document places the first page's content and a hint table at the front of the file, so a browser can render the first page without downloading the whole file.

Linearization.create checks whether the first object in the file is a /Linearized dictionary; if so it returns { numPages, pageFirst, objectNumberFirst, ... }. MissingDataException must be rethrown — it is the "data has not arrived yet" signal of streaming; all other errors degrade to a non-linearized document.

The get startXRef method

get startXRef() {
  const stream = this.stream;
  let startXRef = 0;
 
  if (this.linearization) {
    // linearized document: find the first object's endobj; the xref of the first page section follows
    stream.reset();
    if (find(stream, ENDOBJ_SIGNATURE)) {
      stream.skip(6);
      let ch = stream.peekByte();
      while (isWhiteSpace(ch)) { stream.pos++; ch = stream.peekByte(); }
      startXRef = stream.pos - stream.start;
    }
  } else {
    // ordinary document: scan backwards from the end of the file for startxref
    const step = 1024;
    const startXRefLength = STARTXREF_SIGNATURE.length;
    let found = false, pos = stream.end;
    while (!found && pos > 0) {
      pos -= step - startXRefLength;
      if (pos < 0) pos = 0;
      stream.pos = pos;
      found = find(stream, STARTXREF_SIGNATURE, step, true); // backwards
    }
    if (found) {
      stream.skip(9);
      let ch;
      do { ch = stream.getByte(); } while (isWhiteSpace(ch));
      let str = "";
      while (ch >= 0x20 && ch <= 0x39) {
        str += String.fromCharCode(ch);
        ch = stream.getByte();
      }
      startXRef = parseInt(str, 10);
      if (isNaN(startXRef)) startXRef = 0;
    }
  }
  return shadow(this, "startXRef", startXRef);
}

Locates the starting offset of the cross-reference table; two paths for two kinds of document:

  • Linearized: the xref offset of the first page section comes right after the endobj of the first object (the linearization dictionary), so a forward find(ENDOBJ_SIGNATURE) suffices;
  • Ordinary document: the specification puts startxref at the end of the file, so it scans backwards from stream.end in steps of 1024 - 9 (subtracting the signature length ensures a signature spanning two chunks is still found); on a hit it skips the startxref keyword, reads a run of digit characters, and calls parseInt to get the offset.

Backward scanning is a classic PDF parsing pattern — files can be large, and the last 1KB almost always contains startxref, so a whole-file scan is avoided.

The checkHeader method

checkHeader() {
  const stream = this.stream;
  stream.reset();
 
  if (!find(stream, PDF_HEADER_SIGNATURE)) {
    // probably not a PDF file, but do not throw; let parsing continue (best effort)
    return;
  }
  stream.moveStart(); // move the stream start to the signature; all later offsets are relative to it
 
  stream.skip(PDF_HEADER_SIGNATURE.length); // skip "%PDF-"
  let version = "", ch;
  while ((ch = stream.getByte()) > 0x20 && version.length < 7) {
    version += String.fromCharCode(ch);
  }
  if (PDF_VERSION_REGEXP.test(version)) {
    this.#version = version; // e.g. "1.7"
  } else {
    warn(`Invalid PDF header version: ${version}`);
  }
}

Header check: find %PDF- within the first 1024 bytes, then moveStart() aligns the stream's logical start with the signature — this step is critical because every byte offset inside a PDF is relative to the header (files with garbage bytes before the header are corrected here). The version number is then read into #version (PDF_VERSION_REGEXP validates something shaped like 1.0–2.0).

Not finding the header does not throw — PDF.js's philosophy is "if it can be opened, open it", leaving broken files to later parsing.

The parseStartXRef method

parseStartXRef() {
  this.xref.setStartXRef(this.startXRef);
}

Feeds the offset computed by the startXRef getter into XRef as the entry offset for xref parsing. One line, but it links the "locate" and "parse" stages.

The get numPages method

get numPages() {
  let num = 0;
  if (this.catalog.hasActualNumPages) {
    num = this.catalog.numPages;
  } else if (this.xfaFactory) {
    num = this.xfaFactory.getNumPages(); // returns a Promise
  } else if (this.linearization) {
    num = this.linearization.numPages;
  } else {
    num = this.catalog.numPages;
  }
  return shadow(this, "numPages", num);
}

The multi-source fallback chain for the page count, in priority order:

  1. catalog.hasActualNumPages: the "actual page count" set after checkLastPage repaired it, highest priority;
  2. xfaFactory: XFA documents compute the page count from the template (returns a Promise);
  3. linearization.numPages: the count declared in the linearization dictionary — during streaming the page tree may not be downloaded yet, so this stands in first;
  4. catalog.numPages: the normal path, walking the /Count of the page tree root.

The #hasOnlyDocumentSignatures method

#hasOnlyDocumentSignatures(fields, recursionDepth = 0) {
  const RECURSION_LIMIT = 10;
  if (!Array.isArray(fields)) return false;
  return fields.every(field => {
    field = this.xref.fetchIfRef(field);
    if (!(field instanceof Dict)) return false;
    if (field.has("Kids")) {
      if (++recursionDepth > RECURSION_LIMIT) {
        warn("#hasOnlyDocumentSignatures: maximum recursion depth reached");
        return false;
      }
      return this.#hasOnlyDocumentSignatures(field.get("Kids"), recursionDepth);
    }
    const isSignature = isName(
      getInheritableProperty({ dict: field, key: "FT" }),
      "Sig"
    );
    const rectangle = field.get("Rect");
    const isInvisible = Array.isArray(rectangle) && rectangle.every(v => v === 0);
    return isSignature && isInvisible;
  });
}

Recursively checks whether the /Fields array contains only document-level signatures. The condition: FT is Sig (a signature field, may be inherited) and Rect is all zeros (invisible). Intermediate nodes with Kids are descended into recursively, with a depth limit of 10 to prevent cycles.

This is used by formInfo: some XFA documents stuff invisible document signatures into Fields to pass as an AcroForm, and they must be recognized so the document is not mistaken for "having interactive forms".

The get _xfaStreams method

get _xfaStreams() {
  const { acroForm } = this.catalog;
  if (!acroForm) return null;
 
  const xfa = acroForm.get("XFA");
  const entries = new Map(
    ["xdp:xdp", "template", "datasets", "config", "connectionSet",
     "localeSet", "stylesheet", "/xdp:xdp"].map(e => [e, null])
  );
  if (xfa instanceof BaseStream && !xfa.isEmpty) {
    entries.set("xdp:xdp", xfa); // single-stream form: the whole XDP packed into one stream
    return entries;
  }
  if (!Array.isArray(xfa) || xfa.length === 0) return null;
 
  for (let i = 0, ii = xfa.length; i < ii; i += 2) {
    let name;
    if (i === 0) name = "xdp:xdp";
    else if (i === ii - 2) name = "/xdp:xdp";
    else name = xfa[i];
    if (!entries.has(name)) continue;
    const data = this.xref.fetchIfRef(xfa[i + 1]);
    if (!(data instanceof BaseStream) || data.isEmpty) continue;
    entries.set(name, data);
  }
  return entries;
}

Reads the /XFA entry of the AcroForm dictionary and normalizes it into a map of "section name to stream". XFA data is stored in two shapes:

  • A single stream: the whole XDP (XML Data Package) in one stream, recorded under the xdp:xdp key;
  • An array: alternating "name, value" pairs [name1, ref1, name2, ref2, ...], where the first and last pairs are fixed as the opening and closing tags of xdp:xdp, and the middle holds sections such as template (form template), datasets (data), and config (configuration).

Sections that are not streams or are empty streams stay null, and consumers check for emptiness as needed.

The get xfaDatasets method

get xfaDatasets() {
  const streams = this._xfaStreams;
  if (!streams) return shadow(this, "xfaDatasets", null);
  for (const key of ["datasets", "xdp:xdp"]) {
    const stream = streams.get(key);
    if (!stream) continue;
    try {
      const str = stringToUTF8String(stream.getString());
      return shadow(this, "xfaDatasets", new DatasetReader({ [key]: str }));
    } catch {
      warn("XFA - Invalid utf-8 string.");
      break;
    }
  }
  return shadow(this, "xfaDatasets", null);
}

Extracts the datasets section from the XFA streams (the current values of form fields, in XML), wrapping it in a DatasetReader for field value queries. It prefers the datasets key and falls back to taking it out of the whole xdp:xdp.

The get xfaData method

get xfaData() {
  const streams = this._xfaStreams;
  if (!streams) return null;
  const data = new Map();
  for (const [key, stream] of streams) {
    if (!stream) continue;
    try {
      data.set(key, stringToUTF8String(stream.getString()));
    } catch {
      warn("XFA - Invalid utf-8 string.");
      return null;
    }
  }
  return data;
}

Fully decodes every section in _xfaStreams into UTF-8 strings, producing a Map<section name, XML text>. This is the input to XFAFactory — XFA rendering happens entirely at the XML level (it never goes through content streams).

The get xfaFactory method

get xfaFactory() {
  let data;
  if (
    this.pdfManager.enableXfa &&
    this.catalog.needsRendering &&
    this.formInfo.hasXfa &&
    !this.formInfo.hasAcroForm
  ) {
    data = this.xfaData;
  }
  return shadow(this, "xfaFactory", data ? new XFAFactory(data) : null);
}

The guard conditions for creating an XFAFactory; all four must hold at once:

ConditionMeaning
enableXfaThe user/API allows XFA rendering (it can be turned off to fall back to an "open in Acrobat" notice page)
catalog.needsRenderingThe document actually needs rendering (rather than, say, text extraction only)
formInfo.hasXfaThe AcroForm really has XFA data
!formInfo.hasAcroFormNo AcroForm at the same time — when both exist, AcroForm wins

XFAFactory holds the parsed XFA data and is responsible for computing the page count and bbox, generating HTML, and so on.

The get isPureXfa method

get isPureXfa() {
  return this.xfaFactory ? this.xfaFactory.isValid() : false;
}

Whether this is a pure XFA document (the XFA template is valid and the document has no traditional page content). Pure XFA documents take a completely separate rendering pipeline (htmlForXfa), and getPage returns an empty page dictionary directly.

The get htmlForXfa method

get htmlForXfa() {
  return this.xfaFactory ? this.xfaFactory.getPages() : null;
}

The rendering output for pure XFA documents: XFAFactory.getPages() turns the XFA template into an HTML structure ({ pages: [...], width, height }), and the main thread renders HTML instead of a canvas. This is PDF.js's strategy for XFA — it does not implement XFA drawing, it maps it to the DOM.

The async #loadXfaImages method

async #loadXfaImages() {
  const xfaImages = await this.pdfManager.ensureCatalog("xfaImages");
  if (!xfaImages) return;
  this.xfaFactory.setImages(xfaImages);
}

Loads images referenced by the XFA template (the catalog's /Names → XFAImages name tree), decodes them, and hands them to XFAFactory so HTML rendering can embed them as data URLs.

The async #loadXfaFonts method

async #loadXfaFonts(handler, task) {
  const acroForm = await this.pdfManager.ensureCatalog("acroForm");
  if (!acroForm) return;
  const resources = await acroForm.getAsync("DR"); // document-level default resources
  if (!(resources instanceof Dict)) return;
  await ObjectLoader.load(resources, ["Font"], this.xref);
 
  const fontRes = resources.get("Font");
  if (!(fontRes instanceof Dict)) return;
 
  const options = Object.assign(
    Object.create(null),
    this.pdfManager.evaluatorOptions,
    { useSystemFonts: false } // XFA fonts must use embedded data for consistent rendering
  );
  const { builtInCMapCache, fontCache, standardFontDataCache } = this.catalog;
 
  const partialEvaluator = new PartialEvaluator({
    xref: this.xref,
    handler,
    pageIndex: -1, // global context, not belonging to any page
    idFactory: this._globalIdFactory,
    fontCache, builtInCMapCache, standardFontDataCache,
    options,
  });
  const pdfFonts = [];
  const initialState = {
    get font() { return pdfFonts.at(-1); },
    set font(font) { pdfFonts.push(font); },
    clone() { return this; },
  };
  /* ... walk fontRes, build cssFontInfo from the FontDescriptor, load via parseFont ... */
  await Promise.all(promises);
  const missingFonts = this.xfaFactory.setFonts(pdfFonts);
  /* ... for missing fonts, use getXfaFontDict to generate Regular/Bold/Italic/BoldItalic
         substitutes and load one more round, then appendFonts ... */
}

The XFA font loading pipeline — XFA never goes through content streams, so fonts need separate handling. This is where the "global-level PartialEvaluator" mentioned in article 7's constructor section is actually invoked, and it has three distinctive designs:

  1. pageIndex: -1 plus _globalIdFactory: fonts belong to the whole document rather than a page, so the generated font IDs are document-level (g_${docId}_f${n}) and are sent over the commonobj channel.
  2. useSystemFonts: false: embedded font data is mandatory — XFA's HTML rendering needs exact font metrics, and system font differences would break the layout.
  3. A fake initialState: handleSetFont needs a state object to hold the "current font", so a getter/setter pushes each font that gets set into the pdfFonts array, neatly turning an "evaluation side effect" into "result collection".

Two loading rounds: the first loads the Font dictionary in AcroForm /DR (default resources) and converts the FontDescriptor's FontFamily/FontWeight/ItalicAngle (note that PDF angles are counter-clockwise and CSS ones clockwise, so the value is negated) into CSS font information; xfaFactory.setFonts returns the fonts the template actually uses but that were not loaded, and the second round fills them in with getXfaFontDict substitutes (the Myriad family) in all four styles: Regular/Bold/Italic/BoldItalic.

The loadXfaResources method

loadXfaResources(handler, task) {
  return Promise.all([
    this.#loadXfaFonts(handler, task).catch(() => {
      // ignore errors so the document can still open
    }),
    this.#loadXfaImages(),
  ]);
}

The overall XFA resource entry point: load fonts and images concurrently. A font failure is caught for the whole call — missing XFA fonts must not prevent the document from opening (Myriad is the fallback).

The serializeXfaData method

serializeXfaData(annotationStorage) {
  return this.xfaFactory ? this.xfaFactory.serializeData(annotationStorage) : null;
}

Serializes the XFA form data the user entered (stored in annotationStorage) back to XML, used to write back into XFA datasets when saving the document.

The get version method

get version() {
  return this.catalog.version || this.#version;
}

How the PDF version is determined: specification §7.5.2 states that a /Version in the Catalog takes priority over the file header version (an incremental update can raise the version without changing the header). #version is the header version read by checkHeader, used as the fallback.

The get formInfo method

get formInfo() {
  const formInfo = {
    hasFields: false, hasAcroForm: false, hasXfa: false, hasSignatures: false,
  };
  const { acroForm } = this.catalog;
  if (!acroForm) return shadow(this, "formInfo", formInfo);
 
  try {
    const fields = acroForm.get("Fields");
    const hasFields = Array.isArray(fields) && fields.length > 0;
    formInfo.hasFields = hasFields;
 
    const xfa = acroForm.get("XFA");
    formInfo.hasXfa =
      (Array.isArray(xfa) && xfa.length > 0) ||
      (xfa instanceof BaseStream && !xfa.isEmpty);
 
    // some documents contain only XFA but put invisible document signatures in Fields;
    // bit 1 of SigFlags (spec Table 219) detects that signatures exist
    const sigFlags = acroForm.get("SigFlags");
    const hasSignatures = !!(sigFlags & 0x1);
    const hasOnlyDocumentSignatures =
      hasSignatures && this.#hasOnlyDocumentSignatures(fields);
    formInfo.hasAcroForm = hasFields && !hasOnlyDocumentSignatures;
    formInfo.hasSignatures = hasSignatures;
  } catch (ex) {
    if (ex instanceof MissingDataException) throw ex;
    warn(`Cannot fetch form information: "${ex}".`);
  }
  return shadow(this, "formInfo", formInfo);
}

The overall form determination, returning four booleans:

FieldCondition
hasFields/Fields is a non-empty array (raw fields exist)
hasXfa/XFA is a non-empty array or a non-empty stream
hasAcroFormhasFields and not "only document signatures" — i.e. interactive forms really exist
hasSignaturesbit 0 of /SigFlags is set (spec Table 219: signatures exist)

The correction for hasAcroForm matters: XFA documents often stuff invisible document signatures into Fields, and looking only at hasFields would misclassify a pure XFA document as an AcroForm and take the wrong rendering pipeline. On exceptions, everything except MissingDataException (the streaming signal, which is rethrown) degrades to all false.

The get documentInfo method

get documentInfo() {
  const docInfo = {
    PDFFormatVersion: this.version,
    Language: catalog.lang,
    EncryptFilterName: xref.encrypt?.filterName ?? null,
    IsLinearized: !!this.linearization,
    IsAcroFormPresent: formInfo.hasAcroForm,
    IsXFAPresent: formInfo.hasXfa,
    IsCollectionPresent: !!catalog.collection,
    IsSignaturesPresent: formInfo.hasSignatures,
  };
 
  let infoDict;
  try {
    infoDict = xref.trailer.get("Info");
  } catch (err) { /* rethrow MissingDataException, log the rest via info */ }
  if (!(infoDict instanceof Dict)) return shadow(this, "documentInfo", docInfo);
 
  for (const [key, value] of infoDict) {
    switch (key) {
      case "Title": case "Author": /* ... the 8 standard keys ... */
        if (typeof value === "string") { docInfo[key] = stringToPDFString(value); continue; }
        break;
      case "Trapped":
        if (value instanceof Name) { docInfo[key] = value; continue; }
        break;
      default:
        // custom keys: only whitelisted types string/number/boolean/Name are accepted,
        // so that non-serializable objects such as Dict/Stream are not sent to the main thread
        /* ... */
        (docInfo.Custom ??= new Map()).set(key, customValue);
        continue;
    }
    warn(`Bad value, for key "${key}", in Info: ${value}.`);
  }
  return shadow(this, "documentInfo", docInfo);
}

Assembles the document information used by the main thread's getMetadata(). It has two layers:

  1. Synthesized fields: version, language, encryption filter name, whether linearized, form type, whether there is an attachment collection (/Collection), whether there are signatures;
  2. The Info dictionary: Title/Author/Subject/Keywords/Creator/Producer/CreationDate/ModDate from the trailer's /Info (strings decoded from PDFDocEncoding by stringToPDFString) plus Trapped (a Name); all other keys go into docInfo.Custom through a type whitelist — a Map only accepts structured-cloneable types, which is the safety gate for worker-to-main-thread transport.

The get fingerprints method

get fingerprints() {
  const FINGERPRINT_FIRST_BYTES = 1024;
  const EMPTY_FINGERPRINT = "\x00".repeat(16);
 
  function validate(data) {
    return typeof data === "string" && data.length === 16 && data !== EMPTY_FINGERPRINT;
  }
 
  const id = this.xref.trailer.get("ID");
  let hashOriginal, hashModified;
  if (Array.isArray(id) && validate(id[0])) {
    hashOriginal = stringToBytes(id[0]);
    if (id[1] !== id[0] && validate(id[1])) {
      hashModified = stringToBytes(id[1]);
    }
  } else {
    hashOriginal = calculateMD5(
      this.stream.getByteRange(0, FINGERPRINT_FIRST_BYTES), 0, FINGERPRINT_FIRST_BYTES
    );
  }
  return shadow(this, "fingerprints", [hashOriginal.toHex(), hashModified?.toHex() ?? null]);
}

Computes the document fingerprint (the source of the main thread's loadingTask.docId, used for cache keys and document identity). Two paths:

  • The trailer has a valid /ID (two 16-byte strings) → use it directly, with ID[0] the original fingerprint and ID[1] the last-modified fingerprint;
  • Otherwise → compute an MD5 over the first 1024 bytes of the file as the fingerprint, with hashModified set to null.

An all-zero ID (\x00 × 16) is treated as invalid — some generators write an empty ID.

The async #getLinearizationPage method

async #getLinearizationPage(pageIndex) {
  /* test builds assert linearization.pageFirst === pageIndex */
  const ref = Ref.get(linearization.objectNumberFirst, 0);
  try {
    const obj = await xref.fetchAsync(ref);
    if (obj instanceof Dict) {
      let type = obj.getRaw("Type");
      if (type instanceof Ref) type = await xref.fetchAsync(type);
      if (
        isName(type, "Page") ||
        (!obj.has("Type") && !obj.has("Kids") && obj.has("Contents"))
      ) {
        // opportunistically fill the two Catalog caches to speed up later getPageIndex
        if (!catalog.pageKidsCountCache.has(ref)) catalog.pageKidsCountCache.put(ref, 1);
        if (!catalog.pageIndexCache.has(ref)) catalog.pageIndexCache.put(ref, 0);
        return [obj, ref];
      }
    }
    throw new FormatError("The Linearization dictionary doesn't point to a valid Page dictionary.");
  } catch (reason) {
    warn(`_getLinearizationPage: "${reason.message}".`);
    return catalog.getPageDict(pageIndex); // fall back to the normal page tree walk
  }
}

The linearized first-page shortcut: the linearization dictionary's objectNumberFirst directly gives the objId of the first page object, saving a walk of the page tree (whose data may not be downloaded yet). The fetched object then has to pass a validity check — /Type /Page, or (for fault tolerance) a dictionary with no Type, no Kids, but a Contents entry is also accepted as a page.

Once it validates, pageKidsCountCache/pageIndexCache are filled in opportunistically so later Catalog.getPageIndex hits the cache. On failure it falls back to catalog.getPageDict and a normal walk.

The getPage method

getPage(pageIndex) {
  const cachedPromise = this.#pagePromises.get(pageIndex);
  if (cachedPromise) return cachedPromise;
  const { catalog, linearization, xfaFactory } = this;
 
  let promise;
  if (xfaFactory) {
    promise = Promise.resolve([Dict.empty, null]); // pure XFA: empty page dict
  } else if (linearization?.pageFirst === pageIndex) {
    promise = this.#getLinearizationPage(pageIndex); // linearized first-page shortcut
  } else {
    promise = catalog.getPageDict(pageIndex); // normal: walk the page tree
  }
  promise = promise.then(
    ([pageDict, ref]) =>
      new Page({
        pdfManager: this.pdfManager,
        xref: this.xref, pageIndex, pageDict, ref,
        globalIdFactory: this._globalIdFactory,
        fontCache: catalog.fontCache,
        /* ... every document-level cache held by Catalog ... */
        nonBlendModesSet: catalog.nonBlendModesSet,
        xfaFactory,
      })
  );
 
  this.#pagePromises.set(pageIndex, promise);
  return promise;
}

The worker-side implementation of the main thread's getPage(pageNumber). Three fetch paths correspond to three document shapes, and then everything is wrapped uniformly into a Page instance — note that this completes the other half of the Page constructor's dependency injection: all caches come from Catalog (shared at document level), while globalIdFactory is supplied by PDFDocument.

#pagePromises (a Map) caches each page's Promise — each page is parsed only once, and repeated getPage calls hit the cache. That is also the precondition for page-level caches such as annotations and operatorLists: a single Page instance runs through all operations on that page.

The async checkFirstPage method

async checkFirstPage(recoveryMode = false) {
  if (recoveryMode) return;
  try {
    await this.getPage(0);
  } catch (reason) {
    if (reason instanceof XRefEntryException) {
      // clear the cache first so that stale inconsistent state does not break later getPage calls
      this.#pagePromises.delete(0);
      await this.cleanup();
      throw new XRefParseException();
    }
  }
}

First-page pre-check: right after the document is opened, try to parse page 0. If it hits an XRefEntryException (a corrupted xref entry), the xref table cannot be trusted — clear the page cache and the global caches, then throw XRefParseException, which the caller (PDFManager) catches to enter recovery mode and rescan the whole file to rebuild the xref. Under recoveryMode the check is skipped entirely, avoiding infinite recursion.

The async checkLastPage method

async checkLastPage(recoveryMode = false) {
  const { catalog, pdfManager } = this;
  catalog.setActualNumPages(); // always reset first
  let numPages;
  try {
    await Promise.all([
      pdfManager.ensureDoc("xfaFactory"),
      pdfManager.ensureDoc("linearization"),
      pdfManager.ensureCatalog("numPages"),
    ]);
    if (this.xfaFactory) return; // XFA page counts are always reliable
    numPages = this.linearization ? this.linearization.numPages : catalog.numPages;
    if (!Number.isInteger(numPages)) {
      throw new FormatError("Page count is not an integer.");
    } else if (numPages <= 1) return;
    await this.getPage(numPages - 1); // try to fetch the last page
  } catch (reason) {
    this.#pagePromises.delete(numPages - 1);
    await this.cleanup();
    if (reason instanceof XRefEntryException && !recoveryMode) {
      throw new XRefParseException();
    }
    warn(`checkLastPage - invalid /Pages tree /Count: ${numPages}.`);
 
    let pagesTree;
    try {
      pagesTree = await catalog.getAllPageDicts(recoveryMode); // walk the entire page tree
    } catch (reasonAll) {
      /* XRefEntryException: throw to recovery as above; otherwise settle on 1 page */
      catalog.setActualNumPages(1);
      return;
    }
    // rebuild the whole page cache from the real walk and correct the page count
    for (const [pageIndex, [pageDict, ref]] of pagesTree) {
      let promise;
      if (pageDict instanceof Error) {
        promise = Promise.reject(pageDict);
        promise.catch(() => {}); // avoid flooding with "uncaught exception"
      } else {
        promise = Promise.resolve(new Page({ /* ... */ }));
      }
      this.#pagePromises.set(pageIndex, promise);
    }
    catalog.setActualNumPages(pagesTree.size);
  }
}

Last-page pre-check plus page-count validation, going further than checkFirstPage: it verifies whether the /Count declared by the /Pages tree root can be trusted. It does so by trying to fetch page numPages - 1 — failure means the count is inflated. The response has two levels:

  1. Corrupted xref entries → same as checkFirstPage: throw XRefParseException to trigger recovery;
  2. Merely an inflated Count → walk the whole page tree (catalog.getAllPageDicts) and rebuild #pagePromises with the real page count (even a corrupted page's rejected Promise is registered, so the main thread can show a per-page error), then correct the count with setActualNumPages(pagesTree.size) — after that, the first-priority hasActualNumPages branch of the numPages getter will hit the corrected value.

Together these two checks are the core of PDF.js's "leniently open broken files" policy: rescan the whole file rather than fail to open the document.

The async fontFallback method

async fontFallback(id, handler) {
  const { catalog, pdfManager } = this;
  for (const translatedFont of await Promise.all(catalog.fontCache)) {
    if (translatedFont.loadedName === id) {
      translatedFont.fallback(handler, pdfManager.evaluatorOptions);
      return;
    }
  }
}

The worker-side handler for when the main thread's FontFace loading fails: walk the font cache (which holds Promises, resolved en masse by Promise.all), find the TranslatedFont with the matching loadedName, and call its fallback (as covered in article 7: disableFontFace = true, switching to sending glyph path data). Note that catalog.fontCache is directly iterable — it is a RefSetCache-style structure.

The async cleanup method

async cleanup(manuallyTriggered = false) {
  return this.catalog ? this.catalog.cleanup(manuallyTriggered) : clearGlobalCaches();
}

The cache cleanup entry point. Normally it goes through catalog.cleanup (clearing document-level caches for fonts, images, color spaces, and so on); if the catalog does not exist yet (early in a failed open) it can only call clearGlobalCaches for the global caches. manuallyTriggered distinguishes a main-thread-triggered cleanup from an automatic one under memory pressure, which affects how aggressive the cleanup is.

The async #collectFieldObjects method

async #collectFieldObjects(
  name, parentRef, fieldRef, promises, annotationGlobals, visitedRefs, orphanFields
) {
  const { xref } = this;
  if (!(fieldRef instanceof Ref) || visitedRefs.has(fieldRef)) return;
  visitedRefs.put(fieldRef);
  const field = await xref.fetchAsync(fieldRef);
  if (!(field instanceof Dict)) return;
 
  let subtype = await field.getAsync("Subtype");
  subtype = subtype instanceof Name ? subtype.name : null;
  switch (subtype) {
    case "Link": return; // skip unrelated annotation types (issue 19281)
  }
 
  if (field.has("T")) {
    const partName = stringToPDFString(await field.getAsync("T"));
    name = name === "" ? partName : `${name}.${partName}`; // build the hierarchical name a.b.c
  } else {
    // the field has no /T of its own: walk up the Parent chain (with cycle detection)
    let obj = field;
    const walkedRefs = new RefSet();
    while (true) {
      obj = obj.getRaw("Parent") || parentRef;
      /* ... visitedRefs + walkedRefs, two sets, guard against Parent cycles ... */
      if (obj.has("T")) { /* build the name and break */ }
    }
  }
 
  if (parentRef && !field.has("Parent") && isName(field.get("Subtype"), "Widget")) {
    orphanFields.put(fieldRef, parentRef); // the parent field refers to it, but it has no Parent
  }
 
  promises.getOrInsertComputed(name, makeArr).push(
    AnnotationFactory.create(xref, fieldRef, annotationGlobals, null, true, orphanFields, null, null)
      .then(annotation => annotation?.getFieldObject())
      .catch(/* tolerate failure, return null */)
  );
 
  if (!field.has("Kids")) return;
  const kids = await field.getAsync("Kids");
  if (Array.isArray(kids)) {
    for (const kid of kids) {
      await this.#collectFieldObjects(name, fieldRef, kid, promises, ...); // recurse downwards
    }
  }
}

Recursively walks the AcroForm field tree, turning each field into a serializable FieldObject. Key points:

  1. Full field name assembly: PDF field names are hierarchical (T joined along the ancestor chain with ., such as address.zip). When a field has no /T of its own, the Parent chain is walked upwards — with two RefSets (visitedRefs global plus walkedRefs local) to guard against Parent cycles.
  2. Orphan field repair: the Fields array claims a Widget is a child field, but the Widget has no /Parent back-reference — it is registered in orphanFields (a RefMap) for _parsedAnnotations to put back.
  3. Promise collection grouped by name: promises is a Map<full name, Promise[]> — multiple Widgets of the same field (for example, the several options of a radio group) are aggregated into one array.

The get fieldObjects method

get fieldObjects() {
  const promise = this.pdfManager.ensureDoc("formInfo").then(async formInfo => {
    if (!formInfo.hasFields) return null;
    const annotationGlobals = await this.annotationGlobals;
    if (!annotationGlobals) return null;
    const { acroForm } = annotationGlobals;
 
    const visitedRefs = new RefSet();
    const allFields = new Map();
    const fieldPromises = new Map();
    const orphanFields = new RefMap();
    for (const fieldRef of acroForm.get("Fields")) {
      await this.#collectFieldObjects("", null, fieldRef, fieldPromises,
        annotationGlobals, visitedRefs, orphanFields);
    }
 
    const allPromises = [];
    for (const [name, promises] of fieldPromises) {
      allPromises.push(
        Promise.all(promises).then(fields => {
          fields = fields.filter(Boolean);
          if (fields.length > 0) allFields.set(name, fields);
        })
      );
    }
    await Promise.all(allPromises);
    return {
      allFields: allFields.size ? allFields : null,
      orphanFields,
    };
  });
  return shadow(this, "fieldObjects", promise);
}

The overall entry point for form fields (the main thread's "fill in a form" feature depends on it): starting from acroForm.Fields, collect every field recursively and aggregate them into a Map<name, FieldObject[]>. It returns { allFields, orphanFields }, and the latter feeds the orphan repair in _parsedAnnotations. The whole result is a Promise frozen with shadow, so collection happens only once.

The async #collectSignatureFields method

async #collectSignatureFields(fields, out, visitedRefs) {
  if (!Array.isArray(fields)) return;
  for (const fieldRef of fields) {
    if (fieldRef instanceof Ref) {
      if (visitedRefs.has(fieldRef)) continue;
      visitedRefs.put(fieldRef);
    }
    const field = await this.xref.fetchIfRefAsync(fieldRef);
    if (!(field instanceof Dict)) continue;
    if (isName(await field.getAsync("FT"), "Sig")) {
      const sigDict = await field.getAsync("V"); // the signature dictionary
      if (sigDict instanceof Dict) {
        const parsed = await this.#parseSignatureDict(field, sigDict, fieldRef);
        if (parsed) out.push(parsed);
      }
    }
    if (field.has("Kids")) {
      // the Widget child annotations of a terminal field must be visited too, so collect self first, then recurse
      await this.#collectSignatureFields(await field.getAsync("Kids"), out, visitedRefs);
    }
  }
}

Walks the field tree collecting the /V signature dictionaries of every signature field (FT === "Sig") and hands them to #parseSignatureDict. Note the comment: the children of a terminal field may be Widget annotations, so "collect self, then recurse into Kids" cannot be reversed.

The async #getByteRange method

async #getByteRange(begin, end) {
  try {
    return this.stream.getByteRange(begin, end);
  } catch (ex) {
    if (!(ex instanceof MissingDataException)) throw ex;
    await this.pdfManager.requestRange(begin, end); // streaming: request the missing range, then retry
    return this.#getByteRange(begin, end);
  }
}

Reads a byte range from the raw stream with streaming retry: if the data has not arrived (MissingDataException), it issues an HTTP Range request via pdfManager.requestRange and retries recursively once the data is there. The signed byte ranges can fall anywhere in the file, and this wrapper makes signature verification transparent for streaming documents.

The async #coversWholeDocument method

async #coversWholeDocument(signedEnd, modificationsAfterSignature) {
  if (modificationsAfterSignature > 0) return false;
 
  const fileLength = this.stream.end;
  for (let begin = signedEnd; begin < fileLength; begin += SIGNATURE_TAIL_CHUNK_SIZE) {
    const end = Math.min(begin + SIGNATURE_TAIL_CHUNK_SIZE, fileLength);
    const tail = await this.#getByteRange(begin, end);
    for (const byte of tail) {
      if (byte !== 0x00 && byte !== 0x09 && byte !== 0x0a &&
          byte !== 0x0c && byte !== 0x0d && byte !== 0x20) {
        return false; // a non-whitespace byte after the signed range means the whole file is not covered
      }
    }
  }
  return true;
}

Determines whether a signature covers the entire document: only PDF whitespace characters (null/tab/LF/FF/CR/space) may follow the signed range (at c + d of ByteRange), scanned in 64KB chunks. If xref.countUpdatesAfter(signedEnd) finds an incremental update after the signature, it returns false immediately — the file was appended to and the signature no longer covers the whole document.

The async #parseSignatureDict method

async #parseSignatureDict(field, sigDict, fieldRef) {
  const byteRange = await sigDict.getAsync("ByteRange");
  if (!Array.isArray(byteRange) || byteRange.length !== 4 ||
      byteRange.some(n => !Number.isInteger(n) || n < 0)) {
    return null;
  }
  // ByteRange = [a, b, c, d]: the signed bytes are the two ranges [a..a+b] and [c..c+d];
  // the gap in between is the PKCS#7 hex string of /Contents itself
  const [a, b, c, d] = byteRange;
  const fileLength = this.stream.end || 0; // use stream.end, not length (offsets are relative to the raw bytes)
  // reject structurally impossible ByteRange values: it must start at the file header, the first range must be non-empty,
  // there must be room for /Contents between the two ranges, and nothing may go out of bounds — this prevents forged "full coverage"
  if (a !== 0 || b <= 0 || a + b > c || c + d > fileLength || fileLength === 0) {
    return null;
  }
 
  const contents = await sigDict.getAsync("Contents");
  if (typeof contents !== "string" || contents.length === 0) return null;
 
  const [filterName, subFilterName, t, name, reason, location, contactInfo, m] =
    await Promise.all([/* Filter/SubFilter/T/Name/Reason/Location/ContactInfo/M */]);
  let signatureType = null;
  if (subFilter === "adbe.pkcs7.detached") signatureType = 0;
  else if (subFilter === "adbe.pkcs7.sha1") signatureType = 1;
 
  const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline";
  return {
    id: `${refKey}:${a}-${b}-${c}-${d}`,
    fieldName, signerName, reason, location, contactInfo, signingTime,
    filter, subFilter, signatureType, byteRange,
    pkcs7: stringToBytes(contents),
    revisionIndex: 0, parentId: null,
  };
}

Parses a single signature dictionary into a structured object. The core is the safety validation of /ByteRange — the four-tuple [a, b, c, d] describes the two signed byte ranges (the gap between them is the signature value itself), and the code explicitly rejects malformed values such as a !== 0, b <= 0, or out-of-bounds ranges. The comment explains why: "without this validation, a carefully crafted PDF could claim to cover the whole document while actually signing only a short preamble".

The metadata (signer, reason, location, time, and so on) is read concurrently; signatureType maps SubFilter to an enum (0 = detached, 1 = sha1). pkcs7 is the raw PKCS#7 signature data.

The get signatures method

get signatures() {
  const promise = this.pdfManager.ensureDoc("formInfo").then(async formInfo => {
    if (!formInfo.hasSignatures || !formInfo.hasFields) return null;
    const annotationGlobals = await this.annotationGlobals;
    if (!annotationGlobals) return null;
    const fields = annotationGlobals.acroForm.get("Fields");
 
    const collected = [];
    await this.#collectSignatureFields(fields, collected, new RefSet());
 
    await Promise.all(
      collected.map(async signature => {
        const signedEnd = signature.byteRange[2] + signature.byteRange[3];
        signature.modificationsAfterSignature = this.xref.countUpdatesAfter(signedEnd);
        signature.coversWholeDocument = await this.#coversWholeDocument(
          signedEnd, signature.modificationsAfterSignature
        );
      })
    );
 
    // sort by ByteRange coverage (descending): the widest coverage is the outermost revision;
    // each signature points to the first larger signature before it as its parentId (the revision chain)
    collected.sort((a, b) => b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3]));
    for (let i = 0, ii = collected.length; i < ii; i++) {
      const sig = collected[i];
      sig.revisionIndex = i;
      for (let j = i - 1; j >= 0; j--) {
        const candidate = collected[j];
        if (candidate.byteRange[2] + candidate.byteRange[3] > sig.byteRange[2] + sig.byteRange[3]) {
          sig.parentId = candidate.id;
          break;
        }
      }
    }
 
    // keep pkcs7 and byteRange on the worker side to slim down the metadata;
    // during verification the main thread fetches them on demand by id via getSignatureData
    const signatureData = new Map();
    const metadata = collected.map(sig => {
      const { pkcs7, ...rest } = sig;
      signatureData.set(sig.id, { byteRange: sig.byteRange, pkcs7 });
      return rest;
    });
    this.#signatureData = signatureData;
    return metadata.length ? metadata : null;
  });
  return shadow(this, "signatures", promise);
}

The overall signature entry point, which does three things on top of #collectSignatureFields:

  1. Add integrity information: compute signedEnd for each signature, count the incremental updates after it with xref.countUpdatesAfter, then use #coversWholeDocument to decide whether the whole document is covered;
  2. Build the revision chain: multiple signatures (the incremental-update case) are sorted by coverage in descending order, revisionIndex records the order, and each signature is attached to "the first larger predecessor" to form a parent-child chain — the main thread uses it to show the hierarchy of "modified after signature N";
  3. Slim the metadata: the large PKCS#7 objects and byteRange stay on the worker (#signatureData), and only lightweight metadata is sent to the main thread, fetched on demand during verification — a typical optimization of worker-to-main-thread transfer volume.

The async getSignatureData method

async getSignatureData(id) {
  await this.signatures; // ensure parsing is complete and #signatureData is populated
  const signature = this.#signatureData?.get(id);
  if (!signature) return null;
  const { byteRange, pkcs7 } = signature;
  const [a, b, c, d] = byteRange;
  const data = await Promise.all([
    this.#getByteRange(a, a + b),
    this.#getByteRange(c, c + d),
  ]);
  return { data, pkcs7 };
}

Called on demand when the main thread verifies a signature: it slices the two signed byte ranges out of the stream (#getByteRange brings its own streaming retry) and sends them back along with the PKCS#7 so the main thread can run the cryptographic verification. Only one signature is fetched at a time, avoiding pointless transfers of large objects.

The get hasJSActions method

get hasJSActions() {
  const promise = Promise.all([
    this.pdfManager.ensureCatalog("jsActions"),
    this.pdfManager.ensureDoc("fieldObjects"),
  ]).then(([catalogJsActions, fieldObjects]) => {
    if (catalogJsActions) return true;
    if (fieldObjects?.allFields) {
      return fieldObjects.allFields
        .values()
        .some(fieldObj => fieldObj.some(obj => obj.actions !== null));
    }
    return false;
  });
  return shadow(this, "hasJSActions", promise);
}

Determines whether the document contains JavaScript actions. The check has two levels: catalog level (global actions such as document open/save, or page-level AA already aggregated in the Catalog), and field level — any form field carrying actions counts. The main thread uses this to decide whether to initialize the JS sandbox engine.

The get calculationOrderIds method

get calculationOrderIds() {
  const calculationOrder = this.catalog.acroForm?.get("CO");
  if (!Array.isArray(calculationOrder) || calculationOrder.length === 0) {
    return shadow(this, "calculationOrderIds", null);
  }
  const ids = [];
  for (const id of calculationOrder) {
    if (id instanceof Ref) ids.push(id.toString());
  }
  return shadow(this, "calculationOrderIds", ids.length ? ids : null);
}

Reads the AcroForm's /CO (Calculation Order) array — the execution order of form-field calculation scripts (for example, "total = unit price × quantity" requires the unit price to be computed first). It returns an ordered array of field reference IDs, and the main thread's scripting engine triggers the calculations in that order.

The get annotationGlobals method

get annotationGlobals() {
  return shadow(
    this,
    "annotationGlobals",
    AnnotationFactory.createGlobals(this.pdfManager)
  );
}

Creates the annotation global data (AnnotationGlobals): the AcroForm dictionary, the default appearance (/DA), the default resources (/DR), the default field font, and so on — the context shared by all annotation parsing. _parsedAnnotations, fieldObjects, and signatures all depend on it. It returns a Promise frozen with shadow.

The async toJSObject method

async toJSObject(value, firstCall = true) {
  /* only available in TESTING / INTERNAL_VIEWER builds; other builds throw */
  const { InternalViewerUtils } = await import("./internal_viewer_utils.js");
 
  if (value === null && firstCall) {
    return this.toJSObject(this.xref.trailer, false); // entry point: start from the trailer
  }
  if (value instanceof Dict) {
    const obj = Object.create(null);
    const isPage = isName(value.get("Type"), "Page");
    for (const [key, val] of value.getRawEntries()) {
      obj[key] = isPage && key === "Contents"
        ? InternalViewerUtils.getContentTokens(val, this.xref) // turn the page content stream into a token list
        : await this.toJSObject(val, false);
    }
    return obj;
  }
  if (Array.isArray(value)) { /* recursive map */ }
  if (value instanceof Ref) {
    if (firstCall) return this.toJSObject(this.xref.fetch(value), false);
    return { num: value.num, gen: value.gen }; // deep Refs keep only their coordinates, unexpanded, to prevent cycles
  }
  if (value instanceof BaseStream) {
    /* Subtype/Image → decode into imageData; Subtype/Form → lex into an instruction sequence;
       PostScript calculator functions → convert into JS code; everything else → raw byte string */
  }
  return value;
}

An internal debugging tool (available only in test builds or the internal viewer): it recursively converts the PDF object tree into plain JS objects so the internal viewer (debug panel) can inspect the document structure. Three specialized treatments:

  • Page Contents: not decoded; converted into a token sequence (operator names plus arguments) so the raw content stream is easy to inspect;
  • Image streams: PDFImage.buildImage decodes them into imageData (width, height, and RGBA data);
  • Form XObjects / PS calculator functions: Form streams become instruction groups, and PS calculation functions are translated into equivalent JS code by postScriptToJSCode — turning reverse Polish notation into readable code.

Deep Refs only output {num, gen} without recursive expansion, avoiding stack overflows from cyclic references.

Summary

document.js is the document hub on the worker side: it turns the xref parsed in article 6 into a usable document model and prepares every input that article 7's PartialEvaluator needs.

Division of labor between the two classes

ClassResponsibilityKey output
PDFDocumentDocument-level data holder: header/xref/linearization, page count, forms, signatures, fingerprints, XFA, JS actionscatalog, getPage(), document metadata
PagePage-level model: page dictionary wrapper, attribute inheritance, box fallback chain, content-stream assembly, annotation parsingContent stream + Resources + operatorList

Key design principles

  1. Inheritance and fallbacks are everywhere. Page attributes are inherited along the page tree (#getInheritableProperty), boxes fall back through CropBox ∩ MediaBox, and the page count falls back across catalog → xfaFactory → linearization — the "inheritable" feature of the PDF specification is absorbed by one unified mechanism.

  2. Promise caching plus shadow freezing. Everything expensive — #pagePromises, _parsedAnnotations, fieldObjects, signatures — is "compute once, cache forever": the Promise is written directly onto the instance property by shadow, so repeat access has zero cost.

  3. Leniency first, recovery as the safety net. checkHeader does not throw when the signature is missing; checkFirstPage/checkLastPage clear caches and then throw XRefParseException to trigger a whole-file rescan when they hit a corrupted xref; an inflated page count is corrected by walking the entire page tree. "If it can be opened, open it" runs throughout.

  4. Transfer slimming. A signature's PKCS#7 data and other large objects stay on the worker and are fetched on demand by the main thread (getSignatureData); custom keys in documentInfo go through a type whitelist; annotation data and operatorLists travel on separate channels. Every byte between worker and main thread is accounted for.

  5. The editor is bidirectional. Page does not only parse annotations; it can also save/saveNewAnnotations to serialize edited results back into the PDF object tree (changes, the change set), and combined with the incremental write-back of #replaceIdByRef that supports annotation editing and form filling in the browser.

With that, the full worker-side chain is complete: file bytes (article 6, xref) → document model (this article) → operator list (article 7). What the main thread receives is a structured document that is ready to render, search, and edit.

StreamPDF's design principle: anything that can be handled ahead of time is never deferred to parse time, and the server solves every performance problem in one pass. The browser only fetches and renders the data it must.

Related articles