StreamPDFStreamPDF
返回博客

PDF.js 源码解读: catalog

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: catalog

前言

上一篇章讲完了 XRef:它把字节变成「可以按对象号取回的对象」。可是取回对象只是第一步。一份 PDF 里真正被上层反复使用的东西并不是零散的 12 0 R,而是文档级的语义结构:一共有多少页、页面树长什么样、每页显示什么标签、书签大纲是什么、有哪些命名目的地、附件列表、交互表单、可选内容组、结构树、元数据。把这些语义从根字典里读出来的那一层,就是 src/core/catalog.js 里的 Catalog。

用一句话概括:XRef 是图书馆的目录卡,Catalog 是把目录卡翻译成一本可读的书。它拿到 /Root 根字典,按 PDF 规范逐个读取文档级条目,并把结果整理成 PDFDocument、Page、PartialEvaluator 以及 Worker 消息层可以直接消费的形状。

Catalog 的输入非常窄:一个 pdfManager 和一个 xref。它的输出却很宽:numPages、getPageDict、getPageIndex、pageLabels、destinations、documentOutline、attachments、optionalContentConfig、viewerPreferences、openAction、jsActions、structTreeRoot、acroForm、permissions、metadata、baseUrl 等等。它的所有工作都围绕一个私有字段 #catDict 展开。

版本说明:本篇严格按当前仓库这一版 catalog.js 讲解。它大量使用属性 getter(例如 version、pageLabels、documentOutline),而不是 getVersion()、getPageLabels() 这样的方法;页面相关方法只有 getPageDict、getAllPageDicts、getPageIndex 三个是显式方法。旧版本或其它分支里常见的 getMarkedContentIdToPage、getAutoPrint、getXfa、getNeedsRendering、getCollection、getAcroForm、getOutline、getAttachments、getPermissions 在本版都不存在:needsRendering、collection、acroForm、documentOutline、attachments、permissions 这些 getter 分别承接了其中六项能力;XFA 数据在 PDFDocument 的 _xfaStreams 与 xfaData 中读取,表单字段填充在 annotation.js;本版没有 getMarkedContentIdToPage 与自动打印的对应实现。读源码时不要套用记忆里的其它版本 API。

模块总览

catalog.js 共 2069 行,可以切成十块。每一块负责一类文档级语义,而不是把根字典的每个键平铺成一个大函数:

区块关键成员职责
根与缓存constructor、#catDict、cloneDict、各类 cache 字段持有根字典,提供文档级共享缓存
标量开关version、lang、needsRendering读取文件版本、自然语言、纯 XFA 渲染开关
表单与权限acroForm、acroFormRef、permissions、markInfo、metadata暴露交互表单字典、权限位、标记信息与 XMP 元数据
页面树toplevelPagesDict、numPages、getPageDict、getAllPageDicts、getPageIndex遍历 /Pages 树,定位页字典,反查页序号
页面标签rawPageLabels、pageLabels、#readPageLabels把 /PageLabels 数字树展开成每页的显示标签
命名目的地destinations、getDestination、#readDests读取名称树与旧式 /Dests 字典
查看器行为pageLayout、pageMode、viewerPreferences、openAction、baseUrl读取打开文档时的布局、模式、偏好与基准 URL
大纲与附件documentOutline、documentOutlineForEditor、attachments、attachmentContent、rawEmbeddedFiles、xfaImages构造书签树、附件清单与 XFA 图片
脚本与 OCGjsActions、optionalContentConfig、structTreeRoot文档级动作、可选内容组、结构树入口
目标解析parseDestDictionary、#getDestFromStructElement把 Action、Dest、结构元素统一解析成 URL 或目标

再看对外暴露的成员类型,它们对应完全不同的调用方式:

种类例子是否缓存典型调用方
属性 getternumPages、pageLabels、documentOutline多数用 shadow 固化为数据属性pdfManager.ensureCatalog
同步查询方法getDestination、attachmentContent依赖内部缓存,但不固化自身Worker 消息处理
异步方法getPageDict、getAllPageDicts、getPageIndex、cleanup通过 pageDictCache、pageIndexCache 命中PDFDocument
静态工具parseDestDictionary无状态,额外目标解析复用大纲、OpenAction、注释

它在整条链路里的位置可以画成这样:

// PDF 字节
//   → XRef.parse / getCatalogObj        (上一篇章:定位与取回)
//   → Catalog.constructor                (本篇:根字典进入文档模型)
//   → #catDict                           (所有 getter 的共同起点)
//   → 页面树 / 名称树 / 大纲 / 附件 / 表单 / OCG / 结构树
//   → PDFDocument / Page / PartialEvaluator / Worker 消息

Catalog 不是解析器。它不负责把内容流变成 operatorList,也不负责绘制页面。它做的是文档模型的第一次组织:把规范里那些「文档级条目」从字典形态变成 JavaScript 里可查询的结构。下面的章节按源码顺序展开,但会把相关 getter 归并到同一主题,避免逐行翻译。

构造函数、根字典与文档级缓存

构造函数非常短,却包含了两个重要动作:校验根字典、强制验证顶层页面字典。

  constructor(pdfManager, xref) {
    this.pdfManager = pdfManager;
    this.xref = xref;
 
    this.#catDict = xref.getCatalogObj();
    if (!(this.#catDict instanceof Dict)) {
      throw new FormatError("Catalog object is not a dictionary.");
    }
    // Given that `XRef.parse` will both fetch *and* validate the /Pages-entry,
    // the following call must always succeed here:
    this.toplevelPagesDict; // eslint-disable-line no-unused-expressions
  }

xref.getCatalogObj() 返回的是 XRef.parse 阶段已经通过 Root 与 Pages 双重校验的根字典。即便如此,构造函数仍然再检查一次 instanceof Dict,因为 getCatalogObj 的返回值可能被恢复模式或第三方调用污染。第二个动作 this.toplevelPagesDict; 看起来像一句废话,实际是主动触发 getter:toplevelPagesDict 内部会检查 /Pages 是不是字典,如果不是就抛 FormatError。源码注释说明,XRef.parse 已经取回并验证过 /Pages,所以这里正常情况下不会失败;把失败提前到构造阶段,可以让「根字典坏了」的错误早于任何页面请求暴露。

cloneDict 是根字典唯一的复制出口:

  cloneDict() {
    return this.#catDict.clone();
  }

它被 StructTreeRoot 使用。结构树在编辑或打印时需要在不污染原始根字典的前提下附加信息,因此先克隆一份。Dict.clone 是浅拷贝,引用值仍然是 Ref,真正的对象取回依然走 xref。

私有字段与公开缓存字段全部在类顶部声明:

class Catalog {
  #actualNumPages = null;
 
  #annotationAttachmentIdByRef = new RefMap();
 
  #annotationAttachmentRefById = new Map();
 
  #soundAttachmentIds = new Set();
 
  #catDict = null;
 
  builtInCMapCache = new Map();
 
  fontCache = new RefMap();
 
  globalColorSpaceCache = new GlobalColorSpaceCache();
 
  globalImageCache = new GlobalImageCache();
 
  nonBlendModesSet = new RefSet();
 
  pageDictCache = new RefMap();
 
  pageIndexCache = new RefMap();
 
  pageKidsCountCache = new RefMap();
 
  standardFontDataCache = new Map();
 
  systemFontCache = new Map();
字段结构生命周期与作用
#catDictDict文档根字典,所有 getter 的起点
#actualNumPages整数或 null页面树 /Count 不可信时,由全文遍历得到的真实页数
#annotationAttachmentIdByRefRefMap注释附件引用到稳定 id 的映射
#annotationAttachmentRefByIdMap稳定 id 回到引用,供 attachmentContent 按需取回
#soundAttachmentIdsSet标记哪些附件是声音流,取内容时要包成 WAV
builtInCMapCacheMap内置 CMap 缓存,按编码名索引
fontCacheRefMap字体字典缓存,按字体 Ref 索引
globalColorSpaceCacheGlobalColorSpaceCache颜色空间缓存,跨文档任务共享
globalImageCacheGlobalImageCache图片缓存,cleanup 时按需清空
nonBlendModesSetRefSet已确认不是混合模式的图形状态引用
pageDictCacheRefMap页字典缓存,值可能是 Promise,用于并行预取
pageIndexCacheRefMap页引用到页序号的缓存
pageKidsCountCacheRefMap页树节点到其下页数的缓存
standardFontDataCacheMap标准字体数据缓存
systemFontCacheMap系统字体缓存

这些缓存分为两类:解析缓存(字体、颜色空间、图片)和页面树索引缓存(pageDict、pageIndex、pageKidsCount)。前者服务渲染,后者服务导航。把它们挂在 Catalog 上而不是模块全局,是因为它们必须随文档一起销毁;cleanup 会统一清空。

shadow 是理解整个类行为的关键。它把原型上的 getter 替换成对象自身的只读数据属性:

function shadow(obj, prop, value, nonSerializable = false) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(prop in obj, `shadow: Property "${prop && prop.toString()}" not found in object.`);
  }
  Object.defineProperty(obj, prop, {
    value,
    enumerable: !nonSerializable,
    configurable: true,
    writable: false,
  });
  return value;
}

第一次访问 catalog.pageLabels 时执行 getter,算出结果后写入 catalog.pageLabels 这个自有属性;第二次访问直接读属性,不再执行任何解析。enumerable 默认为真,是因为这些值需要被结构化克隆或序列化;configurable 为真,是为了 cleanup 或测试能够重新定义。shadow 同时是懒加载与记忆化:没有访问就不解析,访问一次就固化。

设计要点:Catalog 的缓存不是「一个万能 Map」,而是按数据性质拆分:RefMap 用于以引用为键的页面、字体;普通 Map 用于名称字符串;Set 用于标记;全局缓存类用于跨任务共享。拆分的收益是 cleanup 可以精确决定清谁、不清谁。比如 pageDictCache 必须清,因为页字典可能持有解码后的流;#catDict 不能清,因为它是文档根。把所有东西塞进一个 Map,清理时只能一刀切,阅读场景下会反复重建字体索引。

版本、语言、渲染开关与表单入口

这些 getter 都很短,但每一个都对应规范里的一个文档级条目,并且都遵守同一条容错策略:缺失或类型不对就回退默认值,绝不把坏值丢给上层。

version 与 lang

  get version() {
    const version = this.#catDict.get("Version");
    if (version instanceof Name) {
      if (PDF_VERSION_REGEXP.test(version.name)) {
        return shadow(this, "version", version.name);
      }
      warn(`Invalid PDF catalog version: ${version.name}`);
    }
    return shadow(this, "version", null);
  }
 
  get lang() {
    const lang = this.#catDict.get("Lang");
    return shadow(
      this,
      "lang",
      lang && typeof lang === "string" ? stringToPDFString(lang) : null
    );
  }

/Version 是根字典里的 PDF 版本名。规范允许它覆盖文件头里的版本;PDF_VERSION_REGEXP 是 /^[1-9]\.\d$/,只接受「一位数字、点、一位数字」。像 /1.10 或 /PDF-1.7 这样的写法会被拒绝,返回 null,由 PDFDocument 回退到文件头解析出的版本。/Lang 是文档的自然语言标签,规范用 BCP 47 风格的语言代码;PDF 字符串可能是 PDFDocEncoding 或 UTF-16BE,因此必须走 stringToPDFString。这个值会进入文本内容,最终写到文本层的 canvas.lang,供无障碍工具与文本选择使用。

needsRendering:纯 XFA 文档的开关

  /**
   * @type {boolean} `true` for pure XFA documents,
   *   `false` for XFA Foreground documents.
   */
  get needsRendering() {
    const needsRendering = this.#catDict.get("NeedsRendering");
    return shadow(
      this,
      "needsRendering",
      typeof needsRendering === "boolean" ? needsRendering : false
    );
  }

/NeedsRendering 是 XFA 相关条目:为真时表示这份文档是纯 XFA,传统页面内容没有意义,必须由 XFA 布局引擎生成页面;为假时是 XFA Foreground,AcroForm 页面仍然有效。PDFDocument.xfaFactory 的判断条件是 enableXfa、catalog.needsRendering、formInfo.hasXfa、!formInfo.hasAcroForm 四者同时成立。也就是说,Catalog 只提供「是否纯 XFA」这一个布尔信号,真正的 XFA 流解析在 PDFDocument 的 _xfaStreams 里完成。

