StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: struct_tree

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: struct_tree

Introduction

This article covers the worker-side src/core/struct_tree.js, which corresponds to the marked-content and logical-structure facilities in the PDF specification: /StructTreeRoot and /MarkInfo under the document catalog, /StructParents on pages, BMC, BDC and EMC in content streams, and /StructParent on annotations.

The previous articles all dealt with visible things: XRef fetches objects, Parser cuts tokens, and PartialEvaluator turns a content stream into drawing instructions and text items. This article deals with something invisible but mandatory — reading order, semantic roles, and which element is a heading and which is a table. A screen reader does not read pixels; it reads this tree. Browser find, selection and read-aloud, and a PDF editor's "mark this text as H2", all depend on it as well.

In one sentence, this layer's job is: StructTreeRoot is document-level metadata and indexing (role map, class map, parent tree), StructTreePage is a single-page view builder (it assembles only the structure elements relevant to this page), StructElementNode and StructElement are the node model, and serializable is the cross-thread output contract.

One premise has to be stated up front: a structure tree is not required. Most scans, old files, and PDFs produced by print drivers have no structure tree at all. When Catalog cannot find /StructTreeRoot, the whole chain quietly returns null and rendering proceeds as usual. That is also why this file is full of early returns for "if it is missing, bail out" — it is an enhancement, not a requirement.

Module Overview

struct_tree.js is 1143 lines long and splits into six blocks by responsibility:

BlockKey membersResponsibility
Root and mappingsStructTreeRoot construction, roleMap, parentTree, structParentIds, getKidPositionParse /RoleMap and /ParentTree, record how annotations map to pages
Page constructionStructTreePage's parse, collectObjects, addNode, addTopLevelNodePull this page's structure elements out of the root, ordered by the root's kid positions
Node modelStructElementNode, StructElement, parseKids, parseKidTranslate integers, MCR, OBJR and dictionaries inside K into a uniform node
Attributesrole, attributes, #collectAttributes, tableAttributes, mathMLRole normalization, class attribute merging, table and formula specific attributes
Write-backcanCreateStructureTree, createStructureTree, canUpdateStructTree, updateStructureTreeCreate or update tags when annotations are edited, staging the changes into changes
Internal write-back tools#writeKids, #writeProperties, #collectParents, #updateParentTagBuild tag dictionaries, find parents, insert kids in order
Outputserializable, nodeToSerializableFlatten the tree into plain object literals and send them to the main thread

Where it sits in the chain:

Catalog.#readStructTreeRoot        (read /StructTreeRoot, construct StructTreeRoot)
  → StructTreeRoot                 (RoleMap / ParentTree / structParentIds)
  → PDFPage.getStructTree          (document.js, waits for annotation parsing first)
  → StructTreePage.parse(pageRef)  (pick only this page's nodes)
  → addNode / addTopLevelNode      (walk up to parents, order by K index)
  → serializable                   (role, alt, lang, bbox, children id)
  → worker message                 (plain objects, structured-cloneable)
  → StructTreeLayerBuilder         (web/struct_tree_layer_builder.js, builds the DOM)
  → text layer / annotation layer  (link content and annotations by id)

The Catalog side is only a dozen lines, and it is worth reading first because it decides the entry conditions for this tree:

  #readStructTreeRoot() {
    const rawObj = this.#catDict.getRaw("StructTreeRoot"),
      obj = this.xref.fetchIfRef(rawObj);
    return obj instanceof Dict
      ? new StructTreeRoot(this.xref, obj, rawObj)
      : null;
  }

The outer getter also wraps this in try and shadow; only the core is kept here. Two points are worth noting. First, getRaw keeps the original reference, so StructTreeRoot can obtain its own ref; the write-back path needs it as the parent reference of the root node, and without a reference nothing can be updated. Second, MissingDataException must be rethrown as is, because the structure tree may live in a file that is still loading in chunks; the data is simply not there yet, and that must not be treated as "there is no structure tree".

Version note: This article follows exactly the version of struct_tree.js in the current repository. This version has no findNode, RootNode, StructContentNode or #parseParentTree; those names belong to older versions or other implementations. The parent tree is delegated directly to NumberTree, the tree root is a plain object literal at serialization time (with role set to "Root"), and a content node is a StructElement rather than a separate class. /IDTree is not read in this file; only /ID is used, for the structId of TH. When reading the source, treat the real structure described here as authoritative.

Root Object: Constructing StructTreeRoot

StructTreeRoot has few fields, but each one maps to a class of facility in the specification:

class StructTreeRoot {
  kidRefToPosition = undefined;
 
  parentTree = null;
 
  roleMap = new Map();
 
  structParentIds = null;
 
  constructor(xref, rootDict, rootRef) {
    this.xref = xref;
    this.dict = rootDict;
    this.ref = rootRef instanceof Ref ? rootRef : null;
 
    const roleMap = rootDict.get("RoleMap");
    if (roleMap instanceof Dict) {
      for (const [key, value] of roleMap) {
        if (value instanceof Name) {
          this.roleMap.set(key, value.name);
        }
      }
    }
    const parentTree = rootDict.getRaw("ParentTree");
    if (parentTree) {
      this.parentTree = new NumberTree(parentTree, xref);
    }
  }
FieldSourceRole
xrefconstructor argumentFetch dictionaries and streams by reference later
dictconstructor argumentThe root dictionary itself, containing /K, /RoleMap and /ParentTree
refconstructor argument; empty when not a RefUsed as the parent reference on write-back; without it nothing can update
roleMap/RoleMapMapping from custom role names to standard role names
parentTree/ParentTreeParent tree; look up a structure element or array by integer key
structParentIdsFilled at runtime by annotation parsingIncremental index from page reference to annotation tag ids
kidRefToPositionFirst call to getKidPositionRoot kid references to indices, used for stable ordering

roleMap parsing is deliberately conservative: only entries whose value is a Name are accepted. The specification requires /RoleMap values to be name objects, but corrupt files may contain strings or dictionaries, and those are skipped rather than guessed at.

parentTree uses getRaw, because NumberTree handles references and the Kids hierarchy internally. As an aside, NumberTree lookup is not a linear scan: it first binary-searches by /Limits, then binary-searches the even indices of the Nums array, and finally restores the value with fetchIfRef:

  get(key) {
    return this.xref.fetchIfRef(this.getRaw(key));
  }

getRaw may return a reference or a direct object. That distinction matters in page construction: parse uses get and receives dereferenced dictionaries, while collectObjects uses getRaw and keeps the references themselves, because write-back needs the reference numbers.

The getKidPosition Method

The order of the root's kids is not naturally given by array indices, because the parent chain of a structure element may cross pages and levels. PDF.js takes this approach: the order of the root dictionary's /K is the reading-order baseline for the whole document.

  getKidPosition(kidRef) {
    if (this.kidRefToPosition === undefined) {
      const obj = this.dict.get("K");
      if (Array.isArray(obj)) {
        const map = (this.kidRefToPosition = new Map());
        for (let i = 0, ii = obj.length; i < ii; i++) {
          const ref = obj[i];
          if (ref) {
            map.set(ref.toString(), i);
          }
        }
      } else if (obj instanceof Dict) {
        this.kidRefToPosition = new Map([[obj.objId, 0]]);
      } else if (!obj) {
        this.kidRefToPosition = new Map();
      } else {
        this.kidRefToPosition = null;
      }
    }
    return this.kidRefToPosition
      ? (this.kidRefToPosition.get(kidRef) ?? NaN)
      : -1;
  }

The return value carries three different meanings, and callers must tell them apart:

Return valueMeaning
Non-negative intIndex of the reference inside the root /K, used directly as a nodes index
NaNThe root has /K, but this element is not in it (cross-page or orphan node)
-1The shape of the root /K is not recognized (neither array nor dictionary); unsupported

this.dict.get("K") dereferences, so the array still holds the original elements (which may be Refs or direct dictionaries). ref.toString() produces exactly the "12 0 R" form, which matches the dict.objId passed in by StructTreePage.addNode. String keys are used rather than Ref objects because a Ref may be newly created on each fetch and has no stable identity; strings have value semantics.

The kidRefToPosition = null state is interesting: it means "the root has /K, but its shape cannot be understood". In that case getKidPosition returns -1, addTopLevelNode returns false, and the node is removed from the map — the whole page's structure tree is abandoned rather than raising an error. That matches this layer's general tone: an optional enhancement that degrades to no structure tree on failure.

#addIdToPage and addAnnotationIdToPage

The structure tree has two entry channels: MCID in content streams goes through the page's /StructParents, and annotations go through their own /StructParent. The latter is only known at runtime, because annotations are constructed one by one after the page is parsed.

