StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: catalog

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: catalog

Introduction

The previous article covered XRef: it turns bytes into objects that can be retrieved by object number. But retrieving objects is only the first step. What the higher layers actually reuse in a PDF is not a pile of scattered 12 0 R references, but document-level semantic structure: how many pages there are, what the page tree looks like, what label each page displays, what the bookmark outline is, which named destinations exist, the attachment list, interactive forms, optional content groups, the structure tree and metadata. The layer that reads this semantics out of the root dictionary is Catalog in src/core/catalog.js.

In one sentence: XRef is the library's card catalogue; Catalog is the layer that turns those cards into a readable book. It takes the /Root dictionary, reads each document-level entry according to the PDF specification, and shapes the results into something PDFDocument, Page, PartialEvaluator and the worker message layer can consume directly.

Catalog has a very narrow input: one pdfManager and one xref. Its output is broad: numPages, getPageDict, getPageIndex, pageLabels, destinations, documentOutline, attachments, optionalContentConfig, viewerPreferences, openAction, jsActions, structTreeRoot, acroForm, permissions, metadata, baseUrl and more. All of its work revolves around one private field, #catDict.

Version note: This article follows exactly the version of catalog.js in the current repository. It relies heavily on property getters (for example version, pageLabels, documentOutline) rather than methods such as getVersion() and getPageLabels(); the only explicit page-related methods are getPageDict, getAllPageDicts and getPageIndex. The getMarkedContentIdToPage, getAutoPrint, getXfa, getNeedsRendering, getCollection, getAcroForm, getOutline, getAttachments and getPermissions methods common in older versions or other branches do not exist here: the needsRendering, collection, acroForm, documentOutline, attachments and permissions getters cover six of those capabilities; XFA data is read in PDFDocument's _xfaStreams and xfaData, and form-field filling lives in annotation.js; this version has no getMarkedContentIdToPage or auto-print counterpart. Do not apply APIs from other versions you remember when reading the source.

Module Overview

catalog.js is 2069 lines and splits into ten blocks. Each block owns one kind of document-level semantics instead of flattening every key of the root dictionary into one giant function:

BlockKey membersResponsibility
Root and cachesconstructor, #catDict, cloneDict, the various cache fieldsHold the root dictionary and provide document-level shared caches
Scalar switchesversion, lang, needsRenderingRead the file version, natural language and pure-XFA rendering switch
Forms and permissionsacroForm, acroFormRef, permissions, markInfo, metadataExpose the interactive form dictionary, permission bits, mark information and XMP metadata
Page treetoplevelPagesDict, numPages, getPageDict, getAllPageDicts, getPageIndexWalk the /Pages tree, locate page dictionaries and resolve page indices
Page labelsrawPageLabels, pageLabels, #readPageLabelsExpand the /PageLabels number tree into a display label per page
Named destinationsdestinations, getDestination, #readDestsRead the name tree and the legacy /Dests dictionary
Viewer behaviourpageLayout, pageMode, viewerPreferences, openAction, baseUrlRead layout, mode, preferences and the base URL used when the document opens
Outline and attachmentsdocumentOutline, documentOutlineForEditor, attachments, attachmentContent, rawEmbeddedFiles, xfaImagesBuild the bookmark tree, attachment list and XFA images
Scripts and OCGjsActions, optionalContentConfig, structTreeRootDocument-level actions, optional content groups and the structure-tree entry point
Destination parsingparseDestDictionary, #getDestFromStructElementNormalise actions, destinations and structure elements into a URL or a destination

Now look at the exposed member kinds, because they map to completely different calling patterns:

KindExamplesCached?Typical caller
Property getternumPages, pageLabels, documentOutlineMostly frozen into data properties by shadowpdfManager.ensureCatalog
Synchronous query methodgetDestination, attachmentContentRelies on internal caches but does not freeze itselfWorker message handling
Asynchronous methodgetPageDict, getAllPageDicts, getPageIndex, cleanupHit through pageDictCache and pageIndexCachePDFDocument
Static utilityparseDestDictionaryStateless, reused for extra destination parsingOutline, OpenAction, annotations

Its place in the overall chain can be drawn like this:

// PDF bytes
//   → XRef.parse / getCatalogObj        (previous article: locate and retrieve)
//   → Catalog.constructor               (this article: the root dictionary enters the document model)
//   → #catDict                          (the common starting point of every getter)
//   → page tree / name tree / outline / attachments / forms / OCG / structure tree
//   → PDFDocument / Page / PartialEvaluator / Worker messages

Catalog is not a parser. It does not turn content streams into operatorLists, and it does not draw pages. What it does is the first organisation of the document model: it turns the document-level entries defined by the specification from dictionary form into queryable JavaScript structures. The sections below follow the source order, but related getters are grouped under the same topic instead of being translated line by line.

Constructor, Root Dictionary and Document-Level Caches

The constructor is very short, but it contains two important actions: validating the root dictionary and forcing validation of the top-level page dictionary.

  constructor(pdfManager, xref) {
    this.pdfManager = pdfManager;
    this.xref = xref;
 
    this.#catDict = xref.getCatalogObj();
    if (!(this.#catDict instanceof Dict)) {
      throw new FormatError("Catalog object is not a dictionary.");
    }
    // Given that `XRef.parse` will both fetch *and* validate the /Pages-entry,
    // the following call must always succeed here:
    this.toplevelPagesDict; // eslint-disable-line no-unused-expressions
  }

xref.getCatalogObj() returns the root dictionary that XRef.parse has already validated through both Root and Pages. Even so, the constructor checks instanceof Dict once more, because the return value of getCatalogObj may have been polluted by recovery mode or a third-party caller. The second action, the bare this.toplevelPagesDict;, looks like a no-op but actually triggers the getter on purpose: toplevelPagesDict checks whether /Pages is a dictionary and throws FormatError if it is not. The source comment notes that XRef.parse has already fetched and validated /Pages, so this should never fail in practice; moving the failure into the construction phase makes a broken root dictionary surface before any page request.

cloneDict is the only copy outlet for the root dictionary:

  cloneDict() {
    return this.#catDict.clone();
  }

It is used by StructTreeRoot. When the structure tree needs to attach information for editing or printing without polluting the original root dictionary, it clones one first. Dict.clone is a shallow copy: reference values stay as Ref, and actual object retrieval still goes through xref.

The private fields and public cache fields are all declared at the top of the class:

class Catalog {
  #actualNumPages = null;
 
  #annotationAttachmentIdByRef = new RefMap();
 
  #annotationAttachmentRefById = new Map();
 
  #soundAttachmentIds = new Set();
 
  #catDict = null;
 
  builtInCMapCache = new Map();
 
  fontCache = new RefMap();
 
  globalColorSpaceCache = new GlobalColorSpaceCache();
 
  globalImageCache = new GlobalImageCache();
 
  nonBlendModesSet = new RefSet();
 
  pageDictCache = new RefMap();
 
  pageIndexCache = new RefMap();
 
  pageKidsCountCache = new RefMap();
 
  standardFontDataCache = new Map();
 
  systemFontCache = new Map();
FieldStructureLifetime and role
#catDictDictRoot dictionary; starting point for every getter
#actualNumPagesInteger or nullReal page count obtained by walking the whole file when the page-tree /Count cannot be trusted
#annotationAttachmentIdByRefRefMapMaps annotation attachment references to stable ids
#annotationAttachmentRefByIdMapMaps stable ids back to references, so attachmentContent can fetch on demand
#soundAttachmentIdsSetMarks attachments whose stream is sound, to be wrapped as WAV when fetched
builtInCMapCacheMapBuilt-in CMap cache, indexed by encoding name
fontCacheRefMapFont dictionary cache, indexed by font Ref
globalColorSpaceCacheGlobalColorSpaceCacheColour-space cache shared across document tasks
globalImageCacheGlobalImageCacheImage cache, cleared on demand during cleanup
nonBlendModesSetRefSetGraphic-state references already confirmed not to use a blend mode
pageDictCacheRefMapPage dictionary cache whose values may be promises, used for parallel prefetching
pageIndexCacheRefMapPage reference to page index cache
pageKidsCountCacheRefMapPage-tree node to descendant page count cache
standardFontDataCacheMapStandard font data cache
systemFontCacheMapSystem font cache

These caches fall into two groups: parse caches (fonts, colour spaces, images) and page-tree index caches (pageDict, pageIndex, pageKidsCount). The former serve rendering, the latter serve navigation. They live on Catalog rather than in module globals because they must be destroyed together with the document; cleanup clears them all.

shadow is the key to understanding the behaviour of the whole class. It replaces a prototype getter with an object's own read-only data property:

function shadow(obj, prop, value, nonSerializable = false) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(prop in obj, `shadow: Property "${prop && prop.toString()}" not found in object.`);
  }
  Object.defineProperty(obj, prop, {
    value,
    enumerable: !nonSerializable,
    configurable: true,
    writable: false,
  });
  return value;
}

The first access to catalog.pageLabels runs the getter and writes the result into the own property catalog.pageLabels; the second access reads the property directly and performs no parsing at all. enumerable defaults to true because these values need to survive structured cloning or serialisation; configurable is true so that cleanup or tests can redefine them. shadow is both lazy loading and memoisation: nothing is parsed until it is accessed, and one access freezes the value.

Design note: Catalog's caching is not one universal Map; it is split by the nature of the data: RefMap for pages and fonts keyed by reference; plain Map for name strings; Set for flags; global cache classes for cross-task sharing. The benefit of the split is that cleanup can decide exactly what to clear and what to keep. For example, pageDictCache must be cleared because a page dictionary may hold decoded streams, while #catDict must not be cleared because it is the document root. Putting everything into one Map would force all-or-nothing cleanup, and the reading flow would rebuild its font index over and over.

Version, Language, Rendering Switch and Form Entry

These getters are all short, but each corresponds to a document-level entry in the specification, and all follow the same fault-tolerance policy: on a missing or mistyped value, fall back to the default and never pass a bad value upward.

version and lang

  get version() {
    const version = this.#catDict.get("Version");
    if (version instanceof Name) {
      if (PDF_VERSION_REGEXP.test(version.name)) {
        return shadow(this, "version", version.name);
      }
      warn(`Invalid PDF catalog version: ${version.name}`);
    }
    return shadow(this, "version", null);
  }
 
  get lang() {
    const lang = this.#catDict.get("Lang");
    return shadow(
      this,
      "lang",
      lang && typeof lang === "string" ? stringToPDFString(lang) : null
    );
  }

/Version is the PDF version name in the root dictionary. The specification allows it to override the version in the file header; PDF_VERSION_REGEXP is /^[1-9]\.\d$/, accepting only one digit, a dot, and one digit. Forms such as /1.10 or /PDF-1.7 are rejected and null is returned, so PDFDocument falls back to the version parsed from the header. /Lang is the document's natural-language tag; the specification uses BCP 47-style language codes. A PDF string may be PDFDocEncoding or UTF-16BE, so it must go through stringToPDFString. The value flows into text content and ends up in the text layer's canvas.lang, where accessibility tools and text selection use it.

needsRendering: The Pure-XFA Switch

  /**
   * @type {boolean} `true` for pure XFA documents,
   *   `false` for XFA Foreground documents.
   */
  get needsRendering() {
    const needsRendering = this.#catDict.get("NeedsRendering");
    return shadow(
      this,
      "needsRendering",
      typeof needsRendering === "boolean" ? needsRendering : false
    );
  }

/NeedsRendering is XFA-related: when true, the document is pure XFA, the traditional page content is meaningless, and pages must be generated by the XFA layout engine; when false, it is XFA Foreground and the AcroForm pages remain valid. PDFDocument.xfaFactory is created only when all four conditions hold: enableXfa, catalog.needsRendering, formInfo.hasXfa and !formInfo.hasAcroForm. In other words, Catalog only provides the single boolean "is this pure XFA"; the actual XFA stream parsing happens in PDFDocument's _xfaStreams.

