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

前言
这是 PDF.js 源码解读系列中关于「PDF 对象原语」的一篇,介绍 src/core/primitives.js(466 行)。这个文件不解析任何 PDF 语法,却定义了 Parser、XRef、Catalog、Evaluator 共同使用的最小数据模型。
前面的文章已经从不同高度经过这里:Parser 把字节切成 token,再把 token 组装成对象;XRef 按对象号把对象取回来;ChunkedStream 与 PDFWorkerStream 负责把字节送进 Worker。但「对象」到底长什么样,一直是用到就提一句。本篇把这个底座完整拆开。
文件里有三类东西:
- 值对象:
Name、Cmd、Dict、Ref,分别对应 PDF 里的名字、操作符、字典与间接引用; - 引用容器:
RefSet、RefMap,用字符串形式的引用号做键,服务于缓存与环检测; - 全局约定:两个
Symbol哨兵、三个驻留缓存,以及四个轻量判断函数。
一句话概括:它是 PDF.js 的对象模型层,向下承接分词结果,向上提供「取值、解引用、合并、比较」的统一语义。
版本说明:本篇按当前仓库这一版
primitives.js讲解。Dict的底层是私有字段#map,不是旧版本常见的_map;引用缓存由Ref.get与Ref.fromString共享;Dict.merge使用了Map.prototype.getOrInsertComputed。阅读时不要套用记忆里的其它版本实现。
模块总览
primitives.js 可以切成七块:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 哨兵 | CIRCULAR_REF、EOF | 用唯一 Symbol 表示循环引用与流结束 |
| 全局缓存 | CmdCache、NameCache、RefCache、clearPrimitiveCaches | 驻留同名对象,减少重复分配 |
| 名字与命令 | Name、Cmd | 包装 PDF 名字与内容流操作符 |
| 字典 | Dict | 私有 Map、解引用、条件写入、合并与克隆 |
| 间接引用 | Ref | 对象号与 generation 的紧凑字符串表示 |
| 引用集合与映射 | RefSet、RefMap | 以引用字符串为键的集合与缓存 |
| 类型判断 | isName、isCmd、isDict、isRefsEqual | 把常见的 instanceof 与字段比较收口 |
它在整条链路里的位置:
| 阶段 | 模块 | 输出 |
|---|---|---|
| 词法 | Lexer | Name、Cmd、数字、字符串、EOF |
| 语法 | Parser | Dict、数组、Ref |
| 定位 | XRef.fetch | 已解析对象或 CIRCULAR_REF |
| 取值 | Dict.get / getArray | 解引用后的值 |
| 文档 | Catalog / Page / Evaluator | 页面树、资源、operatorList |
这里有两个维度要区分:
| 维度 | 例子 | 是否指向别处 |
|---|---|---|
| 直接值 | Name、Cmd、数字、字符串 | 否,本身就是值 |
| 间接引用 | Ref | 是,需要 XRef.fetch 才能取值 |
| 容器 | Dict、数组 | 可能包含 Ref,取法决定是否解引用 |
primitives.js 的核心设计都围绕这张表展开:什么时候保留 Ref,什么时候解引用,什么时候把多个字典合成一个。
两个 Symbol 哨兵
文件开头先定义两个常量:
const CIRCULAR_REF = Symbol('CIRCULAR_REF');
const EOF = Symbol('EOF');它们不是字符串,也不是特殊的 Dict,而是身份唯一的哨兵值。调用方用 === 比较,不会和任何合法 PDF 对象混淆。
CIRCULAR_REF 由 XRef.fetch 在检测到循环引用时返回。损坏的 PDF 可以让 12 0 R 最终又指回 12 0 R;如果解析器继续递归,Worker 会直接卡死。XRef 用 RefSet 记录正在解析的引用,再次遇到同一个引用时返回这个 Symbol,让上层看到一个「不是对象」的结果,从而停止展开。
EOF 由 Lexer.getObj 在输入结束时返回。Parser 的循环会直接比较身份,例如数组解析里的条件写成 while (!isCmd(this.buf1, "]") && this.buf1 !== EOF),读到哨兵就停止。
如果这里用 null 或空字符串表示结束,就会和 PDF 里合法的 null 对象、空字符串冲突。Symbol 没有这个问题:它只能由这个模块导出,外部无法伪造一个相等的值。
设计要点:错误与结束都属于「控制信号」,不应该复用业务值。用
Symbol而不是字符串,既避免了命名冲突,也让类型判断变成一次身份比较;代价是它们不可序列化,所以只能留在 Worker 内部的同步解析路径上。
三个缓存与清理
三个驻留缓存都用无原型对象:
let CmdCache = Object.create(null);
let NameCache = Object.create(null);
let RefCache = Object.create(null);
function clearPrimitiveCaches() {
CmdCache = Object.create(null);
NameCache = Object.create(null);
RefCache = Object.create(null);
}Object.create(null) 没有 Object.prototype,因此 "toString"、"constructor"、"__proto__" 这些名字不会撞上继承属性。PDF 名字与操作符来自文件内容,完全可能长成这些字符串;普通对象字面量会让缓存查询出现假命中。
为什么用对象而不是 Map?这三个缓存都在热路径上:每解析一个名字、每个操作符、每个间接引用都会查一次。对象属性访问通常比 Map.get 更轻,而且键本来就是字符串。代价是只能存字符串键,但这三个场景恰好都满足。
clearPrimitiveCaches 由 cleanup_helper.js 的 clearGlobalCaches 调用。清理发生在文档 cleanup 时,目的不是让旧对象失效,而是释放全局驻留表,避免长期存活的页面反复打开不同文档后不断累积字符串与对象。缓存只是驻留优化,不是语义状态;清空之后重新构造同名对象,行为不变。
设计要点:驻留缓存放大了对象身份的作用,但代码不能把身份当作唯一真相。
Name与Cmd都保留了原始字符串,判断函数也比较字段;清理缓存只会让后续对象换一个实例,不会改变值相等性。
Name 与 Cmd
Name 的 constructor 与 get 方法
Name 包装 PDF 的名字对象,源码只有十几行:
class Name {
constructor(name) {
if ((typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) && typeof name !== 'string') {
unreachable('Name: The "name" must be a string.');
}
this.name = name;
}
static get(name) {
// eslint-disable-next-line no-restricted-syntax
return (NameCache[name] ||= new Name(name));
}
}构造函数只做类型校验,而且校验被 PDFJSDev 条件包住:测试构建里传错类型会立刻抛错,生产构建则信任 Parser。name 字段保存去掉斜杠后的名字文本,例如 PDF 源码里的 /Type 在对象模型里是 Name,其 name 为 "Type"。
static get 是真正的入口。||= 的语义是:先读 NameCache[name],如果结果是假值,就执行右侧的 new Name(name) 并写回;最后返回这个值。缓存里存的是对象,永远为真,所以同一个字符串只会创建一个实例。
这带来两个结果:
- 大量重复的
/Type、/Subtype、/Width共享同一个Name对象,减少分配与 GC 压力; - 调用方可以用
Name.get("Page")快速拿到稳定实例,而不需要自己维护常量表。
但不要因此在所有地方依赖对象身份。clearPrimitiveCaches 之后,同一个名字会得到新实例;判断语义应该用 isName,或者直接比较 .name。
Cmd 的 constructor 与 get 方法
Cmd 与 Name 结构几乎一样,只是字段叫 cmd:
class Cmd {
constructor(cmd) {
if ((typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) && typeof cmd !== 'string') {
unreachable('Cmd: The "cmd" must be a string.');
}
this.cmd = cmd;
}
static get(cmd) {
// eslint-disable-next-line no-restricted-syntax
return (CmdCache[cmd] ||= new Cmd(cmd));
}
}Cmd 表示内容流里的操作符,例如 BT、ET、Tj、TJ、Do。它和 Name 的区别不在实现,而在语义与来源:Name 来自带斜杠的名字 token,Cmd 来自裸操作符 token。Parser 与 Evaluator 用不同缓存保存它们,因此同名的 "BT" 不会在 NameCache 与 CmdCache 之间串味。
| 维度 | Name | Cmd |
|---|---|---|
| 字段 | name | cmd |
| 来源 | PDF 名字对象,例如 /Type | 内容流操作符,例如 BT |
| 缓存 | NameCache | CmdCache |
| 典型判断 | isName(value, "Page") | isCmd(value, "Tj") |
| 是否带斜杠 | 否,Parser 已去掉 | 否,操作符本身没有斜杠 |
设计要点:
Name与Cmd都是「字符串的强类型包装」。如果直接用字符串,Dict的键、内容流操作符、普通字符串值会混在一起,类型判断只能靠上下文猜。包一层之后,instanceof与isName、isCmd就能把语义固定下来,同时仍保留原始字符串用于比较与调试。
Dict
结构与元数据
Dict 是 PDF 字典的对象表示,内部是一个私有 Map:
class Dict {
__nonSerializable__ = nonSerializable; // Disable cloning of the Dict.
#map = new Map();
objId = null;
suppressEncryption = false;
xref;
constructor(xref = null) {
this.xref = xref;
}
assignXref(newXref) {
this.xref = newXref;
}
get size() {
return this.#map.size;
}几个字段各有明确用途:
| 字段 | 类型 | 作用 |
|---|---|---|
#map | Map | 键是 PDF 名字字符串,值是原始值或 Ref |
objId | 字符串或 null | XRef 缓存对象时写入的引用号,例如 "12R" |
xref | XRef 或 null | 解引用时使用的交叉引用表 |
suppressEncryption | 布尔 | 取回值时是否跳过解密,供加密字典自身使用 |
__nonSerializable__ | 函数 | 克隆工具的标记,阻止把整个字典当成可克隆数据 |
__nonSerializable__ 的值是一个返回自身的函数:
const nonSerializable = () => nonSerializable; // Creating closure on some variable.它不是业务字段,而是给克隆逻辑看的标记。Dict 持有 xref 与内部 Map,深拷贝既昂贵又容易把整张交叉引用表拖进来;这个字段让通用克隆器在遇到字典时停下。
objId 不是对象号数字,而是 Ref.toString() 的结果。XRef.fetch 在缓存对象时会补上它,资源缓存、字体缓存、图像缓存都用这个字符串识别「同一个间接对象」。内联字典没有引用号,objId 保持 null。
xref 允许为空。Parser 刚构造字典时可能还不知道它属于哪张 xref,trailer 字典甚至会在 XRef.parse 之后通过 assignXref 补上。没有 xref 时,Dict.get 不会解引用,只会把 Ref 原样返回。
#getValue 方法
所有取值方法最终都经过这个私有方法:
#getValue(isAsync, key1, key2) {
let value = this.#map.get(key1);
if (value === undefined && key2 !== undefined) {
if (
(typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
key2.length < key1.length
) {
unreachable("Dict.#getValue: Expected keys to be ordered by length.");
}
value = this.#map.get(key2);
}
if (value instanceof Ref && this.xref) {
return isAsync
? this.xref.fetchAsync(value, this.suppressEncryption)
: this.xref.fetch(value, this.suppressEncryption);
}
return value;
}它做了三件事:
- 双键回退。先查
key1,只有结果严格等于undefined时才查key2。PDF 里存在缩写键与完整键并用的历史写法,例如"W"与"Width"、"F"与"Filter";调用方按「短键优先」传入,源码用测试断言保证参数顺序。 - 按需解引用。如果取到的是
Ref且字典持有xref,同步路径调用xref.fetch,异步路径调用xref.fetchAsync。解引用结果可能是Dict、数组、流或基本值。 - 原样返回。不是
Ref,或者字典没有xref,就直接返回原始值。这样内联字典、独立构造的字典不会被强行解引用。
suppressEncryption 会一路传给 fetch。加密字典在解析自身时不能再次走解密流程,否则会递归;这个标志就是为那条特殊路径准备的。
key2.length < key1.length 的断言不是业务规则,而是防止调用方把完整键写在前面,导致短键永远查不到。生产构建会去掉这段检查,只留下两次 Map.get。
设计要点:
#getValue把「查表」与「解引用」合并成一个动作,但保留了一个明确的开关:有没有xref。这让同一个Dict类可以同时表示「已绑定文档的字典」与「解析过程中的临时字典」。临时字典不会偷偷触发网络读取,绑定后的字典才会走完整的fetch链路。
get、getAsync 与 getArray 方法
三个公开取值方法共用上面的私有方法:
// Automatically dereferences Ref objects.
get(key1, key2) {
return this.#getValue(/* isAsync = */ false, key1, key2);
}
// Same as get(), but returns a promise and uses fetchIfRefAsync().
async getAsync(key1, key2) {
return this.#getValue(/* isAsync = */ true, key1, key2);
}
// Same as get(), but dereferences all elements if the result is an Array.
getArray(key1, key2) {
let value = this.#getValue(/* isAsync = */ false, key1, key2);
if (Array.isArray(value)) {
value = value.slice(); // Ensure that we don't modify the Dict data.
for (let i = 0, ii = value.length; i < ii; i++) {
if (value[i] instanceof Ref && this.xref) {
value[i] = this.xref.fetch(value[i], this.suppressEncryption);
}
}
}
return value;
}get 是同步取值的默认入口。它可能抛 MissingDataException:当对象在网络分块里尚未到达时,xref.fetch 会抛出这个异常,由 PDFManager.ensure 接住、请求区间、再重试。因此同步不等于「不加载数据」,而是「调用方已经处在可重试的解析上下文里」。
getAsync 是异步版本。#getValue 在异步模式下返回 fetchAsync 的 Promise,async 函数会把它展平成最终的普通值。它适合在没有 ensure 包裹的异步业务代码里读取可能触发网络加载的字段。
getArray 只做一层解引用:
- 先通过
#getValue解引用字典字段本身; - 如果结果是数组,先
slice()复制一份,避免把缓存里的数组原地改掉; - 再逐项检查元素,只解引用顶层
Ref。
它不会递归进入嵌套数组,也不会解引用数组里的字典字段。如果数组元素本身是 Dict,字典内部的 Ref 仍然保留,需要该字典自己的 get 去处理。这个边界很重要:无限递归解引用会在大资源树上产生不可控的读取量。
| 方法 | 同步或异步 | 解引用字段 | 解引用数组元素 | 是否复制数组 | 典型场景 |
|---|---|---|---|---|---|
get | 同步 | 是 | 不适用 | 否 | 解析上下文中读取普通字段 |
getAsync | 异步 | 是 | 不适用 | 否 | 需要等待网络数据的异步流程 |
getArray | 同步 | 是 | 只解一层 | 是 | /Widths、/Decode 等数组 |
getRaw | 同步 | 否 | 否 | 否 | 需要保留 Ref 或原样转发 |
getRaw 与原始遍历
不是所有调用方都想解引用。getRaw 系列保留 Parser 放进去的原值:
// No dereferencing.
getRaw(key) {
return this.#map.get(key);
}
getKeys() {
return this.#map.keys();
}
// No dereferencing.
getRawValues() {
return this.#map.values();
}
getRawEntries() {
return this.#map.entries();
}getRaw 只接受一个键,没有双键回退;它直接返回 Map 里的值,可能是 Ref,也可能是任何 PDF 对象。getRawValues 与 getRawEntries 同样是惰性迭代器,遍历过程中不会触发 xref.fetch。
这几个方法在需要「保留引用身份」的场景里不可替代。例如 XRef 需要知道某个字段到底是直接对象还是间接引用;Dict.merge 需要先收集原始值,再决定怎么合并;写回 PDF 时也必须保留原来的 Ref 结构。如果这些地方误用 get,引用会被提前展开,既丢失结构,也可能触发不必要的网络读取。
set 与 setIf 家族
写入接口分成两类:严格写入与条件写入。
set(key, value) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
if (typeof key !== "string") {
unreachable('Dict.set: The "key" must be a string.');
} else if (value === undefined) {
unreachable('Dict.set: The "value" cannot be undefined.');
}
}
this.#map.set(key, value);
}
setIfNotExists(key, value) {
if (!this.has(key)) {
this.set(key, value);
}
}
setIfNumber(key, value) {
if (typeof value === "number") {
this.set(key, value);
}
}
setIfArray(key, value) {
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
this.set(key, value);
}
}
setIfDefined(key, value) {
if (value !== undefined && value !== null) {
this.set(key, value);
}
}
setIfName(key, value) {
if (typeof value === "string") {
this.set(key, Name.get(value));
} else if (value instanceof Name) {
this.set(key, value);
}
}
setIfDict(key, value) {
if (value instanceof Dict) {
this.set(key, value);
}
}set 在测试构建里要求键是字符串、值不是 undefined。PDF 字典的键本来就是名字字符串,值可以是任意对象,但「没有值」不应该用 undefined 表示;缺失字段的正确表达是键不存在。生产构建去掉断言,只留一次 Map.set。
setIfNotExists 是合并时的「先到先得」工具。它先查 has,已经有值就不覆盖。Dict.merge 合并子字典时正是靠它实现第一份字典优先。
其余 setIf 都是宽容写入:只有值符合预期类型才写入,否则静默跳过。它们主要服务于解析器与文档模型的可选字段:
| 方法 | 接受的值 | 典型用途 |
|---|---|---|
setIfNumber | number | 可选数值字段,避免字符串混入 |
setIfArray | 真数组或 ArrayBuffer 视图 | 数组型字段;也接受类型化数组 |
setIfDefined | 非 undefined 且非 null | 有值才写,过滤空值 |
setIfName | 字符串或 Name | 字符串自动转成驻留的 Name |
setIfDict | Dict 实例 | 可选的子字典 |
setIfArray 的名字容易误解:它不仅接受 Array.isArray 为真的数组,也接受 ArrayBuffer.isView 的类型化数组,例如 Uint8Array。这样图片像素、字体表等二进制数组也能直接挂到字典上。
setIfName 对字符串调用 Name.get,保证写入后仍然是 Name 类型,而不是裸字符串。这个细节避免了「同一个字段有时是 Name,有时是 string」的类型漂移。
设计要点:条件写入把「字段是否合法」的判断放在写入点,而不是散布到每个调用方。PDF 文件经常出现类型错误、缺字段、空值;解析器不应该因为一个可选字段坏了就整份文档失败。
setIf家族提供的是局部降级:坏字段被忽略,其余结构继续建立。
has、Symbol.iterator 与 empty
has 只查键是否存在,不看值:
has(key) {
return this.#map.has(key);
}
*[Symbol.iterator]() {
for (const [key, value] of this.#map) {
yield [
key,
value instanceof Ref && this.xref
? this.xref.fetch(value, this.suppressEncryption)
: value,
];
}
}迭代器会自动解引用。for (const [key, value] of dict) 拿到的 value 已经过 xref.fetch,和 get 的语义一致;这与 getRawEntries() 完全不同。后者保留 Ref,适合需要原始结构的场景。
由于迭代器是同步生成器,它不能使用 fetchAsync。遍历一个包含尚未下载对象的字典时,可能抛 MissingDataException;调用方仍然需要 ensure 或先加载所需区间。这个限制与 get 一致,不是迭代器独有的问题。
空字典单例的设计更特别:
static get empty() {
const emptyDict = new Dict(null);
emptyDict.set = (key, value) => {
unreachable("Should not call `set` on the empty dictionary.");
};
return shadow(this, "empty", emptyDict);
}第一次访问 Dict.empty 时,创建一个 xref 为 null 的字典,并把实例上的 set 覆盖成抛错函数;随后 shadow 把类上的 getter 替换成这个实例,后续访问直接命中原对象。shadow 还会把属性设为不可写,因此单例不会被替换。
Dict.empty 是「没有字段」的安全返回值,Dict.merge 在没有可合并内容时返回它。它仍然可以 get、has、遍历,但任何写入都会立刻抛错。这比返回一个普通空字典更安全:调用方如果误以为可以补字段,会马上暴露问题,而不是悄悄污染一个全局共享对象。
设计要点:
empty用「单例 + 写保护」把不可变约定变成运行时行为。普通空对象只能靠注释提醒,误写会污染所有拿到它的调用方;这里把set替换成失败点,错误在第一次写入时就出现,且不会影响共享状态。
merge 方法
Dict.merge 负责把多个字典按优先级合并,支持浅合并与子字典合并:
static merge({ xref, dictArray, mergeSubDicts = false }) {
const mergedDict = new Dict(xref),
properties = new Map();
for (const dict of dictArray) {
if (!(dict instanceof Dict)) {
continue;
}
for (const [key, value] of dict.getRawEntries()) {
const property = properties.getOrInsertComputed(key, makeArr);
if (property.length && !(mergeSubDicts && value instanceof Dict)) {
// Ignore additional entries, if either:
// - This is a "shallow" merge, where only the first element matters.
// - The value is *not* a `Dict`, since other types cannot be merged.
continue;
}
property.push(value);
}
}
for (const [name, values] of properties) {
if (values.length === 1 || !(values[0] instanceof Dict)) {
mergedDict.set(name, values[0]);
continue;
}
const subDict = new Dict(xref);
for (const dict of values) {
for (const [key, value] of dict.getRawEntries()) {
subDict.setIfNotExists(key, value);
}
}
if (subDict.size > 0) {
mergedDict.set(name, subDict);
}
}
properties.clear();
return mergedDict.size > 0 ? mergedDict : Dict.empty;
}它分两轮处理。
第一轮按 dictArray 的顺序遍历每个字典,用 getRawEntries() 拿到原始键值,不提前解引用。properties 是「字段名到值数组」的临时索引;getOrInsertComputed(key, makeArr) 在键不存在时创建一个空数组,然后把当前值 push 进去。
真正决定是否继续收集的是这个条件:property.length && !(mergeSubDicts && value instanceof Dict)。
- 默认
mergeSubDicts为false:只要该字段已经有值,后续字典的同名字段全部忽略,也就是第一份字典优先的浅合并。 mergeSubDicts为true:如果当前值也是Dict,允许继续收集,留到第二轮做子字典合并;如果当前值不是Dict,仍然跳过。- 注意判断的是「当前值」,不是「已收集的第一个值」。所以第一份字典决定该字段最终是不是子字典合并路径。
第二轮逐个字段生成结果:
- 只有一个值,或者第一个值不是
Dict,直接写入第一个值。PDF 数组、名字、数字、字符串都不能合并,后来的值没有意义。 - 第一个值是
Dict且收集了多个值,创建新的subDict,按收集顺序遍历每个子字典,用setIfNotExists写入。仍然是第一份优先,但不同子字典里的不同键会汇合在一起。 - 合并后的子字典如果为空,不写入结果,避免产生空壳字段。
- 没有任何字段时返回
Dict.empty。
需要特别说明:properties 里的「数组」只是合并算法的中间容器,不是 PDF 数组。PDF 数组值在合并时按不可合并类型处理,只保留第一份。
| 场景 | mergeSubDicts | 结果 |
|---|---|---|
A 有 X,B 也有 X | false | 保留 A 的 X |
A 与 B 的 Y 都是字典 | false | 保留 A 的 Y,不合并子键 |
A 与 B 的 Y 都是字典 | true | 新字典包含 A 与 B 的全部子键,冲突取 A |
A 的 Y 是字典,B 的 Y 是数组 | true | 保留 A 的 Y,数组不参与合并 |
dictArray 里全是非字典 | 任意 | 返回 Dict.empty |
合并只做一层。子字典内部的嵌套字典仍然是原始值,不会递归展开;这与 setIfNotExists 只比较顶层键一致。对 PDF 的 /Resources、/Group 这类结构,一层通常已经够用,递归合并反而会改变对象图。
设计要点:
merge用「先收集、后决策」替代边遍历边写入。第一轮只记录每个字段出现过哪些值,第二轮才知道该字段是单值、不可合并值还是多个子字典。这个两阶段结构让浅合并与子字典合并共用同一条收集路径,条件只出现在「是否继续 push」这一处。
clone 与 delete
clone() {
const dict = new Dict(this.xref);
for (const [key, value] of this.#map) {
dict.set(key, value);
}
return dict;
}
delete(key) {
this.#map.delete(key);
}clone 是浅克隆:新字典共享同一个 xref,逐个复制原始键值,不复制 objId、suppressEncryption 等元数据,也不会递归复制数组或子字典。复制出来的字典可以独立增删顶层字段,但嵌套对象仍然共享。如果调用方要修改子字典,需要自己再克隆那一层。
delete 直接删键,没有返回值。它不检查键是否存在,也不触发任何解引用。
objId、xref 与 assignXref
这三个成员经常一起出现,但职责不同:
| 成员 | 谁写入 | 用途 |
|---|---|---|
objId | XRef.fetch | 给资源、字体、图像缓存提供稳定的字符串身份 |
xref | 构造函数或 assignXref | 决定 get 是否能解引用 |
suppressEncryption | 加密解析代码 | 让加密字典自身取回值时不重复解密 |
XRef.fetch 在缓存对象时写入 objId,赋值来自 xrefEntry.objId = ref.toString()。对包含流的对象,objId 会同时写到外层字典与 dict 上。Catalog、PartialEvaluator 用这个字符串去重,避免同一个间接对象被重复处理。
assignXref 是为「先构造、后绑定」准备的。trailer 字典先由 Parser 构造,此时 xref 可能为空;XRef.parse 建立好自身后调用 trailerDict.assignXref(this),字典从此才能解引用 /Root、/Encrypt 等字段。如果在绑定前调用 get,Ref 会被原样返回。
suppressEncryption 默认 false。加密字典在解析 /CF、/StmF 等结构时会把它设为 true,避免 fetch 再次进入解密流程造成无限递归。它只影响解引用时传给 fetch 的参数,不改变字典里的值。
Ref
构造与 toString 方法
Ref 表示 PDF 的间接引用,例如 12 0 R:
class Ref {
#str;
constructor(str, num, gen) {
this.#str = str;
this.num = num;
this.gen = gen;
}
toString() {
// This function is hot, so we make the string as compact as possible.
// |this.gen| is almost always zero, so we treat that case specially.
return this.#str;
}num 是对象号,gen 是 generation。#str 是预先拼好的紧凑字符串,toString() 直接返回它,不做任何格式化。这个方法是热路径:RefSet、RefMap、objId、日志与错误信息都会调用它;如果每次临时拼接模板字符串,会制造大量短命字符串。
generation 为 0 时,字符串是 "12R",省略末尾的 0;generation 非 0 时才是 "12R4"。注释里说「generation 几乎总是零」,所以专门优化最常见的情况。
这里没有空格,也没有 "0 R" 这种 PDF 源文本形式。对象模型里的 Ref 是规范化后的紧凑表示,Parser 负责把三个 token 变成一个 Ref。
fromString 方法
Ref.fromString 把字符串解析回 Ref,并复用缓存:
static fromString(str) {
const ref = RefCache[str];
if (ref) {
return ref;
}
const m = /^(\d+)R(\d*)$/.exec(str);
if (!m || m[1] === "0") {
return null;
}
const num = parseInt(m[1], 10),
gen = !m[2] ? 0 : parseInt(m[2], 10);
// eslint-disable-next-line no-restricted-syntax
return (RefCache[str] = new Ref(str, num, gen));
}正则只接受「数字 + R + 可选数字」的紧凑格式:
"12R"解析为对象号 12、generation 0;"12R4"解析为对象号 12、generation 4;"12 R"、"12 0 R"、"R12"都不匹配,返回null;- 对象号为 0 时返回
null,源码把 0 号视为无效引用。
解析成功后会写回 RefCache[str],键就是传入的字符串。因此 fromString 适合处理 RefMap 里保存的规范键,不适合拿用户输入或带空格的 PDF 源文本直接调用。
get 方法
正常构造引用应该走 Ref.get:
static get(num, gen) {
const str = gen === 0 ? `${num}R` : `${num}R${gen}`;
// eslint-disable-next-line no-restricted-syntax
return (RefCache[str] ||= new Ref(str, num, gen));
}它先用 num 与 gen 拼出规范字符串,再用 ||= 查缓存。generation 为 0 时拼 "12R",否则拼 "12R4",与 fromString 的格式一致。因此 Ref.get(12, 0) 与 Ref.fromString("12R") 会命中同一个缓存项。
| 调用 | 规范字符串 | 结果 |
|---|---|---|
Ref.get(12, 0) | "12R" | 对象号 12、generation 0 |
Ref.get(12, 4) | "12R4" | 对象号 12、generation 4 |
Ref.fromString("12R") | "12R" | 与第一行同一个实例 |
Ref.fromString("12 R") | 无 | null |
Ref.fromString("0R") | 无 | null |
Ref 的字段是公开的,但调用方应把它当成不可变值:不要修改 num 或 gen。字符串键与字段不一致会破坏 RefSet、RefMap 的查找。需要新引用时用 Ref.get 构造,而不是在原对象上改字段。
设计要点:
Ref把「对象号 + generation」压成一个缓存字符串,既降低了内存占用,也让Set与Map可以直接用值键。toString之所以是热路径,是因为引用在环检测、对象缓存、日志里出现得极其频繁;把格式化成本前移到构造时,后续每次比较与索引都只剩字符串哈希。
RefSet
RefSet 是引用集合,内部保存的是字符串而不是 Ref 对象:
// The reference is identified by number and generation.
// This structure stores only one instance of the reference.
class RefSet {
#set = new Set();
constructor(parent = null) {
if (parent) {
if (
(typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
!(parent instanceof RefSet)
) {
unreachable('RefSet: Invalid "parent" value.');
}
for (const refStr of parent) {
this.#set.add(refStr);
}
}
}
has(ref) {
if (
(typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
!(ref instanceof Ref) &&
typeof ref !== 'string'
) {
unreachable('RefSet: Invalid "ref" value in has.');
}
return this.#set.has(ref.toString());
}
put(ref) {
if (
(typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
!(ref instanceof Ref) &&
typeof ref !== 'string'
) {
unreachable('RefSet: Invalid "ref" value in put.');
}
this.#set.add(ref.toString());
}
remove(ref) {
this.#set.delete(ref.toString());
}
[Symbol.iterator]() {
return this.#set.keys();
}
clear() {
this.#set.clear();
}
}has、put、remove 都先调用 ref.toString(),所以参数既可以是 Ref,也可以是字符串。字符串必须与 Ref.toString() 的紧凑格式一致;传 "12 0 R" 不会命中 "12R"。测试构建会拒绝其它类型,生产构建则直接调用 toString(),传错类型可能得到意外键。
构造函数的 parent 用来复制一份已有集合。它遍历父集合,把字符串逐个加入新集合;这是一次快照复制,不是共享视图。父集合后续增删不会影响子集合,反之亦然。
迭代器直接返回内部 Set 的键迭代器,因此 for (const ref of refSet) 里的 ref 是字符串,例如 "12R",不是 Ref。如果调用方需要对象号,得自己 Ref.fromString(ref)。这个选择与内部存储一致,也避免了在遍历时反复构造包装对象。
XRef 用 RefSet 记录正在解析的引用,检测循环;Evaluator 用 RefSet 记录 Type3 字体引用,避免重复处理。它们要的都是「一组引用号」,不需要额外值,所以 Set 足够。
设计要点:
RefSet不在内部保存Ref对象,而是统一保存toString()的结果。Ref的缓存可能被清理,同一引用也可能出现不同实例;字符串键让集合只依赖「对象号 + generation」的值语义,不受对象身份影响。parent复制又让调用方能在不破坏原集合的前提下创建子集。
RefMap
RefMap 是引用到值的映射:
class RefMap {
#map = new Map();
get size() {
return this.#map.size;
}
get(ref) {
return this.#map.get(ref.toString());
}
has(ref) {
return this.#map.has(ref.toString());
}
put(ref, obj) {
this.#map.set(ref.toString(), obj);
}
putAlias(ref, aliasRef) {
this.#map.set(ref.toString(), this.get(aliasRef));
}
getOrPutComputed(ref, callback) {
const map = this.#map,
refStr = ref.toString();
if (!map.has(refStr)) {
map.set(refStr, callback(ref));
}
return map.get(refStr);
}
[Symbol.iterator]() {
return this.#map.values();
}
clear() {
this.#map.clear();
}
*values() {
yield* this.#map.values();
}
*items() {
for (const [ref, value] of this.#map) {
yield [Ref.fromString(ref), value];
}
}
*keys() {
for (const ref of this.#map.keys()) {
yield Ref.fromString(ref);
}
}
}基础方法与 Map 类似,但键统一经过 ref.toString() 归一化。get、has、put 都接受 Ref;字符串也可以,因为字符串同样有 toString(),但必须已经是规范格式。
putAlias 做的是即时复制:
this.#map.set(ref.toString(), this.get(aliasRef));它先读出 aliasRef 当前的值,再写到 ref 的键上。写入之后,两个键互不关联;之后修改 aliasRef 对应的对象或重新 put,不会自动反映到 ref。如果 aliasRef 还没有值,这里会写入 undefined,之后也不会自动补上。名字里的「alias」描述的是调用时的语义,不是持续的双向绑定。
getOrPutComputed 是「有则返回,无则计算并写入」。它先算一次 refStr,避免在 has、set、get 中重复格式化;如果键不存在,调用 callback(ref) 计算并存入;最后返回 map.get(refStr)。同一个键的后续调用不会再次执行回调,即使回调返回 undefined 也会被当作已存在的值。
迭代行为与原生 Map 不同:
| 方法 | 产出 | 用途 |
|---|---|---|
| 默认迭代 | 只有值 | for (const value of map) |
values() | 只有值 | 明确表达只关心值 |
items() | [Ref, value] 二元组 | 需要引用号与值 |
keys() | Ref | 需要遍历引用号 |
items 与 keys 会用 Ref.fromString 把内部字符串还原成 Ref。如果字符串不是规范格式,fromString 可能返回 null;但正常写入路径都经过 Ref.toString(),因此不会出现这个问题。
PartialEvaluator 的 fontCache 就使用了 RefMap。同一个字体被不同页面引用时,可以用 putAlias 让新引用直接指向已经解析好的字体对象;也可以用 getOrPutComputed 做「首次解析、后续复用」。
设计要点:
RefMap与RefSet都把引用压成字符串键,但迭代契约不同:集合只需要成员关系,迭代字符串即可;映射需要把键还原成Ref,所以额外提供items()与keys()。默认迭代选择值,是因为绝大多数调用方只关心缓存对象,不关心引用号。
四个判断函数
文件末尾是四个轻量判断函数:
function isName(v, name) {
return v instanceof Name && (name === undefined || v.name === name);
}
function isCmd(v, cmd) {
return v instanceof Cmd && (cmd === undefined || v.cmd === cmd);
}
function isDict(v, type) {
return v instanceof Dict && (type === undefined || isName(v.get('Type'), type));
}
function isRefsEqual(v1, v2) {
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
assert(
v1 instanceof Ref && v2 instanceof Ref,
'isRefsEqual: Both parameters should be `Ref`s.',
);
}
return v1.num === v2.num && v1.gen === v2.gen;
}它们统一了「类型 + 可选值」的判断方式:
| 函数 | 第一个参数要求 | 第二个参数 | 比较内容 |
|---|---|---|---|
isName | Name | 可选名字字符串 | instanceof 与 v.name |
isCmd | Cmd | 可选操作符字符串 | instanceof 与 v.cmd |
isDict | Dict | 可选类型名 | instanceof 与 Type 字段 |
isRefsEqual | Ref | 另一个 Ref | num 与 gen |
isDict 的第二个参数不是字典子类型字符串,而是 Type 字段的名字,例如 isDict(node, "Page")。它等价于先确认 node 是 Dict,再读 node.get("Type"),最后用 isName 比较 "Page"。get 会自动解引用,所以即使 /Type 是间接引用也能正确判断;但这也意味着 isDict 可能触发一次 xref.fetch,不适合在完全不想加载数据的路径上使用。
isRefsEqual 只比较 num 与 gen,不比较字符串,也不比较对象身份。即使两个 Ref 来自不同缓存、#str 不同(例如格式异常),只要对象号与 generation 相同,就视为同一引用。这正是 PDF 的引用语义:引用由这两个数字唯一确定。
测试构建会断言两个参数都是 Ref;生产构建省略断言,直接读字段。传入其它类型会得到 undefined === undefined 之类的意外结果,所以调用方仍应保证类型。
设计要点:判断函数不是语法糖,而是把「什么算一个 Page 字典」「什么算同一个引用」这类业务规则集中到一处。调用方不需要记住
Type字段要解引用、引用相等只看两个数字;规则改动时也只需要改这四个小函数。
总结
primitives.js 是 PDF.js 的对象模型底座:它定义了名字、操作符、字典、间接引用四种值,以及围绕它们的缓存、解引用、合并与集合工具。Parser 产出的每个对象、XRef 缓存的每个条目、Catalog 读取的每个字段,最终都落在这几个类上。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 词法 | Lexer | PDF 字节 | Name、Cmd、数字、字符串、EOF |
| 语法 | Parser | token 序列 | Dict、数组、Ref |
| 定位 | XRef | Ref | 已解析对象或 CIRCULAR_REF |
| 取值 | Dict.get / getArray | 字段名 | 解引用后的值 |
| 文档与求值 | Catalog / Evaluator | 字典与引用 | 页面树、资源、operatorList |
用一句话串起来:Lexer 产出带类型的 token,Parser 把它们装进 Dict 与 Ref,XRef 负责按引用取回对象,Dict 再把取回过程藏进一次 get。
五条设计原则
- 对象模型与语法解析分离。
primitives.js不读字节、不认关键字,只定义值长什么样;Parser 负责语法,XRef 负责寻址。三者可以独立替换与测试。 - 驻留缓存只优化分配,不承载语义。
NameCache、CmdCache、RefCache让重复值共享实例,但clearPrimitiveCaches随时可以清空;判断与比较始终回到字段值。 - 解引用必须显式选择。
get、getAsync、getArray、getRaw与迭代器各有明确契约,没有「自动递归展开一切」的魔法。调用方按需要选择解析深度,避免无意的网络读取与对象图膨胀。 - 容错写入优于解析失败。
setIf家族把可选字段的类型检查放在写入点,坏值被忽略而不是抛错;这对现实世界里大量不规范 PDF 是必要的降级策略。 - 引用身份统一为字符串。
Ref.toString产出紧凑键,RefSet、RefMap、objId都用它做索引;对象实例可以重建,引用语义只由对象号与 generation 决定。
理解 primitives.js 之后,再看 Parser 的 Dict 构造、XRef.fetch 的缓存、Catalog 的 /Pages 遍历,就会清楚它们共享的是同一套值语义,而不是各自临时拼出来的对象。下一篇可以回到 parser.js,看这些原语是怎样从字节流里被逐个识别出来的。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 源码解读: 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 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日