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

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.jsin the current repository. It relies heavily on property getters (for exampleversion,pageLabels,documentOutline) rather than methods such asgetVersion()andgetPageLabels(); the only explicit page-related methods aregetPageDict,getAllPageDictsandgetPageIndex. ThegetMarkedContentIdToPage,getAutoPrint,getXfa,getNeedsRendering,getCollection,getAcroForm,getOutline,getAttachmentsandgetPermissionsmethods common in older versions or other branches do not exist here: theneedsRendering,collection,acroForm,documentOutline,attachmentsandpermissionsgetters cover six of those capabilities; XFA data is read inPDFDocument's_xfaStreamsandxfaData, and form-field filling lives inannotation.js; this version has nogetMarkedContentIdToPageor 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:
| Block | Key members | Responsibility |
|---|---|---|
| Root and caches | constructor, #catDict, cloneDict, the various cache fields | Hold the root dictionary and provide document-level shared caches |
| Scalar switches | version, lang, needsRendering | Read the file version, natural language and pure-XFA rendering switch |
| Forms and permissions | acroForm, acroFormRef, permissions, markInfo, metadata | Expose the interactive form dictionary, permission bits, mark information and XMP metadata |
| Page tree | toplevelPagesDict, numPages, getPageDict, getAllPageDicts, getPageIndex | Walk the /Pages tree, locate page dictionaries and resolve page indices |
| Page labels | rawPageLabels, pageLabels, #readPageLabels | Expand the /PageLabels number tree into a display label per page |
| Named destinations | destinations, getDestination, #readDests | Read the name tree and the legacy /Dests dictionary |
| Viewer behaviour | pageLayout, pageMode, viewerPreferences, openAction, baseUrl | Read layout, mode, preferences and the base URL used when the document opens |
| Outline and attachments | documentOutline, documentOutlineForEditor, attachments, attachmentContent, rawEmbeddedFiles, xfaImages | Build the bookmark tree, attachment list and XFA images |
| Scripts and OCG | jsActions, optionalContentConfig, structTreeRoot | Document-level actions, optional content groups and the structure-tree entry point |
| Destination parsing | parseDestDictionary, #getDestFromStructElement | Normalise 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:
| Kind | Examples | Cached? | Typical caller |
|---|---|---|---|
| Property getter | numPages, pageLabels, documentOutline | Mostly frozen into data properties by shadow | pdfManager.ensureCatalog |
| Synchronous query method | getDestination, attachmentContent | Relies on internal caches but does not freeze itself | Worker message handling |
| Asynchronous method | getPageDict, getAllPageDicts, getPageIndex, cleanup | Hit through pageDictCache and pageIndexCache | PDFDocument |
| Static utility | parseDestDictionary | Stateless, reused for extra destination parsing | Outline, 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 messagesCatalog 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();| Field | Structure | Lifetime and role |
|---|---|---|
#catDict | Dict | Root dictionary; starting point for every getter |
#actualNumPages | Integer or null | Real page count obtained by walking the whole file when the page-tree /Count cannot be trusted |
#annotationAttachmentIdByRef | RefMap | Maps annotation attachment references to stable ids |
#annotationAttachmentRefById | Map | Maps stable ids back to references, so attachmentContent can fetch on demand |
#soundAttachmentIds | Set | Marks attachments whose stream is sound, to be wrapped as WAV when fetched |
builtInCMapCache | Map | Built-in CMap cache, indexed by encoding name |
fontCache | RefMap | Font dictionary cache, indexed by font Ref |
globalColorSpaceCache | GlobalColorSpaceCache | Colour-space cache shared across document tasks |
globalImageCache | GlobalImageCache | Image cache, cleared on demand during cleanup |
nonBlendModesSet | RefSet | Graphic-state references already confirmed not to use a blend mode |
pageDictCache | RefMap | Page dictionary cache whose values may be promises, used for parallel prefetching |
pageIndexCache | RefMap | Page reference to page index cache |
pageKidsCountCache | RefMap | Page-tree node to descendant page count cache |
standardFontDataCache | Map | Standard font data cache |
systemFontCache | Map | System 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:RefMapfor pages and fonts keyed by reference; plainMapfor name strings;Setfor flags; global cache classes for cross-task sharing. The benefit of the split is thatcleanupcan decide exactly what to clear and what to keep. For example,pageDictCachemust be cleared because a page dictionary may hold decoded streams, while#catDictmust 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:
| Constant | Bit | Specification meaning |
|---|---|---|
PRINT | 0x04 | Printing allowed |
MODIFY_CONTENTS | 0x08 | Modifying content allowed |
COPY | 0x10 | Copying text and graphics allowed |
MODIFY_ANNOTATIONS | 0x20 | Adding and modifying annotations and form fields allowed |
FILL_INTERACTIVE_FORMS | 0x100 | Filling existing form fields allowed |
COPY_FOR_ACCESSIBILITY | 0x200 | Copying for accessibility allowed |
ASSEMBLE | 0x400 | Inserting, rotating and deleting pages allowed |
PRINT_HIGH_QUALITY | 0x800 | High-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.
baseUrl: The Base for Relative Links
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:MissingDataExceptionmust be rethrown unchanged, because the chunked network stream has not received those bytes yet andPDFManager.ensurewill callrequestRangeand 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
getPageDictis not a fixed O(n); it depends on whether/Countcan 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.pageDictCachestores promises rather than dictionaries, which matters: parallel prefetching happens within one event loop, multiple requests share the same promise, andxref.fetchAsyncis 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
| Operation | Input | Output | Direction | Caches | Typical use |
|---|---|---|---|---|---|
getPageDict | Page index | [page dict, reference] | Root to leaf, LIFO stack | pageKidsCountCache, pageDictCache, pageIndexCache | Opening a page normally |
getAllPageDicts | None | Map<index, [dict or error, reference]> | Root to leaf, explicit stack frames | pageIndexCache | Full recovery when /Count is untrustworthy |
getPageIndex | Page reference | Page index | Leaf to root | pageIndexCache | Annotations, 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
/Pagestree can be extremely deep in a broken file, and recursion would overflow the stack; an explicit stack also letsgetAllPageDictskeep resumable state such as which child is being processed. Another key point is deduplication at the reference level:visitedNodesis aRefSet, not aSet<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.RefSetstores 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 value | Output | Examples |
|---|---|---|
D | Decimal Arabic numerals | 1, 2, 3, 10 |
R | Uppercase Roman numerals | I, II, III, X |
r | Lowercase Roman numerals | i, ii, iii, x |
A | Uppercase letters, repeating after 26 | A, B, Z, AA, AB |
a | Lowercase letters, repeating after 26 | a, b, z, aa, ab |
| Missing | Empty string | Prefix 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:
rawPageLabelsandpageLabelsare 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.shadowfreezes only the expanded view; the raw view callsgetAll()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 form | Source handling | Specification |
|---|---|---|
| Explicit array | Validated directly by isValidExplicitDest | 12.3.2.2 Explicit Destinations |
Dictionary with /D | fetchDest takes /D, then validates | Wrapped destination dictionary form |
| Name string | A key in destinations, mapped to an array | 12.3.2.3 Named Destinations |
| Name tree | NameTree.getAll() expands /Names | 7.9.6 Name Dictionaries |
Legacy /Dests dictionary | Iterated directly, lower priority | PDF 1.1 compatibility |
Design note: The merge order in
destinationsexpresses a compatibility principle: new structures win, old structures fill gaps. The name tree is the standard from PDF 1.2 onward; the legacy/Destsdictionary only takes effect when the former has no key of the same name.getDestinationkeeps 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 inCatalog.
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.
| Getter | Entry | Valid values | Default | Why this default |
|---|---|---|---|---|
pageLayout | /PageLayout | SinglePage, OneColumn, TwoColumnLeft, TwoColumnRight, TwoPageLeft, TwoPageRight | Empty string | Distinguishes undeclared from explicit single page |
pageMode | /PageMode | UseNone, UseOutlines, UseThumbs, FullScreen, UseOC, UseAttachments | UseNone | It 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:
viewerPreferencesparses 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.Catalogacts 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:
| Key | DocumentActionEventType | Event meaning |
|---|---|---|
WC | WillClose | The document is about to close |
WS | WillSave | The document is about to be saved |
DS | DidSave | The document has been saved |
WP | WillPrint | The document is about to print |
DP | DidPrint | The 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
openActionandjsActionsexpress the idea of reusing one parser.OpenActiondoes not inspect/Sitself; it disguises itself as/Aand delegates toparseDestDictionary. JavaScript does not walk/AAitself; it uses the genericcollectActions. Adding or removing an action type therefore only touches one place, and every entry point benefits. The cost is that the returned shape ofopenActionis restricted to thedestoractionkey, 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:
| Field | Source | Meaning |
|---|---|---|
title | /Title | PDF string, decoded for display |
color | /C | Three components, default black |
count | /Count | Visible child count when expanded; kept only when an integer |
bold | bit 2 of /F | Bold |
italic | bit 1 of /F | Italic |
dest | parseDestDictionary | Explicit destination array or named destination |
url | parseDestDictionary | External link, after security validation |
action | parseDestDictionary | Named action, for example NextPage |
items | Child nodes | Tree 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
processedsolves both depth and cycles. Another detail is the separation of destination parsing from structure parsing:#readDocumentOutlineonly builds the tree shape, whileparseDestDictionaryhandles destinations, URLs, attachment jumps and named actions uniformly. The outline, link annotations andOpenActiontherefore 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:
| Getter | Specification entry | PDF.js handling | Caller use |
|---|---|---|---|
structTreeRoot | 14.7.2 Structure hierarchy | Builds StructTreeRoot, reads RoleMap and ParentTree | Accessibility, content extraction, annotation structure |
hasStructTree | 14.7 Tagged PDF | Only checks whether the key exists | Lightweight signal for GetMetadata |
metadata | 14.3.2 Metadata streams | Decodes XMP, MetadataParser repairs bad XML | Document information, title and author |
markInfo | 14.7.1 Mark information | Three boolean keys into a Map | Judge the trustworthiness of a tagged PDF |
permissions | 7.6.4 Standard security handler | Reads /Encrypt /P, converts to unsigned and tests bits | UI disabling of printing, copying, etc. |
acroForm | 12.7.2 Interactive form dictionary | Returns a non-empty dictionary or null | Field tree, form rendering |
collection | 12.3.5 Portable collections | Returns a non-empty dictionary or null | Attachment collection view |
Design note: The boundary of
Catalogaround attachments and forms is very clear: it only finds the entry point and does not expand the content. Attachment metadata goes throughFileSpec, content goes throughattachmentContent, 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 inStructTreePage. This boundary keepsCatalogmanageable 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 key | Output field | Rule |
|---|---|---|
/Name | name | Kept only when a string; PDF string is decoded |
/Creator | creator | Same as above |
/BaseState | baseState | Must be a Name |
/ON | on | Array of references, filtered to existing groups |
/OFF | off | Same as above |
/Order | order | Mix of references and nested arrays; hidden groups appended |
/RBGroups | rbGroups on each group | Mutually exclusive sets, written into every member |
/OCGs | groups | Array of references and group objects |
Design note: OCG parsing is build the group index first, then parse relationships. Both
/RBGroupsand/Orderneed to look groups up by reference, so every group under/OCGsis parsed into aRefMapfirst, and the config that follows only matches references. Filling in hidden groups for/Orderis 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:
/S | Specification action | Parse result | Actual PDF.js behaviour |
|---|---|---|---|
ResetForm | Reset form | resetForm | Collects field names and references; bit 1 of Flags selects include or exclude |
URI | Open URI | url, unsafeUrl | A Name gets a leading slash, then goes through safe-URL validation |
GoTo | Jump within the document | dest | Takes /D, parsed uniformly afterwards |
Launch | Start an external program | url, newWindow | Supports only file paths pointing to other PDFs |
GoToR | Jump to a remote document | url, newWindow | File spec or string as URL, with the remote destination appended |
GoToE | Jump to an embedded file | attachmentId, attachment, attachmentDest | Finds the attachment name through /T /R /C /N |
Named | Named action | action | Takes the name in /N |
SetOCGState | Toggle layer state | setOCGState | Parses ON, OFF, Toggle and group references |
JavaScript | Execute script | url, newWindow | Tries to recover a URL from the script, otherwise ignores it |
SubmitForm | Submit form | None | Requires 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 /XYZ | Argument count | Is null allowed? |
|---|---|---|
XYZ | 2 or 3 | Yes |
Fit, FitB | 0 | No |
FitH, FitBH, FitV, FitBV | 0 or 1 | Yes |
FitR | 4 | No |
| Anything else | Any | Validation 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:
parseDestDictionaryis 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,setOCGStateandresetForm. The caller does not need to know whether the original PDF wroteGoToRorLaunch; it only needs to know whether there is a safe URL and whether there is a jump target. Security policy is also centralised here:unsafeUrlalways keeps the original text, whileurlis 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:
| Layer | Cleared | Not cleared | Reason |
|---|---|---|---|
| Global | Pattern, glyph, Unicode and WASM image caches | Module constants | Shared across documents and must be reclaimed together |
| Page tree | pageKidsCountCache, pageIndexCache, pageDictCache | #catDict | Page dictionaries may hold large streams; the indexes can be rebuilt |
| Fonts and colour | Fonts, CMaps, standard fonts, system fonts, colour spaces, images | Root dictionary, name-tree results | Rendering 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:
| Exception | Handling | Result |
|---|---|---|
MissingDataException | Rethrown unchanged | Caught by PDFManager.ensure, which calls requestRange and retries |
FormatError | Warn and return a default | A broken entry does not affect the whole document |
| Other runtime exceptions | Warn and return a default | Degrades in the same way as FormatError |
| Circular reference | Throw FormatError | Cycles 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
Catalogis not try/catch everywhere but layered degradation. When the page tree is broken,getPageDictthrows,PDFDocument.checkLastPagecatches it and callsgetAllPageDictsto rebuild the page map; when one getter is broken, only that getter is affected and it returnsnullor an empty collection; when chunked data is missing, the exception is rethrown unchanged andPDFManagerfetches 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 reasonCatalogcan 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
| Stage | Module | Input | Output |
|---|---|---|---|
| Byte supply | PDFManager, Stream | Local file, network chunks | BaseStream |
| Object addressing | XRef | startxref, xref sections, Ref | Dict, arrays, streams |
| Document modelling | Catalog | /Root root dictionary | Page tree, name tree, outline, attachments, forms, OCG, structure tree |
| Page model | PDFDocument, Page | Page dictionaries, document-level configuration | Page, resources, annotations, operatorList |
| Content evaluation | PartialEvaluator | Page content stream | operatorList |
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
- 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. - Lazy loading plus memoisation.
shadowturns 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. - The page tree must be fault-tolerant, but cycles must fail. When
/Countcannot be trusted,getAllPageDictswalks everything andsetActualNumPagesrecords the real count; inline page dictionaries, missing/Typeand bad/Kidsall have fallbacks; but a circular reference always throwsFormatError, because a cycle cannot be solved by skipping and would make the walk run forever. - New structures win, old structures fill gaps. Name trees take precedence over the legacy
/Destsdictionary; 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. - Destination parsing is unified and security validation is centralised. Outlines, links and
OpenActionall go throughparseDestDictionary; action types, destination forms, URL validation and the structure-element fallback are implemented only once.urlis written only after validation, andunsafeUrlalways keeps the original text, which is the security baseline of never executing an untrusted link automatically. - Classify errors instead of swallowing them.
MissingDataExceptionis rethrown soPDFManagercan fetch the missing data;FormatErrorand 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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026