collection and acroForm

  get collection() {
    let collection = null;
    try {
      const obj = this.#catDict.get("Collection");
      if (obj instanceof Dict && obj.size > 0) {
        collection = obj;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info("Cannot fetch Collection entry; assuming no collection is present.");
    }
    return shadow(this, "collection", collection);
  }

/Collection is the entry dictionary of a portable collection, describing how attachments are presented as a list, covers, columns and so on. The field definitions in Detail and the ordering rules in Sort all live inside this dictionary. PDF.js only cares whether it exists and hands the dictionary upward; it does not expand the fields here. Note obj.size > 0: an empty dictionary is treated as no collection, so the caller does not enable a collection UI for an empty one.

  get acroForm() {
    let acroForm = null;
    try {
      const obj = this.#catDict.get("AcroForm");
      if (obj instanceof Dict && obj.size > 0) {
        acroForm = obj;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info("Cannot fetch AcroForm entry; assuming no forms are present.");
    }
    return shadow(this, "acroForm", acroForm);
  }
 
  get acroFormRef() {
    const value = this.#catDict.getRaw("AcroForm");
    return shadow(this, "acroFormRef", value instanceof Ref ? value : null);
  }

/AcroForm is the interactive form dictionary; the specification defines /Fields, /NeedAppearances, /DA, /DR, /XFA, /CO and more. Catalog only exposes the dictionary itself; field-tree expansion and field-value filling are done by WidgetAnnotation and AnnotationFactory in annotation.js, and the calculation order /CO is read by PDFDocument. Many PDFs write /AcroForm as an indirect reference; acroFormRef uses getRaw to keep the reference number, which editing scenarios need in order to locate the object. The two getters are separate because "is there a form" and "which object is the form" are two questions: the former feeds a boolean in formInfo, the latter is used for writing back and signing.

permissions: Turning Signed Integers into a Permission Set

  #readPermissions() {
    const encrypt = this.xref.trailer.get("Encrypt");
    if (!(encrypt instanceof Dict)) {
      return null;
    }
 
    let flags = encrypt.get("P");
    if (typeof flags !== "number") {
      return null;
    }
 
    // PDF integer objects are represented internally in signed 2's complement
    // form. Therefore, convert the signed decimal integer to a signed 2's
    // complement binary integer so we can use regular bitwise operations on it.
    flags += 2 ** 32;
 
    const permissions = new Set();
    for (const value of Object.values(PermissionFlag)) {
      if (flags & value) {
        permissions.add(value);
      }
    }
    return permissions;
  }

Permission bits are not written in the root dictionary but in /P of the encryption dictionary, so Catalog reads xref.trailer. In the specification /P is a signed 32-bit integer, with bits 3 to 12 representing printing, modification, copying, annotation and other permissions; PDF.js performs JavaScript 32-bit bitwise operations, so it first adds 2 ** 32 to negative values to obtain an unsigned view. The values of PermissionFlag correspond exactly to those bits:

ConstantBitSpecification meaning
PRINT0x04Printing allowed
MODIFY_CONTENTS0x08Modifying content allowed
COPY0x10Copying text and graphics allowed
MODIFY_ANNOTATIONS0x20Adding and modifying annotations and form fields allowed
FILL_INTERACTIVE_FORMS0x100Filling existing form fields allowed
COPY_FOR_ACCESSIBILITY0x200Copying for accessibility allowed
ASSEMBLE0x400Inserting, rotating and deleting pages allowed
PRINT_HIGH_QUALITY0x800High-quality printing allowed

The constant name in the source is FILL_IN_INTERACTIVE_FORMS; it denotes the same capability as "fill in existing form fields" in the table above. When there is no /Encrypt, or /P is not a number, the getter returns null, meaning "there is no reported permission restriction" rather than "nothing is allowed". That is an important semantic choice: the reader treats null as "no encryption, no restrictions" and a Set as "the allow-set declared by an encrypted document".

markInfo, metadata, structTreeRoot

  #readMarkInfo() {
    const obj = this.#catDict.get("MarkInfo");
    if (!(obj instanceof Dict)) {
      return null;
    }
    const markInfo = new Map();
 
    for (const key of ["Marked", "UserProperties", "Suspects"]) {
      const val = obj.get(key);
      markInfo.set(key, typeof val === "boolean" ? val : false);
    }
    return markInfo;
  }

/MarkInfo is the declaration dictionary of a tagged PDF. Its three keys mean: Marked says whether the content carries structure markup, UserProperties says whether user properties exist, and Suspects says whether suspicious structure is present. User properties and suspicious structure affect accessibility and content-extraction strategy, so the source always returns a Map and fills missing keys with false. The markInfo getter catches exceptions around it: a broken dictionary only produces a warning and null.

  get metadata() {
    const streamRef = this.#catDict.getRaw("Metadata");
    if (!(streamRef instanceof Ref)) {
      return shadow(this, "metadata", null);
    }
 
    let metadata = null;
    try {
      const stream = this.xref.fetch(
        streamRef,
        /* suppressEncryption = */ !this.xref.encrypt?.encryptMetadata
      );
 
      if (
        stream instanceof BaseStream &&
        isDict(stream.dict, "Metadata") &&
        isName(stream.dict.get("Subtype"), "XML")
      ) {
        // XXX: This should examine the charset the XML document defines,
        // however since there are currently no real means to decode arbitrary
        // charsets, let's just hope that the author of the PDF was reasonable
        // enough to stick with the XML default charset, which is UTF-8.
        const data = stringToUTF8String(stream.getString());
        if (data) {
          metadata = new MetadataParser(data).serializable;
        }
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info(`Skipping invalid Metadata: "${ex}".`);
    }
    return shadow(this, "metadata", metadata);
  }

/Metadata is the XMP metadata stream; the specification requires the stream dictionary to carry /Type /Metadata and /Subtype /XML. The source grabs the reference with getRaw and fetches the stream with xref.fetch; suppressEncryption is set to !encryptMetadata, meaning that if the encryption dictionary explicitly says metadata is not encrypted, the raw bytes are read without decryption. The bytes are decoded as UTF-8 and handed to MetadataParser, which repairs bad XML produced by Ghostscript, and the result is the serialisable pair { parsedData, rawData }. XMP commonly holds the document title, author and producing tool, which the caller reads through parsedData.

  get structTreeRoot() {
    let structTree = null;
    try {
      structTree = this.#readStructTreeRoot();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable read to structTreeRoot info.");
    }
    return shadow(this, "structTreeRoot", structTree);
  }
 
  #readStructTreeRoot() {
    const rawObj = this.#catDict.getRaw("StructTreeRoot"),
      obj = this.xref.fetchIfRef(rawObj);
    return obj instanceof Dict
      ? new StructTreeRoot(this.xref, obj, rawObj)
      : null;
  }

/StructTreeRoot is the root of the tagged-PDF structure tree; the specification defines /K children, the /ParentTree, /RoleMap role mapping, /ClassMap attribute-class mapping and more. The StructTreeRoot constructor reads RoleMap and ParentTree and turns the structure tree into a queryable object. The root reference (rawObj) is passed along because later getKidPosition calls need to distinguish whether this dictionary is the root and compare children by reference. hasStructTree is only a cheap boolean, used by GetMetadata to return both metadata and whether a structure tree exists.

  get baseUrl() {
    const uri = this.#catDict.get("URI");
    if (uri instanceof Dict) {
      const base = uri.get("Base");
      if (typeof base === "string") {
        const absoluteUrl = createValidAbsoluteUrl(base, null, {
          tryConvertEncoding: true,
        });
        if (absoluteUrl) {
          return shadow(this, "baseUrl", absoluteUrl.href);
        }
      }
    }
    return shadow(this, "baseUrl", this.pdfManager.docBaseUrl);
  }

/URI is the URI dictionary in the document catalog, and /Base is the base used to resolve relative URIs. The source first tries to turn /Base into a valid absolute URL; on failure it falls back to pdfManager.docBaseUrl (usually the address of the page that loaded the PDF). This value is later used when parsing GoToR, Launch and URI actions to complete relative paths into absolute links. Without it, links could only be passed upward verbatim, and security validation would have no way to judge the target protocol.

Design note: The common pattern of these short getters is read one key, check the type, fall back to a default, freeze with shadow. What really deserves attention is not the code but the error classification: MissingDataException must be rethrown unchanged, because the chunked network stream has not received those bytes yet and PDFManager.ensure will call requestRange and retry; any other exception only means the entry is broken and warrants a warning plus a fallback. Mixing the two either leaves a chunked stream permanently unable to get its data, or makes the whole document fail to load because of one bad entry.

Page Tree: From /Pages to the Page Dictionary

The page tree is the core of Catalog. The specification defines it as a tree rooted at /Pages: internal nodes have /Kids and /Count, leaf nodes are /Page dictionaries, and every node has a /Parent pointing upward. /Count is how many pages sit under a subtree; a correct /Count can reduce lookup from O(n) to O(log n). Unfortunately, real files often get it wrong, so Catalog must support both skipping by Count and walking page by page.

toplevelPagesDict, _pagesCount and numPages

  get toplevelPagesDict() {
    const pagesObj = this.#catDict.get("Pages");
    if (!(pagesObj instanceof Dict)) {
      throw new FormatError("Invalid top-level pages dictionary.");
    }
    return shadow(this, "toplevelPagesDict", pagesObj);
  }

/Pages is the root node of the page tree. Here the source throws FormatError instead of falling back, because a PDF without a page tree has nothing to display; the constructor has already triggered this getter, so the error shows up at new Catalog time.

  get _pagesCount() {
    const obj = this.toplevelPagesDict.get("Count");
    if (!Number.isInteger(obj)) {
      throw new FormatError(
        "Page count in top-level pages dictionary is not an integer."
      );
    }
    return shadow(this, "_pagesCount", obj);
  }
 
  get numPages() {
    return this.#actualNumPages ?? this._pagesCount;
  }

numPages prefers #actualNumPages. That field is only written through setActualNumPages(num) when PDFDocument.checkLastPage finds that /Count disagrees with the actual page count. The underscore prefix of _pagesCount marks it as an internal fallback value, and it requires /Count to be an integer. Negatives are not checked here, because checkLastPage later applies its own Number.isInteger and <= 1 checks.

  setActualNumPages(num = null) {
    this.#actualNumPages = num;
  }
 
  get hasActualNumPages() {
    return this.#actualNumPages !== null;
  }

Calling setActualNumPages() without an argument resets the value to null; checkLastPage does exactly that at its start, so every validation begins again from /Count. hasActualNumPages tells PDFDocument.numPages which source to trust: if the whole file has already been walked, use the real page count and stop reading /Count.