collection 与 acroForm

  get collection() {
    let collection = null;
    try {
      const obj = this.#catDict.get("Collection");
      if (obj instanceof Dict && obj.size > 0) {
        collection = obj;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info("Cannot fetch Collection entry; assuming no collection is present.");
    }
    return shadow(this, "collection", collection);
  }

/Collection 是可移植集合(Portable Collection)的入口字典,描述附件如何以列表、封面、分栏等形式展示。Detail 里的字段定义、Sort 排序规则都在这个字典内。PDF.js 只关心「是否存在」以及把字典交给上层,不在这里展开字段。注意 obj.size > 0:空字典按「没有集合」处理,避免上层为一个空集合启用集合界面。

  get acroForm() {
    let acroForm = null;
    try {
      const obj = this.#catDict.get("AcroForm");
      if (obj instanceof Dict && obj.size > 0) {
        acroForm = obj;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info("Cannot fetch AcroForm entry; assuming no forms are present.");
    }
    return shadow(this, "acroForm", acroForm);
  }
 
  get acroFormRef() {
    const value = this.#catDict.getRaw("AcroForm");
    return shadow(this, "acroFormRef", value instanceof Ref ? value : null);
  }

/AcroForm 是交互表单字典,规范里包含 /Fields、/NeedAppearances、/DA、/DR、/XFA、/CO 等。Catalog 只负责暴露字典本身,字段树的展开与字段值的填充由 annotation.js 的 WidgetAnnotation / AnnotationFactory 完成,计算顺序 /CO 由 PDFDocument 读取。很多 PDF 把 /AcroForm 写成间接引用,acroFormRef 用 getRaw 保留引用号,编辑场景需要它来定位对象。两个 getter 分开是因为「有没有表单」和「表单对象是哪个」是两个问题:前者用于 formInfo 的布尔判断,后者用于写回与签名。

permissions:把有符号整数变成权限集合

  #readPermissions() {
    const encrypt = this.xref.trailer.get("Encrypt");
    if (!(encrypt instanceof Dict)) {
      return null;
    }
 
    let flags = encrypt.get("P");
    if (typeof flags !== "number") {
      return null;
    }
 
    // PDF integer objects are represented internally in signed 2's complement
    // form. Therefore, convert the signed decimal integer to a signed 2's
    // complement binary integer so we can use regular bitwise operations on it.
    flags += 2 ** 32;
 
    const permissions = new Set();
    for (const value of Object.values(PermissionFlag)) {
      if (flags & value) {
        permissions.add(value);
      }
    }
    return permissions;
  }

权限位不写在根字典里,而写在加密字典的 /P 里,所以 Catalog 去读 xref.trailer。规范里 /P 是一个有符号 32 位整数,位 3 到位 12 表示打印、修改、复制、注释等权限;PDF.js 内部用 JavaScript 的 32 位位运算,就先把负数加上 2 ** 32 转成无符号视图。PermissionFlag 的值正好对应这些位:

常量位值规范含义
PRINT0x04允许打印
MODIFY_CONTENTS0x08允许修改内容
COPY0x10允许复制文本与图形
MODIFY_ANNOTATIONS0x20允许添加、修改注释与表单字段
FILL_INTERACTIVE_FORMS0x100允许填写已有表单字段
COPY_FOR_ACCESSIBILITY0x200允许为无障碍复制
ASSEMBLE0x400允许插入、旋转、删除页面
PRINT_HIGH_QUALITY0x800允许高质量打印

源码里的常量名是 FILL_IN_INTERACTIVE_FORMS,与本表的「填写已有字段」是同一件事。没有 /Encrypt 或 /P 不是数字时返回 null,表示「没有可报告的权限限制」,而不是「什么都不能做」。这是一个重要的语义选择:阅读器把 null 当成「无加密、无限制」,把 Set 当成「加密文档声明的允许集合」。

markInfo、metadata、structTreeRoot

  #readMarkInfo() {
    const obj = this.#catDict.get("MarkInfo");
    if (!(obj instanceof Dict)) {
      return null;
    }
    const markInfo = new Map();
 
    for (const key of ["Marked", "UserProperties", "Suspects"]) {
      const val = obj.get(key);
      markInfo.set(key, typeof val === "boolean" ? val : false);
    }
    return markInfo;
  }

/MarkInfo 是标记 PDF(Tagged PDF)的声明字典,三个键的含义分别是:Marked 表示内容是否带结构标记,UserProperties 表示是否有用户属性,Suspects 表示是否含可疑结构。用户属性、可疑结构会影响无障碍与内容提取策略,因此源码统一返回 Map,缺键补 false。markInfo getter 外层捕获异常,坏字典只警告并返回 null。

  get metadata() {
    const streamRef = this.#catDict.getRaw("Metadata");
    if (!(streamRef instanceof Ref)) {
      return shadow(this, "metadata", null);
    }
 
    let metadata = null;
    try {
      const stream = this.xref.fetch(
        streamRef,
        /* suppressEncryption = */ !this.xref.encrypt?.encryptMetadata
      );
 
      if (
        stream instanceof BaseStream &&
        isDict(stream.dict, "Metadata") &&
        isName(stream.dict.get("Subtype"), "XML")
      ) {
        // XXX: This should examine the charset the XML document defines,
        // however since there are currently no real means to decode arbitrary
        // charsets, let's just hope that the author of the PDF was reasonable
        // enough to stick with the XML default charset, which is UTF-8.
        const data = stringToUTF8String(stream.getString());
        if (data) {
          metadata = new MetadataParser(data).serializable;
        }
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info(`Skipping invalid Metadata: "${ex}".`);
    }
    return shadow(this, "metadata", metadata);
  }

/Metadata 是 XMP 元数据流,规范要求流字典带 /Type /Metadata 与 /Subtype /XML。源码用 getRaw 拿引用,再用 xref.fetch 取流;suppressEncryption 传入 !encryptMetadata,意思是:如果加密字典明确说「元数据不加密」,就不解密,直接读原始字节。读出的字节按 UTF-8 解码,交给 MetadataParser 修复 Ghostscript 产生的坏 XML,再返回 { parsedData, rawData } 两段可序列化结果。XMP 里常用于文档标题、作者、创建工具,上层通过 parsedData 查询。

  get structTreeRoot() {
    let structTree = null;
    try {
      structTree = this.#readStructTreeRoot();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable read to structTreeRoot info.");
    }
    return shadow(this, "structTreeRoot", structTree);
  }
 
  #readStructTreeRoot() {
    const rawObj = this.#catDict.getRaw("StructTreeRoot"),
      obj = this.xref.fetchIfRef(rawObj);
    return obj instanceof Dict
      ? new StructTreeRoot(this.xref, obj, rawObj)
      : null;
  }

/StructTreeRoot 是 Tagged PDF 结构树的根,规范里包含 /K 子节点、/ParentTree 父树、/RoleMap 角色映射、/ClassMap 属性类映射等。StructTreeRoot 构造函数会读取 RoleMap 与 ParentTree,把结构树变成可查询对象。根引用(rawObj)被一起传进去,是因为后续 getKidPosition 需要区分「这个字典是不是根」以及按引用比较子节点。hasStructTree 只是一个廉价的布尔判断,供 GetMetadata 同时返回「元数据」和「是否有结构树」。

baseUrl:相对链接的基准

  get baseUrl() {
    const uri = this.#catDict.get("URI");
    if (uri instanceof Dict) {
      const base = uri.get("Base");
      if (typeof base === "string") {
        const absoluteUrl = createValidAbsoluteUrl(base, null, {
          tryConvertEncoding: true,
        });
        if (absoluteUrl) {
          return shadow(this, "baseUrl", absoluteUrl.href);
        }
      }
    }
    return shadow(this, "baseUrl", this.pdfManager.docBaseUrl);
  }

/URI 是文档目录里的 URI 字典,/Base 是解析相对 URI 的基准。源码先尝试把 /Base 变成合法绝对 URL;失败就回退到 pdfManager.docBaseUrl(通常是加载 PDF 的页面地址)。这个值在后面解析 GoToR、Launch、URI 动作时用于把相对路径补成绝对链接。没有它,链接只能原样交给上层,安全校验也无法判断目标协议。

设计要点:这些短 getter 的共同模式是「读一个键、校验类型、回退默认值、shadow 固化」。真正值得注意的不是代码,而是错误分类:MissingDataException 必须原样上抛,因为分块网络流还没拿到那段字节,上层 PDFManager.ensure 会 requestRange 后重试;其它异常只说明这个条目坏了,警告并回退即可。把这两类错误混在一起,要么导致分块流永远拿不到数据,要么导致整个文档因一个坏条目而加载失败。

页面树:从 /Pages 到页字典

页面树是 Catalog 最核心的部分。规范把它定义成一棵以 /Pages 为根的树:内部节点有 /Kids 与 /Count,叶节点是 /Page 字典;每个节点有 /Parent 指回上级。/Count 是「这个子树下有多少页」,正确的 /Count 能让查找从 O(n) 降到 O(log n) 级别;可惜现实里经常写错,所以 Catalog 必须同时支持「按 Count 跳」与「逐页遍历」两条路径。

toplevelPagesDict、_pagesCount 与 numPages

  get toplevelPagesDict() {
    const pagesObj = this.#catDict.get("Pages");
    if (!(pagesObj instanceof Dict)) {
      throw new FormatError("Invalid top-level pages dictionary.");
    }
    return shadow(this, "toplevelPagesDict", pagesObj);
  }

/Pages 是页面树的根节点。这里直接抛 FormatError 而不是回退,因为一个没有页面树的 PDF 没有可展示内容;构造函数已经主动触发过这个 getter,所以错误会在 new Catalog 时就被发现。

  get _pagesCount() {
    const obj = this.toplevelPagesDict.get("Count");
    if (!Number.isInteger(obj)) {
      throw new FormatError(
        "Page count in top-level pages dictionary is not an integer."
      );
    }
    return shadow(this, "_pagesCount", obj);
  }
 
  get numPages() {
    return this.#actualNumPages ?? this._pagesCount;
  }

numPages 优先返回 #actualNumPages。这个字段只在 PDFDocument.checkLastPage 发现 /Count 与实际页数不一致时,通过 setActualNumPages(num) 写入。_pagesCount 的下划线前缀表示「内部兜底值」,它要求 /Count 必须是整数;负数没有在这里检查,因为后面 checkLastPage 会做 Number.isInteger 与 <= 1 的判断。

  setActualNumPages(num = null) {
    this.#actualNumPages = num;
  }
 
  get hasActualNumPages() {
    return this.#actualNumPages !== null;
  }

setActualNumPages() 不传参数时把值重置为 null,checkLastPage 开头就会这样做,确保每次校验都从 /Count 重新开始。hasActualNumPages 让 PDFDocument.numPages 知道应该相信哪个来源:如果已经全文遍历过,直接用真实页数,不再读 /Count。

