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

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:
| Block | Key members | Responsibility |
|---|---|---|
| Root and mappings | StructTreeRoot construction, roleMap, parentTree, structParentIds, getKidPosition | Parse /RoleMap and /ParentTree, record how annotations map to pages |
| Page construction | StructTreePage's parse, collectObjects, addNode, addTopLevelNode | Pull this page's structure elements out of the root, ordered by the root's kid positions |
| Node model | StructElementNode, StructElement, parseKids, parseKid | Translate integers, MCR, OBJR and dictionaries inside K into a uniform node |
| Attributes | role, attributes, #collectAttributes, tableAttributes, mathML | Role normalization, class attribute merging, table and formula specific attributes |
| Write-back | canCreateStructureTree, createStructureTree, canUpdateStructTree, updateStructureTree | Create or update tags when annotations are edited, staging the changes into changes |
| Internal write-back tools | #writeKids, #writeProperties, #collectParents, #updateParentTag | Build tag dictionaries, find parents, insert kids in order |
| Output | serializable, nodeToSerializable | Flatten 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.jsin the current repository. This version has nofindNode,RootNode,StructContentNodeor#parseParentTree; those names belong to older versions or other implementations. The parent tree is delegated directly toNumberTree, the tree root is a plain object literal at serialization time (withroleset to"Root"), and a content node is aStructElementrather than a separate class./IDTreeis not read in this file; only/IDis used, for thestructIdofTH. 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);
}
}| Field | Source | Role |
|---|---|---|
xref | constructor argument | Fetch dictionaries and streams by reference later |
dict | constructor argument | The root dictionary itself, containing /K, /RoleMap and /ParentTree |
ref | constructor argument; empty when not a Ref | Used as the parent reference on write-back; without it nothing can update |
roleMap | /RoleMap | Mapping from custom role names to standard role names |
parentTree | /ParentTree | Parent tree; look up a structure element or array by integer key |
structParentIds | Filled at runtime by annotation parsing | Incremental index from page reference to annotation tag ids |
kidRefToPosition | First call to getKidPosition | Root 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 value | Meaning |
|---|---|
| Non-negative int | Index of the reference inside the root /K, used directly as a nodes index |
NaN | The root has /K, but this element is not in it (cross-page or orphan node) |
-1 | The 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:
RoleMapandParentTreeare document structure, read once at construction;structParentIdsis 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 theParentTreeparsing 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:
| Level | Standard types |
|---|---|
| Document | Document, Part, Art, Sect, Div, BlockQuote, Caption, TOC, TOCI, Index |
| Grouping | NonStruct, Private, Quote, Note, Reference, BibEntry, Code, Span |
| Block | P, H1 to H6, Title, FENote, Sub |
| Inline | Lbl, Em, Strong, Link, Annot, Form, Ruby, RB, RT, RP, Warichu, WT, WP |
| Illustration | Figure, Formula |
| Table | Table, TR, TH, TD, THead, TBody, TFoot |
| List | L, 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:
- Class names referenced by
/C, looked up in/ClassMapfor attribute objects; - 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:
pendingis used as a stack, so recursion depth is not affected by the attribute graph;visitedrecords 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 key | Applicable roles | Condition | Serialized key | Value |
|---|---|---|---|---|
Summary | Table | Non-empty string | summary | Decoded PDF string |
RowSpan | TH, TD | Integer greater than 1 | rowSpan | Integer |
ColSpan | TH, TD | Integer greater than 1 | colSpan | Integer |
Headers | TH, TD | At least one string in the array | headers | Array of strings |
Scope | TH | Name equal to Row, Column or Both | scope | The original name |
Short | TH | Non-empty string | short | Decoded 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 shape | Node type | Precondition | Serialized output |
|---|---|---|---|
| Integer | PAGE_CONTENT | Owning page equals current page | type: "content", id is page plus MCID |
Dictionary with /Type /MCR | STREAM_CONTENT | Owning page equals current page | Same as above; /Stm does not reach the output |
Dictionary with /Type /OBJR | OBJECT | Owning page equals current page | type: "object", id is the object reference |
| Any other dictionary | ELEMENT | None | Recursed into as a child role |
Annotation (not from K; injected by parse) | ANNOTATION | Registered during annotation parsing | type: "annotation", with an internal prefix |
| Number, name, string, boolean, null | None | Always dropped | None |
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:
parseKidcompresses 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 theMCRandOBJRchecks, cannot be swapped:MCRis a dictionary andOBJRis a dictionary, so only/Typeseparates them; and integers must be checked before dictionaries, otherwise they fall into theinstanceof Dictbranch. Every unrecognized value returnsnulland is filtered out byparseKids, 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:
- Page content:
/StructParentsis an integer key; use it to fetch an array fromParentTree, containing references to all the top-level structure elements on this page. Fetch each one and calladdNode. - Annotations: iterate this page's records in
structParentIds, and use the annotation'sStructParentnumber to fetch the structure element fromParentTree. Once found, locate the entry in that element'skidswhose type isOBJECTand whoseObjequals the annotation reference, then change its type fromOBJECTtoANNOTATION.
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:
- 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. - Deduplication:
mapkeys 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 themapdirectly. - Build the node first, then fill in lists and tables downward:
new StructElementNodehas already parsedkids, but only lists and tables proactively collect empty containers.level - 1is 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 emptyTRmust be kept, otherwise the table's row and column structure would lose cells. - Walk up to the parent:
dict.get("P")is dereferenced to get the parent dictionary. If the parent is missing, or its type isStructTreeRoot, the current node is top-level and is handed toaddTopLevelNodeto be placed by its index in the root/K. If it cannot be placed (returnsfalse), it is removed frommap— orphan nodes do not enter the tree. - Recurse into the parent:
level + 1. If building the parent fails (returnsnull, usually because of the depth limit), the current node is still returned, but is no longer attached to its parent. - Establish the upward pointer: iterate the parent's
kids, find the entry with typeELEMENTand the same dictionary identity, and point itsparentNodeat the current element. If none is found, the parent'sKdoes not contain this child (inconsistent data), and the node is likewise removed frommap.
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. Returnfalse, and the caller deletes the node.-1: the shape of the root/Kis not recognized. It still returnstrue, so the node stays inmapbut does not enternodes. That means it can only be reached through another parent'skids; 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:
addNodedoes 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 samemap: downward so that empty containers still get nodes, upward to fill inparentNodepointers, sideways to prevent cycles. The order cannot be shuffled, or nodes get built twice or parent pointers get missed.map.deleteis not a failure marker but "this one did not attach; it may be retried later". The key precondition for a local tree isParentTree: without it there is only traversal from the root, which is whyparsechecksparentTreefirst 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
| Field | Source | Condition | Notes |
|---|---|---|---|
role | /S normalized through RoleMap | Always present | Unknown roles pass through unchanged |
children | Projection of kids | Always present, possibly an empty array | Child nodes or content items |
alt | /Alt, falling back to /ActualText | Value is a string | Decoded readable text |
structId | /ID | Role is TH and the value is non-empty | Stable identifier for table headers, deliberately not id |
summary | Table attribute Summary | Role is Table | Table summary |
rowSpan | Table attribute RowSpan | Greater than 1 | Row span |
colSpan | Table attribute ColSpan | Greater than 1 | Column span |
headers | Table attribute Headers | Non-empty array of strings | Associated header ids |
scope | Table attribute Scope | Row, Column or Both | Header scope |
short | Table attribute Short | Non-empty string | Header abbreviation |
mathML | AF attachment or MSFT_MathML | Role is Formula and parsing succeeds | MathML text of the formula |
bbox | Attribute BBox, falling back to Width and Height | At least one is usable | Element bounding box |
lang | /Lang | Value is a string | Language 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 type | Output type | id form | How consumers use it |
|---|---|---|---|
ELEMENT | Recursed into a child | None | Continue building the DOM |
PAGE_CONTENT | content | p plus page object number, plus _mc, plus MCID | Aligns with the id of the text layer's marked-content container |
STREAM_CONTENT | content | Same as above | Same as above; /Stm is ignored |
OBJECT | object | Object reference string | Handed to the annotation layer to claim |
ANNOTATION | annotation | pdfjs_internal_id_ plus the reference string | Aligns 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.
| Layer | Producer | Sample id | How it links |
|---|---|---|---|
| Structure tree | serializable | p1R_mc5 | As the id of a content child |
| Text layer | PartialEvaluator marked-content branch | p1R_mc5 | beginMarkedContentProps |
| Annotation layer | AnnotationLayer | pdfjs_internal_id_12R | The annotation element's own id |
| Structure tree annotation | serializable | pdfjs_internal_id_12R | As 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,bboxandlang, discarding internal details such as/P, the raw shape of/K, and/Pgreferences.iduses agreed strings rather than reference objects for cross-thread reasons;structIdis 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:
- Conformant path: a file specification in
/AF, withAFRelationshipset toSupplementand the embedded file subtypeapplication/mathml+xml. Take the stream contents and decode them as UTF-8. - Compatibility path: an attribute object whose
OisMSFT_Officeand which carriesMSFT_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":
althas a fallback,bboxhas a fallback,mathMLhas a compatibility path, andlangis 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:
| Stage | Check | Branch |
|---|---|---|
| Root presence | ensureCatalog("structTreeRoot") | With a root, update; without one, create |
| Pre-create check | canCreateStructureTree | Catalog has a reference, pages have references, at least one tag |
| Create | createStructureTree | Build the root, build ParentTree, write changes |
| Pre-update check | canUpdateStructTree | Root has a reference, ParentTreeNextKey is valid, ids are usable |
| Update | updateStructureTree | Clone 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:
- The catalog must have a reference. Without one,
StructTreeRootcannot be written back into the catalog dictionary. - Every page must have a reference. Without a page reference, the parent tree's keys have nowhere to land.
- At least one annotation must carry
accessibilityData.type.typeis 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
StructTreeRootto the catalog with a temporary reference; - the root has only four keys:
Type,ParentTree,KandParentTreeNextKey; neitherRoleMapnorClassMapis created, because there are no custom roles; ParentTreeinlines theNumsarray directly, without aKidslayer;- all new objects go into
cachefirst and are converted intochangesat 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:
| Check | Requirement | Consequence of failure |
|---|---|---|
| Root reference | Must be a Ref | Cannot write back StructTreeRoot |
ParentTreeNextKey | Non-negative integer | New key allocation has no starting point |
ParentTree | Is a dictionary | Cannot append Nums |
Nums | Is an array | Cannot append key-value pairs |
Page StructParents | If present, must be an integer and must resolve to an array in the parent tree | The 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.dictis not polluted; - if
ParentTreeis 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
Numsis a reference, recordnumsReffirst 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#writeKidspushes 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:
| Case | Action | Result |
|---|---|---|
An old root exists, the annotation has structParent, the tag is found | Clone the old tag dictionary, change properties, write changes | In-place update, no new node |
No structParent, or the tag is not found | Allocate a temporary reference, create a tag, insert it into the parent's K | New tag, written changes |
The annotation has no accessibilityData.type | Skip | No 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 field | PDF key | Encoding |
|---|---|---|
type | S | Name, always written |
title | T | ASCII or UTF-16BE, chosen as needed |
lang | Lang | Same as above |
alt | Alt | Same as above |
expanded | E | Same as above |
actualText | ActualText | Same 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
Dictis never modified. New objects are reserved with temporary references (getNewTemporaryRef) and finally handed tochangesfor 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 point | Strategy | Protection | Behaviour when exceeded |
|---|---|---|---|
#collectAttributes | Explicit stack iteration | RefSet records visited references | Skip repeated references |
addNode | Recursion | MAX_DEPTH = 40, map deduplication | Warn and return null |
nodeToSerializable | Recursion | MAX_DEPTH = 40 | Warn 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
| Scenario | Handling | Code location |
|---|---|---|
| Structure tree not yet loaded in a chunked file | Rethrow MissingDataException | Catalog, Formula branch |
| Root dictionary is not a dictionary | Return null | #readStructTreeRoot |
| Page is not a reference | Do not build, do not collect objects | parse, collectObjects |
| Parent tree missing | Return immediately | parse |
ParentTree does not resolve to an array | Skip this page's content entry | parse |
| Parent dictionary cannot be fetched | Node is not attached to a parent, or falls back to the root K | addNode, #updateParentTag |
mathML parsing throws | Warn, drop mathML | nodeToSerializable |
| Whole-page structure tree generation fails | Warn, return null | PDFPage.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 returnnull; 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
| Stage | Module | Input | Output |
|---|---|---|---|
| Root read | Catalog.#readStructTreeRoot | /StructTreeRoot dictionary and reference | StructTreeRoot |
| Mapping setup | StructTreeRoot constructor | /RoleMap, /ParentTree | roleMap, parentTree |
| Page build | StructTreePage.parse | Page reference, /StructParents, annotation ids | nodes array |
| Node expansion | addNode, parseKids, parseKid | Structure element dictionaries, K | StructElementNode, StructElement |
| Attribute normalization | role, attributes, tableAttributes | /S, /C, /A | Normalized roles and table attributes |
| Output | serializable | Local tree | Plain object literals |
| Consumption | StructTreeLayerBuilder | Serialized tree | DOM with role and aria |
| Write-back | createStructureTree, updateStructureTree | Edited annotations and accessibility data | New 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
- Build only this page, never the full tree. A structure tree is document-level, but a page only needs its own slice.
parsestarts from theParentTreearray and the incremental records instructParentIds, walks up to parents and fills lists and tables downward, so the amount of work depends on the page, not on the document. - Two entry channels, content and annotations separated. Content streams go through the page's
/StructParentsplusMCID; annotations go through their own/StructParent. The latter is registered intostructParentIdsonly 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. - Nodes deduplicated by dictionary identity, ordered by root
/Kindex.mapis keyed by dictionary object, which naturally prevents duplicate construction and some cycles;getKidPositionmaps the root/Kindex directly onto thenodesindex, so serialization needs no re-sorting. Deduplication and ordering are two independent problems solved by two separate structures. - Roles are only normalized, never whitelisted. Expanding
RoleMapone level is enough, and unknown roles are passed to the main thread unchanged; the actual HTML role mapping lives instruct_tree_layer_builder.js. The parsing side stays faithful and the consumer side adapts, so spec evolution never makes the parser stale. - Only a string contract crosses threads.
serializableoutputs onlyrole,alt, table attributes,bboxandlang, plus the ids of the three child kindscontent,objectandannotation. 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. - Write-back only touches clones and always goes through
changes. Both create and update clone first, reserve temporary references, and finally put the results intochanges. 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
- 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: catalogOctober 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