getPageDict: Pruned Depth-First Traversal

  async getPageDict(pageIndex) {
    const nodesToVisit = [this.toplevelPagesDict];
    const visitedNodes = new RefSet();
 
    const pagesRef = this.#catDict.getRaw("Pages");
    if (pagesRef instanceof Ref) {
      visitedNodes.put(pagesRef);
    }
    const xref = this.xref,
      pageKidsCountCache = this.pageKidsCountCache,
      pageIndexCache = this.pageIndexCache,
      pageDictCache = this.pageDictCache;
    let currentPageIndex = 0;
 
    while (nodesToVisit.length) {
      const currentNode = nodesToVisit.pop();
 
      if (currentNode instanceof Ref) {
        const count = pageKidsCountCache.get(currentNode);
        // Skip nodes where the page can't be.
        if (count >= 0 && currentPageIndex + count <= pageIndex) {
          currentPageIndex += count;
          continue;
        }
        // Prevent circular references in the /Pages tree.
        if (visitedNodes.has(currentNode)) {
          throw new FormatError("Pages tree contains circular reference.");
        }
        visitedNodes.put(currentNode);
 
        const obj = await (pageDictCache.get(currentNode) ||
          xref.fetchAsync(currentNode));

This preamble defines four key pieces of state: nodesToVisit is a stack (pop takes from the end), visitedNodes records references that have already been expanded, and currentPageIndex is the number of pages already skipped; the three caches correspond to page counts, page indices and page dictionaries. The /Pages reference is preloaded into visitedNodes so that a broken tree where the root references itself is still detected.

For each reference node, pageKidsCountCache is consulted first. If the cached value is valid (count >= 0) and currentPageIndex + count still cannot reach the target page, the whole subtree is skipped. This is the heart of page-tree pruning: in a document with 5000 pages, 100 per chapter, finding page 4000 only requires skipping 39 chapters instead of expanding 5000 leaves.

        if (obj instanceof Dict) {
          let type = obj.getRaw("Type");
          if (type instanceof Ref) {
            type = await xref.fetchAsync(type);
          }
          if (isName(type, "Page") || !obj.has("Kids")) {
            // Cache the Page reference, since it can *greatly* improve
            // performance by reducing redundant lookups in long documents
            // where all nodes are found at *one* level of the tree.
            if (!pageKidsCountCache.has(currentNode)) {
              pageKidsCountCache.put(currentNode, 1);
            }
            // Help improve performance of the `getPageIndex` method.
            if (!pageIndexCache.has(currentNode)) {
              pageIndexCache.put(currentNode, currentPageIndex);
            }
 
            if (currentPageIndex === pageIndex) {
              return [obj, currentNode];
            }
            currentPageIndex++;
            continue;
          }
        }
        nodesToVisit.push(obj);
        continue;
      }

A leaf is recognised in two ways: /Type is /Page, or there is no /Kids at all. The second condition is fault tolerance: many producers omit /Type, but a dictionary that only has /Contents and /MediaBox is clearly not an internal node. Once a leaf is hit, pageKidsCountCache is set to 1 and pageIndexCache records the current index; then the target is compared. On a match it returns [obj, ref]; otherwise the index is incremented and the loop continues. pageIndexCache is filled here so that a later getPageIndex does not have to walk from the root again.

      // Must be a child page dictionary.
      if (!(currentNode instanceof Dict)) {
        throw new FormatError(
          "Page dictionary kid reference points to wrong type of object."
        );
      }
      const { objId } = currentNode;
 
      let count = currentNode.getRaw("Count");
      if (count instanceof Ref) {
        count = await xref.fetchAsync(count);
      }
      if (Number.isInteger(count) && count >= 0) {
        // Cache the Kids count, since it can reduce redundant lookups in
        // documents where all nodes are found at *one* level of the tree.
        if (objId && !pageKidsCountCache.has(objId)) {
          pageKidsCountCache.put(objId, count);
        }
 
        // Skip nodes where the page can't be.
        if (currentPageIndex + count <= pageIndex) {
          currentPageIndex += count;
          continue;
        }
      }
 
      let kids = currentNode.getRaw("Kids");
      if (kids instanceof Ref) {
        kids = await xref.fetchAsync(kids);
      }

When the top of the stack is not a reference, it must be an inline dictionary (one written directly inside the /Kids array). /Count is read and cached first; if the value is valid and the target is not inside this subtree, the whole subtree is skipped. Then /Kids is read, and fetched asynchronously if it is itself a reference. Note objId: only dictionaries retrieved through a reference have an object number, inline dictionaries do not, so the pageKidsCountCache write can only be attempted when objId exists.

      if (!Array.isArray(kids)) {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts directly in the Kids
        // array, rather than using indirect objects (fixes issue9540.pdf).
        let type = currentNode.getRaw("Type");
        if (type instanceof Ref) {
          type = await xref.fetchAsync(type);
        }
        if (isName(type, "Page") || !currentNode.has("Kids")) {
          if (currentPageIndex === pageIndex) {
            return [currentNode, null];
          }
          currentPageIndex++;
          continue;
        }
 
        throw new FormatError("Page dictionary kids object is not an array.");
      }
 
      // Always check all `Kids` nodes, to avoid getting stuck in an empty
      // node further down in the tree (see issue5644.pdf, issue8088.pdf),
      // and to ensure that we actually find the correct `Page` dict.
      for (let last = kids.length - 1; last >= 0; last--) {
        const lastKid = kids[last];
        nodesToVisit.push(lastKid);
 
        // Launch all requests in parallel so we don't wait for each one in turn
        // when looking for a page near the end, if all the pages are top level.
        if (
          currentNode === this.toplevelPagesDict &&
          lastKid instanceof Ref &&
          !pageDictCache.has(lastKid)
        ) {
          pageDictCache.put(lastKid, xref.fetchAsync(lastKid));
        }
      }
    }
 
    throw new Error(`Page index ${pageIndex} not found.`);
  }

When /Kids is not an array, the source first checks whether the current node is a leaf: if so, it returns the current dictionary directly (with a null reference because it is inline); otherwise it throws FormatError. This is for broken files such as issue9540.pdf, which put page dictionaries straight into /Kids.

Children are pushed from last to first, because the stack is LIFO, so the first child is processed first. This is not a short-circuit walk that stops on the first match: the comment explicitly says every Kids node must be checked, to avoid getting stuck in an empty node (issue5644.pdf, issue8088.pdf). In addition, when the current node is the top-level /Pages and a child is a reference, the xref.fetchAsync promise is written straight into pageDictCache, so all top-level pages are fetched in parallel. For a long document whose pages all sit on one level, finding the last page no longer waits serially for every earlier page.

Design note: The complexity of getPageDict is not a fixed O(n); it depends on whether /Count can be trusted. When it can, subtrees are pruned and lookup approaches O(log n); when it cannot, the walk degrades to node-by-node, though it still expands only the references it needs. pageDictCache stores promises rather than dictionaries, which matters: parallel prefetching happens within one event loop, multiple requests share the same promise, and xref.fetchAsync is not called twice. A design that fetches first and caches the dictionary afterwards would degrade parallel prefetching back into serial work.

getAllPageDicts: A Fallback Walk of the Whole Tree