getPageDict:带剪枝的深度优先遍历

  async getPageDict(pageIndex) {
    const nodesToVisit = [this.toplevelPagesDict];
    const visitedNodes = new RefSet();
 
    const pagesRef = this.#catDict.getRaw("Pages");
    if (pagesRef instanceof Ref) {
      visitedNodes.put(pagesRef);
    }
    const xref = this.xref,
      pageKidsCountCache = this.pageKidsCountCache,
      pageIndexCache = this.pageIndexCache,
      pageDictCache = this.pageDictCache;
    let currentPageIndex = 0;
 
    while (nodesToVisit.length) {
      const currentNode = nodesToVisit.pop();
 
      if (currentNode instanceof Ref) {
        const count = pageKidsCountCache.get(currentNode);
        // Skip nodes where the page can't be.
        if (count >= 0 && currentPageIndex + count <= pageIndex) {
          currentPageIndex += count;
          continue;
        }
        // Prevent circular references in the /Pages tree.
        if (visitedNodes.has(currentNode)) {
          throw new FormatError("Pages tree contains circular reference.");
        }
        visitedNodes.put(currentNode);
 
        const obj = await (pageDictCache.get(currentNode) ||
          xref.fetchAsync(currentNode));

这段前奏定义了四个关键状态:nodesToVisit 是栈(pop 从尾部取),visitedNodes 记录已展开的引用,currentPageIndex 是「已经越过的页数」,三个缓存分别对应页数、页序号、页字典。visitedNodes 预先放入 /Pages 的引用,是为了让「根节点引用自己」这种坏树也能被识别。

对每个引用节点,先查 pageKidsCountCache。如果缓存值有效(count >= 0),并且 currentPageIndex + count 仍然到不了目标页,就整棵子树跳过。这是页面树剪枝的核心:在一个有 5000 页、每章 100 页的文档里,找第 4000 页只需要跳过 39 个章节,而不是展开 5000 个叶节点。

        if (obj instanceof Dict) {
          let type = obj.getRaw("Type");
          if (type instanceof Ref) {
            type = await xref.fetchAsync(type);
          }
          if (isName(type, "Page") || !obj.has("Kids")) {
            // Cache the Page reference, since it can *greatly* improve
            // performance by reducing redundant lookups in long documents
            // where all nodes are found at *one* level of the tree.
            if (!pageKidsCountCache.has(currentNode)) {
              pageKidsCountCache.put(currentNode, 1);
            }
            // Help improve performance of the `getPageIndex` method.
            if (!pageIndexCache.has(currentNode)) {
              pageIndexCache.put(currentNode, currentPageIndex);
            }
 
            if (currentPageIndex === pageIndex) {
              return [obj, currentNode];
            }
            currentPageIndex++;
            continue;
          }
        }
        nodesToVisit.push(obj);
        continue;
      }

叶节点判定有两个条件:/Type 是 /Page,或者根本没有 /Kids。第二个条件是容错——很多生成器省略 /Type,但只有 /Contents、/MediaBox 的字典显然不是内部节点。命中叶节点后,把 pageKidsCountCache 记为 1、pageIndexCache 记为当前序号,然后比较目标;命中就返回 [obj, ref],否则序号加一并继续。pageIndexCache 在这里顺手写入,是为了让之后 getPageIndex 不必从根重新走一遍。

      // Must be a child page dictionary.
      if (!(currentNode instanceof Dict)) {
        throw new FormatError(
          "Page dictionary kid reference points to wrong type of object."
        );
      }
      const { objId } = currentNode;
 
      let count = currentNode.getRaw("Count");
      if (count instanceof Ref) {
        count = await xref.fetchAsync(count);
      }
      if (Number.isInteger(count) && count >= 0) {
        // Cache the Kids count, since it can reduce redundant lookups in
        // documents where all nodes are found at *one* level of the tree.
        if (objId && !pageKidsCountCache.has(objId)) {
          pageKidsCountCache.put(objId, count);
        }
 
        // Skip nodes where the page can't be.
        if (currentPageIndex + count <= pageIndex) {
          currentPageIndex += count;
          continue;
        }
      }
 
      let kids = currentNode.getRaw("Kids");
      if (kids instanceof Ref) {
        kids = await xref.fetchAsync(kids);
      }

当栈顶不是引用时,它必须是一个内联字典(直接写在 /Kids 数组里的字典)。先读 /Count 并缓存;如果缓存值有效且目标不在这个子树里,整段跳过。接着读 /Kids,如果它本身是引用就异步取回。注意 objId:只有从引用取回的字典才有对象号,内联字典没有,所以只能跳过 pageKidsCountCache 的写入。

      if (!Array.isArray(kids)) {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts directly in the Kids
        // array, rather than using indirect objects (fixes issue9540.pdf).
        let type = currentNode.getRaw("Type");
        if (type instanceof Ref) {
          type = await xref.fetchAsync(type);
        }
        if (isName(type, "Page") || !currentNode.has("Kids")) {
          if (currentPageIndex === pageIndex) {
            return [currentNode, null];
          }
          currentPageIndex++;
          continue;
        }
 
        throw new FormatError("Page dictionary kids object is not an array.");
      }
 
      // Always check all `Kids` nodes, to avoid getting stuck in an empty
      // node further down in the tree (see issue5644.pdf, issue8088.pdf),
      // and to ensure that we actually find the correct `Page` dict.
      for (let last = kids.length - 1; last >= 0; last--) {
        const lastKid = kids[last];
        nodesToVisit.push(lastKid);
 
        // Launch all requests in parallel so we don't wait for each one in turn
        // when looking for a page near the end, if all the pages are top level.
        if (
          currentNode === this.toplevelPagesDict &&
          lastKid instanceof Ref &&
          !pageDictCache.has(lastKid)
        ) {
          pageDictCache.put(lastKid, xref.fetchAsync(lastKid));
        }
      }
    }
 
    throw new Error(`Page index ${pageIndex} not found.`);
  }

/Kids 不是数组时,源码先看当前节点是不是叶节点:如果是,就直接返回当前字典(返回的引用为 null,因为它是内联的);否则抛 FormatError。这是为了兼容 issue9540.pdf 这类把页字典直接塞进 /Kids 的坏文件。

遍历子节点时从后往前入栈,因为栈是后进先出,这样第一个孩子会最先被处理。这个过程不是「找到目标就停」的短路遍历:注释明确说要检查所有 Kids 节点,避免在空节点里卡住(issue5644.pdf、issue8088.pdf)。此外,当当前节点是顶层 /Pages 且孩子是引用时,会把 xref.fetchAsync 的 Promise 直接写进 pageDictCache,让所有顶层页并行取回。对于「所有页都平铺在一层」的长文档,找最后一页不再需要串行等待前面所有页。

设计要点:getPageDict 的复杂度不是固定的 O(n),而是取决于 /Count 是否可信。可信时按子树剪枝,接近 O(log n);不可信时退化成逐节点遍历,但仍然只展开必要的引用。pageDictCache 存的是 Promise 而不是字典,这一点很关键:并行预取发生在同一个事件循环里,多个请求共享同一个 Promise,不会重复调用 xref.fetchAsync。如果是「先取回再缓存字典」,并行预取就退化成串行。

getAllPageDicts:整棵树的兜底遍历