  #addIdToPage(pageRef, id, type, objId) {
    if (!(pageRef instanceof Ref) || id < 0) {
      return;
    }
    (this.structParentIds ??= new RefMap())
      .getOrPutComputed(pageRef, makeArr)
      .push([id, type, objId]);
  }
 
  addAnnotationIdToPage(pageRef, id, ref) {
    this.#addIdToPage(
      pageRef,
      id,
      StructElementType.ANNOTATION,
      ref instanceof Ref ? ref.toString() : null
    );
  }

The annotation constructor calls it:

if (annotationGlobals.structTreeRoot) {
  let structParent = dict.get('StructParent');
  this.data.structParent = structParent =
    Number.isInteger(structParent) && structParent >= 0 ? structParent : -1;
 
  annotationGlobals.structTreeRoot.addAnnotationIdToPage(params.pageRef, structParent, this.ref);
}

Note the id < 0 check: when structParent is missing it is normalized to -1, so the record never enters structParentIds. A page without a reference or an annotation without a reference is likewise dropped silently. None of these failures affect annotation rendering; they only affect whether the annotation enters the accessibility tree.

structParentIds uses RefMap rather than Map, keyed by page reference; the value is an array of triples [id, type, objId]. type currently has only one value, ANNOTATION, and objId is the reference string of the annotation object. There is exactly one reason it exists: during page-level parsing, /StructParents alone cannot find documents whose tags exist only on annotations. The pages in such documents may have no /StructParents, but their annotations have /StructParent, and the parent tree has matching entries.

Design note: RoleMap and ParentTree are document structure, read once at construction; structParentIds is runtime observation, accumulated as annotations are parsed. Keeping the two apart means the structure tree can be established early (page rendering does not have to wait for all annotations), while annotations can in turn add to the tree's contents. If annotation ids were forced into the ParentTree parsing flow, every annotation in the document would have to be parsed first, and page rendering would be held up by the annotation count.

Roles and Classes: RoleMap, ClassMap and Attribute Normalization

PDF structure elements express their role with /S, which in theory should be one of the standard structure types such as P, H1, Table or Figure. In practice, however, many files come from Word, InDesign or LaTeX and write custom names like MyHeading, Heading1 or Para. The specification's answer is /RoleMap: custom names mapped to standard names.

  get role() {
    const nameObj = this.dict.get("S");
    const name = nameObj instanceof Name ? nameObj.name : "";
    const { root } = this.tree;
    return root.roleMap.get(name) ?? name;
  }

There is exactly one level of mapping, with no recursive expansion. The specification also requires /RoleMap values to be standard types and forbids pointing them at another custom name, so no chained lookup happens here. When /S is missing or is not a name, an empty string is returned; every later switch and mapping then naturally falls through to "unknown role" without throwing.

The standard structure types can be roughly grouped by level:

LevelStandard types
DocumentDocument, Part, Art, Sect, Div, BlockQuote, Caption, TOC, TOCI, Index
GroupingNonStruct, Private, Quote, Note, Reference, BibEntry, Code, Span
BlockP, H1 to H6, Title, FENote, Sub
InlineLbl, Em, Strong, Link, Annot, Form, Ruby, RB, RT, RP, Warichu, WT, WP
IllustrationFigure, Formula
TableTable, TR, TH, TD, THead, TBody, TFoot
ListL, LI, LBody

PDF.js performs no whitelist validation here, for a practical reason: the specification's standard types have been extended many times over the years (for example Ruby and Warichu were added later), so a hardcoded whitelist would degrade new files into unknown roles. The place that actually needs semantics is the main thread: web/struct_tree_layer_builder.js contains a PDF_ROLE_TO_HTML_ROLE table that maps P to paragraph, Table to table, splits H2 into heading plus aria-level by regex, and degrades unknown roles to a plain span. Normalization lives at the consumer side; the parsing side is only responsible for faithful reading.

The attributes and #collectAttributes Methods

A structure element's attributes are more complicated than its role. Attributes can come from two places:

  1. Class names referenced by /C, looked up in /ClassMap for attribute objects;
  2. Attribute objects attached directly to /A, which may be dictionaries, arrays, or even streams.

The specification also allows revision-number integers to be interleaved in an attribute array, indicating the revision from which an attribute takes effect. PDF.js takes all of them: integers are skipped, and everything else is pushed in source order. When merging, class attributes come first and explicit attributes come after, so the latter override the former.

  #collectAttributes(value, attributes) {
    const pending = [value];
    const visited = new RefSet();
 
    while (pending.length > 0) {
      value = pending.pop();
      if (value instanceof Ref) {
        // Only indirect references can make a PDF attribute graph cyclic.
        if (visited.has(value)) {
          continue;
        }
        visited.put(value);
        value = this.xref.fetch(value);
      }
      if (value instanceof BaseStream) {
        value = value.dict;
      }
      if (value instanceof Dict) {
        attributes.push(value);
        continue;
      }
      if (!Array.isArray(value)) {
        continue;
      }
 
      // Process entries in source order. Attribute arrays may interleave
      // attribute objects and revision numbers.
      for (let i = value.length - 1; i >= 0; i--) {
        if (!Number.isInteger(value[i])) {
          pending.push(value[i]);
        }
      }
    }
  }

This is one of the few explicitly iterative traversals in the file, and it is worth reading line by line:

  • pending is used as a stack, so recursion depth is not affected by the attribute graph;
  • visited records only indirect references, because only references can form cycles. Directly nested dictionaries form a finite tree and cannot loop;
  • streams are downgraded to their dictionaries, because attributes hang off streams as dictionaries;
  • arrays are pushed in reverse order, so popping yields source order. The comment explicitly says "Process entries in source order", because override behaviour depends on the order;
  • integers are skipped and never treated as attribute values.

visited uses RefSet, keyed by reference number plus generation, not object identity. The same object pointed to by different Refs will be visited once more, but that does not cause a misjudgement; it is just one extra fetch, and fetch has a cache.

  get attributes() {
    const attributes = [];
 
    const classes = this.dict.getArray("C");
    if (classes !== undefined) {
      const classMap = this.tree.rootDict?.get("ClassMap");
      if (classMap instanceof Dict) {
        // Class names may be interleaved with revision numbers.
        for (const className of Array.isArray(classes) ? classes : [classes]) {
          if (className instanceof Name) {
            this.#collectAttributes(
              classMap.getRaw(className.name),
              attributes
            );
          }
        }
      }
    }
 
    // Explicit attributes take precedence over attributes from a class.
    this.#collectAttributes(this.dict.getRaw("A"), attributes);
 
    return shadow(this, "attributes", attributes);
  }

getArray("C") wraps a non-array value in a single-element array, which is why Array.isArray is checked again below. classMap.getRaw takes the raw value, because attribute objects can be shared by multiple elements and may exist as references.

Finally shadow freezes the result into an own property. shadow's job is to replace the getter with a data property so later accesses skip the function call; it also means attributes are computed once, and later in-document attribute changes will not be reflected here. For read-only rendering that is an optimization; for editing write-back the clone path has to be used instead.

The tableAttributes Method

Table attributes are the most practically useful part of the structure tree: screen readers need scope, headers and rowSpan to read tables correctly. PDF.js translates them from PDF attribute objects into short keys:

const TABLE_SPAN_ATTRIBUTES = [
  ['RowSpan', 'rowSpan'],
  ['ColSpan', 'colSpan'],
];
  get tableAttributes() {
    const { role } = this;
    if (role !== "Table" && role !== "TH" && role !== "TD") {
      return null;
    }
 
    const map = new Map();
    for (const attributes of this.attributes) {
      if (!isName(attributes.get("O"), "Table")) {
        continue;
      }
      // ...
    }
    return map.size ? map : null;
  }
