StreamPDFStreamPDF
返回博客

PDF.js 源码解读: struct_tree

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: struct_tree

前言

这一篇讲 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 方法

结构元素的属性比角色复杂。属性可以来自两处:

  1. /C 引用的类名,去 /ClassMap 里查属性对象;
  2. /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 键适用角色条件序列化键值
SummaryTable非空字符串summary解码后的 PDF 字符串
RowSpanTH、TD整数且大于 1rowSpan整数
ColSpanTH、TD整数且大于 1colSpan整数
HeadersTH、TD数组里至少一个字符串headers字符串数组
ScopeTH名字且为 Row、Column、Bothscope原名字
ShortTH非空字符串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;
        }
      }
    }
  }

这段代码把两条入口合并成一次构建:

  1. 页面内容:/StructParents 是一个整数键,用它去 ParentTree 取出一个数组,数组里是本页所有顶层结构元素的引用。逐个 fetch 后 addNode。
  2. 注释:遍历 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;
  }

执行顺序值得拆开看:

  1. 深度闸门:超过 40 层就告警并返回 null。这个检查在入口,防止恶意或损坏文件制造极深的父子链。
  2. 去重:map 的键是字典对象身份。同一个字典被多个父元素引用时(规范不允许,但现实中存在),只构建一次,返回已有节点。这同时切断了环:环上的第二次访问会直接命中 map。
  3. 先建节点,再向下补列表与表格:new StructElementNode 已经解析了 kids,但只有列表与表格会主动把空容器也收进来。level - 1 是刻意的:这部分递归不消耗深度预算,因为这些容器通常层级深但结构固定。注释说得很直白:「even empty ones」——空的 TR 也要保留,否则表格的行列结构会缺格。
  4. 向上找父:dict.get("P") 解引用后得到父字典。父字典缺失,或者父的类型是 StructTreeRoot,说明当前已经是顶层,交给 addTopLevelNode 按根 /K 的下标放置。放不进去(返回 false)就从 map 删除——游离节点不进树。
  5. 递归父节点:level + 1。父节点构建失败(返回 null,通常是超过深度)时,当前节点仍返回,但不再挂到父上。
  6. 建立向上指针:遍历父的 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 归一化总是存在未知角色原样传递
childrenkids 的投影总是存在,可能为空数组子节点或内容项
alt/Alt,回退 /ActualText值为字符串解码后的可读文本
structId/ID角色为 TH 且值非空表格头的稳定标识,不叫 id
summary表格属性 Summary角色为 Table表格摘要
rowSpan表格属性 RowSpan大于 1跨行
colSpan表格属性 ColSpan大于 1跨列
headers表格属性 Headers非空字符串数组关联表头 id
scope表格属性 ScopeRow、Column、Both表头作用域
short表格属性 Short非空字符串表头缩写
mathMLAF 附件或 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}`,
    });
  }
}
节点类型输出 typeid 形式消费端如何用
ELEMENT递归成子对象无继续建 DOM
PAGE_CONTENTcontentp 加页对象号,加 _mc 加 MCID与文本层标记内容容器的 id 对齐
STREAM_CONTENTcontent同上同上,忽略 /Stm
OBJECTobject对象引用字符串交给注释层认领
ANNOTATIONannotationpdfjs_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 样例关联方式
结构树serializablep1R_mc5作为 content 子项的 id
文本层PartialEvaluator 标记内容分支p1R_mc5beginMarkedContentProps
注释层AnnotationLayerpdfjs_internal_id_12R注释元素自身的 id
结构树注释项serializablepdfjs_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());

两条路径,一条合规一条兼容:

  1. 合规路径:/AF 里的文件规范,AFRelationship 为 Supplement,嵌入文件子类型为 application/mathml+xml。取流内容,按 UTF-8 解码。
  2. 兼容路径:属性对象里 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;
    }
  }
}
// ...

三个前置条件缺一不可:

  1. 目录必须有引用。没有引用就无法把 StructTreeRoot 写回目录字典。
  2. 每一页必须有引用。页面没有引用,父树的键就没有着落。
  3. 至少有一个注释带 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 键编码
typeSName,必写
titleTASCII 或 UTF-16BE,按需选择
langLang同上
altAlt同上
expandedE同上
actualTextActualText同上

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 层递归,所以没有做更强的环检测。

异常与缺数据的处理

场景处理代码位置
结构树在分块文件中未加载重抛 MissingDataExceptionCatalog、Formula 分支
根字典不是字典返回 null#readStructTreeRoot
页面不是引用不构建、不收集对象parse、collectObjects
父树缺失直接返回parse
ParentTree 取不到数组跳过该页的内容入口parse
父字典取不到节点不挂父,或退回根 KaddNode、#updateParentTag
mathML 解析抛错告警,丢弃 mathMLnodeToSerializable
整页结构树生成失败告警,返回 nullPDFPage.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、/ParentTreeroleMap、parentTree
页面构建StructTreePage.parse页面引用、/StructParents、注释编号nodes 数组
节点展开addNode、parseKids、parseKid结构元素字典、KStructElementNode、StructElement
属性归一化role、attributes、tableAttributes/S、/C、/A标准化角色与表格属性
输出serializable局部树纯对象字面量
消费StructTreeLayerBuilder序列化树带 role 与 aria 的 DOM
写回createStructureTree、updateStructureTree编辑后的注释与无障碍数据changes 里的新标签

用一句话串起来:Catalog 找到根,StructTreeRoot 提供映射与索引,StructTreePage 只挑本页的节点,serializable 把树压成字符串契约,主线程用它给文本与注释补上语义。

六条设计原则

  1. 只构建本页,不做全量树。结构树是文档级的,但页面只需要自己那一段。parse 从 ParentTree 的数组与 structParentIds 的增量记录出发,向上找父、向下补列表表格,构建量与页面相关而不是与文档相关。
  2. 两条入口通道,内容与注释分开。内容流走页面 /StructParents 加 MCID,注释走自身 /StructParent。后者在注释解析时才登记进 structParentIds,因此页面渲染不必等全文档注释,注释也不会因为页面没有 /StructParents 而丢失语义。
  3. 节点按字典身份去重,按根 /K 下标排序。map 以字典对象为键,天然切断重复构建与部分环;getKidPosition 把根 /K 的下标直接映射成 nodes 的下标,序列化时无需再排序。去重与排序是两个独立问题,用两套结构解决。
  4. 角色只归一化,不做白名单。RoleMap 展开一层就够,未知角色原样传给主线程;真正的 HTML 角色映射放在 struct_tree_layer_builder.js。解析端忠实,消费端适配,规范演进不会让解析端过期。
  5. 跨线程只传字符串契约。serializable 只输出 role、alt、表格属性、bbox、lang,以及 content、object、annotation 三类子项的 id。内容 id 与文本层标记内容的 id 拼法完全一致,注释 id 与注释层 DOM 的 id 完全一致;没有对象身份,只有约定。
  6. 写回只改克隆体,统一走 changes。新建与更新都先克隆,再用临时引用占位,最后把结果塞进 changes。中途任何一步失败,原文档对象都不受影响。增量更新的底线是「改不坏」,而不是「一定改对」。

结构树是 PDF 里最容易被忽略的一层:它不参与绘制,不影响排版,绝大多数阅读器也只是部分支持。但正是这一层把「一堆字形」变成「一段有标题、有列表、有表格头、有替代文本的文档」。PDF.js 用一棵按需构建的局部树、一套字符串化的输出契约、一组只改克隆体的写回路径,把这件事做成了一个可以随时降级为 null 的可选增强。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章