当 /Count 与实际页数不符,checkLastPage 会调用 getAllPageDicts 强制把整棵树读一遍:

  async getAllPageDicts(recoveryMode = false) {
    const { ignoreErrors } = this.pdfManager.evaluatorOptions;
 
    const queue = [{ currentNode: this.toplevelPagesDict, posInKids: 0 }];
    const visitedNodes = new RefSet();
 
    const pagesRef = this.#catDict.getRaw("Pages");
    if (pagesRef instanceof Ref) {
      visitedNodes.put(pagesRef);
    }
    const map = new Map(),
      xref = this.xref,
      pageIndexCache = this.pageIndexCache;
    let pageIndex = 0;
 
    function addPageDict(pageDict, pageRef) {
      // Help improve performance of the `getPageIndex` method.
      if (pageRef && !pageIndexCache.has(pageRef)) {
        pageIndexCache.put(pageRef, pageIndex);
      }
 
      map.set(pageIndex++, [pageDict, pageRef]);
    }
    function addPageError(error) {
      if (error instanceof XRefEntryException && !recoveryMode) {
        throw error;
      }
      if (recoveryMode && ignoreErrors && pageIndex === 0) {
        // Ensure that the viewer will always load (fixes issue15590.pdf).
        warn(`getAllPageDicts - Skipping invalid first page: "${error}".`);
        error = Dict.empty;
      }
 
      map.set(pageIndex++, [error, null]);
    }

它用的是一个显式栈,每个栈帧记录 currentNode 与 posInKids。这样可以在遍历过程中暂停、继续,而不需要递归。addPageDict 把页字典和引用写进 Map,同时更新 pageIndexCache;addPageError 把错误也当成一个「页槽位」写进结果,索引继续前进。这样即使中间某页坏了,后面的页序号也不会错位。

    while (queue.length > 0) {
      const queueItem = queue.at(-1);
      const { currentNode, posInKids } = queueItem;
 
      let kids = currentNode.getRaw("Kids");
      if (kids instanceof Ref) {
        try {
          kids = await xref.fetchAsync(kids);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      }
      if (!Array.isArray(kids)) {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts (fixes issue21436.pdf).
        let type = currentNode.getRaw("Type");
        if (type instanceof Ref) {
          try {
            type = await xref.fetchAsync(type);
          } catch (ex) {
            addPageError(ex);
            break;
          }
        }
        if (isName(type, "Page") || !currentNode.has("Kids")) {
          addPageDict(currentNode, null);
          break;
        }
 
        addPageError(
          new FormatError("Page dictionary kids object is not an array.")
        );
        break;
      }
 
      if (posInKids >= kids.length) {
        queue.pop();
        continue;
      }

内层循环每次只处理当前栈帧的一个孩子。kids 不是数组时同样走「内联页字典」或「报错」两条路。posInKids >= kids.length 表示当前节点处理完,弹栈回到父节点。

      const kidObj = kids[posInKids];
      let obj;
      if (kidObj instanceof Ref) {
        // Prevent circular references in the /Pages tree.
        if (visitedNodes.has(kidObj)) {
          addPageError(
            new FormatError("Pages tree contains circular reference.")
          );
          break;
        }
        visitedNodes.put(kidObj);
 
        try {
          obj = await xref.fetchAsync(kidObj);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      } else {
        // Prevent errors in corrupt PDF documents that violate the
        // specification by *inlining* Page dicts directly in the Kids
        // array, rather than using indirect objects (see issue9540.pdf).
        obj = kidObj;
      }
      if (!(obj instanceof Dict)) {
        addPageError(
          new FormatError(
            "Page dictionary kid reference points to wrong type of object."
          )
        );
        break;
      }
 
      let type = obj.getRaw("Type");
      if (type instanceof Ref) {
        try {
          type = await xref.fetchAsync(type);
        } catch (ex) {
          addPageError(ex);
          break;
        }
      }
      if (isName(type, "Page") || !obj.has("Kids")) {
        addPageDict(obj, kidObj instanceof Ref ? kidObj : null);
      } else {
        queue.push({ currentNode: obj, posInKids: 0 });
      }
      queueItem.posInKids++;
    }
    return map;
  }

遇到引用先查 visitedNodes,有环就写一个错误槽位并结束;没有环就取回。遇到内联字典直接使用。判断叶节点后,要么 addPageDict,要么压栈进入子树。整个方法的返回值是 Map<number, [Dict, Ref|null]>,错误槽位通常是 [Error, null]。

getAllPageDicts 只在兜底路径使用,因此它的目标不是快,而是完整:即使坏页、循环引用、/Count 错误,也要给上层一个覆盖所有页序号的映射。PDFDocument.checkLastPage 拿到这个 Map 后会 setActualNumPages(map.size),并把它作为页面来源。

getPageIndex:从页反查序号

getPageIndex 解决相反的问题:给定一个页引用,它在文档里排第几页。规范没有「页序号」字段,只能靠页面树的父子关系与兄弟节点的 /Count 累加:

  async getPageIndex(pageRef) {
    const cachedPageIndex = this.pageIndexCache.get(pageRef);
    if (cachedPageIndex !== undefined) {
      return cachedPageIndex;
    }
 
    // The page tree nodes have the count of all the leaves below them. To get
    // how many pages are before we just have to walk up the tree and keep
    // adding the count of siblings to the left of the node.
    const xref = this.xref;
    let total = 0,
      ref = pageRef;
    // Prevent circular references in the /Pages tree.
    const visited = new RefSet();
    visited.put(pageRef);
 
    while (true) {
      const node = await xref.fetchAsync(ref);
      if (
        isRefsEqual(ref, pageRef) &&
        !isDict(node, "Page") &&
        !(node instanceof Dict && !node.has("Type") && node.has("Contents"))
      ) {
        throw new FormatError(
          "The reference does not point to a /Page dictionary."
        );
      }
      if (!node) {
        break;
      }
      if (!(node instanceof Dict)) {
        throw new FormatError("Node must be a dictionary.");
      }
      const parentRef = node.getRaw("Parent");
      if (parentRef instanceof Ref) {
        if (visited.has(parentRef)) {
          throw new FormatError("Pages tree contains circular reference.");
        }
        visited.put(parentRef);
      }
 
      const parent = await node.getAsync("Parent");
      if (!parent) {
        break;
      }
      if (!(parent instanceof Dict)) {
        throw new FormatError("Parent must be a dictionary.");
      }

第一步校验「起点必须是页字典」:/Type 是 /Page,或者没有 /Type 但有 /Contents。后一种是容错分支,针对省略 /Type 的叶节点。然后沿着 Parent 向上走,每走一步都把父引用加入 visited,一旦重复就说明页面树有环。

      const kids = await parent.getAsync("Kids");
      if (!kids) {
        break;
      }
      if (!Array.isArray(kids)) {
        throw new FormatError("Kids must be an array.");
      }
 
      const kidPromises = [];
      let found = false;
      for (const kid of kids) {
        if (!(kid instanceof Ref)) {
          throw new FormatError("Kid must be a reference.");
        }
        if (isRefsEqual(kid, ref)) {
          found = true;
          break;
        }
        kidPromises.push(
          xref.fetchAsync(kid).then(obj => {
            if (!(obj instanceof Dict)) {
              throw new FormatError("Kid node must be a dictionary.");
            }
            if (obj.has("Count")) {
              const count = obj.get("Count");
              if (Number.isInteger(count) && count >= 0) {
                total += count;
                return;
              }
              throw new FormatError("Count must be a (positive) integer.");
            }
            // Page leaf node.
            total++;
          })
        );
      }
      if (!found) {
        throw new FormatError("Kid reference not found in parent's kids.");
      }
      await Promise.all(kidPromises);
      ref = parentRef;
    }
 
    this.pageIndexCache.put(pageRef, total);
    return total;
  }

在父节点的 /Kids 里找到当前节点之前,所有兄弟节点都在当前页之前。对每个左边的兄弟:如果它有 /Count,就把 /Count 累加;如果没有 /Count,说明它是叶节点,累加 1。源码把所有兄弟的 fetchAsync 放进 kidPromises 并行执行,最后 Promise.all 等齐。这也是为什么这个方法必须是异步的:/Count 可能在间接引用里,甚至可能触发分块流的补数据。

有一个容易忽略的语义:找到当前节点后立刻 break,右边的兄弟不会被取回。found 为假说明当前节点不在这组 /Kids 里,直接抛错。循环结束后 ref = parentRef 继续向上,直到没有 /Parent。最终 total 就是页序号,写进 pageIndexCache。

三种页面树操作的对比

操作输入输出遍历方向缓存典型场景
getPageDict页序号[页字典, 引用]根到叶,LIFO 栈pageKidsCountCache、pageDictCache、pageIndexCache正常打开某页
getAllPageDicts无Map<序号, [字典或错误, 引用]>根到叶,显式栈帧pageIndexCache/Count 不可信时的全量恢复
getPageIndex页引用页序号叶到根pageIndexCache注释、链接、目标解析需要页序号

三个方法共用 pageIndexCache,但写入时机不同:getPageDict 在碰到叶节点时写,getAllPageDicts 在 addPageDict 时写,getPageIndex 在算出总数后写。共享缓存让「先打开某页,再反查页序号」这种常见组合只付出一次树遍历。

设计要点:页面树遍历没有采用递归,而是显式栈。原因是 /Pages 树的深度在坏文件里可能极深,递归会爆栈;显式栈还能在 getAllPageDicts 里保存「处理到第几个孩子」这种断点状态。另一个关键是引用级去重:visitedNodes 用 RefSet 而不是 Set<Dict>,因为同一个字典可能被多个引用共享,按对象身份去重会漏掉「同号引用成环」的情况。RefSet 存储的是引用的字符串形式,正好解决这个问题。

页面标签 PageLabels

页面标签是「页面上显示的页码」与「物理页序号」的分离。一份文档可以第 1 页是封面、第 2 页是罗马数字 i、第 3 页开始是阿拉伯数字 1。规范用 /PageLabels 数字树描述这种映射:键是物理页序号,值是标签字典。

rawPageLabels 与 pageLabels

  get rawPageLabels() {
    const obj = this.#catDict.getRaw("PageLabels");
    if (!obj) {
      return null;
    }
 
    const numberTree = new NumberTree(obj, this.xref);
    return numberTree.getAll();
  }

rawPageLabels 直接返回数字树的 Map,键可能是 Ref、值可能是原始字典。编辑器需要原始结构,以便修改标签后写回;它不需要「展开后的每页字符串」。NumberTree.getAll() 会遍历 /Kids 与 /Nums,把数字键映射到值。

  get pageLabels() {
    let obj = null;
    try {
      obj = this.#readPageLabels();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read page labels.");
    }
    return shadow(this, "pageLabels", obj);
  }
 
  #readPageLabels() {
    const nums = this.rawPageLabels;
    if (!nums) {
      return null;
    }
 
    const pageLabels = new Array(this.numPages);
    let style = null,
      prefix = "";
 
    let currentLabel = "",
      currentIndex = 1;
 
    for (let i = 0, ii = this.numPages; i < ii; i++) {
      const labelDict = nums.get(i);
 
      if (labelDict !== undefined) {
        if (!(labelDict instanceof Dict)) {
          throw new FormatError("PageLabel is not a dictionary.");
        }
 
        if (
          labelDict.has("Type") &&
          !isName(labelDict.get("Type"), "PageLabel")
        ) {
          throw new FormatError("Invalid type in PageLabel dictionary.");
        }
 
        if (labelDict.has("S")) {
          const s = labelDict.get("S");
          if (!(s instanceof Name)) {
            throw new FormatError("Invalid style in PageLabel dictionary.");
          }
          style = s.name;
        } else {
          style = null;
        }
 
        if (labelDict.has("P")) {
          const p = labelDict.get("P");
          if (typeof p !== "string") {
            throw new FormatError("Invalid prefix in PageLabel dictionary.");
          }
          prefix = stringToPDFString(p);
        } else {
          prefix = "";
        }
 
        if (labelDict.has("St")) {
          const st = labelDict.get("St");
          if (!(Number.isInteger(st) && st >= 1)) {
            throw new FormatError("Invalid start in PageLabel dictionary.");
          }
          currentIndex = st;
        } else {
          currentIndex = 1;
        }
      }

#readPageLabels 的算法是顺序扫描而不是逐个键查表:从第 0 页走到最后一页,维护当前的 style、prefix、currentIndex。数字树里没有出现的页沿用上一段标签设置;一旦出现新的标签字典,就替换这三项。/Type 可省略,但出现时必须等于 /PageLabel;/S 是样式名;/P 是前缀字符串;/St 是起始编号,必须是不小于 1 的整数。

      switch (style) {
        case "D":
          currentLabel = currentIndex;
          break;
        case "R":
        case "r":
          currentLabel = toRomanNumerals(currentIndex, style === "r");
          break;
        case "A":
        case "a":
          const LIMIT = 26; // Use only the characters A-Z, or a-z.
          const letterIndex = currentIndex - 1;
          const character = String.fromCharCode(
            style.charCodeAt(0) + (letterIndex % LIMIT)
          );
          currentLabel = character.repeat(Math.floor(letterIndex / LIMIT) + 1);
          break;
        default:
          if (style) {
            throw new FormatError(
              `Invalid style "${style}" in PageLabel dictionary.`
            );
          }
          currentLabel = "";
      }
 
      pageLabels[i] = prefix + currentLabel;
      currentIndex++;
    }
    return pageLabels;
  }

样式映射完全按规范的表:

/S 值输出例子
D十进制阿拉伯数字1、2、3、10
R大写罗马数字I、II、III、X
r小写罗马数字i、ii、iii、x
A大写字母,超过 26 后重复A、B、Z、AA、AB
a小写字母,超过 26 后重复a、b、z、aa、ab
缺失空字符串只有前缀,或完全没有标签

字母样式的实现值得单独看:letterIndex = currentIndex - 1,letterIndex % 26 选字母,Math.floor(letterIndex / 26) + 1 决定重复次数。因此第 27 页是 AA,第 53 页是 BA,而不是 Excel 风格的 AA、AB。这是 PDF 规范规定的「重复字母」表示,不是 Excel 列号算法。currentIndex 在每页结束后自增,所以即使没有 /St,标签也会连续递增。

pageLabels 外层捕获 FormatError 后返回 null。这意味着一个坏标签条目会让整份文档的标签全部失效,而不是部分可用。这是一个保守选择:标签是导航辅助信息,坏掉不应阻止阅读;但如果继续输出半套标签,用户看到的页码可能前后矛盾。

设计要点:rawPageLabels 与 pageLabels 是同一数据的两种视图。原始视图保留数字树的键值结构,适合编辑与写回;展开视图是长度等于页数的字符串数组,适合查看器直接按索引取。二者分开实现,避免编辑器为了拿原始字典而反向解析字符串。shadow 只固化展开视图,原始视图每次都调用 getAll()——因为编辑可能改变数字树,不能永久缓存。

命名目的地与名称树

「目的地」(Destination)描述「跳到哪一页、什么位置、什么缩放」。它有两种形态:显式目的地是一个数组,第一项是页引用或页序号,第二项是缩放类型名,后面是参数;命名目的地是一个名字,通过名称树或旧式字典映射到显式目的地。

destinations、getDestination 与 #readDests

  get destinations() {
    const dests = new Map();
 
    for (const obj of this.#readDests()) {
      if (obj instanceof NameTree) {
        for (const [key, value] of obj.getAll()) {
          const dest = fetchDest(value);
          if (dest) {
            dests.set(
              stringToPDFString(key, /* keepEscapeSequence = */ true),
              dest
            );
          }
        }
      } else if (obj instanceof Dict) {
        for (const [key, value] of obj) {
          const dest = fetchDest(value);
          if (dest) {
            // Always let the NameTree take precedence.
            dests.getOrInsert(
              stringToPDFString(key, /* keepEscapeSequence = */ true),
              dest
            );
          }
        }
      }
    }
    return shadow(this, "destinations", dests);
  }

#readDests 返回一个数组,顺序很重要:先名称树,后旧式字典。于是 dests.set 对名称树是覆盖,dests.getOrInsert 对旧式字典是插入——名称树里的同名目的地永远优先。这是为了兼容 PDF 1.1 的 /Dests 字典和 PDF 1.2 之后的 /Names /Dests 名称树共存的文件。

  #readDests() {
    const obj = this.#catDict.get("Names");
    const rawDests = [];
    if (obj?.has("Dests")) {
      rawDests.push(new NameTree(obj.getRaw("Dests"), this.xref));
    }
    if (this.#catDict.has("Dests")) {
      // Simple destination dictionary.
      rawDests.push(this.#catDict.get("Dests"));
    }
    return rawDests;
  }

fetchDest 负责把各种形态折叠成「合法显式目的地」:

function fetchDest(dest) {
  if (dest instanceof Dict) {
    dest = dest.get('D');
  }
  return isValidExplicitDest(dest) ? dest : null;
}

目的地值可以是一个字典,里面 D 才是真正的数组;也可以是直接数组。isValidExplicitDest 是 _isValidExplicitDest 的绑定版本,校验第一项是引用或整数、第二项是名字、后面参数数量与类型符合缩放模式。校验细节后面在目标解析一节展开。

  getDestination(id) {
    // Avoid extra lookup/parsing when all destinations are already available.
    if (Object.hasOwn(this, "destinations")) {
      return this.destinations.get(id) ?? null;
    }
 
    for (const obj of this.#readDests()) {
      if (obj instanceof NameTree || obj instanceof Dict) {
        const dest = fetchDest(obj.get(id));
        if (dest) {
          return dest;
        }
      }
    }
 
    // Always fallback to checking all destinations, in order to support:
    //  - PDF documents with out-of-order NameTrees (fixes issue 10272).
    //  - Destination keys that use PDFDocEncoding (fixes issue 19835).
    return this.destinations.get(id) ?? null;
  }

getDestination 有一条快速路径:如果 destinations 已经被 shadow 固化(Object.hasOwn 为真),直接查 Map。否则按顺序查名称树与旧式字典;单键查询走 NameTree.get 或 Dict.get,比全量展开便宜。查不到时,源码仍然回退到 this.destinations,原因写在注释里:名称树的内部顺序可能不符合规范,或者键使用了 PDFDocEncoding 而不是 UTF-16,单键查找会漏掉,只能全量展开后按解码后的字符串匹配。

目的地形态源码处理规范对应
显式数组isValidExplicitDest 直接校验12.3.2.2 Explicit Destinations
字典带 /DfetchDest 取 /D 再校验目标字典的包装形态
名字字符串destinations 的键,映射到数组12.3.2.3 Named Destinations
名称树NameTree.getAll() 展开 /Names7.9.6 Name Dictionaries
旧式 /Dests 字典直接遍历键值,低优先级PDF 1.1 兼容

设计要点:destinations 的合并顺序体现了一条兼容原则:新结构优先,旧结构补缺。名称树是 PDF 1.2 之后的标准,旧式 /Dests 字典只在前者没有同名键时生效。getDestination 又保留单键快速路径与全量回退两条路:正常文档不需要为一次跳转展开整棵名称树;坏文档在单键失败后仍能通过全量展开找到目标。这种「快路径 + 慢兜底」的模式在 Catalog 里反复出现。

页面布局、页面模式与查看器偏好

这三个 getter 都对应「打开文档时查看器应该怎么显示」,但各有各的默认值与容错规则。

pageLayout 与 pageMode

  get pageLayout() {
    const obj = this.#catDict.get("PageLayout");
    // Purposely use a non-standard default value, rather than 'SinglePage', to
    // allow differentiating between `undefined` and /SinglePage since that does
    // affect the Scroll mode (continuous/non-continuous) used in Adobe Reader.
    let pageLayout = "";
 
    if (obj instanceof Name) {
      switch (obj.name) {
        case "SinglePage":
        case "OneColumn":
        case "TwoColumnLeft":
        case "TwoColumnRight":
        case "TwoPageLeft":
        case "TwoPageRight":
          pageLayout = obj.name;
      }
    }
    return shadow(this, "pageLayout", pageLayout);
  }

/PageLayout 控制页面的排布方式:单页、连续单列、双列左右、双页左右等。源码的默认值是空字符串而不是 SinglePage,注释解释得很清楚:查看器需要区分「文档没有声明布局」与「文档明确声明了单页」,因为这会影响到 Adobe Reader 的连续滚动行为。非法名字不报错,直接保留空字符串。

  get pageMode() {
    const obj = this.#catDict.get("PageMode");
    let pageMode = "UseNone"; // Default value.
 
    if (obj instanceof Name) {
      switch (obj.name) {
        case "UseNone":
        case "UseOutlines":
        case "UseThumbs":
        case "FullScreen":
        case "UseOC":
        case "UseAttachments":
          pageMode = obj.name;
      }
    }
    return shadow(this, "pageMode", pageMode);
  }

/PageMode 控制侧边栏与全屏:不显示、显示大纲、显示缩略图、全屏、显示可选内容、显示附件。它的默认值是 UseNone,与 /PageLayout 的处理不同——规范本身就把 UseNone 定为默认值,不存在「未声明」与「明确 UseNone」的区分需求。

getter条目合法值默认值为什么这样默认
pageLayout/PageLayoutSinglePage、OneColumn、TwoColumnLeft、TwoColumnRight、TwoPageLeft、TwoPageRight空字符串区分未声明与明确单页
pageMode/PageModeUseNone、UseOutlines、UseThumbs、FullScreen、UseOC、UseAttachmentsUseNone规范默认值就是它

viewerPreferences:逐键白名单解析

/ViewerPreferences 是一个字典,但 PDF.js 不会把字典原样交给上层,而是逐键校验并转换成统一的 Map:

  get viewerPreferences() {
    const obj = this.#catDict.get("ViewerPreferences");
    if (!(obj instanceof Dict)) {
      return shadow(this, "viewerPreferences", null);
    }
    let prefs = null;
 
    for (const [key, value] of obj) {
      let prefValue;
 
      switch (key) {
        case "HideToolbar":
        case "HideMenubar":
        case "HideWindowUI":
        case "FitWindow":
        case "CenterWindow":
        case "DisplayDocTitle":
        case "PickTrayByPDFSize":
          if (typeof value === "boolean") {
            prefValue = value;
          }
          break;

布尔键只接受真正的布尔值。PickTrayByPDFSize 是打印相关:是否按 PDF 页面尺寸选择纸盘。接着是若干枚举键:

        case "NonFullScreenPageMode":
          if (value instanceof Name) {
            switch (value.name) {
              case "UseNone":
              case "UseOutlines":
              case "UseThumbs":
              case "UseOC":
                prefValue = value.name;
                break;
              default:
                prefValue = "UseNone";
            }
          }
          break;
        case "Direction":
          if (value instanceof Name) {
            switch (value.name) {
              case "L2R":
              case "R2L":
                prefValue = value.name;
                break;
              default:
                prefValue = "L2R";
            }
          }
          break;
        case "ViewArea":
        case "ViewClip":
        case "PrintArea":
        case "PrintClip":
          if (value instanceof Name) {
            switch (value.name) {
              case "MediaBox":
              case "CropBox":
              case "BleedBox":
              case "TrimBox":
              case "ArtBox":
                prefValue = value.name;
                break;
              default:
                prefValue = "CropBox";
            }
          }
          break;
        case "PrintScaling":
          if (value instanceof Name) {
            switch (value.name) {
              case "None":
              case "AppDefault":
                prefValue = value.name;
                break;
              default:
                prefValue = "AppDefault";
            }
          }
          break;
        case "Duplex":
          if (value instanceof Name) {
            switch (value.name) {
              case "Simplex":
              case "DuplexFlipShortEdge":
              case "DuplexFlipLongEdge":
                prefValue = value.name;
                break;
              default:
                prefValue = "None";
            }
          }
          break;

枚举键的共同点是:非法名字回退到规范默认值,而不是丢弃这个键。Direction 默认 L2R,PrintScaling 默认 AppDefault,Duplex 默认 None。这比直接忽略更友好:上层总能拿到一个确定的布局方向或打印缩放策略。ViewArea、ViewClip、PrintArea、PrintClip 的默认值是 CropBox,对应规范里「使用裁剪框」的常规选择。

        case "PrintPageRange":
          // The number of elements must be even.
          if (
            Array.isArray(value) &&
            value.length % 2 === 0 &&
            value.every(
              (page, i, arr) =>
                Number.isInteger(page) &&
                page > 0 &&
                (i === 0 || page >= arr[i - 1]) &&
                page <= this.numPages
            )
          ) {
            prefValue = value;
          }
          break;
        case "NumCopies":
          if (Number.isInteger(value) && value > 0) {
            prefValue = value;
          }
          break;
        default:
          warn(`Ignoring non-standard key in ViewerPreferences: ${key}.`);
          continue;
      }
 
      if (prefValue === undefined) {
        warn(`Bad value, for key "${key}", in ViewerPreferences: ${value}.`);
        continue;
      }
      (prefs ??= new Map()).set(key, prefValue);
    }
    return shadow(this, "viewerPreferences", prefs);

PrintPageRange 是「成对出现的页范围」,因此要求长度为偶数、每个页码是正整数、非递减、不超过 numPages。NumCopies 必须是正整数。未知键只警告并跳过,非法值只警告并跳过——这两类都不影响其它键。最终返回 null(没有字典)或 Map,没有有效键时 prefs 仍为 null。

设计要点:viewerPreferences 是「白名单式」解析,而不是「原样返回字典」。原因是查看器只使用规范定义的几十个键,原样返回会迫使每个调用方重复做类型校验;而 PDF 生成器又经常把值写成 Name 与字符串混用、枚举拼错、页码范围乱序。Catalog 在这里当一次守门员:合法的值转成确定类型,非法的值回退默认值或丢弃。上层因此拿到的是「已经规范化的偏好」,不是「需要自己猜的原始字典」。

OpenAction 与文档级 JavaScript

/OpenAction 是「文档打开后自动执行的动作或跳转目标」。它有两种形态:一个显式目的地数组,或一个动作字典。Catalog 把它统一解析成 Map,键是 dest 或 action。

  get openAction() {
    const obj = this.#catDict.get("OpenAction");
    const openAction = new Map();
 
    if (obj instanceof Dict) {
      // Convert the OpenAction dictionary into a format that works with
      // `parseDestDictionary`, to avoid having to re-implement those checks.
      const destDict = new Dict(this.xref);
      destDict.set("A", obj);
 
      const resultObj = { url: null, dest: null, action: null };
      Catalog.parseDestDictionary({ destDict, resultObj });
 
      if (Array.isArray(resultObj.dest)) {
        openAction.set("dest", resultObj.dest);
      } else if (resultObj.action) {
        openAction.set("action", resultObj.action);
      }
    } else if (isValidExplicitDest(obj)) {
      openAction.set("dest", obj);
    }
    return shadow(this, "openAction", openAction.size ? openAction : null);
  }

如果 /OpenAction 是字典,源码构造一个临时 destDict,把原字典塞进 /A,再交给 parseDestDictionary。这样做的原因是避免重复实现动作类型判断:OpenAction 字典本质上就是一个动作字典,和链接、大纲项里的动作没有区别。解析结果只保留 dest(数组)或 action(命名动作),URL 形态不会出现在 OpenAction 的返回 Map 里。如果 /OpenAction 本来就是显式目的地数组,直接校验并写入 dest。没有任何有效内容时返回 null。

jsActions 与 #collectJavaScript

文档级 JavaScript 有两个来源:/Names /JavaScript 名称树,以及 /OpenAction 里的 JavaScript 动作。

  #collectJavaScript() {
    const obj = this.#catDict.get("Names");
    let javaScript = null;
 
    function appendIfJavaScriptDict(name, jsDict) {
      if (!(jsDict instanceof Dict) || !isName(jsDict.get("S"), "JavaScript")) {
        return;
      }
      let js = jsDict.get("JS");
      if (js instanceof BaseStream) {
        js = js.getString();
      } else if (typeof js !== "string") {
        return;
      }
      js = stringToPDFString(js, /* keepEscapeSequence = */ true).replaceAll(
        "\x00",
        ""
      );
      // Skip empty entries, similar to the `_collectJS` function.
      if (js) {
        (javaScript ??= new Map()).set(name, js);
      }
    }
 
    if (obj instanceof Dict && obj.has("JavaScript")) {
      const nameTree = new NameTree(obj.getRaw("JavaScript"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        appendIfJavaScriptDict(
          stringToPDFString(key, /* keepEscapeSequence = */ true),
          value
        );
      }
    }
    // Append OpenAction "JavaScript" actions, if any, to the JavaScript map.
    const openAction = this.#catDict.get("OpenAction");
    if (openAction) {
      appendIfJavaScriptDict("OpenAction", openAction);
    }
 
    return javaScript;
  }

appendIfJavaScriptDict 要求动作字典的 /S 必须是 /JavaScript,脚本体 /JS 可以是字符串或流。字符串按 PDFDocEncoding 或 UTF-16BE 解码,并去掉 \x00——有些生成器用 NUL 填充字符串。空脚本不进入 Map。名称树的键就是脚本名,OpenAction 的脚本固定用 "OpenAction" 作为键,因此同名时不会覆盖名称树里的脚本,而是作为另一个条目共存。

  get jsActions() {
    const javaScript = this.#collectJavaScript();
    let actions = collectActions(
      this.xref,
      this.#catDict,
      DocumentActionEventType
    );
 
    if (javaScript) {
      actions ??= new Map();
 
      for (const [key, val] of javaScript) {
        actions.getOrInsertComputed(key, makeArr).push(val);
      }
    }
    return shadow(this, "jsActions", actions);
  }

collectActions 读取根字典的 /AA(Additional Actions),按事件类型映射表把动作收集成 Map<事件名, 脚本数组>:

键DocumentActionEventType事件含义
WCWillClose文档即将关闭
WSWillSave文档即将保存
DSDidSave文档已保存
WPWillPrint文档即将打印
DPDidPrint文档已打印

名称树与 OpenAction 里的脚本随后被追加到同一个 Map,getOrInsertComputed(key, makeArr) 保证每个事件名都对应一个数组。这个合并顺序意味着:如果 /AA 与名称树使用同一个键,二者会进入同一个数组,而不是互相覆盖。上层按事件顺序执行脚本。

设计要点:openAction 与 jsActions 都体现了「复用同一个解析器」的思路。OpenAction 不自己判断 /S,而是伪装成 /A 交给 parseDestDictionary;JavaScript 不自己遍历 /AA,而是用通用的 collectActions。这样动作类型的增删只改一处,所有入口同时受益。代价是 openAction 的返回结构被限制成 dest 或 action 两种键,URL 形态被刻意丢弃——打开文档时不自动跳外链,这是安全上的保守选择。

大纲:从链表到树

PDF 规范里的大纲不是嵌套数组,而是一条双向链表:/Outlines 字典的 /First 指向第一个书签项,每个书签项有 /Next 指向下一个、/First 指向第一个子项、/Parent 指回上级。查看器要的是树,因此 Catalog 必须把链表重新组织成嵌套的 items。

documentOutline 与 #readDocumentOutline

  get documentOutline() {
    let obj = null;
    try {
      obj = this.#readDocumentOutline();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read document outline.");
    }
    return shadow(this, "documentOutline", obj);
  }
 
  #readDocumentOutline(options = {}) {
    let obj = this.#catDict.get("Outlines");
    if (!(obj instanceof Dict)) {
      return null;
    }
    obj = obj.getRaw("First");
    if (!(obj instanceof Ref)) {
      return null;
    }
 
    const root = { items: [] };
    const queue = [{ obj, parent: root }];
    // To avoid recursion, keep track of the already processed items.
    const processed = new RefSet();
    processed.put(obj);
    const xref = this.xref,
      blackColor = new Uint8ClampedArray(3);
 
    while (queue.length > 0) {
      const i = queue.shift();
      const outlineDict = xref.fetchIfRef(i.obj);
      if (outlineDict === null) {
        continue;
      }
      if (!outlineDict.has("Title")) {
        warn("Invalid outline item encountered.");
      }
 
      const data = { url: null, dest: null, action: null };
      Catalog.parseDestDictionary({
        destDict: outlineDict,
        resultObj: data,
        docBaseUrl: this.baseUrl,
        docAttachments: this.attachments,
      });

documentOutline 是懒加载 getter。没有 /Outlines 或 /First 不是引用时返回 null。队列的初始元素是「第一个书签项 + 虚拟根节点」,processed 里预先放入第一个引用,防止第一个节点通过 /Next 或 /First 指回自己。outlineDict.has("Title") 不满足时只警告,不中断——标题缺失的书签仍然可以被解析出目标,显示时标题会是空串。

const title = outlineDict.get('Title');
const flags = outlineDict.get('F') || 0;
const color = outlineDict.getArray('C');
const count = outlineDict.get('Count');
let rgbColor = blackColor;
 
// We only need to parse the color when it's valid, and non-default.
if (isNumberArray(color, 3) && (color[0] !== 0 || color[1] !== 0 || color[2] !== 0)) {
  rgbColor = ColorSpaceUtils.rgb.getRgb(color, 0);
}
 
const outlineItem = {
  action: data.action,
  attachmentId: data.attachmentId,
  attachment: data.attachment,
  dest: data.dest,
  url: data.url,
  unsafeUrl: data.unsafeUrl,
  newWindow: data.newWindow,
  setOCGState: data.setOCGState,
  title: typeof title === 'string' ? stringToPDFString(title) : '',
  color: rgbColor,
  count: Number.isInteger(count) ? count : undefined,
  bold: !!(flags & 2),
  italic: !!(flags & 1),
  items: [],
};
 
if (options.keepRawDict) {
  outlineItem.rawDict = outlineDict;
}

每个书签项被转换成统一的普通对象:

字段来源含义
title/TitlePDF 字符串,解码后显示
color/C三个分量,默认黑色
count/Count展开时的可见子项数;整数才保留
bold/F 的位 2粗体
italic/F 的位 1斜体
destparseDestDictionary显式目的地数组或命名目的地
urlparseDestDictionary外部链接,经过安全校验
actionparseDestDictionary命名动作,例如 NextPage
items子节点逐层填充的树

/F 的位定义来自规范:位 1 是斜体,位 2 是粗体。颜色 /C 只在三个分量都是数字且不全为 0 时才转换;全 0 直接复用共享的 blackColor,省掉一次颜色空间转换。count 的符号在规范里还有「负数表示默认折叠」的含义,但 PDF.js 只保留整数值本身,折叠状态由查看器根据正负号决定。

      i.parent.items.push(outlineItem);
      obj = outlineDict.getRaw("First");
      if (obj instanceof Ref && !processed.has(obj)) {
        queue.push({ obj, parent: outlineItem });
        processed.put(obj);
      }
      obj = outlineDict.getRaw("Next");
      if (obj instanceof Ref && !processed.has(obj)) {
        queue.push({ obj, parent: i.parent });
        processed.put(obj);
      }
    }
    return root.items.length > 0 ? root.items : null;
  }

子项与兄弟项都通过 getRaw 取引用,避免 get 提前把字典取回来。processed 是全局去重,不区分「已作为子项处理」与「已作为兄弟处理」:一个引用只能出现在树的一个位置。这与规范里「每个书签项只有一个父节点」一致,也能防止坏文件构造出的交叉引用导致无限循环。队列用 shift 从头部取,因此是广度优先;每个父节点的 items 在出队时已经存在,子项直接 push 进去即可。

documentOutlineForEditor 复用同一套逻辑,只是传入 keepRawDict:

  get documentOutlineForEditor() {
    let obj = null;
    try {
      obj = this.#readDocumentOutline({ keepRawDict: true });
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read document outline.");
    }
    return shadow(this, "documentOutlineForEditor", obj);
  }

编辑场景需要保留原始字典,才能在修改书签后写回 /Title、/Dest、/F、/C 等条目。阅读场景不需要 rawDict,因为额外字典会阻止结构化克隆,也会让消息体积变大。

设计要点:大纲解析刻意不用递归。书签链表可以很深,坏文件还能构造环;用队列与 processed 同时解决「深」与「环」两个问题。另一个细节是目标解析与结构解析分离:#readDocumentOutline 只负责树形结构,parseDestDictionary 统一处理目的地、URL、附件跳转与命名动作。这样大纲、链接注释、OpenAction 三个入口共享同一套目标语义,不会出现「大纲支持 GoToE,链接却不支持」这种不一致。

附件、表单与结构树

Catalog 在附件与表单上扮演的是「入口与清单」的角色,真正的文件读取、字段填充、结构树遍历分别由 file_spec.js、annotation.js、struct_tree.js 完成。

attachments 与 attachmentContent

  get attachments() {
    const obj = this.#catDict.get("Names");
    /** @type {Map<string, CatalogAttachment> | null} */
    let attachments = null;
 
    if (obj instanceof Dict && obj.has("EmbeddedFiles")) {
      const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        (attachments ??= new Map()).set(
          stringToPDFString(key, /* keepEscapeSequence = */ true),
          new FileSpec(value).serializable
        );
      }
    }
    return shadow(this, "attachments", attachments);
  }