PDF keyApplicable rolesConditionSerialized keyValue
SummaryTableNon-empty stringsummaryDecoded PDF string
RowSpanTH, TDInteger greater than 1rowSpanInteger
ColSpanTH, TDInteger greater than 1colSpanInteger
HeadersTH, TDAt least one string in the arrayheadersArray of strings
ScopeTHName equal to Row, Column or BothscopeThe original name
ShortTHNon-empty stringshortDecoded PDF string

One easily overlooked detail: invalid values delete an existing key. For example, if a class attribute supplied RowSpan 2 and an explicit attribute writes RowSpan 1, the code executes map.delete("rowSpan") rather than keeping the class value of 2. That is the correct override semantics — an explicit attribute of 1 is saying "do not span rows". Likewise, an illegal Scope value or an empty Headers array clears the value that came from the class.

O must be Table, which is the attribute owner name. Attributes with other owners (for example Layout) are ignored, to avoid mistaking layout attributes for table semantics.

Node Model: StructElement and Kid Parsing

There are only two kinds of nodes in the tree: structure elements (with a role and children) and content items (pointing at a marked-content sequence in a content stream, or at an annotation object). The source uses a very light class for the latter:

const StructElementType = {
  PAGE_CONTENT: 1,
  STREAM_CONTENT: 2,
  OBJECT: 3,
  ANNOTATION: 4,
  ELEMENT: 5,
};
class StructElement {
  constructor({ type, dict = null, mcid = null, pageObjId = null, refObjId = null }) {
    this.type = type;
    this.dict = dict;
    this.mcid = mcid;
    this.pageObjId = pageObjId;
    this.refObjId = refObjId;
    this.parentNode = null;
  }
}

parentNode is not a parent chain from the PDF, but an upward pointer filled in during construction. When addNode walks up to find the parent, it locates the entry in the parent's kids that points at the current dictionary and sets that entry's parentNode to the current element; serialization relies on this pointer to walk down from any child node.

StructElementNode Construction and parseKids

class StructElementNode {
  constructor(tree, dict) {
    this.tree = tree;
    this.xref = tree.xref;
    this.dict = dict;
    this.kids = [];
    this.parseKids();
  }

A node parses K at construction time, with no laziness. The reason is that a page-level structure tree only picks this page's nodes anyway, so the count is bounded; laziness would only add another asynchronous check to the serialization stage.

  parseKids() {
    let pageObjId = null;
    const objRef = this.dict.getRaw("Pg");
    if (objRef instanceof Ref) {
      pageObjId = objRef.toString();
    }
    const kids = this.dict.get("K");
    if (Array.isArray(kids)) {
      for (const kid of kids) {
        const element = this.parseKid(pageObjId, this.xref.fetchIfRef(kid));
        if (element) {
          this.kids.push(element);
        }
      }
    } else {
      const element = this.parseKid(pageObjId, kids);
      if (element) {
        this.kids.push(element);
      }
    }
  }

/Pg is an inherited attribute: when a structure element does not specify a page, its content children inherit the page of the parent element. The parent's pageObjId is taken first as the default for parseKid; if a kid carries its own /Pg, parseKid overrides it.

K allows two shapes: a single value or an array. The specification permits omitting the array for a single kid, and that branch exists for exactly that reason.

The parseKid Method

  parseKid(pageObjId, kid) {
    // A direct link to content, the integer is an mcid.
    if (Number.isInteger(kid)) {
      if (this.tree.pageDict.objId !== pageObjId) {
        return null;
      }
 
      return new StructElement({
        type: StructElementType.PAGE_CONTENT,
        mcid: kid,
        pageObjId,
      });
    }
 
    if (!(kid instanceof Dict)) {
      return null;
    }
 
    const pageRef = kid.getRaw("Pg");
    if (pageRef instanceof Ref) {
      pageObjId = pageRef.toString();
    }
 
    const type = kid.get("Type") instanceof Name ? kid.get("Type").name : null;
    if (type === "MCR") {
      if (this.tree.pageDict.objId !== pageObjId) {
        return null;
      }
      const kidRef = kid.getRaw("Stm");
      return new StructElement({
        type: StructElementType.STREAM_CONTENT,
        refObjId: kidRef instanceof Ref ? kidRef.toString() : null,
        pageObjId,
        mcid: kid.get("MCID"),
      });
    }
 
    if (type === "OBJR") {
      if (this.tree.pageDict.objId !== pageObjId) {
        return null;
      }
      const kidRef = kid.getRaw("Obj");
      return new StructElement({
        type: StructElementType.OBJECT,
        refObjId: kidRef instanceof Ref ? kidRef.toString() : null,
        pageObjId,
      });
    }
 
    return new StructElement({
      type: StructElementType.ELEMENT,
      dict: kid,
    });
  }

How kid shapes map to node types:

Raw kid shapeNode typePreconditionSerialized output
IntegerPAGE_CONTENTOwning page equals current pagetype: "content", id is page plus MCID
Dictionary with /Type /MCRSTREAM_CONTENTOwning page equals current pageSame as above; /Stm does not reach the output
Dictionary with /Type /OBJROBJECTOwning page equals current pagetype: "object", id is the object reference
Any other dictionaryELEMENTNoneRecursed into as a child role
Annotation (not from K; injected by parse)ANNOTATIONRegistered during annotation parsingtype: "annotation", with an internal prefix
Number, name, string, boolean, nullNoneAlways droppedNone

Page filtering is the most important line here:

if (this.tree.pageDict.objId !== pageObjId) {
  return null;
}

A structure tree is document-level, so a single P can hold content from page 2 and page 5 at the same time. StructTreePage serves a single page, so every content item whose pageObjId differs from the current page dictionary's object number is dropped. As a result, the same structure element sees a different subset of kids when it is built on different pages — StructTreePage is a projection, not the full tree.

MCR's /Stm is an optional content stream reference. In the specification it binds a content item to the stream of a particular form XObject; the current serialization does not use refObjId and only outputs p plus the page object number, _mc, and the MCID. The reason is direct: the DOM ids of the text layer and annotation layer are generated from the page and the MCID, with no stream dimension. If the same MCID appeared in different streams of one page, ids would theoretically collide, but the specification requires MCIDs to be unique within a page, so the code chooses to trust its input.

Design note: parseKid compresses four specification shapes into five type constants by dispatching on "raw shape first, then /Type". The order of the integer and dictionary checks, and of the MCR and OBJR checks, cannot be swapped: MCR is a dictionary and OBJR is a dictionary, so only /Type separates them; and integers must be checked before dictionaries, otherwise they fall into the instanceof Dict branch. Every unrecognized value returns null and is filtered out by parseKids, with no exception path.

Page-Level Structure Tree: StructTreePage

StructTreePage is the centre of the file. Its comment is clear:

/**
 * Instead of loading the whole tree we load just the page's relevant structure
 * elements, which means we need a wrapper structure to represent the tree.
 */

That is this layer's performance strategy: do not load the whole tree, load only the parts relevant to this page.

class StructTreePage {
  constructor(structTreeRoot, pageDict) {
    this.root = structTreeRoot;
    this.xref = structTreeRoot?.xref ?? null;
    this.rootDict = structTreeRoot?.dict ?? null;
    this.pageDict = pageDict;
    this.nodes = [];
  }

The constructor accepts an empty structTreeRoot, in which case xref and rootDict are both null and every later method returns early. This "constructible even when empty" design saves callers from null checks everywhere.

The parse Method