When /Count disagrees with the actual page count, checkLastPage calls getAllPageDicts to force a full read of the tree:

  async getAllPageDicts(recoveryMode = false) {
    const { ignoreErrors } = this.pdfManager.evaluatorOptions;
 
    const queue = [{ currentNode: this.toplevelPagesDict, posInKids: 0 }];
    const visitedNodes = new RefSet();
 
    const pagesRef = this.#catDict.getRaw("Pages");
    if (pagesRef instanceof Ref) {
      visitedNodes.put(pagesRef);
    }
    const map = new Map(),
      xref = this.xref,
      pageIndexCache = this.pageIndexCache;
    let pageIndex = 0;
 
    function addPageDict(pageDict, pageRef) {
      // Help improve performance of the `getPageIndex` method.
      if (pageRef && !pageIndexCache.has(pageRef)) {
        pageIndexCache.put(pageRef, pageIndex);
      }
 
      map.set(pageIndex++, [pageDict, pageRef]);
    }
    function addPageError(error) {
      if (error instanceof XRefEntryException && !recoveryMode) {
        throw error;
      }
      if (recoveryMode && ignoreErrors && pageIndex === 0) {
        // Ensure that the viewer will always load (fixes issue15590.pdf).
        warn(`getAllPageDicts - Skipping invalid first page: "${error}".`);
        error = Dict.empty;
      }
 
      map.set(pageIndex++, [error, null]);
    }

It uses an explicit stack, with each frame storing currentNode and posInKids. That allows the walk to pause and resume without recursion. addPageDict writes the page dictionary and its reference into the Map and updates pageIndexCache; addPageError also writes the error as a page slot and advances the index. So even if a page in the middle is broken, the indices of later pages do not shift.

    while (queue.length > 0) {
      const queueItem = queue.at(-1);
      const { currentNode, posInKids } = queueItem;
 
      let kids = currentNode.getRaw("Kids");
      if (kids instanceof Ref) {
        try {
          kids = await xref.fetchAsync(kids);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      }
      if (!Array.isArray(kids)) {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts (fixes issue21436.pdf).
        let type = currentNode.getRaw("Type");
        if (type instanceof Ref) {
          try {
            type = await xref.fetchAsync(type);
          } catch (ex) {
            addPageError(ex);
            break;
          }
        }
        if (isName(type, "Page") || !currentNode.has("Kids")) {
          addPageDict(currentNode, null);
          break;
        }
 
        addPageError(
          new FormatError("Page dictionary kids object is not an array.")
        );
        break;
      }
 
      if (posInKids >= kids.length) {
        queue.pop();
        continue;
      }

The inner loop processes one child of the current stack frame at a time. When kids is not an array, the same two paths apply: inline page dictionary or error. posInKids >= kids.length means the current node is finished, so the frame is popped back to its parent.

      const kidObj = kids[posInKids];
      let obj;
      if (kidObj instanceof Ref) {
        // Prevent circular references in the /Pages tree.
        if (visitedNodes.has(kidObj)) {
          addPageError(
            new FormatError("Pages tree contains circular reference.")
          );
          break;
        }
        visitedNodes.put(kidObj);
 
        try {
          obj = await xref.fetchAsync(kidObj);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      } else {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts directly in the Kids
        // array, rather than using indirect objects (see issue9540.pdf).
        obj = kidObj;
      }
      if (!(obj instanceof Dict)) {
        addPageError(
          new FormatError(
            "Page dictionary kid reference points to wrong type of object."
          )
        );
        break;
      }
 
      let type = obj.getRaw("Type");
      if (type instanceof Ref) {
        try {
          type = await xref.fetchAsync(type);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      }
      if (isName(type, "Page") || !obj.has("Kids")) {
        addPageDict(obj, kidObj instanceof Ref ? kidObj : null);
      } else {
        queue.push({ currentNode: obj, posInKids: 0 });
      }
      queueItem.posInKids++;
    }
    return map;
  }

A reference is checked against visitedNodes first: a cycle writes an error slot and stops; otherwise the object is fetched. An inline dictionary is used as is. After the leaf test, the code either calls addPageDict or pushes the child onto the stack. The method returns Map<number, [Dict, Ref|null]>, and an error slot is usually [Error, null].

getAllPageDicts is used only on the fallback path, so its goal is not speed but completeness: even with broken pages, circular references or a wrong /Count, it must give the caller a map covering every page index. PDFDocument.checkLastPage takes that map, calls setActualNumPages(map.size) and uses it as the page source.

getPageIndex: Resolving an Index from a Page

getPageIndex solves the opposite problem: given a page reference, which page number is it in the document? The specification has no page index field, so the answer must be accumulated from parent-child relationships and the /Count of sibling nodes:

  async getPageIndex(pageRef) {
    const cachedPageIndex = this.pageIndexCache.get(pageRef);
    if (cachedPageIndex !== undefined) {
      return cachedPageIndex;
    }
 
    // The page tree nodes have the count of all the leaves below them. To get
    // how many pages are before we just have to walk up the tree and keep
    // adding the count of siblings to the left of the node.
    const xref = this.xref;
    let total = 0,
      ref = pageRef;
    // Prevent circular references in the /Pages tree.
    const visited = new RefSet();
    visited.put(pageRef);
 
    while (true) {
      const node = await xref.fetchAsync(ref);
      if (
        isRefsEqual(ref, pageRef) &&
        !isDict(node, "Page") &&
        !(node instanceof Dict && !node.has("Type") && node.has("Contents"))
      ) {
        throw new FormatError(
          "The reference does not point to a /Page dictionary."
        );
      }
      if (!node) {
        break;
      }
      if (!(node instanceof Dict)) {
        throw new FormatError("Node must be a dictionary.");
      }
      const parentRef = node.getRaw("Parent");
      if (parentRef instanceof Ref) {
        if (visited.has(parentRef)) {
          throw new FormatError("Pages tree contains circular reference.");
        }
        visited.put(parentRef);
      }
 
      const parent = await node.getAsync("Parent");
      if (!parent) {
        break;
      }
      if (!(parent instanceof Dict)) {
        throw new FormatError("Parent must be a dictionary.");
      }

The first step verifies that the starting point is a page dictionary: /Type is /Page, or there is no /Type but there is /Contents. The second case is a fault-tolerance branch for leaves that omit /Type. Then the walk goes up along Parent, adding each parent reference to visited; a repeat means the page tree has a cycle.

      const kids = await parent.getAsync("Kids");
      if (!kids) {
        break;
      }
      if (!Array.isArray(kids)) {
        throw new FormatError("Kids must be an array.");
      }
 
      const kidPromises = [];
      let found = false;
      for (const kid of kids) {
        if (!(kid instanceof Ref)) {
          throw new FormatError("Kid must be a reference.");
        }
        if (isRefsEqual(kid, ref)) {
          found = true;
          break;
        }
        kidPromises.push(
          xref.fetchAsync(kid).then(obj => {
            if (!(obj instanceof Dict)) {
              throw new FormatError("Kid node must be a dictionary.");
            }
            if (obj.has("Count")) {
              const count = obj.get("Count");
              if (Number.isInteger(count) && count >= 0) {
                total += count;
                return;
              }
              throw new FormatError("Count must be a (positive) integer.");
            }
            // Page leaf node.
            total++;
          })
        );
      }
      if (!found) {
        throw new FormatError("Kid reference not found in parent's kids.");
      }
      await Promise.all(kidPromises);
      ref = parentRef;
    }
 
    this.pageIndexCache.put(pageRef, total);
    return total;
  }

Before the current node is found in the parent's /Kids, every sibling to its left is before the current page. For each left sibling: if it has /Count, add /Count; if it has no /Count, it is a leaf, so add 1. The source puts all sibling fetchAsync calls into kidPromises so they run in parallel, then waits on Promise.all. This is also why the method must be asynchronous: /Count may sit behind an indirect reference and may even trigger a chunked-stream data request.

One easily missed semantic detail: once the current node is found, the loop breaks immediately, and right-hand siblings are never fetched. If found is false, the current node is not in this /Kids array and the method throws. After the loop, ref = parentRef moves one level up until there is no /Parent. The final total is the page index, written into pageIndexCache.

Comparing the Three Page-Tree Operations

OperationInputOutputDirectionCachesTypical use
getPageDictPage index[page dict, reference]Root to leaf, LIFO stackpageKidsCountCache, pageDictCache, pageIndexCacheOpening a page normally
getAllPageDictsNoneMap<index, [dict or error, reference]>Root to leaf, explicit stack framespageIndexCacheFull recovery when /Count is untrustworthy
getPageIndexPage referencePage indexLeaf to rootpageIndexCacheAnnotations, links and destination parsing that need an index

All three share pageIndexCache, but write it at different moments: getPageDict when it hits a leaf, getAllPageDicts inside addPageDict, and getPageIndex after the total is computed. The shared cache means the common combination of opening a page and then resolving its index pays for a tree walk only once.

Design note: Page-tree walking uses an explicit stack rather than recursion. The /Pages tree can be extremely deep in a broken file, and recursion would overflow the stack; an explicit stack also lets getAllPageDicts keep resumable state such as which child is being processed. Another key point is deduplication at the reference level: visitedNodes is a RefSet, not a Set<Dict>, because the same dictionary can be shared by several references, and identity-based deduplication would miss a cycle formed by the same reference number. RefSet stores the string form of a reference, which solves exactly that problem.

Page Labels

Page labels separate the page number displayed on the page from the physical page index. A document can have a cover as page 1, Roman numeral i on page 2, and Arabic 1 starting on page 3. The specification describes this mapping with a /PageLabels number tree: the key is the physical page index, and the value is a label dictionary.

rawPageLabels and pageLabels

  get rawPageLabels() {
    const obj = this.#catDict.getRaw("PageLabels");
    if (!obj) {
      return null;
    }
 
    const numberTree = new NumberTree(obj, this.xref);
    return numberTree.getAll();
  }

rawPageLabels returns the number tree's Map directly; keys may be references and values may be raw dictionaries. An editor needs the raw structure so that it can write back after changing labels; it does not need the expanded per-page strings. NumberTree.getAll() walks /Kids and /Nums and maps numeric keys to values.

  get pageLabels() {
    let obj = null;
    try {
      obj = this.#readPageLabels();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read page labels.");
    }
    return shadow(this, "pageLabels", obj);
  }
 
  #readPageLabels() {
    const nums = this.rawPageLabels;
    if (!nums) {
      return null;
    }
 
    const pageLabels = new Array(this.numPages);
    let style = null,
      prefix = "";
 
    let currentLabel = "",
      currentIndex = 1;
 
    for (let i = 0, ii = this.numPages; i < ii; i++) {
      const labelDict = nums.get(i);
 
      if (labelDict !== undefined) {
        if (!(labelDict instanceof Dict)) {
          throw new FormatError("PageLabel is not a dictionary.");
        }
 
        if (
          labelDict.has("Type") &&
          !isName(labelDict.get("Type"), "PageLabel")
        ) {
          throw new FormatError("Invalid type in PageLabel dictionary.");
        }
 
        if (labelDict.has("S")) {
          const s = labelDict.get("S");
          if (!(s instanceof Name)) {
            throw new FormatError("Invalid style in PageLabel dictionary.");
          }
          style = s.name;
        } else {
          style = null;
        }
 
        if (labelDict.has("P")) {
          const p = labelDict.get("P");
          if (typeof p !== "string") {
            throw new FormatError("Invalid prefix in PageLabel dictionary.");
          }
          prefix = stringToPDFString(p);
        } else {
          prefix = "";
        }
 
        if (labelDict.has("St")) {
          const st = labelDict.get("St");
          if (!(Number.isInteger(st) && st >= 1)) {
            throw new FormatError("Invalid start in PageLabel dictionary.");
          }
          currentIndex = st;
        } else {
          currentIndex = 1;
        }
      }

#readPageLabels uses a sequential scan rather than a per-key lookup: it walks from page 0 to the last page while maintaining the current style, prefix and currentIndex. Pages that do not appear in the number tree keep the previous label settings; when a new label dictionary appears, those three values are replaced. /Type may be omitted, but when present it must equal /PageLabel; /S is the style name; /P is the prefix string; /St is the starting number and must be an integer no less than 1.

      switch (style) {
        case "D":
          currentLabel = currentIndex;
          break;
        case "R":
        case "r":
          currentLabel = toRomanNumerals(currentIndex, style === "r");
          break;
        case "A":
        case "a":
          const LIMIT = 26; // Use only the characters A-Z, or a-z.
          const letterIndex = currentIndex - 1;
          const character = String.fromCharCode(
            style.charCodeAt(0) + (letterIndex % LIMIT)
          );
          currentLabel = character.repeat(Math.floor(letterIndex / LIMIT) + 1);
          break;
        default:
          if (style) {
            throw new FormatError(
              `Invalid style "${style}" in PageLabel dictionary.`
            );
          }
          currentLabel = "";
      }
 
      pageLabels[i] = prefix + currentLabel;
      currentIndex++;
    }
    return pageLabels;
  }

The style mapping follows the specification's table exactly:

/S valueOutputExamples
DDecimal Arabic numerals1, 2, 3, 10
RUppercase Roman numeralsI, II, III, X
rLowercase Roman numeralsi, ii, iii, x
AUppercase letters, repeating after 26A, B, Z, AA, AB
aLowercase letters, repeating after 26a, b, z, aa, ab
MissingEmpty stringPrefix only, or no label at all

The letter-style implementation deserves a closer look: letterIndex = currentIndex - 1, letterIndex % 26 selects the letter, and Math.floor(letterIndex / 26) + 1 decides how many times it repeats. Page 27 is therefore AA and page 53 is BA, not Excel-style AA and AB. This is the repeated-letters representation required by the PDF specification, not the Excel column-number algorithm. currentIndex increments after every page, so labels continue to advance even without /St.

The pageLabels getter catches FormatError and returns null. That means one bad label entry invalidates the labels of the whole document rather than making part of them available. This is a conservative choice: labels are navigational aids, and a broken set should not prevent reading; but continuing with a half-set of labels could show the user inconsistent page numbers.

Design note: rawPageLabels and pageLabels are two views of the same data. The raw view keeps the key-value structure of the number tree and is suitable for editing and writing back; the expanded view is a string array whose length equals the page count and is suitable for direct index access in the viewer. They are implemented separately so that an editor does not have to reverse-parse strings to get the raw dictionary. shadow freezes only the expanded view; the raw view calls getAll() every time, because editing may change the number tree and it must not be cached permanently.

Named Destinations and Name Trees

A destination describes which page to jump to, at what position, and with what zoom. It has two forms: an explicit destination is an array whose first item is a page reference or page index, whose second item is a zoom-type name, and whose remaining items are parameters; a named destination is a name that a name tree or legacy dictionary maps to an explicit destination.

destinations, getDestination and #readDests

  get destinations() {
    const dests = new Map();
 
    for (const obj of this.#readDests()) {
      if (obj instanceof NameTree) {
        for (const [key, value] of obj.getAll()) {
          const dest = fetchDest(value);
          if (dest) {
            dests.set(
              stringToPDFString(key, /* keepEscapeSequence = */ true),
              dest
            );
          }
        }
      } else if (obj instanceof Dict) {
        for (const [key, value] of obj) {
          const dest = fetchDest(value);
          if (dest) {
            // Always let the NameTree take precedence.
            dests.getOrInsert(
              stringToPDFString(key, /* keepEscapeSequence = */ true),
              dest
            );
          }
        }
      }
    }
    return shadow(this, "destinations", dests);
  }

#readDests returns an array, and the order matters: the name tree first, the legacy dictionary second. So dests.set overwrites for the name tree, while dests.getOrInsert inserts for the legacy dictionary; a destination with the same name in the name tree always wins. This supports files where the PDF 1.1 /Dests dictionary and the PDF 1.2-and-later /Names /Dests name tree coexist.

  #readDests() {
    const obj = this.#catDict.get("Names");
    const rawDests = [];
    if (obj?.has("Dests")) {
      rawDests.push(new NameTree(obj.getRaw("Dests"), this.xref));
    }
    if (this.#catDict.has("Dests")) {
      // Simple destination dictionary.
      rawDests.push(this.#catDict.get("Dests"));
    }
    return rawDests;
  }

fetchDest folds the various shapes into a valid explicit destination:

function fetchDest(dest) {
  if (dest instanceof Dict) {
    dest = dest.get('D');
  }
  return isValidExplicitDest(dest) ? dest : null;
}

A destination value may be a dictionary in which D is the real array, or it may be the array itself. isValidExplicitDest is the bound version of _isValidExplicitDest; it checks that the first item is a reference or an integer, that the second item is a name, and that the number and types of the remaining arguments match the zoom mode. The validation details are covered later in the destination-parsing section.

  getDestination(id) {
    // Avoid extra lookup/parsing when all destinations are already available.
    if (Object.hasOwn(this, "destinations")) {
      return this.destinations.get(id) ?? null;
    }
 
    for (const obj of this.#readDests()) {
      if (obj instanceof NameTree || obj instanceof Dict) {
        const dest = fetchDest(obj.get(id));
        if (dest) {
          return dest;
        }
      }
    }
 
    // Always fallback to checking all destinations, in order to support:
    //  - PDF documents with out-of-order NameTrees (fixes issue 10272).
    //  - Destination keys that use PDFDocEncoding (fixes issue 19835).
    return this.destinations.get(id) ?? null;
  }

getDestination has a fast path: if destinations has already been frozen by shadow (Object.hasOwn is true), look it up in the Map directly. Otherwise it checks the name tree and then the legacy dictionary in order; a single-key query through NameTree.get or Dict.get is cheaper than a full expansion. If that fails, the source still falls back to this.destinations, for the reasons given in the comment: the internal order of a name tree may violate the specification, or keys may use PDFDocEncoding instead of UTF-16, so a single-key lookup can miss them; only a full expansion followed by matching on the decoded string is reliable.

Destination formSource handlingSpecification
Explicit arrayValidated directly by isValidExplicitDest12.3.2.2 Explicit Destinations
Dictionary with /DfetchDest takes /D, then validatesWrapped destination dictionary form
Name stringA key in destinations, mapped to an array12.3.2.3 Named Destinations
Name treeNameTree.getAll() expands /Names7.9.6 Name Dictionaries
Legacy /Dests dictionaryIterated directly, lower priorityPDF 1.1 compatibility

Design note: The merge order in destinations expresses a compatibility principle: new structures win, old structures fill gaps. The name tree is the standard from PDF 1.2 onward; the legacy /Dests dictionary only takes effect when the former has no key of the same name. getDestination keeps both a single-key fast path and a full-expansion fallback: normal documents do not expand an entire name tree for one jump, while broken documents can still find the target through full expansion after the single-key lookup fails. This fast-path plus slow-fallback pattern appears repeatedly in Catalog.

Page Layout, Page Mode and Viewer Preferences

These three getters all answer how the viewer should display the document when it opens, but each has its own default value and fault-tolerance rules.

pageLayout and pageMode

  get pageLayout() {
    const obj = this.#catDict.get("PageLayout");
    // Purposely use a non-standard default value, rather than 'SinglePage', to
    // allow differentiating between `undefined` and /SinglePage since that does
    // affect the Scroll mode (continuous/non-continuous) used in Adobe Reader.
    let pageLayout = "";
 
    if (obj instanceof Name) {
      switch (obj.name) {
        case "SinglePage":
        case "OneColumn":
        case "TwoColumnLeft":
        case "TwoColumnRight":
        case "TwoPageLeft":
        case "TwoPageRight":
          pageLayout = obj.name;
      }
    }
    return shadow(this, "pageLayout", pageLayout);
  }

/PageLayout controls how pages are arranged: single page, continuous single column, two columns left or right, two pages left or right and so on. The default here is an empty string rather than SinglePage, and the comment explains why: the viewer needs to distinguish a document that declares no layout from one that explicitly declares a single page, because that affects Adobe Reader's continuous-scroll behaviour. An invalid name does not raise an error; the empty string is simply kept.

  get pageMode() {
    const obj = this.#catDict.get("PageMode");
    let pageMode = "UseNone"; // Default value.
 
    if (obj instanceof Name) {
      switch (obj.name) {
        case "UseNone":
        case "UseOutlines":
        case "UseThumbs":
        case "FullScreen":
        case "UseOC":
        case "UseAttachments":
          pageMode = obj.name;
      }
    }
    return shadow(this, "pageMode", pageMode);
  }

/PageMode controls the sidebar and full-screen behaviour: show nothing, show outlines, show thumbnails, full screen, show optional content, show attachments. Its default is UseNone, in contrast to /PageLayout, because the specification itself defines UseNone as the default and there is no need to distinguish undeclared from an explicit UseNone.

GetterEntryValid valuesDefaultWhy this default
pageLayout/PageLayoutSinglePage, OneColumn, TwoColumnLeft, TwoColumnRight, TwoPageLeft, TwoPageRightEmpty stringDistinguishes undeclared from explicit single page
pageMode/PageModeUseNone, UseOutlines, UseThumbs, FullScreen, UseOC, UseAttachmentsUseNoneIt is the specification default

viewerPreferences: a Per-Key Allowlist

/ViewerPreferences is a dictionary, but PDF.js does not pass it upward as is; it validates each key and converts the result into a uniform Map:

  get viewerPreferences() {
    const obj = this.#catDict.get("ViewerPreferences");
    if (!(obj instanceof Dict)) {
      return shadow(this, "viewerPreferences", null);
    }
    let prefs = null;
 
    for (const [key, value] of obj) {
      let prefValue;
 
      switch (key) {
        case "HideToolbar":
        case "HideMenubar":
        case "HideWindowUI":
        case "FitWindow":
        case "CenterWindow":
        case "DisplayDocTitle":
        case "PickTrayByPDFSize":
          if (typeof value === "boolean") {
            prefValue = value;
          }
          break;

Boolean keys accept only real booleans. PickTrayByPDFSize is print-related: whether to select the paper tray according to the PDF page size. Then come several enumerated keys:

        case "NonFullScreenPageMode":
          if (value instanceof Name) {
            switch (value.name) {
              case "UseNone":
              case "UseOutlines":
              case "UseThumbs":
              case "UseOC":
                prefValue = value.name;
                break;
              default:
                prefValue = "UseNone";
            }
          }
          break;
        case "Direction":
          if (value instanceof Name) {
            switch (value.name) {
              case "L2R":
              case "R2L":
                prefValue = value.name;
                break;
              default:
                prefValue = "L2R";
            }
          }
          break;
        case "ViewArea":
        case "ViewClip":
        case "PrintArea":
        case "PrintClip":
          if (value instanceof Name) {
            switch (value.name) {
              case "MediaBox":
              case "CropBox":
              case "BleedBox":
              case "TrimBox":
              case "ArtBox":
                prefValue = value.name;
                break;
              default:
                prefValue = "CropBox";
            }
          }
          break;
        case "PrintScaling":
          if (value instanceof Name) {
            switch (value.name) {
              case "None":
              case "AppDefault":
                prefValue = value.name;
                break;
              default:
                prefValue = "AppDefault";
            }
          }
          break;
        case "Duplex":
          if (value instanceof Name) {
            switch (value.name) {
              case "Simplex":
              case "DuplexFlipShortEdge":
              case "DuplexFlipLongEdge":
                prefValue = value.name;
                break;
              default:
                prefValue = "None";
            }
          }
          break;

The common trait of the enumerated keys is that an invalid name falls back to the specification default instead of dropping the key. Direction defaults to L2R, PrintScaling to AppDefault, and Duplex to None. That is friendlier than ignoring the entry: the caller always gets a definite layout direction or print-scaling policy. ViewArea, ViewClip, PrintArea and PrintClip default to CropBox, the conventional use-the-crop-box choice in the specification.

        case "PrintPageRange":
          // The number of elements must be even.
          if (
            Array.isArray(value) &&
            value.length % 2 === 0 &&
            value.every(
              (page, i, arr) =>
                Number.isInteger(page) &&
                page > 0 &&
                (i === 0 || page >= arr[i - 1]) &&
                page <= this.numPages
            )
          ) {
            prefValue = value;
          }
          break;
        case "NumCopies":
          if (Number.isInteger(value) && value > 0) {
            prefValue = value;
          }
          break;
        default:
          warn(`Ignoring non-standard key in ViewerPreferences: ${key}.`);
          continue;
      }
 
      if (prefValue === undefined) {
        warn(`Bad value, for key "${key}", in ViewerPreferences: ${value}.`);
        continue;
      }
      (prefs ??= new Map()).set(key, prefValue);
    }
    return shadow(this, "viewerPreferences", prefs);

PrintPageRange holds page ranges in pairs, so its length must be even, and each page number must be a positive integer, non-decreasing, and no greater than numPages. NumCopies must be a positive integer. Unknown keys and invalid values only produce warnings and are skipped, without affecting the other keys. The getter returns either null (no dictionary) or a Map; if no key is valid, prefs stays null.

Design note: viewerPreferences parses against an allowlist rather than returning the dictionary verbatim. The viewer only uses the few dozen keys defined by the specification, and returning the raw dictionary would force every caller to repeat type validation. Meanwhile PDF producers often mix names with strings, misspell enumerations or write page ranges out of order. Catalog acts as a gatekeeper here: valid values become definite types, invalid ones fall back to defaults or are dropped. The caller therefore receives normalised preferences, not a raw dictionary it has to guess at.

OpenAction and Document-Level JavaScript

/OpenAction is the action or destination executed automatically after the document opens. It has two forms: an explicit destination array, or an action dictionary. Catalog normalises it into a Map whose key is either dest or action.

  get openAction() {
    const obj = this.#catDict.get("OpenAction");
    const openAction = new Map();
 
    if (obj instanceof Dict) {
      // Convert the OpenAction dictionary into a format that works with
      // `parseDestDictionary`, to avoid having to re-implement those checks.
      const destDict = new Dict(this.xref);
      destDict.set("A", obj);
 
      const resultObj = { url: null, dest: null, action: null };
      Catalog.parseDestDictionary({ destDict, resultObj });
 
      if (Array.isArray(resultObj.dest)) {
        openAction.set("dest", resultObj.dest);
      } else if (resultObj.action) {
        openAction.set("action", resultObj.action);
      }
    } else if (isValidExplicitDest(obj)) {
      openAction.set("dest", obj);
    }
    return shadow(this, "openAction", openAction.size ? openAction : null);
  }

If /OpenAction is a dictionary, the source builds a temporary destDict, puts the original dictionary under /A, and hands it to parseDestDictionary. The reason is to avoid duplicating action-type logic: an OpenAction dictionary is essentially an action dictionary, no different from actions inside links or outline items. The parse result keeps only dest (an array) or action (a named action); URL forms never appear in the returned Map for OpenAction. If /OpenAction is already an explicit destination array, it is validated and stored as dest directly. When nothing valid is present, the getter returns null.

jsActions and #collectJavaScript

Document-level JavaScript has two sources: the /Names /JavaScript name tree, and JavaScript actions inside /OpenAction.

  #collectJavaScript() {
    const obj = this.#catDict.get("Names");
    let javaScript = null;
 
    function appendIfJavaScriptDict(name, jsDict) {
      if (!(jsDict instanceof Dict) || !isName(jsDict.get("S"), "JavaScript")) {
        return;
      }
      let js = jsDict.get("JS");
      if (js instanceof BaseStream) {
        js = js.getString();
      } else if (typeof js !== "string") {
        return;
      }
      js = stringToPDFString(js, /* keepEscapeSequence = */ true).replaceAll(
        "\x00",
        ""
      );
      // Skip empty entries, similar to the `_collectJS` function.
      if (js) {
        (javaScript ??= new Map()).set(name, js);
      }
    }
 
    if (obj instanceof Dict && obj.has("JavaScript")) {
      const nameTree = new NameTree(obj.getRaw("JavaScript"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        appendIfJavaScriptDict(
          stringToPDFString(key, /* keepEscapeSequence = */ true),
          value
        );
      }
    }
    // Append OpenAction "JavaScript" actions, if any, to the JavaScript map.
    const openAction = this.#catDict.get("OpenAction");
    if (openAction) {
      appendIfJavaScriptDict("OpenAction", openAction);
    }
 
    return javaScript;
  }

appendIfJavaScriptDict requires the action dictionary's /S to be /JavaScript, and the script body /JS may be a string or a stream. Strings are decoded as PDFDocEncoding or UTF-16BE and have \x00 removed, because some producers pad strings with NULs. Empty scripts do not enter the Map. A name-tree key is the script name; the OpenAction script always uses "OpenAction" as its key, so a duplicate name does not overwrite the name-tree script but coexists as another entry.

  get jsActions() {
    const javaScript = this.#collectJavaScript();
    let actions = collectActions(
      this.xref,
      this.#catDict,
      DocumentActionEventType
    );
 
    if (javaScript) {
      actions ??= new Map();
 
      for (const [key, val] of javaScript) {
        actions.getOrInsertComputed(key, makeArr).push(val);
      }
    }
    return shadow(this, "jsActions", actions);
  }

collectActions reads /AA (Additional Actions) from the root dictionary and, using the event-type mapping table, collects actions into a map of event names to script arrays:

KeyDocumentActionEventTypeEvent meaning
WCWillCloseThe document is about to close
WSWillSaveThe document is about to be saved
DSDidSaveThe document has been saved
WPWillPrintThe document is about to print
DPDidPrintThe document has printed

Scripts from the name tree and OpenAction are then appended to the same Map; getOrInsertComputed(key, makeArr) guarantees that each event name maps to an array. The merge order means that if /AA and the name tree use the same key, both scripts go into the same array instead of overwriting each other. The caller runs scripts in event order.

Design note: Both openAction and jsActions express the idea of reusing one parser. OpenAction does not inspect /S itself; it disguises itself as /A and delegates to parseDestDictionary. JavaScript does not walk /AA itself; it uses the generic collectActions. Adding or removing an action type therefore only touches one place, and every entry point benefits. The cost is that the returned shape of openAction is restricted to the dest or action key, and URL forms are deliberately dropped: opening a document never auto-navigates to an external link, which is a conservative security choice.

Outline: From a Linked List to a Tree

In the PDF specification the outline is not a nested array but a doubly linked list: /First in the /Outlines dictionary points at the first bookmark item, each item has /Next pointing at the following item, /First at its first child and /Parent back to its parent. The viewer wants a tree, so Catalog must reorganise the linked list into nested items.

documentOutline and #readDocumentOutline

  get documentOutline() {
    let obj = null;
    try {
      obj = this.#readDocumentOutline();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read document outline.");
    }
    return shadow(this, "documentOutline", obj);
  }
 
  #readDocumentOutline(options = {}) {
    let obj = this.#catDict.get("Outlines");
    if (!(obj instanceof Dict)) {
      return null;
    }
    obj = obj.getRaw("First");
    if (!(obj instanceof Ref)) {
      return null;
    }
 
    const root = { items: [] };
    const queue = [{ obj, parent: root }];
    // To avoid recursion, keep track of the already processed items.
    const processed = new RefSet();
    processed.put(obj);
    const xref = this.xref,
      blackColor = new Uint8ClampedArray(3);
 
    while (queue.length > 0) {
      const i = queue.shift();
      const outlineDict = xref.fetchIfRef(i.obj);
      if (outlineDict === null) {
        continue;
      }
      if (!outlineDict.has("Title")) {
        warn("Invalid outline item encountered.");
      }
 
      const data = { url: null, dest: null, action: null };
      Catalog.parseDestDictionary({
        destDict: outlineDict,
        resultObj: data,
        docBaseUrl: this.baseUrl,
        docAttachments: this.attachments,
      });

documentOutline is a lazy-loading getter. It returns null when there is no /Outlines or when /First is not a reference. The queue starts with the first bookmark item plus a virtual root, and the first reference is preloaded into processed so the first node cannot point back to itself through /Next or /First. A missing Title only produces a warning and does not abort: a bookmark without a title can still have its destination parsed, and the title is simply displayed as an empty string.

const title = outlineDict.get('Title');
const flags = outlineDict.get('F') || 0;
const color = outlineDict.getArray('C');
const count = outlineDict.get('Count');
let rgbColor = blackColor;
 
// We only need to parse the color when it's valid, and non-default.
if (isNumberArray(color, 3) && (color[0] !== 0 || color[1] !== 0 || color[2] !== 0)) {
  rgbColor = ColorSpaceUtils.rgb.getRgb(color, 0);
}
 
const outlineItem = {
  action: data.action,
  attachmentId: data.attachmentId,
  attachment: data.attachment,
  dest: data.dest,
  url: data.url,
  unsafeUrl: data.unsafeUrl,
  newWindow: data.newWindow,
  setOCGState: data.setOCGState,
  title: typeof title === 'string' ? stringToPDFString(title) : '',
  color: rgbColor,
  count: Number.isInteger(count) ? count : undefined,
  bold: !!(flags & 2),
  italic: !!(flags & 1),
  items: [],
};
 
if (options.keepRawDict) {
  outlineItem.rawDict = outlineDict;
}

Every bookmark item is converted into a uniform plain object:

FieldSourceMeaning
title/TitlePDF string, decoded for display
color/CThree components, default black
count/CountVisible child count when expanded; kept only when an integer
boldbit 2 of /FBold
italicbit 1 of /FItalic
destparseDestDictionaryExplicit destination array or named destination
urlparseDestDictionaryExternal link, after security validation
actionparseDestDictionaryNamed action, for example NextPage
itemsChild nodesTree filled level by level

The bit meanings of /F come from the specification: bit 1 is italic and bit 2 is bold. The colour /C is converted only when all three components are numbers and not all zero; an all-zero colour reuses the shared blackColor and saves a colour-space conversion. In the specification the sign of count also means collapsed by default, but PDF.js keeps only the integer value itself, and the viewer decides the collapsed state from its sign.

      i.parent.items.push(outlineItem);
      obj = outlineDict.getRaw("First");
      if (obj instanceof Ref && !processed.has(obj)) {
        queue.push({ obj, parent: outlineItem });
        processed.put(obj);
      }
      obj = outlineDict.getRaw("Next");
      if (obj instanceof Ref && !processed.has(obj)) {
        queue.push({ obj, parent: i.parent });
        processed.put(obj);
      }
    }
    return root.items.length > 0 ? root.items : null;
  }

Both children and siblings are taken as references through getRaw, avoiding an early dictionary fetch by get. processed performs global deduplication and does not distinguish already handled as a child from already handled as a sibling: one reference may appear in only one position in the tree. This matches the specification's rule that each bookmark item has exactly one parent, and it also prevents cross-references in a broken file from causing an infinite loop. The queue takes from the head with shift, so the walk is breadth-first; each parent's items array already exists when the item is dequeued, so children can simply be pushed into it.

documentOutlineForEditor reuses the same logic, only passing keepRawDict:

  get documentOutlineForEditor() {
    let obj = null;
    try {
      obj = this.#readDocumentOutline({ keepRawDict: true });
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read document outline.");
    }
    return shadow(this, "documentOutlineForEditor", obj);
  }

Editing scenarios need the raw dictionary so that changed bookmarks can be written back to /Title, /Dest, /F, /C and so on. The reading path does not need rawDict, because the extra dictionaries would block structured cloning and inflate message size.

Design note: Outline parsing deliberately avoids recursion. A bookmark list can be very deep, and a broken file can even build a cycle; the queue together with processed solves both depth and cycles. Another detail is the separation of destination parsing from structure parsing: #readDocumentOutline only builds the tree shape, while parseDestDictionary handles destinations, URLs, attachment jumps and named actions uniformly. The outline, link annotations and OpenAction therefore share one destination semantics, and inconsistencies such as outlines supporting GoToE while links do not cannot arise.

Attachments, Forms and the Structure Tree

In attachments and forms, Catalog plays the role of entry point and inventory; actual file reading, field filling and structure-tree walking are done by file_spec.js, annotation.js and struct_tree.js respectively.

attachments and attachmentContent

  get attachments() {
    const obj = this.#catDict.get("Names");
    /** @type {Map<string, CatalogAttachment> | null} */
    let attachments = null;
 
    if (obj instanceof Dict && obj.has("EmbeddedFiles")) {
      const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        (attachments ??= new Map()).set(
          stringToPDFString(key, /* keepEscapeSequence = */ true),
          new FileSpec(value).serializable
        );
      }
    }
    return shadow(this, "attachments", attachments);
  }

In the specification an attachment is a /Names /EmbeddedFiles name tree: the key is the attachment name and the value is a file specification dictionary. FileSpec.serializable normalises a file specification into three fields: rawFilename is the original path, filename is the display name with directories removed, and description is /Desc. PDF.js explicitly does not support /RF related files or URL file systems, so only embedded files are handled here. When the name tree is empty or absent, the getter returns null.

  attachmentContent(id) {
    const namedContent = this.#attachmentContentByName(id);
    if (namedContent !== undefined) {
      return namedContent;
    }
 
    // Annotation-local attachments register the reference of their embedded
    // content in the catalog, so it's re-fetched from the xref on demand
    // instead of being cached (which would then need to survive `cleanup`).
    // The reference points either at the file-spec dictionary or, for an inline
    // file-spec, straight at the embedded-file stream.
    const ref = this.#annotationAttachmentRefById.get(id);
    if (ref) {
      const target = this.xref.fetch(ref);
      if (target instanceof BaseStream) {
        const content = FileSpec.readStreamContent(target);
        if (this.#soundAttachmentIds.has(id)) {
          return soundStreamToWav(target, content) ?? content;
        }
        return content;
      }
      return target instanceof Dict ? FileSpec.readContent(target) : null;
    }
    return null;
  }

attachmentContent reads on demand: the attachments getter only returns metadata and does not read embedded file streams; this method is called only when the user actually clicks to download. The lookup order is the attachment name in the name tree first, then the annotation-local attachment. A FileAttachment annotation does not go through the name tree, and getAttachmentIdForAnnotation generates a stable id for its reference:

  getAttachmentIdForAnnotation(ref, isSound = false) {
    let id = this.#annotationAttachmentIdByRef.get(ref);
    if (!id) {
      const baseId = `attachmentRef:${ref.toString()}`;
      id = baseId;
 
      let i = 1;
      while (
        this.#annotationAttachmentRefById.has(id) ||
        this.attachments?.has(id)
      ) {
        id = `${baseId}-${i++}`;
      }
 
      this.#annotationAttachmentIdByRef.put(ref, id);
      this.#annotationAttachmentRefById.set(id, ref);
    }
    if (isSound) {
      this.#soundAttachmentIds.add(id);
    }
    return id;
  }

The id base is attachmentRef: plus the reference string. After generation it checks for collisions with existing ids or name-tree attachment names and appends -1, -2 if needed. Annotation attachment content is not cached, because a cache would have to survive cleanup and that is expensive; only the reference is kept, and every content fetch calls xref.fetch again. When isSound is true the stream holds raw sound samples, and soundStreamToWav wraps a WAV header around them; if wrapping fails it falls back to the raw bytes.

rawEmbeddedFiles and xfaImages are two adjacent name-tree reads:

  get rawEmbeddedFiles() {
    const obj = this.#catDict.get("Names");
    if (!(obj instanceof Dict) || !obj.has("EmbeddedFiles")) {
      return null;
    }
    const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref);
    return nameTree.getAll(/* isRaw = */ true);
  }
 
  get xfaImages() {
    const obj = this.#catDict.get("Names");
    let xfaImages = null;
 
    if (obj instanceof Dict && obj.has("XFAImages")) {
      const nameTree = new NameTree(obj.getRaw("XFAImages"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        if (value instanceof BaseStream) {
          xfaImages ??= new Map();
          xfaImages.set(
            stringToPDFString(key, /* keepEscapeSequence = */ true),
            value.getBytes()
          );
        }
      }
    }
    return shadow(this, "xfaImages", xfaImages);
  }

rawEmbeddedFiles is for the editor; isRaw makes it return the raw key-value pairs instead of parsed file specifications. xfaImages is the XFA image name tree: keys are image names and values must be streams; getBytes() decodes a stream into a byte array for the XFA layout engine. Non-stream values are skipped, so one bad entry cannot invalidate the whole image set.

AcroForm and XFA Boundaries

Catalog only provides the acroForm dictionary and acroFormRef; it does not own the field tree. Actual field filling happens in annotation.js: WidgetAnnotation starts from /AcroForm /Fields, inherits attributes such as /FT, /V and /DA along /Parent, and writes values into the annotation's display state. PDFDocument reads /AcroForm to build formInfo, determining whether fields, AcroForm, XFA and signatures exist.

XFA handling is likewise not in Catalog. PDFDocument._xfaStreams reads /AcroForm /XFA, which may be a single stream or an array of paired names and streams; supported keys include xdp:xdp, template, datasets, config, connectionSet, localeSet and stylesheet. xfaData decodes those streams into strings, and xfaFactory builds the layout factory only when enableXfa, needsRendering, hasXfa and !hasAcroForm all hold. In other words, Catalog only answers whether the document is pure XFA; parsing and layout of XFA are the responsibility of the xfa/ directory.

Summary: Structure Tree, Metadata, Mark Info and Permissions

These getters were covered above; one table summarises how they map to the specification:

GetterSpecification entryPDF.js handlingCaller use
structTreeRoot14.7.2 Structure hierarchyBuilds StructTreeRoot, reads RoleMap and ParentTreeAccessibility, content extraction, annotation structure
hasStructTree14.7 Tagged PDFOnly checks whether the key existsLightweight signal for GetMetadata
metadata14.3.2 Metadata streamsDecodes XMP, MetadataParser repairs bad XMLDocument information, title and author
markInfo14.7.1 Mark informationThree boolean keys into a MapJudge the trustworthiness of a tagged PDF
permissions7.6.4 Standard security handlerReads /Encrypt /P, converts to unsigned and tests bitsUI disabling of printing, copying, etc.
acroForm12.7.2 Interactive form dictionaryReturns a non-empty dictionary or nullField tree, form rendering
collection12.3.5 Portable collectionsReturns a non-empty dictionary or nullAttachment collection view

Design note: The boundary of Catalog around attachments and forms is very clear: it only finds the entry point and does not expand the content. Attachment metadata goes through FileSpec, content goes through attachmentContent, and only sound streams get WAV wrapping; the form dictionary is exposed as is, while field inheritance and value updates live in the annotation layer; the structure tree only builds the root object, and subtrees are expanded per page in StructTreePage. This boundary keeps Catalog manageable and lets each kind of data have its own caching policy.

Optional Content Groups (OCG)

An optional content group is a layer. The specification places this mechanism under /OCProperties: /OCGs is an array of all groups in the document, /D is the default configuration, and the configuration uses /ON, /OFF, /Order and /RBGroups to describe initial visibility, hierarchy and mutual exclusion.

  get optionalContentConfig() {
    let config = null;
    try {
      const properties = this.#catDict.get("OCProperties");
      if (!properties) {
        return shadow(this, "optionalContentConfig", null);
      }
      const defaultConfig = properties.get("D");
      if (!defaultConfig) {
        return shadow(this, "optionalContentConfig", null);
      }
      const groupsData = properties.get("OCGs");
      if (!Array.isArray(groupsData)) {
        return shadow(this, "optionalContentConfig", null);
      }
      const groupRefCache = new RefMap();
      // Ensure all the optional content groups are valid.
      for (const groupRef of groupsData) {
        if (!(groupRef instanceof Ref) || groupRefCache.has(groupRef)) {
          continue;
        }
        groupRefCache.put(groupRef, this.#readOptionalContentGroup(groupRef));
      }
      config = this.#readOptionalContentConfig(defaultConfig, groupRefCache);
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn(`Unable to read optional content config: ${ex}`);
    }
    return shadow(this, "optionalContentConfig", config);
  }

All three of /OCProperties, /D and /OCGs must be present. /OCGs must be an array; non-reference elements and duplicate references in the loop are skipped, and only valid group references are parsed. groupRefCache is a RefMap whose key is the group reference and whose value is the group object; the later config parsing needs to look groups up by reference, so storing only reference strings would not be enough.

#readOptionalContentGroup

  #readOptionalContentGroup(groupRef) {
    const group = this.xref.fetch(groupRef);
    const obj = {
      id: groupRef.toString(),
      name: null,
      intent: null,
      usage: {
        print: null,
        view: null,
      },
      rbGroups: [],
    };
 
    const name = group.get("Name");
    if (typeof name === "string") {
      obj.name = stringToPDFString(name);
    }
 
    let intent = group.getArray("Intent");
    if (!Array.isArray(intent)) {
      intent = [intent];
    }
    if (intent.every(i => i instanceof Name)) {
      obj.intent = intent.map(i => i.name);
    }
 
    const usage = group.get("Usage");
    if (!(usage instanceof Dict)) {
      return obj;
    }
    const usageObj = obj.usage;
 
    const print = usage.get("Print");
    if (print instanceof Dict) {
      const printState = print.get("PrintState");
      if (printState instanceof Name) {
        switch (printState.name) {
          case "ON":
          case "OFF":
            usageObj.print = { printState: printState.name };
        }
      }
    }

The group's output structure has five fields: id is the reference string, name is the display name, intent is the usage array, usage holds print and view state, and rbGroups is the set of groups mutually exclusive with it. In the specification Intent may be omitted, a single name or an array; the source normalises it to an array before checking and keeps it only when every item is a Name. The Print and View states under Usage accept only ON and OFF; other values are ignored. ViewState is handled a few lines later with identical logic.

#readOptionalContentConfig

The core of config parsing is three local functions: parseOnOff, parseOrder and parseRBGroups.

const xref = this.xref,
  parsedOrderRefs = new RefSet(),
  MAX_NESTED_LEVELS = 10;
 
parseRBGroups(config.get('RBGroups'));
 
return {
  name: typeof config.get('Name') === 'string' ? stringToPDFString(config.get('Name')) : null,
  creator:
    typeof config.get('Creator') === 'string' ? stringToPDFString(config.get('Creator')) : null,
  baseState: config.get('BaseState') instanceof Name ? config.get('BaseState').name : null,
  on: parseOnOff(config.get('ON')),
  off: parseOnOff(config.get('OFF')),
  order: parseOrder(config.get('Order')),
  groups: [...groupRefCache],
};

parseOnOff turns references in the /ON or /OFF arrays into id strings, keeping only groups present in groupRefCache. parseRBGroups runs first because it must write the mutual-exclusion relations into each group's rbGroups before /Order is parsed. groups is spread from groupRefCache, producing an array of reference and group-object pairs and preserving the entry shape of the RefMap.

parseOrder handles hierarchy and hidden groups:

function parseOrder(refs, nestedLevels = 0) {
  if (!Array.isArray(refs)) {
    return null;
  }
  const order = [];
 
  for (const value of refs) {
    if (value instanceof Ref && groupRefCache.has(value)) {
      parsedOrderRefs.put(value); // Handle "hidden" groups, see below.
 
      order.push(value.toString());
      continue;
    }
    // Handle nested /Order arrays (see e.g. issue 9462 and bug 1240641).
    const nestedOrder = parseNestedOrder(value, nestedLevels);
    if (nestedOrder) {
      order.push(nestedOrder);
    }
  }
 
  if (nestedLevels > 0) {
    return order;
  }
  const hiddenGroups = [];
  for (const [groupRef] of groupRefCache.items()) {
    if (parsedOrderRefs.has(groupRef)) {
      continue;
    }
    hiddenGroups.push(groupRef.toString());
  }
  if (hiddenGroups.length) {
    order.push({ name: null, order: hiddenGroups });
  }
 
  return order;
}

/Order is a mixed array: an element may be a group reference, or a nested array whose first element is a group name and whose remaining elements are sub-arrays. References become id strings directly; nested arrays go to parseNestedOrder. parsedOrderRefs records every group that appears in /Order. After the outermost traversal, groups that never appeared are collected into hiddenGroups and appended as a virtual node whose name is null. The viewer can therefore show groups that exist but were not declared in the order, instead of hiding them.

function parseNestedOrder(ref, nestedLevels) {
  if (++nestedLevels > MAX_NESTED_LEVELS) {
    warn('parseNestedOrder - reached MAX_NESTED_LEVELS.');
    return null;
  }
  const value = xref.fetchIfRef(ref);
  if (!Array.isArray(value)) {
    return null;
  }
  const nestedName = xref.fetchIfRef(value[0]);
  if (typeof nestedName !== 'string') {
    return null;
  }
  const nestedOrder = parseOrder(value.slice(1), nestedLevels);
  if (!nestedOrder?.length) {
    return null;
  }
  return { name: stringToPDFString(nestedName), order: nestedOrder };
}

Nesting is hard-limited to 10 levels. The counter is incremented before comparison, so anything beyond 10 levels produces a warning and null. The first element of a nested array must be a string (the group name), and the remaining elements are parsed by parseOrder. A result is returned only when non-empty; an empty array never creates an empty node.

Config keyOutput fieldRule
/NamenameKept only when a string; PDF string is decoded
/CreatorcreatorSame as above
/BaseStatebaseStateMust be a Name
/ONonArray of references, filtered to existing groups
/OFFoffSame as above
/OrderorderMix of references and nested arrays; hidden groups appended
/RBGroupsrbGroups on each groupMutually exclusive sets, written into every member
/OCGsgroupsArray of references and group objects

Design note: OCG parsing is build the group index first, then parse relationships. Both /RBGroups and /Order need to look groups up by reference, so every group under /OCGs is parsed into a RefMap first, and the config that follows only matches references. Filling in hidden groups for /Order is a pragmatic choice beyond the specification: the specification allows a group not to appear in the order, but if the viewer simply ignored it, the user could never see that layer. Appending such groups at the end keeps the ordering semantics while ensuring every group is visible. The nesting limit is a hard defence against broken files, preventing deep nesting from blowing the worker stack.

Destination Parsing: parseDestDictionary

Outlines, link annotations and OpenAction all have to answer the same question: where does this dictionary actually point? parseDestDictionary is the single answer. It takes a dictionary containing destination information and writes the result into a resultObj supplied by the caller.

Entry Points and Priority

  static parseDestDictionary({
    destDict,
    resultObj,
    docBaseUrl = null,
    docAttachments = null,
  }) {
    if (!(destDict instanceof Dict)) {
      warn("parseDestDictionary: `destDict` must be a dictionary.");
      return;
    }
 
    let action = destDict.get("A"),
      url,
      dest;
    if (!(action instanceof Dict)) {
      if (destDict.has("Dest")) {
        // A /Dest entry should *only* contain a Name or an Array, but some bad
        // PDF generators ignore that and treat it as an /A entry.
        action = destDict.get("Dest");
      } else {
        action = destDict.get("AA");
        if (action instanceof Dict) {
          if (action.has("D")) {
            // MouseDown
            action = action.get("D");
          } else if (action.has("U")) {
            // MouseUp
            action = action.get("U");
          }
        }
      }
    }

The lookup order is /A, /Dest, /AA. /A is a normal action dictionary; /Dest should contain only a name or an array, but broken producers treat it as an action dictionary, so the source feeds it into the action branch as well; /AA is the additional-actions dictionary, where D (mouse down) is preferred over U (mouse up). All three paths end up as a single action value.

If action is a dictionary, it must have /S and it must be a Name:

    if (action instanceof Dict) {
      const actionType = action.get("S");
      if (!(actionType instanceof Name)) {
        warn("parseDestDictionary: Invalid type in Action dictionary.");
        return;
      }
      const actionName = actionType.name;
 
      switch (actionName) {

The action types fall into ten categories, each handled differently:

/SSpecification actionParse resultActual PDF.js behaviour
ResetFormReset formresetFormCollects field names and references; bit 1 of Flags selects include or exclude
URIOpen URIurl, unsafeUrlA Name gets a leading slash, then goes through safe-URL validation
GoToJump within the documentdestTakes /D, parsed uniformly afterwards
LaunchStart an external programurl, newWindowSupports only file paths pointing to other PDFs
GoToRJump to a remote documenturl, newWindowFile spec or string as URL, with the remote destination appended
GoToEJump to an embedded fileattachmentId, attachment, attachmentDestFinds the attachment name through /T /R /C /N
NamedNamed actionactionTakes the name in /N
SetOCGStateToggle layer statesetOCGStateParses ON, OFF, Toggle and group references
JavaScriptExecute scripturl, newWindowTries to recover a URL from the script, otherwise ignores it
SubmitFormSubmit formNoneRequires scripting support; silently ignored

Launch and GoToR share one code path, a rare case in the specification where two actions share the file-specification design. In GoToE's Target dictionary, when /R is /C and /N is a string, /N is the attachment name; attachmentId and attachment are written only when docAttachments contains it, otherwise the code only warns. In SetOCGState's /State array, names accept only ON, OFF and Toggle, and references become id strings; if any element is invalid, the whole action is dropped so that half a state change is never executed.

        case "URI":
          url = action.get("URI");
          if (url instanceof Name) {
            // Some bad PDFs do not put parentheses around relative URLs.
            url = "/" + url.name;
          }
          break;
 
        case "GoTo":
          dest = action.get("D");
          break;
 
        case "Launch":
        // We neither want, nor can, support arbitrary 'Launch' actions.
        // However, in practice they are mostly used for linking to other PDF
        // files, which we thus attempt to support (utilizing `docBaseUrl`).
        /* falls through */
 
        case "GoToR":
          const urlDict = action.get("F");
          if (urlDict instanceof Dict) {
            url = new FileSpec(urlDict).filename;
          } else if (typeof urlDict === "string") {
            url = urlDict;
          } else {
            break;
          }
 
          // NOTE: the destination is relative to the *remote* document.
          const remoteDest = fetchRemoteDest(action);
          if (remoteDest) {
            // NOTE: We don't use the `updateUrlHash` function here, since
            // the `createValidAbsoluteUrl` function (see below) already handles
            // parsing/validation of the final URL and manual splitting also
            // ensures that the `unsafeUrl` property will be available/correct.
            url = /* baseUrl = */ url.split("#", 1)[0] + "#" + remoteDest;
          }
          // The 'NewWindow' property, equal to `LinkTarget.BLANK`.
          const newWindow = action.get("NewWindow");
          if (typeof newWindow === "boolean") {
            resultObj.newWindow = newWindow;
          }
          break;

fetchRemoteDest encodes a remote destination into the URL fragment: if /D is a name, the name string is used; if it is an explicit array, JSON.stringify is used. url.split("#", 1)[0] removes any existing fragment before the new destination is appended, so a URL never contains two # characters. NewWindow is kept only when it is a boolean and means open in a new window.

Final Unification: URL and dest

After the action is handled, the source processes url and dest together:

if (typeof url === 'string') {
  const absoluteUrl = createValidAbsoluteUrl(url, docBaseUrl, {
    addDefaultProtocol: true,
    tryConvertEncoding: true,
  });
  if (absoluteUrl) {
    resultObj.url = absoluteUrl.href;
  }
  resultObj.unsafeUrl = url;
}
if (dest) {
  if (dest instanceof Name) {
    dest = dest.name;
  }
  if (typeof dest === 'string') {
    resultObj.dest = stringToPDFString(dest, /* keepEscapeSequence = */ true);
  } else if (isValidExplicitDest(dest)) {
    resultObj.dest = dest;
  }
}

createValidAbsoluteUrl allows only safe protocols (HTTP, HTTPS, mail and so on); addDefaultProtocol turns a protocol-less address into HTTP, and tryConvertEncoding handles PDFDocEncoding. resultObj.url is written only after validation passes; whether it passes or not, the original string is written to unsafeUrl, so the caller can show what a link looks like without navigating automatically. If dest is a Name, its name is taken; if it is a string, it is decoded as a named destination; if it is an array, it must pass explicit-destination validation.

The validation rules for an explicit destination are:

Second item such as /XYZArgument countIs null allowed?
XYZ2 or 3Yes
Fit, FitB0No
FitH, FitBH, FitV, FitBV0 or 1Yes
FitR4No
Anything elseAnyValidation fails

The first item may be a reference or an integer, and the second must be a Name. For Fit and FitB, args.length must be exactly 0; for FitR it must be exactly 4; all other arguments must be numbers or an allowed null. This validation guarantees that the destination array the viewer receives is always interpretable, so individual jump targets do not need to repeat the checks.

SE: Deriving a Destination from a Structure Element

If none of the earlier branches produced dest, url, action, attachment, setOCGState or resetForm, the source also tries /SE:

if (
  !resultObj.dest &&
  !resultObj.url &&
  !resultObj.action &&
  !resultObj.attachment &&
  !resultObj.setOCGState &&
  !resultObj.resetForm
) {
  const seRef = destDict.getRaw('SE');
  if (seRef instanceof Ref) {
    try {
      const seDest = Catalog.#getDestFromStructElement(destDict.xref, seRef);
      if (seDest) {
        resultObj.dest = seDest;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info('SE parsing failed.');
    }
  }
}

/SE is a structure-element reference, commonly found in accessible links: the target is not written directly in the annotation but expressed as a jump to wherever a certain structure element sits. #getDestFromStructElement first looks for the structure element's own /Pg; if not found, it walks downward in BFS through /K children until a child carries /Pg; if still not found, it walks upward along the /P parent chain, up to 40 levels, stopping at a /StructTreeRoot. Once a page reference is found, it reads the element's /A /BBox attribute and uses the top-left corner as the x and y of an XYZ destination:

let x = null,
  y = null;
const attrs = seDict.get('A');
if (attrs instanceof Dict) {
  const bbox = lookupRect(attrs.getArray('BBox'), null);
  if (bbox) {
    x = bbox[0];
    y = bbox[3]; // top of the bbox in PDF page coordinates
  }
}
 
return [pageRef, { name: 'XYZ' }, x, y, null];

BBox must be exactly four numbers for lookupRect to return an array; bbox[0] is the left edge and bbox[3] is the top edge. The returned array is a standard XYZ destination: page reference, zoom type, x, y and zoom factor. A zoom factor of null means keep the current zoom.

Design note: parseDestDictionary is where semantic convergence is most visible in this article. It funnels ten action types, four destination forms, three entry points (/A, /Dest, /AA) and one structure-element fallback into a handful of fields: url, unsafeUrl, dest, action, attachment, setOCGState and resetForm. The caller does not need to know whether the original PDF wrote GoToR or Launch; it only needs to know whether there is a safe URL and whether there is a jump target. Security policy is also centralised here: unsafeUrl always keeps the original text, while url is written only after validation, so a bad link is never executed automatically.

Caching, Lazy Loading, Fault Tolerance and Cleanup

Half of Catalog's performance comes from lazy loading and half from caching. Lazy loading is provided by shadow and by fetching an object only when needed; caching is provided by the fields and the page-tree indexes; fault tolerance is provided by a uniform exception-handling pattern.

cleanup

  async cleanup(manuallyTriggered = false) {
    clearGlobalCaches();
    this.globalColorSpaceCache.clear();
    this.globalImageCache.clear(/* onlyData = */ manuallyTriggered);
    this.pageKidsCountCache.clear();
    this.pageIndexCache.clear();
    this.pageDictCache.clear();
    this.nonBlendModesSet.clear();
 
    for (const { dict } of await Promise.all(this.fontCache)) {
      delete dict.cacheKey;
    }
    this.fontCache.clear();
    this.builtInCMapCache.clear();
    this.standardFontDataCache.clear();
    this.systemFontCache.clear();
  }

Cleanup has three layers:

LayerClearedNot clearedReason
GlobalPattern, glyph, Unicode and WASM image cachesModule constantsShared across documents and must be reclaimed together
Page treepageKidsCountCache, pageIndexCache, pageDictCache#catDictPage dictionaries may hold large streams; the indexes can be rebuilt
Fonts and colourFonts, CMaps, standard fonts, system fonts, colour spaces, imagesRoot dictionary, name-tree resultsRendering caches can be rebuilt; document structure must not be lost

globalImageCache.clear(manuallyTriggered) clears image data only during a manual cleanup; automatic cleanup keeps decoded images, so switching pages does not throw away images that are already decoded. fontCache is a RefMap, and iteration yields a reference and a font object; the source destructures { dict } from the font object and deletes dict.cacheKey, cutting the link between the font dictionary and its cache key so old font objects cannot keep referencing the cache. await Promise.all(this.fontCache) waits for every font object to finish parsing before clearing, avoiding cleanup racing with parsing.

The Uniform Pattern for Lazy Loading and Fault Tolerance

Almost every getter in Catalog looks like this:

  get something() {
    let value = null;
    try {
      value = this.#readSomething();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read something.");
    }
    return shadow(this, "something", value);
  }

This is not duplicated code but a deliberate error classification:

ExceptionHandlingResult
MissingDataExceptionRethrown unchangedCaught by PDFManager.ensure, which calls requestRange and retries
FormatErrorWarn and return a defaultA broken entry does not affect the whole document
Other runtime exceptionsWarn and return a defaultDegrades in the same way as FormatError
Circular referenceThrow FormatErrorCycles in page trees, outlines and structure trees must fail explicitly

MissingDataException must not be swallowed, because it does not mean the data is broken but that the data has not arrived yet. PDFManager.ensure on NetworkPdfManager is implemented by catching it, requesting the corresponding byte range, and retrying the same property. If Catalog turned it into a default value, a chunked stream would never receive the missing bytes, and the page tree would return an empty result on first access.

The lifetime of pageDictCache also deserves a note: it stores promises, not dictionaries. getPageDict writes promises during top-level prefetching, and later requests hit the same promise. After cleanup empties it, the next request calls xref.fetchAsync again; because XRef's object cache still exists, the refetch usually hits the cache and does not re-parse bytes.

Design note: Fault tolerance in Catalog is not try/catch everywhere but layered degradation. When the page tree is broken, getPageDict throws, PDFDocument.checkLastPage catches it and calls getAllPageDicts to rebuild the page map; when one getter is broken, only that getter is affected and it returns null or an empty collection; when chunked data is missing, the exception is rethrown unchanged and PDFManager fetches the data and retries. Each layer handles only the errors it can handle, and none swallows information the layer above needs. This is the fundamental reason Catalog can still open damaged PDFs.

Conclusion

Catalog is PDF.js's document-model entry layer: upward it provides document-level semantics to PDFDocument, Page, PartialEvaluator and the worker messages; downward it retrieves the root dictionary and various indirect objects through XRef. It does not parse content or draw; it only organises the entries in /Root into stable JavaScript structures.

Where It Sits in the Overall Chain

StageModuleInputOutput
Byte supplyPDFManager, StreamLocal file, network chunksBaseStream
Object addressingXRefstartxref, xref sections, RefDict, arrays, streams
Document modellingCatalog/Root root dictionaryPage tree, name tree, outline, attachments, forms, OCG, structure tree
Page modelPDFDocument, PagePage dictionaries, document-level configurationPage, resources, annotations, operatorList
Content evaluationPartialEvaluatorPage content streamoperatorList

In one sentence: XRef turns bytes into objects, Catalog turns objects into document semantics, and PDFDocument and Page turn semantics into renderable pages.

Six Design Principles

  1. The root dictionary is the only entry point, and caches are split by responsibility. Every getter starts from #catDict, but the caches are layered into page-tree indexes, fonts, colour spaces, images and name-tree results. Each layer has its own cleanup policy, avoiding a single Map that holds everything and the all-or-nothing cleanup that would follow.
  2. Lazy loading plus memoisation. shadow turns a getter into a compute-once, read-forever data property; page dictionaries, name trees and colours are parsed only when they are actually accessed. Opening a document does not need to expand every attachment, every layer or every bookmark, only the parts the viewer currently needs.
  3. The page tree must be fault-tolerant, but cycles must fail. When /Count cannot be trusted, getAllPageDicts walks everything and setActualNumPages records the real count; inline page dictionaries, missing /Type and bad /Kids all have fallbacks; but a circular reference always throws FormatError, because a cycle cannot be solved by skipping and would make the walk run forever.
  4. New structures win, old structures fill gaps. Name trees take precedence over the legacy /Dests dictionary; page trees, destinations and OCG configuration all follow standard form first, compatibility form as fallback. This lets PDF.js read both old PDF 1.1 files and new files with object streams, name trees and XFA.
  5. Destination parsing is unified and security validation is centralised. Outlines, links and OpenAction all go through parseDestDictionary; action types, destination forms, URL validation and the structure-element fallback are implemented only once. url is written only after validation, and unsafeUrl always keeps the original text, which is the security baseline of never executing an untrusted link automatically.
  6. Classify errors instead of swallowing them. MissingDataException is rethrown so PDFManager can fetch the missing data; FormatError and bad values degrade to defaults; circular references fail explicitly. Each layer handles only its own errors, which is how a document can still open when it is damaged or still loading in chunks.

At this point the Catalog link is complete: it turns objects into document semantics. The next article continues downward into the page model and annotation layer, looking at how PDFDocument and Page organise page dictionaries, resources and annotations into drawable content.

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