附件规范上是 /Names /EmbeddedFiles 名称树:键是附件名,值是文件规格字典(File Specification)。FileSpec.serializable 把文件规格规整成三个字段:rawFilename 是原始路径,filename 是去掉目录后的显示名,description 是 /Desc。PDF.js 明确不支持 /RF 关联文件与 URL 文件系统,因此这里只处理嵌入文件。名称树为空或不存在时返回 null。

  attachmentContent(id) {
    const namedContent = this.#attachmentContentByName(id);
    if (namedContent !== undefined) {
      return namedContent;
    }
 
    // Annotation-local attachments register the reference of their embedded
    // content in the catalog, so it's re-fetched from the xref on demand
    // instead of being cached (which would then need to survive `cleanup`).
    // The reference points either at the file-spec dictionary or, for an inline
    // file-spec, straight at the embedded-file stream.
    const ref = this.#annotationAttachmentRefById.get(id);
    if (ref) {
      const target = this.xref.fetch(ref);
      if (target instanceof BaseStream) {
        const content = FileSpec.readStreamContent(target);
        if (this.#soundAttachmentIds.has(id)) {
          return soundStreamToWav(target, content) ?? content;
        }
        return content;
      }
      return target instanceof Dict ? FileSpec.readContent(target) : null;
    }
    return null;
  }