  parse(pageRef) {
    if (!this.root || !this.rootDict || !(pageRef instanceof Ref)) {
      return;
    }
 
    const { parentTree } = this.root;
    if (!parentTree) {
      return;
    }
    const id = this.pageDict.get("StructParents");
    const ids = this.root.structParentIds?.get(pageRef);
    if (!Number.isInteger(id) && !ids) {
      return;
    }
 
    const map = new Map();
 
    if (Number.isInteger(id)) {
      const parentArray = parentTree.get(id);
      if (Array.isArray(parentArray)) {
        for (const ref of parentArray) {
          if (ref instanceof Ref) {
            this.addNode(this.xref.fetch(ref), map);
          }
        }
      }
    }
 
    if (!ids) {
      return;
    }
    for (const [elemId, type, objId] of ids) {
      const obj = parentTree.get(elemId);
      if (!obj) {
        continue;
      }
      const elem = this.addNode(this.xref.fetchIfRef(obj), map);
      if (!elem || !objId) {
        continue;
      }
      // Match the annotation by object reference because its structure
      // element may have other children.
      for (const kid of elem.kids) {
        if (kid.type === StructElementType.OBJECT && kid.refObjId === objId) {
          kid.type = type;
        }
      }
    }
  }

This code merges the two entry channels into a single build:

  1. Page content: /StructParents is an integer key; use it to fetch an array from ParentTree, containing references to all the top-level structure elements on this page. Fetch each one and call addNode.
  2. Annotations: iterate this page's records in structParentIds, and use the annotation's StructParent number to fetch the structure element from ParentTree. Once found, locate the entry in that element's kids whose type is OBJECT and whose Obj equals the annotation reference, then change its type from OBJECT to ANNOTATION.

The comment on the second point explains why the change cannot be made by number alone: a structure element may contain several annotations or content items at once. For example, a Link element may hold two OBJR children pointing at two link annotations; only the reference number can match one of them precisely. Changing the type rather than creating a new node also guarantees that the same element appears only once during serialization.

The collectObjects Method

The write-back path needs a mapping from "tag id to tag dictionary reference", and collectObjects is its read-only version:

  collectObjects(pageRef) {
    if (!this.root || !this.rootDict || !(pageRef instanceof Ref)) {
      return null;
    }
 
    const parentTree = this.rootDict.get("ParentTree");
    if (!parentTree) {
      return null;
    }
    const ids = this.root.structParentIds?.get(pageRef);
    if (!ids) {
      return null;
    }
    const map = new Map();
    const numberTree = new NumberTree(parentTree, this.xref);
 
    for (const [elemId] of ids) {
      const obj = numberTree.getRaw(elemId);
      if (obj instanceof Ref) {
        map.set(elemId, obj);
      }
    }
    return map;
  }

Both require pageRef to be a Ref. A direct object has no stable number and cannot be located during write-back, so such pages simply give up structure tree editing. parse takes dereferenced nodes, while collectObjects preserves the references themselves; that is the most essential difference between the two.

The addNode Method

addNode is the heart of construction and the only place that recurses downward and then backtracks upward:

  addNode(dict, map, level = 0) {
    if (level > MAX_DEPTH) {
      warn("StructTree MAX_DEPTH reached.");
      return null;
    }
    if (!(dict instanceof Dict)) {
      return null;
    }
 
    if (map.has(dict)) {
      return map.get(dict);
    }
 
    const element = new StructElementNode(this, dict);
    map.set(dict, element);
    switch (element.role) {
      case "L":
      case "LBody":
      case "LI":
      case "Table":
      case "THead":
      case "TBody":
      case "TFoot":
      case "TR": {
        // Always collect all child nodes of lists and tables, even empty ones
        for (const kid of element.kids) {
          if (kid.type === StructElementType.ELEMENT) {
            this.addNode(kid.dict, map, level - 1);
          }
        }
      }
    }
 
    const parent = dict.get("P");
 
    if (
      !(parent instanceof Dict) ||
      isName(parent.get("Type"), "StructTreeRoot")
    ) {
      if (!this.addTopLevelNode(dict, element)) {
        map.delete(dict);
      }
      return element;
    }
 
    const parentNode = this.addNode(parent, map, level + 1);
    if (!parentNode) {
      return element;
    }
    let save = false;
    for (const kid of parentNode.kids) {
      if (kid.type === StructElementType.ELEMENT && kid.dict === dict) {
        kid.parentNode = element;
        save = true;
      }
    }
    if (!save) {
      map.delete(dict);
    }
    return element;
  }

The execution order is worth unpacking:

  1. Depth gate: beyond 40 levels, warn and return null. The check sits at the entry point, preventing malicious or corrupt files from creating extremely deep parent chains.
  2. Deduplication: map keys are dictionary object identity. When the same dictionary is referenced by several parent elements (not allowed by the specification, but it happens in practice), it is built only once and the existing node is returned. This also breaks cycles: a second visit around a cycle hits the map directly.
  3. Build the node first, then fill in lists and tables downward: new StructElementNode has already parsed kids, but only lists and tables proactively collect empty containers. level - 1 is deliberate: this recursion does not consume the depth budget, because these containers are usually deep but structurally fixed. The comment says it plainly: "even empty ones" — an empty TR must be kept, otherwise the table's row and column structure would lose cells.
  4. Walk up to the parent: dict.get("P") is dereferenced to get the parent dictionary. If the parent is missing, or its type is StructTreeRoot, the current node is top-level and is handed to addTopLevelNode to be placed by its index in the root /K. If it cannot be placed (returns false), it is removed from map — orphan nodes do not enter the tree.
  5. Recurse into the parent: level + 1. If building the parent fails (returns null, usually because of the depth limit), the current node is still returned, but is no longer attached to its parent.
  6. Establish the upward pointer: iterate the parent's kids, find the entry with type ELEMENT and the same dictionary identity, and point its parentNode at the current element. If none is found, the parent's K does not contain this child (inconsistent data), and the node is likewise removed from map.

The map.delete(dict) in step 6 is easily misread as "destroying the node". It only removes the dictionary from the "already built" index, so that a later visit through another path can build it again. What really decides whether a node reaches the output is whether it is chained by a parent's kids, or whether it entered this.nodes.

The addTopLevelNode Method

  addTopLevelNode(dict, element) {
    const index = this.root.getKidPosition(dict.objId);
    if (isNaN(index)) {
      return false;
    }
    if (index !== -1) {
      this.nodes[index] = element;
    }
    return true;
  }

The difference between NaN and -1 shows up here:

  • NaN: the root has /K, but the element is not in it. Return false, and the caller deletes the node.
  • -1: the shape of the root /K is not recognized. It still returns true, so the node stays in map but does not enter nodes. That means it can only be reached through another parent's kids; if it is genuinely top-level, it will never appear in the output.

Using the root /K index directly as the nodes array index has the benefit that serialization naturally follows reading order with no extra sorting. The cost is that nodes may have many empty slots (some top-level elements in the root /K belong to other pages), so serialization must skip empty values explicitly.

Design note: addNode does three things at once — fill lists and tables downward, walk the parent chain upward, and deduplicate sideways. It looks coupled, but all three share the same map: downward so that empty containers still get nodes, upward to fill in parentNode pointers, sideways to prevent cycles. The order cannot be shuffled, or nodes get built twice or parent pointers get missed. map.delete is not a failure marker but "this one did not attach; it may be retried later". The key precondition for a local tree is ParentTree: without it there is only traversal from the root, which is why parse checks parentTree first thing.

Serialization: serializable and the Cross-Thread Contract

The structure tree is built in the Worker and consumed on the host thread. Object references cannot cross that boundary, only structured-cloneable plain data. serializable is that boundary:

  get serializable() {
    function nodeToSerializable(node, parent, level = 0) {
      if (level > MAX_DEPTH) {
        warn("StructTree too deep to be fully serialized.");
        return;
      }
      const obj = Object.create(null);
      obj.role = node.role;
      obj.children = [];
      parent.children.push(obj);
      let alt = node.dict.get("Alt");
      if (typeof alt !== "string") {
        alt = node.dict.get("ActualText");
      }
      if (typeof alt === "string") {
        obj.alt = stringToPDFString(alt);
      }
      // ...
    }

Object.create(null) is not fastidiousness but a safety measure: PDFs can contain roles or attributes named __proto__ or constructor, and prototype-less objects avoid prototype pollution. This pattern runs through the whole serialization function.

The root node is a plain literal, not a class:

const root = Object.create(null);
root.children = [];
root.role = 'Root';
for (const child of this.nodes) {
  if (!child) {
    continue;
  }
  nodeToSerializable(child, root);
}
return root;

That is where RootNode used to live in older versions. The current implementation needs it to carry no logic at all, just a container with role set to "Root" and children as an array. The main thread's #walk treats it as an ordinary node and ends up with a top-level span.

Serialization Fields

FieldSourceConditionNotes
role/S normalized through RoleMapAlways presentUnknown roles pass through unchanged
childrenProjection of kidsAlways present, possibly an empty arrayChild nodes or content items
alt/Alt, falling back to /ActualTextValue is a stringDecoded readable text
structId/IDRole is TH and the value is non-emptyStable identifier for table headers, deliberately not id
summaryTable attribute SummaryRole is TableTable summary
rowSpanTable attribute RowSpanGreater than 1Row span
colSpanTable attribute ColSpanGreater than 1Column span
headersTable attribute HeadersNon-empty array of stringsAssociated header ids
scopeTable attribute ScopeRow, Column or BothHeader scope
shortTable attribute ShortNon-empty stringHeader abbreviation
mathMLAF attachment or MSFT_MathMLRole is Formula and parsing succeedsMathML text of the formula
bboxAttribute BBox, falling back to Width and HeightAt least one is usableElement bounding box
lang/LangValue is a stringLanguage tag

The comment about structId is worth quoting verbatim:

// Note that this must not be called `id`, since that name is already
// used, with a different meaning, on the content/object/annotation
// children below.
const structId = node.dict.get('ID');
if (obj.role === 'TH' && typeof structId === 'string' && structId) {
  obj.structId = stringToPDFString(structId);
}

Within the same object, id is already taken by content items (content items use strings like p1R_mc5, annotations use an internal prefix). If structure elements were also called id, the main thread could not tell "this is an element" from "this is a content anchor". The naming conflict here is a semantic conflict, not a style issue. /IDTree is likewise not read by this tree: it is maintained only during editor write-back, and the read side needs only the element's own /ID.

The Three Kinds of Child Output

for (const kid of node.kids) {
  const kidElement = kid.type === StructElementType.ELEMENT ? kid.parentNode : null;
  if (kidElement) {
    nodeToSerializable(kidElement, obj, level + 1);
    continue;
  } else if (
    kid.type === StructElementType.PAGE_CONTENT ||
    kid.type === StructElementType.STREAM_CONTENT
  ) {
    obj.children.push({
      type: 'content',
      id: `p${kid.pageObjId}_mc${kid.mcid}`,
    });
  } else if (kid.type === StructElementType.OBJECT) {
    obj.children.push({
      type: 'object',
      id: kid.refObjId,
    });
  } else if (kid.type === StructElementType.ANNOTATION) {
    obj.children.push({
      type: 'annotation',
      id: `${AnnotationPrefix}${kid.refObjId}`,
    });
  }
}
Node typeOutput typeid formHow consumers use it
ELEMENTRecursed into a childNoneContinue building the DOM
PAGE_CONTENTcontentp plus page object number, plus _mc, plus MCIDAligns with the id of the text layer's marked-content container
STREAM_CONTENTcontentSame as aboveSame as above; /Stm is ignored
OBJECTobjectObject reference stringHanded to the annotation layer to claim
ANNOTATIONannotationpdfjs_internal_id_ plus the reference stringAligns with the id of the annotation layer element

The ELEMENT branch has a trap: it uses kid.parentNode, not kid.dict to rebuild directly. In other words, only children that addNode successfully attached a parent pointer to are serialized. If addNode failed to attach one because of depth or inconsistent data, that child silently disappears. This is intentional: better to lose one child than to output a tree with a broken link.

OBJECT and ANNOTATION differ only in type and prefix. Why does an annotation need an extra prefix? Because the DOM element id in the annotation layer is AnnotationPrefix plus the reference number, and the structure tree must produce the same string so the main thread can find it with getElementById or querySelector. An OBJECT, on the other hand, may be a form field, an XObject or some other object with no corresponding DOM element, so the bare reference number is kept and the upper layer decides how to handle it.

Integration with the Display-Layer Text Layer

The text layer can choose to emit marked-content items during getTextContent:

const mcid = args[1] instanceof Dict ? args[1].get('MCID') : null;
textContent.items.push({
  type: 'beginMarkedContentProps',
  id: Number.isInteger(mcid) ? `${self.idFactory.getPageObjId()}_mc${mcid}` : null,
  tag: args[0] instanceof Name ? args[0].name : null,
});

And getPageObjId is implemented as:

      static getPageObjId() {
        return `p${ref.toString()}`;
      }

So the text layer id takes the form p1R_mc5, exactly matching p${kid.pageObjId}_mc${kid.mcid} in the structure tree. When building the DOM, the main thread's StructTreeLayerBuilder finds the span in the text layer by these ids: for a single content child it pushes the attributes down onto the parent element, and for formula nodes it hides the corresponding text item so the screen reader does not read the MathML and the raw text twice.

LayerProducerSample idHow it links
Structure treeserializablep1R_mc5As the id of a content child
Text layerPartialEvaluator marked-content branchp1R_mc5beginMarkedContentProps
Annotation layerAnnotationLayerpdfjs_internal_id_12RThe annotation element's own id
Structure tree annotationserializablepdfjs_internal_id_12RAs the id of an annotation child

This table explains why serializable goes to the trouble of assembling strings: across threads there is no object identity, only agreed strings. If either side changes the spelling, the other side breaks — and not with an error, just with a silent loss of semantics.

Integration with the Annotation Layer

The annotation layer consumes not only ids but also aria attributes. StructTreeLayerBuilder exposes getAriaAttributes and getAnnotationIds, and AnnotationLayer queries them with ${AnnotationPrefix}${id} while rendering each annotation.

The Link role has its own "link ownership" mechanism: a Link element in the structure tree owns its text by default, but when the corresponding link annotation becomes truly interactive, aria-owns is transferred to the annotation element and the role is removed from the structure tree element. That logic lives in web/struct_tree_layer_builder.js and is out of scope here, but its input comes entirely from the role and children produced by serializable.

Design note: the goal of serialization is not "fully restore the PDF dictionary" but "enough for the main thread to build the DOM". So it picks only role, alt, table attributes, bbox and lang, discarding internal details such as /P, the raw shape of /K, and /Pg references. id uses agreed strings rather than reference objects for cross-thread reasons; structId is named separately to avoid colliding with content anchors. The narrower the output contract, the less likely both ends rot together.

Attribute Semantics: alt, ActualText, BBox and Tables

A few value-reading details in serialization look trivial but actually determine the read-aloud experience.

alt and ActualText

let alt = node.dict.get('Alt');
if (typeof alt !== 'string') {
  alt = node.dict.get('ActualText');
}
if (typeof alt === 'string') {
  obj.alt = stringToPDFString(alt);
}

/Alt is the "alternate description", used for images, formulas and charts; /ActualText is the "actual text", used for content that is visually a graphic but semantically text (for example ligatures and decorative glyphs). The specification gives them different semantics, but for a screen reader both are "the readable text of this piece of content". The priority here is /Alt first, falling back to /ActualText.

Why not concatenate them? Because when both exist, /ActualText describes the character replacement in the content stream, while /Alt describes the replacement of the whole element; the former is finer-grained and the latter is more complete. For leaf elements, /Alt is usually the sentence the author wants people to read. This is an empirical trade-off, not something the specification mandates.

stringToPDFString handles PDF's three string encodings: PDFDocEncoding, UTF-16BE with a BOM, and UTF-8 with a BOM. Without this step, non-ASCII characters and symbols would turn into garbage.

bbox, Width and Height

let bbox = null,
  size = null;
for (const a of node.attributes) {
  bbox = lookupNormalRect(a.getArray('BBox'), bbox);
  const width = a.get('Width');
  const height = a.get('Height');
  if (typeof width === 'number' && width > 0 && typeof height === 'number' && height > 0) {
    size = [0, 0, width, height];
  }
}
// Prefer BBox because Width and Height cannot recover its position.
if (bbox || size) {
  obj.bbox = bbox ?? size;
}

BBox is a four-element array and may be in reverse order (bottom-right corner first); lookupNormalRect normalizes it to top-left through bottom-right. Width and Height can only give a size, not a position, so it degrades to a rectangle anchored at the top-left origin. The comment states the trade-off clearly: prefer BBox, because a size cannot recover the position.

The TODO after it is also worth noting: if neither is available, a bounding box could in theory be recomputed from the marked-content ranges in the content stream, but that is not done today. This shows that bbox is optional "nice to have" information; the main thread can still build the DOM with an empty value, it just cannot support interactions such as "click a structure element to highlight the corresponding region".

Formula and MathML

if (obj.role === 'Formula') {
  try {
    const { mathML } = node;
    if (mathML) {
      obj.mathML = mathML;
    }
  } catch (ex) {
    if (ex instanceof MissingDataException) {
      throw ex;
    }
    warn(`Ignoring mathML: "${ex}".`);
  }
}

Formula handling contains the only explicit try block in the whole file. The reason lies in the mathML getter: it has to walk the /AF attachment array, take the file stream, and compare AFRelationship and Subtype, and any of those steps may hit corrupt data or an unloaded stream.

      const fileStream = FileSpec.pickPlatformItem(af.get("EF"));
      if (
        !(fileStream instanceof BaseStream) ||
        !isDict(fileStream.dict, "EmbeddedFile") ||
        !isName(fileStream.dict.get("Subtype"), "application/mathml+xml")
      ) {
        continue;
      }
      // The default encoding for xml files is UTF-8.
      return stringToUTF8String(fileStream.getString());

There are two paths, one conformant and one for compatibility:

  1. Conformant path: a file specification in /AF, with AFRelationship set to Supplement and the embedded file subtype application/mathml+xml. Take the stream contents and decode them as UTF-8.
  2. Compatibility path: an attribute object whose O is MSFT_Office and which carries MSFT_MathML. The comment says outright "isn't in the spec", but tagged PDFs produced by Office really do write it this way.

This getter re-reads the stream on every access (there is no shadow). But serialization only calls it once for Formula, and the outer try bounds the cost. MissingDataException must be rethrown because the stream may not be fully downloaded yet; other exceptions only warn, and the formula degrades to an ordinary element without affecting the whole page.

lang

const lang = node.dict.get('Lang');
if (typeof lang === 'string') {
  obj.lang = stringToPDFString(lang);
}

/Lang is a language tag such as zh-CN or en-US. It lets a screen reader switch pronunciation dictionaries, and lets the browser choose the right word-breaking and font fallback. Note that only the element's own /Lang is read here, with no inheritance. In the specification /Lang is inheritable, and a parent's Lang should apply to its children. When the main thread builds the DOM, lang is written on the span, and HTML inheritance handles descendants automatically, so outputting only the explicitly written level is enough here.

Design note: attribute reading always follows "read it if you can, degrade if you cannot": alt has a fallback, bbox has a fallback, mathML has a compatibility path, and lang is passed straight through. Nowhere does a missing optional attribute interrupt serialization. This layer's goal is maximum usable semantics, not validating whether the PDF is conformant.

Write-Back Path: Maintaining the Structure Tree When Annotations Are Edited

Everything so far has been read-side. The second half of the file (from canCreateStructureTree to #updateParentTag, about 450 lines) serves writing: when a user adds a "title" or "alternate text" to an untagged annotation, saving must add to or modify this structure tree.

The overall write-back flow is orchestrated in worker.js:

StageCheckBranch
Root presenceensureCatalog("structTreeRoot")With a root, update; without one, create
Pre-create checkcanCreateStructureTreeCatalog has a reference, pages have references, at least one tag
CreatecreateStructureTreeBuild the root, build ParentTree, write changes
Pre-update checkcanUpdateStructTreeRoot has a reference, ParentTreeNextKey is valid, ids are usable
UpdateupdateStructureTreeClone the existing structure, append new tags

The canCreateStructureTree Method

let nextKey = 0;
let hasNothingToUpdate = true;
 
for (const [pageIndex, elements] of newAnnotationsByPage) {
  const { ref: pageRef } = await pdfManager.getPage(pageIndex);
  if (!(pageRef instanceof Ref)) {
    warn(`Cannot save the struct tree: page ${pageIndex} has no ref.`);
    hasNothingToUpdate = true;
    break;
  }
  for (const element of elements) {
    if (element.accessibilityData?.type) {
      // Each tag must have a structure type.
      element.parentTreeId = nextKey++;
      hasNothingToUpdate = false;
    }
  }
}
// ...

Three preconditions, all required:

  1. The catalog must have a reference. Without one, StructTreeRoot cannot be written back into the catalog dictionary.
  2. Every page must have a reference. Without a page reference, the parent tree's keys have nowhere to land.
  3. At least one annotation must carry accessibilityData.type. type is the structure role, and a tag without a role is meaningless.

nextKey starts at 0 and increments, assigning a parentTreeId to each new tag. This is the key space of the parent tree, not object numbers. Starting at 0 is safe when creating a document, because there was no parent tree to begin with.

If any page lacks a reference, the loop breaks and sets hasNothingToUpdate = true. The cleanup branch afterwards deletes the parentTreeId values already assigned, avoiding a half-finished state. This "assign first, roll back later" style is not elegant, but it guarantees the input objects are not polluted on failure.

The createStructureTree Method

  static async createStructureTree({
    newAnnotationsByPage,
    xref,
    catalogRef,
    pdfManager,
    changes,
  }) {
    const root = await pdfManager.ensureCatalog("cloneDict");
    const cache = new RefMap();
    cache.put(catalogRef, root);
 
    const structTreeRootRef = xref.getNewTemporaryRef();
    root.set("StructTreeRoot", structTreeRootRef);
 
    const structTreeRoot = new Dict(xref);
    structTreeRoot.set("Type", Name.get("StructTreeRoot"));
    const parentTreeRef = xref.getNewTemporaryRef();
    structTreeRoot.set("ParentTree", parentTreeRef);
    const kids = [];
    structTreeRoot.set("K", kids);
    cache.put(structTreeRootRef, structTreeRoot);
 
    const parentTree = new Dict(xref);
    const nums = [];
    parentTree.set("Nums", nums);
 
    const nextKey = await this.#writeKids({
      newAnnotationsByPage,
      structTreeRootRef,
      structTreeRoot: null,
      kids,
      nums,
      xref,
      pdfManager,
      changes,
      cache,
    });
    structTreeRoot.set("ParentTreeNextKey", nextKey);
 
    cache.put(parentTreeRef, parentTree);
 
    for (const [ref, obj] of cache.items()) {
      changes.put(ref, {
        data: obj,
      });
    }
  }

The create path builds a minimal usable tree entirely in memory:

  • clone the catalog dictionary, without modifying the original object;
  • attach StructTreeRoot to the catalog with a temporary reference;
  • the root has only four keys: Type, ParentTree, K and ParentTreeNextKey; neither RoleMap nor ClassMap is created, because there are no custom roles;
  • ParentTree inlines the Nums array directly, without a Kids layer;
  • all new objects go into cache first and are converted into changes at the end.

The structTreeRoot: null argument is the switch between create and update: #writeKids uses it to decide "this is a create, there are no old tags to reuse".

The difference between RefMap and Map matters here: the key is a Ref object, but comparison is by reference number, so different Ref instances with the same object number hit the same record.

The canUpdateStructTree Method

  async canUpdateStructTree({ pdfManager, newAnnotationsByPage }) {
    if (!this.ref) {
      warn("Cannot update the struct tree: no root reference.");
      return false;
    }
 
    let nextKey = this.dict.get("ParentTreeNextKey");
    if (!Number.isInteger(nextKey) || nextKey < 0) {
      warn("Cannot update the struct tree: invalid next key.");
      return false;
    }
 
    const parentTree = this.dict.get("ParentTree");
    if (!(parentTree instanceof Dict)) {
      warn("Cannot update the struct tree: ParentTree isn't a dict.");
      return false;
    }
    const nums = parentTree.get("Nums");
    if (!Array.isArray(nums)) {
      warn("Cannot update the struct tree: nums isn't an array.");
      return false;
    }
    const numberTree = new NumberTree(parentTree, this.xref);
 
    for (const pageIndex of newAnnotationsByPage.keys()) {
      const { pageDict } = await pdfManager.getPage(pageIndex);
      if (!pageDict.has("StructParents")) {
        // StructParents is required when the content stream has some tagged
        // contents but a page can just have tagged annotations.
        continue;
      }
      const id = pageDict.get("StructParents");
      if (!Number.isInteger(id) || !Array.isArray(numberTree.get(id))) {
        warn(`Cannot save the struct tree: page ${pageIndex} has a wrong id.`);
        return false;
      }
    }
    // ...
    return true;
  }

Update is much stricter than create, because it touches someone else's data:

CheckRequirementConsequence of failure
Root referenceMust be a RefCannot write back StructTreeRoot
ParentTreeNextKeyNon-negative integerNew key allocation has no starting point
ParentTreeIs a dictionaryCannot append Nums
NumsIs an arrayCannot append key-value pairs
Page StructParentsIf present, must be an integer and must resolve to an array in the parent treeThe page's content index and the parent tree disagree

A page without StructParents is allowed, and the comment explains two reasons: it is not needed when the content stream has no marked content, and a page that tags only annotations may also lack it. This lenient check mirrors the dual-channel design in parse exactly.

After the checks pass, it also calls #collectParents to find the parent reference of each existing tag, and assigns parentTreeId to annotations without a structParent:

StructTreeRoot.#collectParents({
  elements,
  xref: this.xref,
  pageDict,
  numberTree,
});
 
