PDF.js 源码解读: struct_tree
2026年10月27日 · StreamPDF Team

前言
这一篇讲 Worker 端 src/core/struct_tree.js,对应 PDF 规范里的标记内容与逻辑结构设施:文档目录下的 /StructTreeRoot、/MarkInfo,页面上的 /StructParents,内容流里的 BMC、BDC、EMC,以及注释上的 /StructParent。
前几篇一直在处理「看得见的东西」:XRef 负责取对象,Parser 负责切 token,PartialEvaluator 负责把内容流变成绘制指令与文本项。这一篇处理的是「看不见但必须存在的东西」——阅读顺序、语义角色、以及谁是标题谁是表格。屏幕阅读器不读像素,它读的是这棵树;浏览器的查找、选中、朗读、以及 PDF 编辑器的「把这段文字标成 H2」,也都依赖这棵树。
用一句话概括这一层的职责:StructTreeRoot 是文档级的元数据与索引(角色映射、类映射、父树),StructTreePage 是单页的视图构建器(只把本页相关的结构元素拼出来),StructElementNode 与 StructElement 是节点模型,serializable 是跨线程的输出契约。
需要先把一个前提说清楚:结构树不是必需的。绝大多数扫描件、老文件、由打印驱动生成的 PDF 都没有结构树。Catalog 读不到 /StructTreeRoot 时,整条链路会安静地返回 null,渲染照常进行。这也是为什么这一层的代码里充满了「没有就退出」的早返回——它是增强,不是刚需。
模块总览
struct_tree.js 共 1143 行,按职责可以切成六块:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 根与映射 | StructTreeRoot 构造、roleMap、parentTree、structParentIds、getKidPosition | 解析 /RoleMap、/ParentTree,记录注释与页面的对应关系 |
| 页面构建 | StructTreePage 的 parse、collectObjects、addNode、addTopLevelNode | 从根往下捞出本页的结构元素,按根的子节点位置排序 |
| 节点模型 | StructElementNode、StructElement、parseKids、parseKid | 把 K 里的整数、MCR、OBJR、字典翻译成统一节点 |
| 属性 | role、attributes、#collectAttributes、tableAttributes、mathML | 角色归一化、类属性合并、表格与公式的专用属性 |
| 写回 | canCreateStructureTree、createStructureTree、canUpdateStructTree、updateStructureTree | 编辑注释时新建或更新标签,把改动塞进 changes |
| 内部写回件 | #writeKids、#writeProperties、#collectParents、#updateParentTag | 生成标签字典、找父节点、按顺序插入 kid |
| 输出 | serializable、nodeToSerializable | 把树压成纯对象字面量,发给主线程 |
它在整条链路里的位置:
Catalog.#readStructTreeRoot (读 /StructTreeRoot,构造 StructTreeRoot)
→ StructTreeRoot (RoleMap / ParentTree / structParentIds)
→ PDFPage.getStructTree (document.js,先等注释解析完)
→ StructTreePage.parse(pageRef) (只挑本页的节点)
→ addNode / addTopLevelNode (向上找父、按 K 下标排序)
→ serializable (角色、alt、lang、bbox、children id)
→ worker 消息 (纯对象,可结构化克隆)
→ StructTreeLayerBuilder (web/struct_tree_layer_builder.js,建 DOM)
→ text layer / annotation layer (按 id 关联内容与注释)
Catalog 侧只有十几行,值得先看,因为它决定了这棵树的入口条件:
#readStructTreeRoot() {
const rawObj = this.#catDict.getRaw("StructTreeRoot"),
obj = this.xref.fetchIfRef(rawObj);
return obj instanceof Dict
? new StructTreeRoot(this.xref, obj, rawObj)
: null;
}外层 getter 还包了 try 与 shadow,这里只保留核心。两点值得注意。第一,getRaw 保留了原始引用,所以 StructTreeRoot 能拿到自己的 ref;写回路径需要它当根节点的父引用,没有引用就无法更新。第二,MissingDataException 必须原样抛出,因为结构树可能在分块加载的文件里,此时数据还没到,不能当成「没有结构树」处理。
版本说明:本篇严格按当前仓库这一版
struct_tree.js讲解。这一版没有findNode、RootNode、StructContentNode、#parseParentTree这些旧版本或其它实现里的名字:父树直接交给NumberTree,树根在序列化时是一个普通对象字面量(role为"Root"),内容节点是StructElement而不是独立类。/IDTree在这份文件里不被读取,只有/ID会用于TH的structId。读源码时请以这里的真实结构为准。
根对象:StructTreeRoot 的构造
StructTreeRoot 的字段很少,但每一个都对应规范里的一类设施:
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);
}
}| 字段 | 来源 | 作用 |
|---|---|---|
xref | 构造参数 | 后续按引用取字典、取流 |
dict | 构造参数 | 根字典本身,包含 /K、/RoleMap、/ParentTree |
ref | 构造参数,非 Ref 则为空 | 写回时作为父引用;没有它就不能更新 |
roleMap | /RoleMap | 自定义角色名到标准角色名的映射 |
parentTree | /ParentTree | 父树,按整数键取结构元素或数组 |
structParentIds | 运行期由注释解析填充 | 页面引用到注释标签编号的增量索引 |
kidRefToPosition | 首次调用 getKidPosition | 根的子节点引用到下标,用于稳定排序 |
roleMap 的解析刻意保守:只接受值为 Name 的条目。规范要求 /RoleMap 的值是名字对象,但损坏文件里可能出现字符串或字典,这里直接跳过,不做猜测。
parentTree 用的是 getRaw,因为 NumberTree 内部要自己处理引用与 Kids 的层级。顺带一提,NumberTree 的查找不是线性扫描,而是先按 /Limits 做区间二分,再在 Nums 数组的偶数下标上二分,最后用 fetchIfRef 还原值:
get(key) {
return this.xref.fetchIfRef(this.getRaw(key));
}getRaw 返回的可能是引用,也可能是直接对象。这一点在页面构建里很关键:parse 用 get,拿到的是已解引用的字典;collectObjects 用 getRaw,拿到的是引用本身,因为写回时需要引用号。
getKidPosition 方法
根的子节点顺序不是数组下标天然给出的,因为结构元素的父链可能跨页、跨层。PDF.js 的做法是:以根字典 /K 的顺序作为全文档的阅读顺序基准。
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;
}返回值有三种语义,调用方必须区分:
| 返回值 | 含义 |
|---|---|
| 非负整数 | 该引用在根 /K 里的下标,直接作为 nodes 下标 |
NaN | 根有 /K,但这个元素不在其中(跨页或游离节点) |
-1 | 根的 /K 形态不认识(既非数组也非字典),不支持 |
this.dict.get("K") 会解引用,所以数组里拿到的仍然是原始元素(可能是 Ref,也可能是直接字典)。ref.toString() 正是 "12 0 R" 这样的形式,与 StructTreePage.addNode 传进来的 dict.objId 完全一致。这里用字符串键而不是 Ref 对象,是因为 Ref 是每次 fetch 可能新建的对象,身份不稳定;字符串是值语义的。
kidRefToPosition = null 这个状态很有意思:它表示「根有 /K,但形态无法理解」。此时 getKidPosition 返回 -1,addTopLevelNode 会返回 false,节点被从 map 里删除——整页结构树直接放弃,而不是抛错。这符合这一层的总基调:可选增强,失败就退化成无结构树。
#addIdToPage 与 addAnnotationIdToPage
结构树有两条入口:内容流的 MCID 走页面的 /StructParents,注释走注释对象的 /StructParent。后者是运行时才知道的,因为注释是在页面解析之后才逐个构造的。
#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
);
}注释的构造处调用它:
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);
}注意这里的 id < 0 判断:structParent 缺失时被归一化成 -1,于是这条记录根本不会进入 structParentIds。页面没有引用、注释没有引用,同样静默丢弃。这三类失败都不影响注释渲染,只影响它能否进入无障碍树。
structParentIds 用 RefMap 而不是 Map,键是页面引用;值是三元组数组 [id, type, objId]。type 目前只有 ANNOTATION 一种,objId 是注释对象的引用字符串。它存在的理由只有一个:页面级解析时,光靠 /StructParents 找不到「只在注释上打标签」的文档。这类文档的页面可能没有 /StructParents,但注释有 /StructParent,父树里也有对应条目。
设计要点:
RoleMap、ParentTree是文档结构,在构造时一次性读入;structParentIds是运行期观察,随注释解析逐步累积。把两者分开,意味着结构树可以尽早建立(页面渲染不必等所有注释),而注释又能反过来补充树的内容。若把注释编号硬塞进ParentTree的解析流程,就必须先解析全文档注释,页面渲染会被注释数量拖住。
角色与类:RoleMap、ClassMap 与属性归一化
PDF 的结构元素用 /S 表示角色,理论上应当是标准结构类型之一,例如 P、H1、Table、Figure。但现实里大量文件来自 Word、InDesign、LaTeX,它们会写出 MyHeading、Heading1、Para 这类自定义名。规范给的解法是 /RoleMap:自定义名映射到标准名。
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;
}只有一层映射,没有递归展开。规范也要求 /RoleMap 的值必须是标准类型,不允许再指向另一个自定义名,所以这里不做链式查找。/S 缺失或不是名字时返回空字符串,后续所有 switch 和映射自然落到「未知角色」,不会抛错。
标准结构类型可以按层级粗分:
| 层级 | 标准类型 |
|---|---|
| 文档级 | Document、Part、Art、Sect、Div、BlockQuote、Caption、TOC、TOCI、Index |
| 分组级 | NonStruct、Private、Quote、Note、Reference、BibEntry、Code、Span |
| 块级 | P、H、H1 到 H6、Title、FENote、Sub |
| 行内级 | Lbl、Em、Strong、Link、Annot、Form、Ruby、RB、RT、RP、Warichu、WT、WP |
| 插图级 | Figure、Formula |
| 表格级 | Table、TR、TH、TD、THead、TBody、TFoot |
| 列表级 | L、LI、LBody |
PDF.js 在这里不做白名单校验,原因很实际:规范的标准类型在十几年里扩展过多次(例如 Ruby、Warichu 就是后来加的),硬编码白名单会让新文件退化成未知角色。真正需要语义的地方在主线程:web/struct_tree_layer_builder.js 里有一张 PDF_ROLE_TO_HTML_ROLE 表,把 P 映射成 paragraph、Table 映射成 table、H2 按正则拆成 heading 加 aria-level,未知角色退化成普通 span。归一化放在消费端,解析端只负责忠实读取。
attributes 与 #collectAttributes 方法
结构元素的属性比角色复杂。属性可以来自两处:
/C引用的类名,去/ClassMap里查属性对象;/A直接挂的属性对象,可以是字典、数组,甚至流。
规范还允许属性数组里穿插「修订号」整数,表示该属性从第几版开始生效。PDF.js 的处理是全都要:整数直接跳过,其余按源顺序压栈。合并时类属性先,显式属性后,后者覆盖前者。
#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]);
}
}
}
}这段是整个文件里少数显式迭代的遍历,值得逐行看:
- 用
pending当栈,避免递归深度受属性图影响; visited只记录间接引用,因为只有引用能构成环。直接嵌套的字典是有限树,不会循环;- 流被降级成它的字典,因为属性以字典挂在流上;
- 数组倒序压栈,这样弹出顺序就是源顺序。注释里特意写明「Process entries in source order」,因为覆盖关系依赖顺序;
- 整数被跳过,不当成属性值。
visited 用的是 RefSet,键是引用号加 generation,不是对象身份。同一个对象被不同 Ref 指向时会被重复访问一次,但这不会造成误判,只是多一次 fetch,而 fetch 有缓存。
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") 在值不是数组时会包一层单元素数组,所以下面还要再判一次 Array.isArray。classMap.getRaw 取原始值,因为属性对象可以被多个元素共享,可能以引用形式存在。
最终用 shadow 固化成自有属性。shadow 的作用是把 getter 替换成数据属性,后续访问不再走函数调用;这也意味着属性只算一次,之后文档内的属性修改不会反映到这里。对于只读渲染是优化,对于编辑写回则要另走 clone 路径。
tableAttributes 方法
表格属性是结构树里最有实用价值的部分:屏幕阅读器靠 scope、headers 和 rowSpan 才能正确朗读表格。PDF.js 把它们从 PDF 属性对象翻译成短键:
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 键 | 适用角色 | 条件 | 序列化键 | 值 |
|---|---|---|---|---|
Summary | Table | 非空字符串 | summary | 解码后的 PDF 字符串 |
RowSpan | TH、TD | 整数且大于 1 | rowSpan | 整数 |
ColSpan | TH、TD | 整数且大于 1 | colSpan | 整数 |
Headers | TH、TD | 数组里至少一个字符串 | headers | 字符串数组 |
Scope | TH | 名字且为 Row、Column、Both | scope | 原名字 |
Short | TH | 非空字符串 | short | 解码后的 PDF 字符串 |
有一个容易被忽略的细节:无效值会删除已有的键。例如类属性给了 RowSpan 2,显式属性写了 RowSpan 1,代码执行 map.delete("rowSpan"),而不是保留类的 2。这是正确的覆盖语义——显式属性写 1 就是在说「不要跨行」。同理,Scope 写了非法值、Headers 写了空数组,都会把类里的值清掉。
O 必须是 Table,这是属性所有者的名字。其它所有者的属性(例如 Layout)被忽略,避免把版式属性误当成表格语义。
节点模型:StructElement 与 kid 的解析
树里的节点只有两种:结构元素(有角色、有子节点)和内容项(指向内容流里的一个标记内容序列,或一个注释对象)。源码用一个很轻的类表示后者:
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 不是 PDF 里的父链,而是构建期补上的向上指针。addNode 向上找父节点时,会在父节点的 kids 里找到指向当前字典的那一项,把它的 parentNode 设成当前元素;序列化时靠这个指针从任意子节点往下走。
StructElementNode 的构造与 parseKids
class StructElementNode {
constructor(tree, dict) {
this.tree = tree;
this.xref = tree.xref;
this.dict = dict;
this.kids = [];
this.parseKids();
}节点构造时就解析 K,没有惰性。原因是页面级结构树本来就只挑本页的节点,数量可控;惰性只会让序列化阶段多一层异步判断。
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 是继承属性:结构元素自己不写页码时,子内容沿用父元素所在页。这里先把父的 pageObjId 取出来,作为 parseKid 的默认值;如果 kid 自带 /Pg,parseKid 会覆盖它。
K 允许两种形态:单个值或数组。规范允许单 kid 不写数组,这个分支就是为此存在。
parseKid 方法
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,
});
}kid 的形态与节点类型的对应关系:
| kid 的原始形态 | 节点类型 | 必要条件 | 序列化产出 |
|---|---|---|---|
| 整数 | PAGE_CONTENT | 所属页等于当前页 | type: "content",id 为页加 MCID |
带 /Type /MCR 的字典 | STREAM_CONTENT | 所属页等于当前页 | 同上,/Stm 不进入输出 |
带 /Type /OBJR 的字典 | OBJECT | 所属页等于当前页 | type: "object",id 为对象引用 |
| 其它字典 | ELEMENT | 无 | 递归成子角色 |
注释(不来自 K,由 parse 注入) | ANNOTATION | 注释解析时登记过 | type: "annotation",带内部前缀 |
| 数字、名字、字符串、布尔、空值 | 无 | 一律丢弃 | 无 |
页面过滤是这里最关键的一行:
if (this.tree.pageDict.objId !== pageObjId) {
return null;
}结构树是文档级的,一个 P 里可以同时挂第 2 页和第 5 页的内容。StructTreePage 只为单页服务,所以凡是 pageObjId 不等于当前页字典对象号的内容项,全部丢弃。这样同一个结构元素在不同页被构建时,看到的 kids 是不同的子集——StructTreePage 是「投影」,不是「全量树」。
MCR 的 /Stm 是可选的内容流引用。规范里它用于把内容项绑定到特定表单 XObject 的流;当前序列化并不使用 refObjId,只输出 p 加页对象号加 _mc 加 MCID。原因很直接:文本层与注释层的 DOM id 是按页和 MCID 生成的,没有流这一维。如果同一页的不同流里出现重复 MCID,理论上会撞 id,但规范要求 MCID 在页面范围内唯一,所以这里选择信任输入。
设计要点:
parseKid把四种规范形态压成五个类型常量,靠的是「先看原始形态,再看/Type」的分派。整数和字典、MCR和OBJR的判定顺序不能换:MCR也是字典,OBJR也是字典,只能靠/Type区分;而整数必须先于字典判断,否则会走进instanceof Dict分支。所有不认识的值统一返回null,由parseKids过滤,没有异常路径。
页面级结构树:StructTreePage
StructTreePage 是整个文件的中心。它的注释写得很清楚:
/**
* 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.
*/这就是这一层的性能策略:不加载整棵树,只加载本页相关的部分。
class StructTreePage {
constructor(structTreeRoot, pageDict) {
this.root = structTreeRoot;
this.xref = structTreeRoot?.xref ?? null;
this.rootDict = structTreeRoot?.dict ?? null;
this.pageDict = pageDict;
this.nodes = [];
}构造参数允许 structTreeRoot 为空,此时 xref 与 rootDict 都是 null,后续所有方法都会早返回。这个「空对象也能构造」的设计,避免了调用方到处判空。
parse 方法
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;
}
}
}
}这段代码把两条入口合并成一次构建:
- 页面内容:
/StructParents是一个整数键,用它去ParentTree取出一个数组,数组里是本页所有顶层结构元素的引用。逐个fetch后addNode。 - 注释:遍历
structParentIds里本页的记录,用注释的StructParent编号去ParentTree取结构元素。取到后,在这个元素的kids里找到/Type /OBJR且Obj等于该注释引用的那一项,把类型从OBJECT改成ANNOTATION。
第二点的注释解释了为什么不能直接按编号改:一个结构元素可能同时包含多个注释或内容项。例如一个 Link 元素下可能挂两个 OBJR,分别指向两个链接注释;只有引用号能精确匹配到其中一个。改类型而不是新建节点,也保证了序列化时同一个元素只出现一次。
collectObjects 方法
写回路径需要「标签编号到标签字典引用」的映射,collectObjects 就是它的只读版本:
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;
}两者都要求 pageRef 是 Ref。直接对象没有稳定编号,写回时无法定位,所以这类页面直接放弃结构树编辑。parse 取的是解引用后的节点,collectObjects 保留的是引用本身,这是两者最本质的差别。
addNode 方法
addNode 是构建的核心,也是唯一递归向下、再向上回溯的地方:
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;
}执行顺序值得拆开看:
- 深度闸门:超过 40 层就告警并返回
null。这个检查在入口,防止恶意或损坏文件制造极深的父子链。 - 去重:
map的键是字典对象身份。同一个字典被多个父元素引用时(规范不允许,但现实中存在),只构建一次,返回已有节点。这同时切断了环:环上的第二次访问会直接命中map。 - 先建节点,再向下补列表与表格:
new StructElementNode已经解析了kids,但只有列表与表格会主动把空容器也收进来。level - 1是刻意的:这部分递归不消耗深度预算,因为这些容器通常层级深但结构固定。注释说得很直白:「even empty ones」——空的TR也要保留,否则表格的行列结构会缺格。 - 向上找父:
dict.get("P")解引用后得到父字典。父字典缺失,或者父的类型是StructTreeRoot,说明当前已经是顶层,交给addTopLevelNode按根/K的下标放置。放不进去(返回false)就从map删除——游离节点不进树。 - 递归父节点:
level + 1。父节点构建失败(返回null,通常是超过深度)时,当前节点仍返回,但不再挂到父上。 - 建立向上指针:遍历父的
kids,找到类型为ELEMENT且字典身份相同的项,把parentNode指向当前元素。找不到说明父的K里没有这个孩子(数据不一致),同样从map删除。
第 6 步的 map.delete(dict) 容易被误读成「销毁节点」。它只是把该字典从「已构建」索引里移除,让后续若再从别的路径访问时可以重新构建一次。真正决定节点是否进入输出的,是它有没有被父的 kids 链住,或者有没有进入 this.nodes。
addTopLevelNode 方法
addTopLevelNode(dict, element) {
const index = this.root.getKidPosition(dict.objId);
if (isNaN(index)) {
return false;
}
if (index !== -1) {
this.nodes[index] = element;
}
return true;
}NaN 与 -1 的区别在这里体现:
NaN:根有/K,但该元素不在其中。返回false,调用方删除节点。-1:根的/K形态不认识。仍然返回true,节点保留在map里,但不会进入nodes。这意味着它只能通过别的父节点的kids被访问;如果它本来就是顶层,就永远不会出现在输出里。
用根 /K 的下标直接做 nodes 的数组下标,好处是序列化时天然按阅读顺序输出,不需要额外排序。代价是 nodes 可能有大量空槽(根 /K 里有些顶层元素属于别的页),所以序列化时要显式跳过空值。
设计要点:
addNode同时做三件事——向下补列表表格、向上找父链、横向去重。看起来耦合,实际上它们共享同一个map:向下是为了让空容器也有节点,向上是为了补parentNode指针,去重是为了防环。三者顺序不能乱,否则会重复构建或漏挂父指针。map.delete不是失败标记,而是「这次没挂上,允许以后重试」。局部树的关键前提是ParentTree:没有它就只能从根遍历,这也是parse一上来就检查parentTree的原因。
序列化:serializable 与跨线程契约
结构树在 Worker 里构建,在宿主线程里消费。两者之间不能传对象引用,只能传可结构化克隆的纯数据。serializable 就是这条边界:
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) 不是洁癖,而是安全措施:PDF 里可能出现名为 __proto__、constructor 的角色或属性,用无原型对象可以避免原型链污染。这个模式在整个序列化函数里贯穿到底。
根节点是一个普通字面量,而不是某个类:
const root = Object.create(null);
root.children = [];
root.role = 'Root';
for (const child of this.nodes) {
if (!child) {
continue;
}
nodeToSerializable(child, root);
}
return root;这就是旧版本里 RootNode 的位置。当前实现不需要它承担任何逻辑,只要一个 role 为 "Root"、children 为数组的容器。主线程的 #walk 会把它当成普通节点处理,最后拿到一个顶层 span。
序列化字段
| 字段 | 来源 | 条件 | 说明 |
|---|---|---|---|
role | /S 经 RoleMap 归一化 | 总是存在 | 未知角色原样传递 |
children | kids 的投影 | 总是存在,可能为空数组 | 子节点或内容项 |
alt | /Alt,回退 /ActualText | 值为字符串 | 解码后的可读文本 |
structId | /ID | 角色为 TH 且值非空 | 表格头的稳定标识,不叫 id |
summary | 表格属性 Summary | 角色为 Table | 表格摘要 |
rowSpan | 表格属性 RowSpan | 大于 1 | 跨行 |
colSpan | 表格属性 ColSpan | 大于 1 | 跨列 |
headers | 表格属性 Headers | 非空字符串数组 | 关联表头 id |
scope | 表格属性 Scope | Row、Column、Both | 表头作用域 |
short | 表格属性 Short | 非空字符串 | 表头缩写 |
mathML | AF 附件或 MSFT_MathML | 角色为 Formula 且解析成功 | 公式的 MathML 文本 |
bbox | 属性 BBox,回退 Width 与 Height | 至少一个可用 | 元素包围盒 |
lang | /Lang | 值为字符串 | 语言标签 |
关于 structId 的注释值得引用原文:
// 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);
}同一个对象里,id 已经被内容项占用(内容项用 p1R_mc5 这样的字符串,注释用内部前缀)。如果结构元素也叫 id,主线程就无法区分「这是一个元素」还是「这是一个内容锚点」。命名冲突在这里是语义冲突,不是风格问题。/IDTree 同样不被这棵树读取:它只在编辑器写回时维护,读取侧只需要元素自身的 /ID。
子节点的三种输出
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}`,
});
}
}| 节点类型 | 输出 type | id 形式 | 消费端如何用 |
|---|---|---|---|
ELEMENT | 递归成子对象 | 无 | 继续建 DOM |
PAGE_CONTENT | content | p 加页对象号,加 _mc 加 MCID | 与文本层标记内容容器的 id 对齐 |
STREAM_CONTENT | content | 同上 | 同上,忽略 /Stm |
OBJECT | object | 对象引用字符串 | 交给注释层认领 |
ANNOTATION | annotation | pdfjs_internal_id_ 加引用字符串 | 与注释层元素的 id 对齐 |
ELEMENT 分支有一个陷阱:它用的是 kid.parentNode,不是 kid.dict 直接重建。也就是说,只有被 addNode 成功挂上父指针的子节点才会被序列化。如果 addNode 因为深度或数据不一致没挂上,这个孩子就静默消失。这是有意为之:宁可少一个子节点,也不要输出一棵断链的树。
OBJECT 与 ANNOTATION 的区别只在类型和前缀。为什么注释要多一个前缀?因为注释层的 DOM 元素 id 就是 AnnotationPrefix 加引用号,结构树必须产出同样的字符串,主线程才能用 getElementById 或 querySelector 找到它。而 OBJECT 可能是表单域、XObject 等其它对象,没有对应的 DOM 元素,所以保留裸引用号,由上层决定怎么处理。
与 display 层文本层的对接
文本层在 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,
});而 getPageObjId 的实现是:
static getPageObjId() {
return `p${ref.toString()}`;
}所以文本层的 id 是 p1R_mc5 这种形式,和结构树里的 p${kid.pageObjId}_mc${kid.mcid} 完全一致。主线程的 StructTreeLayerBuilder 在建 DOM 时,会按这些 id 找到文本层里的 span:单内容子节点时直接把属性下沉到父元素,公式节点则把对应文本项隐藏,避免屏幕阅读器把 MathML 和原始文本读两遍。
| 层 | 生成者 | id 样例 | 关联方式 |
|---|---|---|---|
| 结构树 | serializable | p1R_mc5 | 作为 content 子项的 id |
| 文本层 | PartialEvaluator 标记内容分支 | p1R_mc5 | beginMarkedContentProps |
| 注释层 | AnnotationLayer | pdfjs_internal_id_12R | 注释元素自身的 id |
| 结构树注释项 | serializable | pdfjs_internal_id_12R | 作为 annotation 子项的 id |
这张表解释了为什么 serializable 要费劲拼字符串:跨线程没有对象身份,只有约定好的字符串。任何一端改了拼法,另一端就断链,而且不会报错,只会静默失去语义。
与注释层的对接
注释层不只消费 id,还要消费 aria 属性。StructTreeLayerBuilder 暴露 getAriaAttributes 与 getAnnotationIds,AnnotationLayer 在渲染每个注释时用 ${AnnotationPrefix}${id} 向它查询。
Link 角色还有一套「链接归属」机制:结构树里的 Link 元素默认持有它下面的文本,但当对应的链接注释真正可交互时,会把 aria-owns 转给注释元素,并把结构树元素的 role 移除。这套逻辑在 web/struct_tree_layer_builder.js 里,不在本篇范围,但它的输入完全来自 serializable 的 role 与 children。
设计要点:序列化的目标不是「完整还原 PDF 字典」,而是「够主线程建 DOM」。因此它只挑
role、alt、表格属性、bbox、lang这几类信息,丢掉/P、/K的原始形态、/Pg引用等内部细节。id用约定字符串而不是引用对象,是为了跨线程;structId单独命名,是为了避免和内容锚点撞车。输出契约越窄,两端越不容易一起腐化。
属性语义:alt、ActualText、BBox 与表格
序列化里几处取值逻辑看似琐碎,实际决定了朗读体验。
alt 与 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 是「替代描述」,给图片、公式、图表用;/ActualText 是「实际文本」,给那些视觉上是图形、语义上是文字的内容用(例如连字、装饰性字形)。规范里两者语义不同,但对屏幕阅读器来说都是「这段内容的可读文本」。这里的优先级是 /Alt 先,缺失才用 /ActualText。
为什么不是拼接?因为两者同时存在时,/ActualText 描述的是内容流里的字符替换,/Alt 描述的是整个元素的替代,前者更细,后者更完整。对于叶子元素,/Alt 通常就是作者想让人读的那句话。这是一个经验取舍,不是规范强制。
stringToPDFString 负责处理 PDF 的三种字符串编码:PDFDocEncoding、UTF-16BE 带 BOM、以及 UTF-8 BOM。不做这一步,中文与符号会变成乱码。
bbox 与 Width、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 是四元数组,可能顺序反了(右下角在前),lookupNormalRect 会把它归一化成左上角到右下角。Width 与 Height 只能给出尺寸,给不出位置,所以退化成原点在左上角的矩形。注释里的取舍写得很清楚:优先 BBox,因为尺寸无法恢复位置。
后面的 TODO 也值得注意:如果两者都没有,理论上可以从内容流的标记内容区间反算包围盒,但当前没有做。这说明 bbox 是「有则更好」的可选信息,主线程拿到空值也能建 DOM,只是无法做「点击结构元素高亮对应区域」这类交互。
Formula 与 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}".`);
}
}公式的处理是整份文件里唯一显式的 try 块。原因在 mathML getter:它要遍历 /AF 附件数组,取文件流、比对 AFRelationship 与 Subtype,任何一步都可能遇到损坏数据或未加载的流。
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());两条路径,一条合规一条兼容:
- 合规路径:
/AF里的文件规范,AFRelationship为Supplement,嵌入文件子类型为application/mathml+xml。取流内容,按 UTF-8 解码。 - 兼容路径:属性对象里
O为MSFT_Office且带MSFT_MathML。注释直说「isn't in the spec」,但 Office 生成的标签 PDF 里确实这么写。
这个 getter 每次访问都会重新读流(没有 shadow)。但序列化里只对 Formula 调用一次,且外层有 try,所以成本可控。MissingDataException 必须重抛,因为流可能还没下载完;其它异常只告警,公式退化成普通元素,不影响整页。
lang
const lang = node.dict.get('Lang');
if (typeof lang === 'string') {
obj.lang = stringToPDFString(lang);
}/Lang 是语言标签,例如 zh-CN、en-US。它可以让屏幕阅读器切换发音词典,也可以让浏览器选择正确的断词与字体回退。注意这里只读元素自身的 /Lang,没有做继承。规范里 /Lang 是可继承属性,父元素的 Lang 应当作用于子元素。主线程建 DOM 时,lang 写在 span 上,HTML 的继承会自动处理后代,所以这里只输出显式写了的那一层就够了。
设计要点:属性读取全部遵循「能读就读,读不到就退化」:
alt有回退、bbox有回退、mathML有兼容路径、lang直接透传。没有任何一处因为缺少可选属性而中断序列化。这一层的目标是最大化可用语义,不是校验 PDF 是否合规。
写回路径:编辑注释时的结构树维护
前面都是读。文件的后半段(从 canCreateStructureTree 到 #updateParentTag,约 450 行)全部服务于写:用户给一个没有标签的注释补了「标题」「替代文本」,保存时必须把这棵结构树补上或改掉。
写回的总流程在 worker.js 里编排:
| 阶段 | 判断 | 分支 |
|---|---|---|
| 有无根 | ensureCatalog("structTreeRoot") | 有根走更新,无根走新建 |
| 新建前检查 | canCreateStructureTree | 目录有引用、页面有引用、至少一个标签 |
| 新建 | createStructureTree | 造根、造 ParentTree、写 changes |
| 更新前检查 | canUpdateStructTree | 根有引用、ParentTreeNextKey 合法、编号可用 |
| 更新 | updateStructureTree | 克隆现有结构,追加新标签 |
canCreateStructureTree 方法
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;
}
}
}
// ...三个前置条件缺一不可:
- 目录必须有引用。没有引用就无法把
StructTreeRoot写回目录字典。 - 每一页必须有引用。页面没有引用,父树的键就没有着落。
- 至少有一个注释带
accessibilityData.type。type就是结构角色,没有角色的标签没有意义。
nextKey 从 0 开始递增,给每个新标签分配 parentTreeId。这是父树的键空间,不是对象号。新建文档时从 0 开始是安全的,因为原本就没有父树。
任何一页缺引用都会 break,并设置 hasNothingToUpdate = true。后面的清理分支会删除已经分配的 parentTreeId,避免留下半成品状态。这个「先分配再回滚」的写法不优雅,但保证了失败时输入对象不被污染。
createStructureTree 方法
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,
});
}
}新建路径完全在内存里造一棵最小可用的树:
- 克隆目录字典,不改原对象;
- 用临时引用把
StructTreeRoot挂到目录上; - 根只有
Type、ParentTree、K、ParentTreeNextKey四个键,RoleMap与ClassMap都不造,因为没有自定义角色; ParentTree直接内联Nums数组,不套Kids;- 所有新对象先放进
cache,最后统一转成changes。
structTreeRoot: null 这个参数是新建与更新的分支开关:#writeKids 里用它判断「这是新建,没有旧标签可复用」。
RefMap 与 Map 的区别在这里很重要:键是 Ref 对象,但按引用号比较,所以同一个对象号的不同 Ref 实例能命中同一条记录。
canUpdateStructTree 方法
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;
}更新比新建苛刻得多,因为它要动别人的数据:
| 检查项 | 要求 | 失败后果 |
|---|---|---|
| 根引用 | 必须是 Ref | 无法写回 StructTreeRoot |
ParentTreeNextKey | 非负整数 | 新键分配没有起点 |
ParentTree | 是字典 | 无法追加 Nums |
Nums | 是数组 | 无法追加键值对 |
页面 StructParents | 若有则必须是整数且能在父树里取到数组 | 页面的内容索引与父树不一致 |
页面没有 StructParents 是被允许的,注释里解释了两点:内容流没有标记内容时不需要它;只给注释打标签的页面也可以没有它。这个宽松判断与 parse 里的双通道设计完全对应。
检查通过后,它还会调用 #collectParents 为每个已有标签找到父引用,并把 parentTreeId 分配给没有 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 而不是「不等于 -1」,是因为 undefined >= 0 为 false,这样就把「字段缺失」也归入需要新建的情况。注释特意说明了这一点,说明这里踩过坑。
updateStructureTree 方法
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);
// ...这段全是「解引用、克隆、再挂回去」的重复动作,但每一步都有理由:
- 根字典
clone(),不污染this.dict; ParentTree若是引用就取出来克隆,若内联就新建一个临时引用替换掉,避免直接改原字典;Nums若是引用,先记下numsRef,最后把新数组挂回同一个引用;若内联,直接替换成切片后的数组;- 数组一定
slice(),因为后续#writeKids会push,不能改到原数组。
newNextKey === -1 表示 #writeKids 没有任何要写的标签,此时直接返回,不写 changes。这个约定让「检查通过但实际无改动」的路径不会产生空更新。
#writeKids 方法
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);这里的分支是整条写回路径的核心:
| 情况 | 动作 | 结果 |
|---|---|---|
有旧根,注释已有 structParent,能找到标签 | 克隆旧标签字典,改属性,写入 changes | 原地更新,不建新节点 |
没有 structParent 或找不到标签 | 分配临时引用,新建标签,插入父的 K | 新增标签,写入 changes |
注释没有 accessibilityData.type | 跳过 | 不产生任何改动 |
旧标签的查找用 collectObjects 建缓存,按页懒加载,避免每个注释都重建整页索引。objs 可能是 null(页面没有引用或没有 ParentTree),此时 ?.get 返回 undefined,自然落到新建分支。
新建标签时还会补一个 OBJR 结构:
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 是可选写,只在页面有引用时加。/Obj 是注释引用,必须写。nums.push(parentTreeId, tagRef) 则是把新键值对追加到父树的 Nums 数组——注意顺序,键在前,值在后。
#writeProperties 方法
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));
}
}| 输入字段 | PDF 键 | 编码 |
|---|---|---|
type | S | Name,必写 |
title | T | ASCII 或 UTF-16BE,按需选择 |
lang | Lang | 同上 |
alt | Alt | 同上 |
expanded | E | 同上 |
actualText | ActualText | 同上 |
stringToAsciiOrUTF16BE 是写侧的编码器:纯 ASCII 直接写单字节,含非 ASCII 则转 UTF-16BE 并加 BOM。这比一律用 UTF-16 省空间,也比一律用 PDFDocEncoding 安全。注意它不处理空字符串:调用方用 if (title) 过滤,空字符串会被当成「没有这个属性」,也就不会覆盖旧值。这正好符合「编辑框留空表示不修改」的交互预期。
#collectParents 方法
更新已有标签时,需要知道它在树里的位置,才能把新标签插到正确的兄弟位置。#collectParents 从页面的 StructParents 数组出发,建立「MCID 或标签编号到父标签」的映射:
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 里存两样东西:标签自身的引用 ref,以及标签字典 dict(后续取 P 用)。element.structTreeParentId 的格式来自编辑器,形如 p7R_mc3,代码用字符串切分取出 MCID:
if (element.structTreeParentId) {
const id = parseInt(element.structTreeParentId.split('_mc')[1], 10);
idToElements.getOrInsertComputed(id, makeArr).push(element);
}这里直接 split("_mc") 而不做完整解析,因为格式由编辑器自身生成,属于内部约定。parseInt 对非法输入返回 NaN,idToElements 里就不会命中,元素保持没有父,最终落到根下。
#updateParentTag 方法
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);插入位置是「旧标签的后面」,不是「父的末尾」。这一点很关键:同一段文字里连续插入多个标签时,如果都塞到末尾,阅读顺序就会错乱。indexOf 找不到时退化成追加到末尾,属于容错。fallbackKids 是新建路径传入的根 K 数组:父字典取不到时,新标签直接挂到根下,至少保证标签不丢,代价是层级可能不对。
设计要点:写回路径的所有改动都发生在克隆体上,原始
Dict不被修改;新对象用临时引用(getNewTemporaryRef)占位,最后统一交给changes序列化。这样即使中途抛错,文档对象也保持原样,保存流程可以整体放弃。增量更新的核心不是「改对」,而是「改不坏」。
容错:深度、循环与缺数据
这一层面对的输入质量极差:标签 PDF 可能由几十种工具生成,结构树可能只有一半,父链可能断,ParentTree 可能指向不存在的对象。代码里没有集中的异常处理,而是把容错分散到每个入口。
三种遍历策略
| 遍历点 | 策略 | 保护手段 | 超限行为 |
|---|---|---|---|
#collectAttributes | 显式栈迭代 | RefSet 记录访问过的引用 | 跳过重复引用 |
addNode | 递归 | MAX_DEPTH = 40、map 去重 | 告警并返回 null |
nodeToSerializable | 递归 | MAX_DEPTH = 40 | 告警并跳过该子树 |
属性图必须迭代,因为环可能很长,而且属性对象数量不受页面限制。结构树必须递归,因为要同时向下展开和向上回溯,迭代写起来会非常绕;深度限制 40 已经远超人眼可读的嵌套层级。序列化的递归无法回溯,深度同样卡 40。
MAX_DEPTH 定义在文件顶部:
const MAX_DEPTH = 40;两个地方各用一次,两处告警文案不同:StructTree MAX_DEPTH reached. 与 StructTree too deep to be fully serialized.。前者是构建失败,后者是输出截断。区分文案是为了线上排查时能看出问题发生在哪一阶段。
addNode 的 map 去重是弱保护:它只能防「同一个字典对象」的重复构建;同形引用成环的情况,属性解析由 RefSet 兜住,父链则由 40 层深度上限兜住。如果文件里存在两个内容相同但对象号不同的字典互相指父,仍然会一直向上走到深度上限。这种情况极少见,代价也只是 40 层递归,所以没有做更强的环检测。
异常与缺数据的处理
| 场景 | 处理 | 代码位置 |
|---|---|---|
| 结构树在分块文件中未加载 | 重抛 MissingDataException | Catalog、Formula 分支 |
| 根字典不是字典 | 返回 null | #readStructTreeRoot |
| 页面不是引用 | 不构建、不收集对象 | parse、collectObjects |
| 父树缺失 | 直接返回 | parse |
ParentTree 取不到数组 | 跳过该页的内容入口 | parse |
| 父字典取不到 | 节点不挂父,或退回根 K | addNode、#updateParentTag |
mathML 解析抛错 | 告警,丢弃 mathML | nodeToSerializable |
| 整页结构树生成失败 | 告警,返回 null | PDFPage.getStructTree |
PDFPage.getStructTree 的兜底值得单独看:
try {
const structTree = await this.pdfManager.ensure(this, '_parseStructTree', [structTreeRoot]);
return await this.pdfManager.ensure(structTree, 'serializable');
} catch (ex) {
warn(`getStructTree: "${ex}".`);
return null;
}它把「构建」和「序列化」都包在 try 里,任何失败都只告警并返回 null。主线程的 StructTreeLayerBuilder 拿到 null 时不建 DOM,注释层与文本层照常工作。结构树永远不是渲染的前置条件,这是整个容错策略的底线。
设计要点:容错的粒度与「失败的代价」对齐。属性解析失败只是少一个
bbox,所以用告警;结构树构建失败会让整页失去语义,所以整段try兜底返回null;数据没下载完则必须重抛,因为重试是有意义的,不能当成永久失败。三种处理对应三种语义,不能混用。
总结
struct_tree.js 是 PDF.js 的无障碍语义层:向上给 PDFPage.getStructTree 提供可序列化的树,向下把 /StructTreeRoot、/ParentTree、/StructParents、/StructParent、MCID、OBJR 串成一条可降级的构建链路。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 根读取 | Catalog.#readStructTreeRoot | /StructTreeRoot 字典与引用 | StructTreeRoot |
| 映射建立 | StructTreeRoot 构造 | /RoleMap、/ParentTree | roleMap、parentTree |
| 页面构建 | StructTreePage.parse | 页面引用、/StructParents、注释编号 | nodes 数组 |
| 节点展开 | addNode、parseKids、parseKid | 结构元素字典、K | StructElementNode、StructElement |
| 属性归一化 | role、attributes、tableAttributes | /S、/C、/A | 标准化角色与表格属性 |
| 输出 | serializable | 局部树 | 纯对象字面量 |
| 消费 | StructTreeLayerBuilder | 序列化树 | 带 role 与 aria 的 DOM |
| 写回 | createStructureTree、updateStructureTree | 编辑后的注释与无障碍数据 | changes 里的新标签 |
用一句话串起来:Catalog 找到根,StructTreeRoot 提供映射与索引,StructTreePage 只挑本页的节点,serializable 把树压成字符串契约,主线程用它给文本与注释补上语义。
六条设计原则
- 只构建本页,不做全量树。结构树是文档级的,但页面只需要自己那一段。
parse从ParentTree的数组与structParentIds的增量记录出发,向上找父、向下补列表表格,构建量与页面相关而不是与文档相关。 - 两条入口通道,内容与注释分开。内容流走页面
/StructParents加MCID,注释走自身/StructParent。后者在注释解析时才登记进structParentIds,因此页面渲染不必等全文档注释,注释也不会因为页面没有/StructParents而丢失语义。 - 节点按字典身份去重,按根
/K下标排序。map以字典对象为键,天然切断重复构建与部分环;getKidPosition把根/K的下标直接映射成nodes的下标,序列化时无需再排序。去重与排序是两个独立问题,用两套结构解决。 - 角色只归一化,不做白名单。
RoleMap展开一层就够,未知角色原样传给主线程;真正的 HTML 角色映射放在struct_tree_layer_builder.js。解析端忠实,消费端适配,规范演进不会让解析端过期。 - 跨线程只传字符串契约。
serializable只输出role、alt、表格属性、bbox、lang,以及content、object、annotation三类子项的 id。内容 id 与文本层标记内容的 id 拼法完全一致,注释 id 与注释层 DOM 的 id 完全一致;没有对象身份,只有约定。 - 写回只改克隆体,统一走
changes。新建与更新都先克隆,再用临时引用占位,最后把结果塞进changes。中途任何一步失败,原文档对象都不受影响。增量更新的底线是「改不坏」,而不是「一定改对」。
结构树是 PDF 里最容易被忽略的一层:它不参与绘制,不影响排版,绝大多数阅读器也只是部分支持。但正是这一层把「一堆字形」变成「一段有标题、有列表、有表格头、有替代文本的文档」。PDF.js 用一棵按需构建的局部树、一套字符串化的输出契约、一组只改克隆体的写回路径,把这件事做成了一个可以随时降级为 null 的可选增强。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日