attachmentContent 是按需读取:attachments getter 只返回元数据,不读取嵌入文件流;用户真正点击下载时才调用这里。查找顺序是「名称树里的附件名」优先,然后是「注释局部附件」。注释里的 FileAttachment 注释不经过名称树,getAttachmentIdForAnnotation 会为它的引用生成稳定 id:

  getAttachmentIdForAnnotation(ref, isSound = false) {
    let id = this.#annotationAttachmentIdByRef.get(ref);
    if (!id) {
      const baseId = `attachmentRef:${ref.toString()}`;
      id = baseId;
 
      let i = 1;
      while (
        this.#annotationAttachmentRefById.has(id) ||
        this.attachments?.has(id)
      ) {
        id = `${baseId}-${i++}`;
      }
 
      this.#annotationAttachmentIdByRef.put(ref, id);
      this.#annotationAttachmentRefById.set(id, ref);
    }
    if (isSound) {
      this.#soundAttachmentIds.add(id);
    }
    return id;
  }

id 的基值是 attachmentRef: 加上引用字符串。生成后要检查是否与已有 id 或名称树附件名冲突,冲突就追加 -1、-2。注释附件的内容不缓存,因为缓存后要跨 cleanup 存活,成本高;这里只保存引用,每次取内容都重新 xref.fetch。isSound 为真时,流里是原始声音采样,soundStreamToWav 会包一层 WAV 头;如果包装失败就退回原始字节。

rawEmbeddedFiles 与 xfaImages 是两个相邻的名称树读取:

  get rawEmbeddedFiles() {
    const obj = this.#catDict.get("Names");
    if (!(obj instanceof Dict) || !obj.has("EmbeddedFiles")) {
      return null;
    }
    const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref);
    return nameTree.getAll(/* isRaw = */ true);
  }
 
  get xfaImages() {
    const obj = this.#catDict.get("Names");
    let xfaImages = null;
 
    if (obj instanceof Dict && obj.has("XFAImages")) {
      const nameTree = new NameTree(obj.getRaw("XFAImages"), this.xref);
      for (const [key, value] of nameTree.getAll()) {
        if (value instanceof BaseStream) {
          xfaImages ??= new Map();
          xfaImages.set(
            stringToPDFString(key, /* keepEscapeSequence = */ true),
            value.getBytes()
          );
        }
      }
    }
    return shadow(this, "xfaImages", xfaImages);
  }

rawEmbeddedFiles 给编辑器用,isRaw 让它返回原始的键值对而不是解析后的文件规格。xfaImages 是 XFA 图片名称树,键是图片名,值必须是流;getBytes() 把流解码成字节数组,交给 XFA 布局引擎使用。非流值直接跳过,避免一个坏条目让整个图片集失效。

AcroForm 与 XFA 的边界

Catalog 只提供 acroForm 字典与 acroFormRef,不负责字段树。字段的实际填充发生在 annotation.js:WidgetAnnotation 从 /AcroForm /Fields 出发,沿 /Parent 继承 /FT、/V、/DA 等属性,把值写进注释的显示状态。PDFDocument 读取 /AcroForm 汇总 formInfo,判断是否存在字段、AcroForm、XFA、签名。

XFA 的处理同样不在 Catalog 里。PDFDocument._xfaStreams 读取 /AcroForm /XFA,它可能是单个流,也可能是成对的名称与流数组;支持的键包括 xdp:xdp、template、datasets、config、connectionSet、localeSet、stylesheet。xfaData 把这些流解成字符串,xfaFactory 在 enableXfa、needsRendering、hasXfa、!hasAcroForm 同时成立时创建布局工厂。换句话说,Catalog 只回答「这份文档是不是纯 XFA」,XFA 的解析与排版是 xfa/ 目录的职责。