for (const element of elements) {
  if (element.accessibilityData?.type) {
    // structParent can be undefined and in this case the positivity check
    // will fail (it's why the expression isn't equivalent to a `.<.`).
    if (!(element.accessibilityData.structParent >= 0)) {
      // Each tag must have a structure type.
      element.parentTreeId = nextKey++;
    }
    hasNothingToUpdate = false;
  }
}

>= 0 rather than "not equal to -1" is used because undefined >= 0 is false, which folds "field missing" into the case that needs a new tag. The comment calls this out explicitly, which suggests it was a real trap.

The updateStructureTree Method

let parentTreeRef = structTreeRoot.getRaw('ParentTree');
let parentTree;
if (parentTreeRef instanceof Ref) {
  parentTree = xref.fetch(parentTreeRef);
} else {
  parentTree = parentTreeRef;
  parentTreeRef = xref.getNewTemporaryRef();
  structTreeRoot.set('ParentTree', parentTreeRef);
}
parentTree = parentTree.clone();
cache.put(parentTreeRef, parentTree);
// ...

This section is all "dereference, clone, attach back" repetition, but every step has a reason:

  • clone the root dictionary so this.dict is not polluted;
  • if ParentTree is a reference, fetch and clone it; if it is inlined, create a temporary reference to replace it, avoiding direct edits to the original dictionary;
  • if Nums is a reference, record numsRef first and attach the new array to the same reference at the end; if it is inlined, replace it directly with a sliced array;
  • arrays are always slice()d, because #writeKids pushes into them later and must not touch the original.

newNextKey === -1 means #writeKids had no tags to write, so it returns immediately without writing changes. That convention keeps "checks passed but nothing actually changed" from producing an empty update.

The #writeKids Method

        const { structParent } = accessibilityData;
 
        if (
          structTreeRoot &&
          Number.isInteger(structParent) &&
          structParent >= 0
        ) {
          let objs = (structTreePageObjs ||= new Map()).get(pageIndex);
          if (objs === undefined) {
            // We need to collect the objects for the page.
            const structTreePage = new StructTreePage(
              structTreeRoot,
              page.pageDict
            );
            objs = structTreePage.collectObjects(pageRef);
            structTreePageObjs.set(pageIndex, objs);
          }
          const objRef = objs?.get(structParent);
          if (objRef) {
            // We update the existing tag.
            const tagDict = xref.fetch(objRef).clone();
            StructTreeRoot.#writeProperties(tagDict, accessibilityData);
            changes.put(objRef, {
              data: tagDict,
            });
            continue;
          }
        }
        nextKey = Math.max(nextKey, parentTreeId);
 
        const tagRef = xref.getNewTemporaryRef();
        const tagDict = new Dict(xref);
 
        StructTreeRoot.#writeProperties(tagDict, accessibilityData);
        // ...
        cache.put(tagRef, tagDict);
        nums.push(parentTreeId, tagRef);

The branches here are the core of the whole write-back path:

CaseActionResult
An old root exists, the annotation has structParent, the tag is foundClone the old tag dictionary, change properties, write changesIn-place update, no new node
No structParent, or the tag is not foundAllocate a temporary reference, create a tag, insert it into the parent's KNew tag, written changes
The annotation has no accessibilityData.typeSkipNo change at all

Old-tag lookup builds a cache through collectObjects, lazily per page, so each annotation does not rebuild the whole page index. objs may be null (the page has no reference or no ParentTree), in which case ?.get returns undefined and the code naturally falls into the create branch.

Creating a tag also writes an OBJR structure:

const objDict = new Dict(xref);
tagDict.set('K', objDict);
objDict.set('Type', objr);
if (isPageRef) {
  // Pg is optional.
  objDict.set('Pg', pageRef);
}
objDict.set('Obj', ref);

/Pg is written optionally, only when the page has a reference. /Obj is the annotation reference and is mandatory. nums.push(parentTreeId, tagRef) appends the new key-value pair to the parent tree's Nums array — note the order: key first, value after.

The #writeProperties Method

  static #writeProperties(
    tagDict,
    { type, title, lang, alt, expanded, actualText }
  ) {
    // The structure type is required.
    tagDict.set("S", Name.get(type));
 
    if (title) {
      tagDict.set("T", stringToAsciiOrUTF16BE(title));
    }
    if (lang) {
      tagDict.set("Lang", stringToAsciiOrUTF16BE(lang));
    }
    if (alt) {
      tagDict.set("Alt", stringToAsciiOrUTF16BE(alt));
    }
    if (expanded) {
      tagDict.set("E", stringToAsciiOrUTF16BE(expanded));
    }
    if (actualText) {
      tagDict.set("ActualText", stringToAsciiOrUTF16BE(actualText));
    }
  }
Input fieldPDF keyEncoding
typeSName, always written
titleTASCII or UTF-16BE, chosen as needed
langLangSame as above
altAltSame as above
expandedESame as above
actualTextActualTextSame as above

stringToAsciiOrUTF16BE is the write-side encoder: pure ASCII is written as single bytes, and anything containing non-ASCII is converted to UTF-16BE with a BOM. That saves space compared to always using UTF-16, and is safer than always using PDFDocEncoding. Note that it does not handle empty strings: callers filter with if (title), so an empty string means "this attribute is absent" and does not overwrite the old value. That matches the interaction expectation that "leaving the edit box empty means no change".

The #collectParents Method

When updating an existing tag, its position in the tree must be known so the new tag can be inserted next to the correct sibling. #collectParents starts from the page's StructParents array and builds a map from "MCID or tag id to parent tag":

const updateElement = (kid, pageKid, kidRef) => {
  const elems = idToElements.get(kid);
  if (elems) {
    const parentRef = pageKid.getRaw('P');
    const parentDict = xref.fetchIfRef(parentRef);
    if (parentRef instanceof Ref && parentDict instanceof Dict) {
      // It should always the case, but we check just in case.
      const params = { ref: kidRef, dict: pageKid };
      for (const element of elems) {
        element.structTreeParent = params;
      }
    }
    return true;
  }
  return false;
};

structTreeParent holds two things: the tag's own reference ref, and the tag dictionary dict (used later to read P). The format of element.structTreeParentId comes from the editor and looks like p7R_mc3; the code splits the string to extract the MCID:

if (element.structTreeParentId) {
  const id = parseInt(element.structTreeParentId.split('_mc')[1], 10);
  idToElements.getOrInsertComputed(id, makeArr).push(element);
}

It calls split("_mc") directly rather than parsing fully, because the format is produced by the editor itself and is an internal convention. parseInt returns NaN for invalid input, idToElements will not match, the element stays without a parent, and it ultimately lands under the root.

The #updateParentTag Method

