StreamPDFStreamPDF
返回博客

PDF.js 源码解读: primitives

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: primitives

前言

这是 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 与字段比较收口

它在整条链路里的位置:

阶段模块输出
词法LexerName、Cmd、数字、字符串、EOF
语法ParserDict、数组、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) 并写回;最后返回这个值。缓存里存的是对象,永远为真,所以同一个字符串只会创建一个实例。

这带来两个结果:

  1. 大量重复的 /Type、/Subtype、/Width 共享同一个 Name 对象,减少分配与 GC 压力;
  2. 调用方可以用 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 之间串味。

维度NameCmd
字段namecmd
来源PDF 名字对象,例如 /Type内容流操作符,例如 BT
缓存NameCacheCmdCache
典型判断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;
  }

几个字段各有明确用途:

字段类型作用
#mapMap键是 PDF 名字字符串,值是原始值或 Ref
objId字符串或 nullXRef 缓存对象时写入的引用号,例如 "12R"
xrefXRef 或 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;
  }

它做了三件事:

  1. 双键回退。先查 key1,只有结果严格等于 undefined 时才查 key2。PDF 里存在缩写键与完整键并用的历史写法,例如 "W" 与 "Width"、"F" 与 "Filter";调用方按「短键优先」传入,源码用测试断言保证参数顺序。
  2. 按需解引用。如果取到的是 Ref 且字典持有 xref,同步路径调用 xref.fetch,异步路径调用 xref.fetchAsync。解引用结果可能是 Dict、数组、流或基本值。
  3. 原样返回。不是 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 都是宽容写入:只有值符合预期类型才写入,否则静默跳过。它们主要服务于解析器与文档模型的可选字段:

方法接受的值典型用途
setIfNumbernumber可选数值字段,避免字符串混入
setIfArray真数组或 ArrayBuffer 视图数组型字段;也接受类型化数组
setIfDefined非 undefined 且非 null有值才写,过滤空值
setIfName字符串或 Name字符串自动转成驻留的 Name
setIfDictDict 实例可选的子字典

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 也有 Xfalse保留 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

这三个成员经常一起出现,但职责不同:

成员谁写入用途
objIdXRef.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;
}

它们统一了「类型 + 可选值」的判断方式:

函数第一个参数要求第二个参数比较内容
isNameName可选名字字符串instanceof 与 v.name
isCmdCmd可选操作符字符串instanceof 与 v.cmd
isDictDict可选类型名instanceof 与 Type 字段
isRefsEqualRef另一个 Refnum 与 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 读取的每个字段,最终都落在这几个类上。

它在整条链路中的位置

阶段模块输入输出
词法LexerPDF 字节Name、Cmd、数字、字符串、EOF
语法Parsertoken 序列Dict、数组、Ref
定位XRefRef已解析对象或 CIRCULAR_REF
取值Dict.get / getArray字段名解引用后的值
文档与求值Catalog / Evaluator字典与引用页面树、资源、operatorList

用一句话串起来:Lexer 产出带类型的 token,Parser 把它们装进 Dict 与 Ref,XRef 负责按引用取回对象,Dict 再把取回过程藏进一次 get。

五条设计原则

  1. 对象模型与语法解析分离。primitives.js 不读字节、不认关键字,只定义值长什么样;Parser 负责语法,XRef 负责寻址。三者可以独立替换与测试。
  2. 驻留缓存只优化分配,不承载语义。NameCache、CmdCache、RefCache 让重复值共享实例,但 clearPrimitiveCaches 随时可以清空;判断与比较始终回到字段值。
  3. 解引用必须显式选择。get、getAsync、getArray、getRaw 与迭代器各有明确契约,没有「自动递归展开一切」的魔法。调用方按需要选择解析深度,避免无意的网络读取与对象图膨胀。
  4. 容错写入优于解析失败。setIf 家族把可选字段的类型检查放在写入点,坏值被忽略而不是抛错;这对现实世界里大量不规范 PDF 是必要的降级策略。
  5. 引用身份统一为字符串。Ref.toString 产出紧凑键,RefSet、RefMap、objId 都用它做索引;对象实例可以重建,引用语义只由对象号与 generation 决定。

理解 primitives.js 之后,再看 Parser 的 Dict 构造、XRef.fetch 的缓存、Catalog 的 /Pages 遍历,就会清楚它们共享的是同一套值语义,而不是各自临时拼出来的对象。下一篇可以回到 parser.js,看这些原语是怎样从字节流里被逐个识别出来的。

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

相关文章