结构树、元数据、标记信息与权限的汇总

前面已经分别讲过这些 getter,这里用一张表收束它们与规范的对应关系:

getter规范条目PDF.js 的处理上层用途
structTreeRoot14.7.2 结构层次构造 StructTreeRoot,读取 RoleMap 与 ParentTree无障碍、内容提取、注释结构
hasStructTree14.7 Tagged PDF只判断键是否存在GetMetadata 的轻量信号
metadata14.3.2 元数据流解 XMP,MetadataParser 修复坏 XML文档信息、标题作者
markInfo14.7.1 标记信息三个布尔键转 Map判断标记 PDF 的可信度
permissions7.6.4 标准安全处理器读 /Encrypt /P,转无符号后测位UI 禁用打印、复制等操作
acroForm12.7.2 交互表单字典返回非空字典或 null字段树、表单渲染
collection12.3.5 可移植集合返回非空字典或 null附件集合视图

设计要点:Catalog 在附件与表单上的边界非常清晰:它只负责找到入口,不负责展开内容。附件元数据走 FileSpec,内容走 attachmentContent,声音流才做 WAV 包装;表单字典原样暴露,字段继承与值更新在注释层;结构树只构造根对象,子树按页在 StructTreePage 里展开。这条边界让 Catalog 的体积可控,也让每一类数据的缓存策略可以独立设计。

可选内容组 OCG