let ref = null;
let parentRef;
if (structTreeParent) {
  ({ ref } = structTreeParent);
 
  // We get the parent of the tag.
  parentRef = structTreeParent.dict.getRaw('P') || structTreeRootRef;
} else {
  parentRef = structTreeRootRef;
}
 
tagDict.set('P', parentRef);
 
// We get the kids in order to insert a new tag at the right position.
const parentDict = xref.fetchIfRef(parentRef);
if (!parentDict) {
  fallbackKids.push(newTagRef);
  return;
}
// ...
const index = cachedParentKids.indexOf(ref);
cachedParentKids.splice(index >= 0 ? index + 1 : cachedParentKids.length, 0, newTagRef);

The insertion position is "after the old tag", not "at the end of the parent". That matters: when several tags are inserted in a row within the same piece of text, appending them all at the end would break reading order. When indexOf finds nothing, it degrades to appending at the end, which is fault tolerance. fallbackKids is the root K array passed in by the create path: when the parent dictionary cannot be fetched, the new tag hangs directly under the root, ensuring at least that the tag is not lost, at the cost of a possibly wrong hierarchy.

Design note: every change in the write-back path happens on a clone; the original Dict is never modified. New objects are reserved with temporary references (getNewTemporaryRef) and finally handed to changes for serialization. This way, even if something throws midway, the document objects remain intact and the save can be abandoned as a whole. The essence of incremental update is not "changing it correctly" but "not breaking it".

Fault Tolerance: Depth, Cycles and Missing Data

This layer faces very poor-quality input: tagged PDFs may be produced by dozens of tools, the structure tree may be only half present, parent chains may be broken, and ParentTree may point at objects that do not exist. The code has no centralized exception handling; instead, fault tolerance is spread across every entry point.

Three Traversal Strategies

Traversal pointStrategyProtectionBehaviour when exceeded
#collectAttributesExplicit stack iterationRefSet records visited referencesSkip repeated references
addNodeRecursionMAX_DEPTH = 40, map deduplicationWarn and return null
nodeToSerializableRecursionMAX_DEPTH = 40Warn and skip that subtree

The attribute graph must be iterative, because cycles can be long and the number of attribute objects is not bounded by the page. The structure tree must be recursive, because it needs to expand downward and backtrack upward at the same time, and an iterative version would be extremely convoluted; a depth limit of 40 is already far beyond any nesting a human can read. Serialization recursion cannot backtrack, and its depth is likewise capped at 40.

MAX_DEPTH is defined at the top of the file:

const MAX_DEPTH = 40;

It is used once in each of two places, and the two warnings differ: StructTree MAX_DEPTH reached. and StructTree too deep to be fully serialized. The first is a construction failure, the second is output truncation. Different wording lets production debugging show which stage the problem occurred in.

Deduplication through addNode's map is a weak protection: it only prevents rebuilding the same dictionary object. For cycles formed by distinct references, attribute parsing is covered by RefSet, and the parent chain is covered by the 40-level depth cap. If a file contains two dictionaries with identical content but different object numbers that point at each other as parents, the code still walks up to the depth limit. That is extremely rare, and the cost is only 40 levels of recursion, so no stronger cycle detection is implemented.

Handling Exceptions and Missing Data

ScenarioHandlingCode location
Structure tree not yet loaded in a chunked fileRethrow MissingDataExceptionCatalog, Formula branch
Root dictionary is not a dictionaryReturn null#readStructTreeRoot
Page is not a referenceDo not build, do not collect objectsparse, collectObjects
Parent tree missingReturn immediatelyparse
ParentTree does not resolve to an arraySkip this page's content entryparse
Parent dictionary cannot be fetchedNode is not attached to a parent, or falls back to the root KaddNode, #updateParentTag
mathML parsing throwsWarn, drop mathMLnodeToSerializable
Whole-page structure tree generation failsWarn, return nullPDFPage.getStructTree

PDFPage.getStructTree's fallback deserves a separate look:

try {
  const structTree = await this.pdfManager.ensure(this, '_parseStructTree', [structTreeRoot]);
  return await this.pdfManager.ensure(structTree, 'serializable');
} catch (ex) {
  warn(`getStructTree: "${ex}".`);
  return null;
}

It wraps both "building" and "serialization" in the try, so any failure only warns and returns null. When the main thread's StructTreeLayerBuilder receives null it builds no DOM, and the annotation layer and text layer keep working. The structure tree is never a precondition for rendering; that is the bottom line of the whole fault-tolerance strategy.

Design note: the granularity of fault tolerance matches the cost of failure. An attribute parsing failure only loses a bbox, so it warns; a structure tree construction failure would strip the whole page of semantics, so the whole block is wrapped to return null; data that has not finished downloading must be rethrown, because retrying is meaningful and it must not be treated as a permanent failure. Three kinds of handling for three kinds of semantics, and they cannot be mixed.

Conclusion

struct_tree.js is PDF.js's accessibility semantics layer: upward it gives PDFPage.getStructTree a serializable tree, and downward it strings /StructTreeRoot, /ParentTree, /StructParents, /StructParent, MCID and OBJR into a construction chain that can always degrade.

Where It Sits in the Overall Chain

StageModuleInputOutput
Root readCatalog.#readStructTreeRoot/StructTreeRoot dictionary and referenceStructTreeRoot
Mapping setupStructTreeRoot constructor/RoleMap, /ParentTreeroleMap, parentTree
Page buildStructTreePage.parsePage reference, /StructParents, annotation idsnodes array
Node expansionaddNode, parseKids, parseKidStructure element dictionaries, KStructElementNode, StructElement
Attribute normalizationrole, attributes, tableAttributes/S, /C, /ANormalized roles and table attributes
OutputserializableLocal treePlain object literals
ConsumptionStructTreeLayerBuilderSerialized treeDOM with role and aria
Write-backcreateStructureTree, updateStructureTreeEdited annotations and accessibility dataNew tags in changes

In a single sentence: Catalog finds the root, StructTreeRoot supplies mappings and indexing, StructTreePage picks only this page's nodes, serializable flattens the tree into a string contract, and the main thread uses it to add semantics to text and annotations.

Six Design Principles

  1. Build only this page, never the full tree. A structure tree is document-level, but a page only needs its own slice. parse starts from the ParentTree array and the incremental records in structParentIds, walks up to parents and fills lists and tables downward, so the amount of work depends on the page, not on the document.
  2. Two entry channels, content and annotations separated. Content streams go through the page's /StructParents plus MCID; annotations go through their own /StructParent. The latter is registered into structParentIds only when annotations are parsed, so page rendering does not wait for all document annotations, and annotations do not lose semantics just because a page has no /StructParents.
  3. Nodes deduplicated by dictionary identity, ordered by root /K index. map is keyed by dictionary object, which naturally prevents duplicate construction and some cycles; getKidPosition maps the root /K index directly onto the nodes index, so serialization needs no re-sorting. Deduplication and ordering are two independent problems solved by two separate structures.
  4. Roles are only normalized, never whitelisted. Expanding RoleMap one level is enough, and unknown roles are passed to the main thread unchanged; the actual HTML role mapping lives in struct_tree_layer_builder.js. The parsing side stays faithful and the consumer side adapts, so spec evolution never makes the parser stale.
  5. Only a string contract crosses threads. serializable outputs only role, alt, table attributes, bbox and lang, plus the ids of the three child kinds content, object and annotation. Content ids use exactly the same spelling as the text layer's marked-content ids, and annotation ids exactly match the annotation layer's DOM ids; there is no object identity, only convention.
  6. Write-back only touches clones and always goes through changes. Both create and update clone first, reserve temporary references, and finally put the results into changes. If any step fails midway, the original document objects are unaffected. The bottom line of incremental update is "do not break it", not "always get it right".

The structure tree is the most easily ignored layer in PDF: it takes no part in drawing, does not affect layout, and most readers support it only partially. Yet this layer is exactly what turns "a pile of glyphs" into "a document with headings, lists, table headers and alternate text". PDF.js does this with an on-demand local tree, a stringified output contract, and a set of write-back paths that only modify clones, making it an optional enhancement that can degrade to null at any time.

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