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

前言
上一篇章讲完了 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 图片 |
| 脚本与 OCG | jsActions、optionalContentConfig、structTreeRoot | 文档级动作、可选内容组、结构树入口 |
| 目标解析 | parseDestDictionary、#getDestFromStructElement | 把 Action、Dest、结构元素统一解析成 URL 或目标 |
再看对外暴露的成员类型,它们对应完全不同的调用方式:
| 种类 | 例子 | 是否缓存 | 典型调用方 |
|---|---|---|---|
| 属性 getter | numPages、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();| 字段 | 结构 | 生命周期与作用 |
|---|---|---|
#catDict | Dict | 文档根字典,所有 getter 的起点 |
#actualNumPages | 整数或 null | 页面树 /Count 不可信时,由全文遍历得到的真实页数 |
#annotationAttachmentIdByRef | RefMap | 注释附件引用到稳定 id 的映射 |
#annotationAttachmentRefById | Map | 稳定 id 回到引用,供 attachmentContent 按需取回 |
#soundAttachmentIds | Set | 标记哪些附件是声音流,取内容时要包成 WAV |
builtInCMapCache | Map | 内置 CMap 缓存,按编码名索引 |
fontCache | RefMap | 字体字典缓存,按字体 Ref 索引 |
globalColorSpaceCache | GlobalColorSpaceCache | 颜色空间缓存,跨文档任务共享 |
globalImageCache | GlobalImageCache | 图片缓存,cleanup 时按需清空 |
nonBlendModesSet | RefSet | 已确认不是混合模式的图形状态引用 |
pageDictCache | RefMap | 页字典缓存,值可能是 Promise,用于并行预取 |
pageIndexCache | RefMap | 页引用到页序号的缓存 |
pageKidsCountCache | RefMap | 页树节点到其下页数的缓存 |
standardFontDataCache | Map | 标准字体数据缓存 |
systemFontCache | Map | 系统字体缓存 |
这些缓存分为两类:解析缓存(字体、颜色空间、图片)和页面树索引缓存(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 的值正好对应这些位:
| 常量 | 位值 | 规范含义 |
|---|---|---|
PRINT | 0x04 | 允许打印 |
MODIFY_CONTENTS | 0x08 | 允许修改内容 |
COPY | 0x10 | 允许复制文本与图形 |
MODIFY_ANNOTATIONS | 0x20 | 允许添加、修改注释与表单字段 |
FILL_INTERACTIVE_FORMS | 0x100 | 允许填写已有表单字段 |
COPY_FOR_ACCESSIBILITY | 0x200 | 允许为无障碍复制 |
ASSEMBLE | 0x400 | 允许插入、旋转、删除页面 |
PRINT_HIGH_QUALITY | 0x800 | 允许高质量打印 |
源码里的常量名是 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 |
字典带 /D | fetchDest 取 /D 再校验 | 目标字典的包装形态 |
| 名字字符串 | destinations 的键,映射到数组 | 12.3.2.3 Named Destinations |
| 名称树 | NameTree.getAll() 展开 /Names | 7.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 | /PageLayout | SinglePage、OneColumn、TwoColumnLeft、TwoColumnRight、TwoPageLeft、TwoPageRight | 空字符串 | 区分未声明与明确单页 |
pageMode | /PageMode | UseNone、UseOutlines、UseThumbs、FullScreen、UseOC、UseAttachments | UseNone | 规范默认值就是它 |
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 | 事件含义 |
|---|---|---|
WC | WillClose | 文档即将关闭 |
WS | WillSave | 文档即将保存 |
DS | DidSave | 文档已保存 |
WP | WillPrint | 文档即将打印 |
DP | DidPrint | 文档已打印 |
名称树与 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 | /Title | PDF 字符串,解码后显示 |
color | /C | 三个分量,默认黑色 |
count | /Count | 展开时的可见子项数;整数才保留 |
bold | /F 的位 2 | 粗体 |
italic | /F 的位 1 | 斜体 |
dest | parseDestDictionary | 显式目的地数组或命名目的地 |
url | parseDestDictionary | 外部链接,经过安全校验 |
action | parseDestDictionary | 命名动作,例如 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 的处理 | 上层用途 |
|---|---|---|---|
structTreeRoot | 14.7.2 结构层次 | 构造 StructTreeRoot,读取 RoleMap 与 ParentTree | 无障碍、内容提取、注释结构 |
hasStructTree | 14.7 Tagged PDF | 只判断键是否存在 | GetMetadata 的轻量信号 |
metadata | 14.3.2 元数据流 | 解 XMP,MetadataParser 修复坏 XML | 文档信息、标题作者 |
markInfo | 14.7.1 标记信息 | 三个布尔键转 Map | 判断标记 PDF 的可信度 |
permissions | 7.6.4 标准安全处理器 | 读 /Encrypt /P,转无符号后测位 | UI 禁用打印、复制等操作 |
acroForm | 12.7.2 交互表单字典 | 返回非空字典或 null | 字段树、表单渲染 |
collection | 12.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 解析。解析结果只有非空才返回,空数组不会生成空节点。
| 配置键 | 输出字段 | 处理规则 |
|---|---|---|
/Name | name | 字符串才保留,PDF 字符串解码 |
/Creator | creator | 同上 |
/BaseState | baseState | 必须是 Name |
/ON | on | 引用数组,过滤不存在的组 |
/OFF | off | 同上 |
/Order | order | 引用与嵌套数组混合,补隐藏组 |
/RBGroups | 组的 rbGroups | 互斥组集合,写入每个成员 |
/OCGs | groups | 组引用与组对象的数组 |
设计要点: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 | 打开 URI | url、unsafeUrl | Name 形态补前导斜杠,再走安全 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 |
|---|---|---|
XYZ | 2 或 3 | 允许 |
Fit、FitB | 0 | 不允许 |
FitH、FitBH、FitV、FitBV | 0 或 1 | 允许 |
FitR | 4 | 不允许 |
| 其它 | 任意 | 校验失败 |
第一项可以是引用或整数,第二项必须是 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 |
| 对象寻址 | XRef | startxref、xref 段、Ref | Dict、数组、流 |
| 文档建模 | Catalog | /Root 根字典 | 页面树、名称树、大纲、附件、表单、OCG、结构树 |
| 页面模型 | PDFDocument、Page | 页字典、文档级配置 | Page、资源、注释、operatorList |
| 内容求值 | PartialEvaluator | 页面内容流 | operatorList |
用一句话串起来:XRef 把字节变成对象,Catalog 把对象变成文档语义,PDFDocument 与 Page 把语义变成可渲染的页面。
六条设计原则
- 根字典是唯一入口,缓存按职责拆分。所有 getter 都从
#catDict出发,但缓存分成页面树索引、字体、颜色空间、图片、名称树结果等多层。每层有自己的清理策略,避免「一个 Map 装下所有东西」导致清理时只能一刀切。 - 懒加载加记忆化。
shadow把 getter 变成一次计算、永久读取的数据属性;页面字典、名称树、颜色只在真正被访问时解析。打开文档不需要展开所有附件、所有图层、所有书签,只需要展开查看器当前需要的部分。 - 页面树必须容错,但环必须失败。
/Count不可信时用getAllPageDicts全量遍历并setActualNumPages;内联页字典、缺失/Type、坏/Kids都有回退;但循环引用一律抛FormatError,因为环无法用「跳过」解决,只会让遍历永远不结束。 - 新结构优先,旧结构补缺。名称树优先于旧式
/Dests字典;页面树、目的地、OCG 配置都遵循「标准形态优先,兼容形态兜底」。这让 PDF.js 既能读 PDF 1.1 的老文件,也能读带对象流、名称树、XFA 的新文件。 - 目标解析统一收敛,安全校验集中。大纲、链接、
OpenAction都走parseDestDictionary;动作类型、目的地形态、URL 校验、结构元素回退只实现一次。url只在校验通过后写入,unsafeUrl永远保留原文,这是「不自动执行不可信链接」的安全底线。 - 错误分类而不是错误吞掉。
MissingDataException上抛给PDFManager补数据;FormatError与坏值降级为默认值;循环引用显式失败。每一层只处理自己的错误,文档才能在损坏或分块加载的情况下仍然打开。
至此,Catalog 这一环补齐了:它把「对象」变成「文档语义」。下一篇会继续向下,进入页面模型与注释层,看 PDFDocument 与 Page 如何把页字典、资源、注释组织成可绘制的内容。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日