可选内容组(Optional Content Group)就是图层。规范把这套机制放在 /OCProperties 里:/OCGs 是文档里所有组的数组,/D 是默认配置,配置里用 /ON、/OFF、/Order、/RBGroups 描述初始可见性、层级与互斥关系。

  get optionalContentConfig() {
    let config = null;
    try {
      const properties = this.#catDict.get("OCProperties");
      if (!properties) {
        return shadow(this, "optionalContentConfig", null);
      }
      const defaultConfig = properties.get("D");
      if (!defaultConfig) {
        return shadow(this, "optionalContentConfig", null);
      }
      const groupsData = properties.get("OCGs");
      if (!Array.isArray(groupsData)) {
        return shadow(this, "optionalContentConfig", null);
      }
      const groupRefCache = new RefMap();
      // Ensure all the optional content groups are valid.
      for (const groupRef of groupsData) {
        if (!(groupRef instanceof Ref) || groupRefCache.has(groupRef)) {
          continue;
        }
        groupRefCache.put(groupRef, this.#readOptionalContentGroup(groupRef));
      }
      config = this.#readOptionalContentConfig(defaultConfig, groupRefCache);
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn(`Unable to read optional content config: ${ex}`);
    }
    return shadow(this, "optionalContentConfig", config);
  }

/OCProperties、/D、/OCGs 三者缺一不可。/OCGs 必须是数组;循环里的非引用元素与重复引用都会被跳过,只解析有效的组引用。groupRefCache 是 RefMap,键是组引用,值是组对象;后面的配置解析需要按引用查组,所以不能只存引用字符串。

#readOptionalContentGroup

  #readOptionalContentGroup(groupRef) {
    const group = this.xref.fetch(groupRef);
    const obj = {
      id: groupRef.toString(),
      name: null,
      intent: null,
      usage: {
        print: null,
        view: null,
      },
      rbGroups: [],
    };
 
    const name = group.get("Name");
    if (typeof name === "string") {
      obj.name = stringToPDFString(name);
    }
 
    let intent = group.getArray("Intent");
    if (!Array.isArray(intent)) {
      intent = [intent];
    }
    if (intent.every(i => i instanceof Name)) {
      obj.intent = intent.map(i => i.name);
    }
 
    const usage = group.get("Usage");
    if (!(usage instanceof Dict)) {
      return obj;
    }
    const usageObj = obj.usage;
 
    const print = usage.get("Print");
    if (print instanceof Dict) {
      const printState = print.get("PrintState");
      if (printState instanceof Name) {
        switch (printState.name) {
          case "ON":
          case "OFF":
            usageObj.print = { printState: printState.name };
        }
      }
    }

组的输出结构有五个字段:id 是引用字符串,name 是显示名,intent 是用途数组,usage 是打印与查看状态,rbGroups 是「与它互斥的组集合」。Intent 在规范里可以省略、单个名字或数组,源码统一包成数组再校验,只有全部是 Name 才保留。Usage 的 Print 与 View 状态只接受 ON、OFF,其它值忽略;ViewState 的处理在后面几行,逻辑相同。

#readOptionalContentConfig

配置解析的核心是三个局部函数:parseOnOff、parseOrder、parseRBGroups。

const xref = this.xref,
  parsedOrderRefs = new RefSet(),
  MAX_NESTED_LEVELS = 10;
 
parseRBGroups(config.get('RBGroups'));
 
return {
  name: typeof config.get('Name') === 'string' ? stringToPDFString(config.get('Name')) : null,
  creator:
    typeof config.get('Creator') === 'string' ? stringToPDFString(config.get('Creator')) : null,
  baseState: config.get('BaseState') instanceof Name ? config.get('BaseState').name : null,
  on: parseOnOff(config.get('ON')),
  off: parseOnOff(config.get('OFF')),
  order: parseOrder(config.get('Order')),
  groups: [...groupRefCache],
};

parseOnOff 把 /ON 或 /OFF 数组里的引用转成 id 字符串,只保留在 groupRefCache 里存在的组。parseRBGroups 先执行,因为它在解析 /Order 之前就要把互斥关系写进每个组的 rbGroups。groups 用 [...groupRefCache] 展开成 [引用, 组对象] 数组,保持 RefMap 的条目形态。

parseOrder 处理层级与「隐藏组」:

function parseOrder(refs, nestedLevels = 0) {
  if (!Array.isArray(refs)) {
    return null;
  }
  const order = [];
 
  for (const value of refs) {
    if (value instanceof Ref && groupRefCache.has(value)) {
      parsedOrderRefs.put(value); // Handle "hidden" groups, see below.
 
      order.push(value.toString());
      continue;
    }
    // Handle nested /Order arrays (see e.g. issue 9462 and bug 1240641).
    const nestedOrder = parseNestedOrder(value, nestedLevels);
    if (nestedOrder) {
      order.push(nestedOrder);
    }
  }
 
  if (nestedLevels > 0) {
    return order;
  }
  const hiddenGroups = [];
  for (const [groupRef] of groupRefCache.items()) {
    if (parsedOrderRefs.has(groupRef)) {
      continue;
    }
    hiddenGroups.push(groupRef.toString());
  }
  if (hiddenGroups.length) {
    order.push({ name: null, order: hiddenGroups });
  }
 
  return order;
}

/Order 是混合数组:元素可以是组引用,也可以是「第一个元素是组名、后面是子数组」的嵌套数组。引用直接转成 id 字符串;嵌套数组交给 parseNestedOrder。parsedOrderRefs 记录所有在 /Order 里出现过的组。最外层遍历结束后,没出现过的组被收集到 hiddenGroups,追加成一个 name 为 null 的虚拟节点。这样查看器能显示「未在顺序中声明、但仍然存在」的组,而不是把它们藏起来。

function parseNestedOrder(ref, nestedLevels) {
  if (++nestedLevels > MAX_NESTED_LEVELS) {
    warn('parseNestedOrder - reached MAX_NESTED_LEVELS.');
    return null;
  }
  const value = xref.fetchIfRef(ref);
  if (!Array.isArray(value)) {
    return null;
  }
  const nestedName = xref.fetchIfRef(value[0]);
  if (typeof nestedName !== 'string') {
    return null;
  }
  const nestedOrder = parseOrder(value.slice(1), nestedLevels);
  if (!nestedOrder?.length) {
    return null;
  }
  return { name: stringToPDFString(nestedName), order: nestedOrder };
}

嵌套层数硬限制为 10。每进入一层先自增再比较,因此超过 10 层就警告并返回 null。嵌套数组的第一个元素必须是字符串(组名),剩余元素继续按 parseOrder 解析。解析结果只有非空才返回,空数组不会生成空节点。

配置键输出字段处理规则
/Namename字符串才保留,PDF 字符串解码
/Creatorcreator同上
/BaseStatebaseState必须是 Name
/ONon引用数组,过滤不存在的组
/OFFoff同上
/Orderorder引用与嵌套数组混合,补隐藏组
/RBGroups组的 rbGroups互斥组集合,写入每个成员
/OCGsgroups组引用与组对象的数组

设计要点:OCG 解析是「先建组索引,再解析关系」。/RBGroups 与 /Order 都需要按引用查组,所以先把 /OCGs 里所有组解析进 RefMap,后面的配置只做引用匹配。/Order 的隐藏组补齐是规范之外的实用选择:规范允许组不出现在顺序里,但查看器如果直接忽略它们,用户就永远看不到这些图层。把它们追加到末尾,既保留顺序语义,又保证所有组可见。嵌套层数限制则是对坏文件的硬防线,避免深层嵌套把 Worker 栈打爆。

目标解析:parseDestDictionary

大纲、链接注释、OpenAction 都要回答同一个问题:这个字典到底指向哪里?parseDestDictionary 就是统一答案。它接受一个包含目标信息的字典,把结果写进调用方提供的 resultObj。

入口与优先级

  static parseDestDictionary({
    destDict,
    resultObj,
    docBaseUrl = null,
    docAttachments = null,
  }) {
    if (!(destDict instanceof Dict)) {
      warn("parseDestDictionary: `destDict` must be a dictionary.");
      return;
    }
 
    let action = destDict.get("A"),
      url,
      dest;
    if (!(action instanceof Dict)) {
      if (destDict.has("Dest")) {
        // A /Dest entry should *only* contain a Name or an Array, but some bad
        // PDF generators ignore that and treat it as an /A entry.
        action = destDict.get("Dest");
      } else {
        action = destDict.get("AA");
        if (action instanceof Dict) {
          if (action.has("D")) {
            // MouseDown
            action = action.get("D");
          } else if (action.has("U")) {
            // MouseUp
            action = action.get("U");
          }
        }
      }
    }

查找顺序是 /A、/Dest、/AA。/A 是普通动作字典;/Dest 本应只放名字或数组,但坏生成器会把它当动作字典用,所以源码把它也交给动作分支;/AA 是附加动作字典,优先取 D(鼠标按下)再取 U(鼠标松开)。这三条路径最终都汇成一个 action 值。

如果 action 是字典,必须有 /S 且是 Name:

    if (action instanceof Dict) {
      const actionType = action.get("S");
      if (!(actionType instanceof Name)) {
        warn("parseDestDictionary: Invalid type in Action dictionary.");
        return;
      }
      const actionName = actionType.name;
 
      switch (actionName) {

动作类型分成十类,处理方式各不相同:

/S规范动作解析结果PDF.js 的实际行为
ResetForm重置表单resetForm收集字段名与字段引用,Flags 位 1 控制包含或排除
URI打开 URIurl、unsafeUrlName 形态补前导斜杠,再走安全 URL 校验
GoTo文档内跳转dest取 /D,后续统一解析
Launch启动外部程序url、newWindow只支持指向其它 PDF 的文件路径
GoToR跳转到远程文档url、newWindow文件规格或字符串作为 URL,追加远程目标
GoToE跳转到嵌入文件attachmentId、attachment、attachmentDest通过 /T /R /C /N 找附件名
Named命名动作action取 /N 的名字
SetOCGState切换图层状态setOCGState解析 ON、OFF、Toggle 与组引用
JavaScript执行脚本url、newWindow尝试从脚本里恢复 URL,否则忽略
SubmitForm提交表单无需要脚本支持,静默忽略

Launch 和 GoToR 共用一段代码,这是规范里少见的「两个动作共享文件规格」设计。GoToE 的 Target 字典里,/R 为 /C 且 /N 是字符串时,/N 就是附件名;docAttachments 里查得到才写入 attachmentId 与 attachment,否则只警告。SetOCGState 的 /State 数组里,Name 只接受 ON、OFF、Toggle,Ref 转成 id 字符串;只要有一个元素非法,整个动作就丢弃,避免执行半套状态切换。

        case "URI":
          url = action.get("URI");
          if (url instanceof Name) {
            // Some bad PDFs do not put parentheses around relative URLs.
            url = "/" + url.name;
          }
          break;
 
        case "GoTo":
          dest = action.get("D");
          break;
 
        case "Launch":
        // We neither want, nor can, support arbitrary 'Launch' actions.
        // However, in practice they are mostly used for linking to other PDF
        // files, which we thus attempt to support (utilizing `docBaseUrl`).
        /* falls through */
 
        case "GoToR":
          const urlDict = action.get("F");
          if (urlDict instanceof Dict) {
            url = new FileSpec(urlDict).filename;
          } else if (typeof urlDict === "string") {
            url = urlDict;
          } else {
            break;
          }
 
          // NOTE: the destination is relative to the *remote* document.
          const remoteDest = fetchRemoteDest(action);
          if (remoteDest) {
            // NOTE: We don't use the `updateUrlHash` function here, since
            // the `createValidAbsoluteUrl` function (see below) already handles
            // parsing/validation of the final URL and manual splitting also
            // ensures that the `unsafeUrl` property will be available/correct.
            url = /* baseUrl = */ url.split("#", 1)[0] + "#" + remoteDest;
          }
          // The 'NewWindow' property, equal to `LinkTarget.BLANK`.
          const newWindow = action.get("NewWindow");
          if (typeof newWindow === "boolean") {
            resultObj.newWindow = newWindow;
          }
          break;

fetchRemoteDest 负责把远程目标编码到 URL 的哈希里:如果 /D 是名字,就用名字字符串;如果是显式数组,就用 JSON.stringify。url.split("#", 1)[0] 先去掉原有的哈希,再拼新的目标,避免一个 URL 里出现两个 #。NewWindow 只在是布尔值时保留,表示「在新窗口打开」。

最终统一:URL 与 dest

动作处理完之后,源码统一处理 url 与 dest:

if (typeof url === 'string') {
  const absoluteUrl = createValidAbsoluteUrl(url, docBaseUrl, {
    addDefaultProtocol: true,
    tryConvertEncoding: true,
  });
  if (absoluteUrl) {
    resultObj.url = absoluteUrl.href;
  }
  resultObj.unsafeUrl = url;
}
if (dest) {
  if (dest instanceof Name) {
    dest = dest.name;
  }
  if (typeof dest === 'string') {
    resultObj.dest = stringToPDFString(dest, /* keepEscapeSequence = */ true);
  } else if (isValidExplicitDest(dest)) {
    resultObj.dest = dest;
  }
}

createValidAbsoluteUrl 只允许安全协议(HTTP、HTTPS、邮件等),addDefaultProtocol 让没有协议的地址补成 HTTP,tryConvertEncoding 处理 PDFDocEncoding。校验通过才写 resultObj.url;无论是否通过,原始字符串都写进 unsafeUrl,让上层可以显示「这个链接看起来像什么」而不自动跳转。dest 如果是 Name,取名字;如果是字符串,解码成命名目的地;如果是数组,必须通过显式目的地校验。

显式目的地的校验规则如下:

第二项 /XYZ 等参数个数是否允许 null
XYZ2 或 3允许
Fit、FitB0不允许
FitH、FitBH、FitV、FitBV0 或 1允许
FitR4不允许
其它任意校验失败

第一项可以是引用或整数,第二项必须是 Name。Fit 与 FitB 的 args.length 必须严格为 0,FitR 必须严格为 4,其余参数只能是数字或允许的 null。这套校验保证查看器拿到的目的地数组一定是可解释的,不需要在每个跳转点重复检查。

SE:从结构元素反推目的地

如果前面所有分支都没有得到 dest、url、action、attachment、setOCGState、resetForm,源码还会尝试 /SE:

if (
  !resultObj.dest &&
  !resultObj.url &&
  !resultObj.action &&
  !resultObj.attachment &&
  !resultObj.setOCGState &&
  !resultObj.resetForm
) {
  const seRef = destDict.getRaw('SE');
  if (seRef instanceof Ref) {
    try {
      const seDest = Catalog.#getDestFromStructElement(destDict.xref, seRef);
      if (seDest) {
        resultObj.dest = seDest;
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      info('SE parsing failed.');
    }
  }
}

/SE 是结构元素引用,常见于无障碍链接:目标不是直接写在注释里,而是「跳到某个结构元素所在的位置」。#getDestFromStructElement 先找结构元素自己的 /Pg,找不到就向下 BFS 遍历 /K 子节点,直到某个子节点带 /Pg;还没有就向上沿 /P 父链找,最多 40 层,遇到 /StructTreeRoot 停止。找到页引用后,再读元素的属性 /A /BBox,取左上角作为 XYZ 目的地的 x 与 y:

let x = null,
  y = null;
const attrs = seDict.get('A');
if (attrs instanceof Dict) {
  const bbox = lookupRect(attrs.getArray('BBox'), null);
  if (bbox) {
    x = bbox[0];
    y = bbox[3]; // top of the bbox in PDF page coordinates
  }
}
 
return [pageRef, { name: 'XYZ' }, x, y, null];

BBox 必须正好四个数字,lookupRect 才返回数组;bbox[0] 是左边界,bbox[3] 是上边界。返回的数组是标准 XYZ 目的地:页引用、缩放类型、x、y、缩放倍数。缩放倍数用 null 表示「保持当前缩放」。

设计要点:parseDestDictionary 是整篇文章里「语义收敛」最明显的地方。它把十种动作、四种目的地形态、三种入口(/A、/Dest、/AA)和一种结构元素回退,统一收敛成 url、unsafeUrl、dest、action、attachment、setOCGState、resetForm 这几个字段。上层不需要知道原始 PDF 写的是 GoToR 还是 Launch,只需要知道「有没有安全 URL」和「有没有可跳转目标」。安全策略也集中在这里:unsafeUrl 永远保留原文,url 只在校验通过后写入,避免坏链接被自动执行。

缓存、懒加载、容错与清理

Catalog 的性能一半来自懒加载,一半来自缓存。懒加载由 shadow 与「只在需要时取对象」实现;缓存由各字段与页面树索引实现;容错则由统一的异常处理模式保证。

cleanup

  async cleanup(manuallyTriggered = false) {
    clearGlobalCaches();
    this.globalColorSpaceCache.clear();
    this.globalImageCache.clear(/* onlyData = */ manuallyTriggered);
    this.pageKidsCountCache.clear();
    this.pageIndexCache.clear();
    this.pageDictCache.clear();
    this.nonBlendModesSet.clear();
 
    for (const { dict } of await Promise.all(this.fontCache)) {
      delete dict.cacheKey;
    }
    this.fontCache.clear();
    this.builtInCMapCache.clear();
    this.standardFontDataCache.clear();
    this.systemFontCache.clear();
  }

清理分三层:

层被清理的内容不清理的内容原因
全局图案、图元、Unicode、WASM 图片缓存模块常量跨文档共享,必须统一回收
页面树pageKidsCountCache、pageIndexCache、pageDictCache#catDict页面字典可能持有大流,索引可以重建
字体与颜色字体、CMap、标准字体、系统字体、颜色空间、图片根字典、名称树结果渲染缓存可重建,文档结构不能丢

globalImageCache.clear(manuallyTriggered) 只在手动清理时清数据;自动清理保留图片数据,避免用户只是切换页面就丢掉已解码的图片。fontCache 是 RefMap,迭代时得到 [引用, 字体对象],源码用 { dict } 解构字体对象并删除 dict.cacheKey,切断字体字典与缓存键的关联,防止旧字体对象继续引用缓存。await Promise.all(this.fontCache) 等待所有字体对象解析完成后再清,避免清理与解析并发。

懒加载与容错的统一模式

Catalog 里几乎每个 getter 都长这样:

  get something() {
    let value = null;
    try {
      value = this.#readSomething();
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn("Unable to read something.");
    }
    return shadow(this, "something", value);
  }

这不是重复代码,而是刻意的错误分类:

异常处理结果
MissingDataException原样上抛PDFManager.ensure 捕获,requestRange 后重试
FormatError警告并返回默认值坏条目不影响整份文档
其它运行时异常警告并返回默认值与 FormatError 同样降级
循环引用抛 FormatError页面树、大纲、结构树的环必须显式失败

MissingDataException 不能吞掉,因为它不是「数据坏了」,而是「数据还没到」。PDFManager.ensure 在 NetworkPdfManager 上的实现就是捕获它、请求对应字节区间、再重试同一个属性。如果 Catalog 把它转成默认值,分块流永远拿不到缺失的字节,页面树会在第一次访问时就返回空结果。

pageDictCache 的生命周期也值得单独说明:它存的是 Promise,不是字典。getPageDict 在顶层节点并行预取时写入 Promise,后续请求命中同一个 Promise。cleanup 清空它之后,下一次请求会重新调用 xref.fetchAsync;由于 XRef 的对象缓存仍然存在,重新取回通常只是命中缓存,不会重复解析字节。

设计要点:Catalog 的容错不是「到处 try/catch」,而是分层降级。页面树坏了,getPageDict 抛错,PDFDocument.checkLastPage 捕获后调用 getAllPageDicts 重建页映射;单个 getter 坏了,只影响它自己,返回 null 或空集合;分块数据缺失,原样上抛,由 PDFManager 补数据后重试。每一层只处理自己能处理的错误,不越权吞掉上层需要的信息。这是 Catalog 能在损坏 PDF 上仍然打开文档的根本原因。

总结

Catalog 是 PDF.js 的文档模型入口层:向上给 PDFDocument、Page、PartialEvaluator 和 Worker 消息提供文档级语义,向下通过 XRef 取回根字典与各类间接对象。它不做内容解析,也不做绘制,只负责把 /Root 里的条目组织成稳定的 JavaScript 结构。

它在整条链路中的位置

阶段模块输入输出
字节供给PDFManager、Stream本地文件、网络分块BaseStream
对象寻址XRefstartxref、xref 段、RefDict、数组、流
文档建模Catalog/Root 根字典页面树、名称树、大纲、附件、表单、OCG、结构树
页面模型PDFDocument、Page页字典、文档级配置Page、资源、注释、operatorList
内容求值PartialEvaluator页面内容流operatorList

用一句话串起来:XRef 把字节变成对象,Catalog 把对象变成文档语义,PDFDocument 与 Page 把语义变成可渲染的页面。

六条设计原则

  1. 根字典是唯一入口,缓存按职责拆分。所有 getter 都从 #catDict 出发,但缓存分成页面树索引、字体、颜色空间、图片、名称树结果等多层。每层有自己的清理策略,避免「一个 Map 装下所有东西」导致清理时只能一刀切。
  2. 懒加载加记忆化。shadow 把 getter 变成一次计算、永久读取的数据属性;页面字典、名称树、颜色只在真正被访问时解析。打开文档不需要展开所有附件、所有图层、所有书签,只需要展开查看器当前需要的部分。
  3. 页面树必须容错,但环必须失败。/Count 不可信时用 getAllPageDicts 全量遍历并 setActualNumPages;内联页字典、缺失 /Type、坏 /Kids 都有回退;但循环引用一律抛 FormatError,因为环无法用「跳过」解决,只会让遍历永远不结束。
  4. 新结构优先,旧结构补缺。名称树优先于旧式 /Dests 字典;页面树、目的地、OCG 配置都遵循「标准形态优先,兼容形态兜底」。这让 PDF.js 既能读 PDF 1.1 的老文件,也能读带对象流、名称树、XFA 的新文件。
  5. 目标解析统一收敛,安全校验集中。大纲、链接、OpenAction 都走 parseDestDictionary;动作类型、目的地形态、URL 校验、结构元素回退只实现一次。url 只在校验通过后写入,unsafeUrl 永远保留原文,这是「不自动执行不可信链接」的安全底线。
  6. 错误分类而不是错误吞掉。MissingDataException 上抛给 PDFManager 补数据;FormatError 与坏值降级为默认值;循环引用显式失败。每一层只处理自己的错误,文档才能在损坏或分块加载的情况下仍然打开。

至此,Catalog 这一环补齐了:它把「对象」变成「文档语义」。下一篇会继续向下,进入页面模型与注释层,看 PDFDocument 与 Page 如何把页字典、资源、注释组织成可绘制的内容。

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

相关文章