StreamPDFStreamPDF
返回博客

PDF.js 源码解读: PDFObjects

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: PDFObjects

前言

这是 PDF.js 源码解读系列中关于「主线程对象就绪协调」的一篇,介绍 src/display/pdf_objects.js。当前仓库中的这个文件只有 116 行,导出也只有 PDFObjects 一个类,却处在 worker 解析与 canvas 渲染的交界上。

前面的 api.js、MessageHandler 与网络流文章解决了另一段问题:PDF 字节如何进入 worker,operatorList 如何流式回到主线程,消息如何分发。本篇接着问:worker 已经解码出的字体、图片和字形路径到了主线程以后,渲染循环怎样在对象就绪时同步取用,又在对象尚未就绪时暂停并等待?

答案不是「每次 get 都返回一个 Promise」,也不是「把跨线程 Promise 直接交给 canvas」。PDFObjects 在主线程为每个对象保存一份稳定条目:条目里的 data 表示当前值,条目里的本地 Promise 只负责通知等待者。真正绘制时仍然走同步读取。

一句话概括它的职责:用一个小型三态注册表,统一对象先到与等待者先到两种时序,并让文档级、页级渲染资源拥有明确的就绪与清理边界。

模块总览

文件没有显式构造函数、继承或外部依赖,全部机制由一个哨兵、一个条目工厂和一个私有 Map 组成。

部分职责
INITIAL_DATA私有 Symbol,标记「条目已登记,但数据尚未解决」
dataObj创建含 data 与 Promise.withResolvers() 结果的条目
#objs以 objId 为键保存条目;条目的身份在等待期间保持不变
get无回调时同步取数据;有回调时登记就绪通知
has只探测是否已解决,不创建条目
resolve写入最终数据并唤醒本地等待者;同一条目只能解决一次
delete删除已解决条目,拒绝删除未登记或未解决条目
clear关闭条目中的 ImageBitmap,再清空整个注册表
Symbol.iterator只迭代已解决的 [objId, data],跳过 pending 项

这里的「对象」是主线程渲染要消费的产物,不是 worker 的 XRef 间接对象缓存。当前消息链路会把字体、FontPath、图片和图案放进来;operatorList 则仍由 PDFPageProxy 的渲染状态单独管理。

条目模型:数据与就绪通知分离

INITIAL_DATA 与 dataObj

const INITIAL_DATA = Symbol('INITIAL_DATA');
 
const dataObj = () => ({
  ...Promise.withResolvers(),
  data: INITIAL_DATA,
});

Promise.withResolvers() 返回 { promise, resolve, reject },展开后再补一个 data 字段,就得到注册表中的一个条目。这个工厂只创建本地 JavaScript 对象,不发送消息,也不启动解码任务。

字段在 PDFObjects 中的作用
promise供一个或多个 get(objId, callback) 注册 .then
resolve在数据写好后履行 promise,唤醒回调
reject由 withResolvers 一并提供,但当前文件没有使用
data初始为 INITIAL_DATA,解决后保存真正的 payload

关键是 data 与 Promise 不承载同一份信息。Promise 的履行值并不是渲染数据;真正的数据始终从条目的 data 读取。这样无论回调是在解决前还是解决后注册,读取的都是同一个条目对象。

INITIAL_DATA 使用模块内私有 Symbol,而不是 null、undefined 或空字符串。于是「有一个值,而且这个值恰好是 null」与「还没有值」不会混淆,任何正常 payload 也不可能意外等于这个哨兵。

三态,而不是简单的有/无

状态Map 中是否有条目data本地 Promise无回调 get
未登记否不存在不存在抛错
已登记、未解决是INITIAL_DATApending抛错
已解决是实际数据,包括 null已履行同步返回 data

未登记与未解决对同步调用方都意味着「不能现在取」,但对异步调用方不同:带回调的 get 会把未登记变成已登记,而未解决条目已经可能有等待者。resolve 则既可以把未登记直接变成已解决,也可以完成已有的 pending 条目。

类的方法从不读取 Promise 的内部状态;它们只用「条目是否存在」与 data === INITIAL_DATA 判断状态。Promise 是通知装置,哨兵才是同步状态机的事实来源。

get:同步读取与回调等待是两种契约

get(objId, callback = null) {
  // If there is a callback, then the get can be async and the object is
  // not required to be resolved right now.
  if (callback) {
    const obj = this.#objs.getOrInsertComputed(objId, dataObj);
    obj.promise.then(() => callback(obj.data));
    return null;
  }
  // If there isn't a callback, the user expects to get the resolved data
  // directly.
  const obj = this.#objs.get(objId);
  // If there isn't an object yet or the object isn't resolved, then the
  // data isn't ready yet!
  if (!obj || obj.data === INITIAL_DATA) {
    throw new Error(`Requesting object that isn't resolved yet ${objId}.`);
  }
  return obj.data;
}

有回调:必要时登记,但不返回 Promise

当前版本直接使用 Map.prototype.getOrInsertComputed:键已存在时返回原条目,不存在时调用 dataObj 创建并插入,再返回新条目。它不是旧代码记忆中的手写「先 get、再 set」展开;这个文件本身也没有提供旧式 Map 的回退实现。

无论条目原本处于 pending 还是已解决,下一行都是 obj.promise.then(...)。因此同一个对象可以登记多个回调,所有回调共享一个就绪信号,而不是各自创建跨线程请求。

回调体写成 () => callback(obj.data),没有使用 .then 的参数。原因在 resolve 中会看到:条目 Promise 是以空参数履行的,回调执行时再从闭包里的 obj 取最新数据。

即使对象已经解决,Promise 回调也只会被排入微任务,不会在 get 的调用栈内同步执行。源码注释里的「right away」表示不必等待新的解决事件,不等于同步调用。

带回调分支固定返回 null,不是 Promise,也不是将来的数据。调用方不能用 await objs.get(id, cb) 等对象就绪;它交出的是回调注册权。

无回调:未就绪就抛错

无回调分支只调用普通 Map.get,绝不偷偷创建条目。未登记与 data === INITIAL_DATA 都抛出同一个 Requesting object that isn't resolved yet 错误;它不会退化成异步等待。

已解决时直接返回 obj.data。因为状态由哨兵判断,resolve(id) 得到的 null 也是合法的同步返回值,不会被误判为 pending。

这就是渲染热路径需要的态度:真正执行绘画算子时,对象必须先就绪;若未就绪,应该由依赖调度器提前暂停,而不是让每个 fill、drawImage 或字体操作自己变成异步函数。

has:无副作用的就绪探测

has(objId) {
  const obj = this.#objs.get(objId);
  return !!obj && obj.data !== INITIAL_DATA;
}

has 与无回调 get 使用同一套状态判定,但把「未就绪抛错」改成 false。未登记和 pending 都是 false,已解决则为 true,即使 data 是 null。

它不会登记对象,也不会注册回调。canvas 的依赖算子先用它决定能否继续;commonobj / obj 消息处理器也用它跳过已经解决的重复对象。

不要把 has(id) === false 理解成「Map 里一定没有 id」:它把未登记和已登记未解决刻意折叠成同一个消费语义——现在还不能同步读取。

resolve:先写数据,再发就绪信号

resolve(objId, data = null) {
  const obj = this.#objs.getOrInsertComputed(objId, dataObj);
  if (obj.data !== INITIAL_DATA) {
    throw new Error(`Object already resolved ${objId}.`);
  }
  obj.data = data;
  obj.resolve();
}

getOrInsertComputed 同时覆盖两条入口:对象先到时新建条目并立即解决;回调先注册时取得原 pending 条目,不替换它。等待者持有的 Promise 因而始终是最终被唤醒的那个。

重复解决检查发生在写入之前。只要 data !== INITIAL_DATA,就抛出 Object already resolved,第二次调用的数据不会覆盖第一次,条目 Promise 也不会被再次利用。

这里的「单次」是单次登记生命周期,不是某个 id 永远不能再用。delete 或 clear 移除条目后,同一 id 可以建立新条目;但在原条目上,resolve 不是更新缓存值的 set。

默认参数是当前版本很容易被忽略的细节:resolve(objId) 会把 data 写成 null。由于 pending 由 INITIAL_DATA 表示,这个对象仍然是已解决状态,has 返回 true,无回调 get 返回 null。

赋值顺序也不能颠倒。obj.data = data 先同步完成,obj.resolve() 再履行 Promise;后续微任务里的 callback(obj.data) 因而一定能看到最终值。

obj.resolve() 没有传参,所以 Promise 的履行值是 undefined。这再次说明它不是数据通道。reject 在当前实现中同样没有参与:对象处理失败时,是否把某种错误值作为 data 解决、是否走备用渲染,是上层消息处理器决定的。

delete:为什么拒绝删除未解决项

delete(objId) {
  const obj = this.#objs.get(objId);
  if (!obj || obj.data === INITIAL_DATA) {
    // Only allow removing the object *after* it's been resolved.
    return false;
  }
  this.#objs.delete(objId);
  return true;
}

未登记返回 false 很直观;pending 也返回 false,则是在保护已经注册的等待者。设想允许删除 pending 条目 A:回调仍挂在 A 的 Promise 上,之后的 resolve 却会通过 getOrInsertComputed 新建条目 B 并唤醒 B,A 上的回调便永远没有下文。

拒绝删除让「等待中」这个生命周期保持完整:要么原条目被解决,要么由更高层的整体销毁终止语义;不能一边留下等待者,一边悄悄换掉它们等待的注册项。

这个方法在字体路径上有实际用途。FontFaceObject.getPathGenerator 把原始绘制命令编译成可复用路径后,会删除不再需要的原始对象:

const objId = this.loadedName + '_path_' + character;
let cmds;
try {
  cmds = objs.get(objId);
} catch (ex) {
  warn(`getPathGenerator - ignoring character: "${ex}".`);
}
const path = makePathFromDrawOPS(cmds?.path);
 
if (!this.fontExtraProperties) {
  // Remove the raw path-string, since we don't need it anymore.
  objs.delete(objId);
}
return (this.compiledGlyphs[character] = path);

同步 get 保证正常走到删除时对象已经解决;若未就绪,前面的异常分支不会把 pending 项删掉。编译结果进入 compiledGlyphs,原始 FontPath 条目则可以退出注册表。

注意 delete 本身只删 Map 项,不调用 bitmap.close()。批量释放位图资源是 clear 的职责,不能把两者当成同一个清理 API。

clear:先释放位图,再整体移除

clear() {
  for (const { data } of this.#objs.values()) {
    data?.bitmap?.close(); // Release any `ImageBitmap` data.
  }
  this.#objs.clear();
}

循环遍历所有条目,对已解决且带 bitmap 的图像数据调用 ImageBitmap.close(),主动释放其图形资源;null 或 pending 的 INITIAL_DATA 会被可选链安全跳过。随后 Map.clear() 去掉所有注册项,其余普通数据交给垃圾回收。

这里释放的是 data.bitmap,不是假定每个对象本身都有 close。字体字节、编译路径、普通像素数组各有自己的生命周期,PDFObjects.clear 只对这一条共用的位图资源约定做特殊处理。

clear 不是逐对象取消协议。 它不 resolve、也不 reject pending 条目的 Promise,已挂在旧条目上的回调不会仅因为 clear 就收到通知。正确性依赖上层生命周期先停止消费者,而不是把这个小类当成通用任务取消器。

页面清理时,PDFPageProxy.#tryCleanup 先确认没有活动 renderTask 且 operatorList 已到最后一块,再清 _intentStates 并调用 objs.clear()。此后迟到的 obj 消息看见 _intentStates.size === 0,会直接关闭随消息到来的 imageData.bitmap,不重新写入已经清理的页缓存。

文档销毁时,WorkerTransport.destroy 先标记 transport 已销毁,让后续对象消息被忽略,再等待页面任务与 worker Terminate 收尾,最后执行 commonObjs.clear()。显式文档 cleanup() 也会在各页清理成功后清文档级对象。

因此,clear 的简单实现与 api.js 的严格清理顺序是配套设计:前者负责释放与移除,后者保证此时不再有人合法等待或继续写入。

Symbol.iterator:只遍历可以消费的数据

*[Symbol.iterator]() {
  for (const [objId, { data }] of this.#objs) {
    if (data !== INITIAL_DATA) {
      yield [objId, data];
    }
  }
}

这是一个生成器方法,所以 PDFObjects 实例可以直接放进 for...of。它产出 [objId, data],不是内部条目,也不产出 Promise。

pending 项被逐项跳过,不会阻塞整个迭代,也不会触发等待;已解决的 null 则仍会被产出。调用方看到的永远是可以同步检查的 payload。

当前 commonobj 的 CopyLocalImage 分支正依赖这个接口。worker 希望复用主线程某页已经解码的图片时,处理器扫描各页对象:

for (const pageProxy of this.#pageCache.values()) {
  for (const [, data] of pageProxy.objs) {
    if (data?.ref !== imageRef) {
      continue;
    }
    if (!data.dataLen) {
      return null;
    }
    const copy = structuredClone(data);
    if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
      copy.CopyLocalImage = true;
    }
    this.commonObjs.resolve(id, copy);
    return data.dataLen;
  }
}

只有已解决的页级数据才参与 ref 匹配。找到后,主线程 structuredClone 一份数据,把副本解决到文档级 commonObjs,再把 dataLen 作为这个 commonobj 请求的答复返回给 worker,避免重复解码。

这里存在两类 Promise,但不能混为一谈:CopyLocalImage 的请求—应答由 MessageHandler.sendWithPromise 完成;commonObjs.resolve 唤醒的则是主线程本地对象等待者。它们服务的对象与方向都不同。

两级注册表:commonObjs 与 objs

PDFObjects 本身不决定作用域,作用域由 api.js 创建实例的位置决定。每个 WorkerTransport 创建一份文档级注册表:

this.commonObjs = new PDFObjects();

每个 PDFPageProxy 共享 transport 的那一份,同时创建自己的页级注册表:

this.commonObjs = transport.commonObjs;
this.objs = new PDFObjects();
注册表拥有者与共享范围入口消息当前主要数据清理边界
commonObjs一个 WorkerTransport 一份,所有页面共享commonobj字体、字形路径、全局图片、部分图案、本地图片副本文档 cleanup / destroy
objs每个 PDFPageProxy 一份obj本页图片与图案页面 cleanup / destroy

PDFPageProxy.render 把两者一起交给 InternalRenderTask,后者再交给 CanvasGraphics。canvas 按 id 约定选池:字符串以 g_ 开头时走 commonObjs,否则走页级 objs。

getObject(opIdx, data, fallback = null) {
  if (typeof data === "string") {
    this.dependencyTracker?.recordNamedDependency(opIdx, data);
    return data.startsWith("g_")
      ? this.commonObjs.get(data)
      : this.objs.get(data);
  }
  return fallback;
}

真正的图片、图案读取会经过这个同步 getObject。字体的 setFont 更直接,调用 this.commonObjs.get(fontRefName);需要内置字形路径时,paintChar 再把同一个 commonObjs 交给 font.getPathGenerator。

worker 侧也按资源范围选择消息:普通页图片发送 obj,Type3 字体内的图片或需要全局缓存的图片发送 commonobj。所以两级缓存不是主线程事后猜测出来的分类,而是生产端与消费端共同遵守的路由协议。

对象消息到达,不等于对象已经就绪

当前 commonobj 处理器按类型决定何时、以什么值调用 resolve:

类型主线程处理最终注册位置
Font构造 FontFaceObject,等待字体绑定或 fallback 链完成commonObjs
FontPath包装成 FontPathInfocommonObjs
Image使用传回的 exportedDatacommonObjs
Pattern经 PatternInfo.getIR() 转换commonObjs
CopyLocalImage扫描页缓存并克隆可复用图片commonObjs
obj 的 Image / Pattern校验页面仍有效后直接解决对应页的 objs

字体路径最能说明「收到数据」与「可以渲染」之间的距离:

this.fontLoader
  .bind(font)
  .catch(() => messageHandler.sendWithPromise('FontFallback', { id }))
  .finally(() => {
    if (!font.fontExtraProperties) {
      // Immediately release the `font.data` property once the font
      // has been attached to the DOM, since it's no longer needed,
      // rather than waiting for a `PDFDocumentProxy.cleanup` call.
      // Since `font.data` could be very large, e.g. in some cases
      // multiple megabytes, this will help reduce memory usage.
      font.clearData();
    }
    this.commonObjs.resolve(id, font);
  });

bind(font) 可能要等待原生 FontFace.loaded,也可能走 DOM 字体加载探测。绑定失败时,catch 返回 FontFallback 请求 Promise;无论直接成功还是进入 fallback,链条最后都在 finally 中解决对象。

所以 PDFObjects 没有替字体加载制造 Promise,也没有监听 worker 的某个 Promise。是 api.js 在主线程完成必要的包装、绑定与降级后,主动宣布「这个 id 现在可供同步渲染使用」。

finally 里的 font.clearData() 又是另一层资源管理:不需要保留额外字体属性时,字体字节挂入 DOM 后就立即释放,不必等整个 commonObjs.clear()。

另外,commonobj 的字体错误分支会 resolve(id, exportedError),而不是 reject 条目 Promise。这并不矛盾:PDFObjects 只统一「已有一个最终值」的就绪语义,不定义上层如何处理错误值。

条目 Promise 从不跨线程。 worker 通过 send("commonobj", ...) 或 send("obj", ...) 发送可结构化克隆/可转移的数据;主线程消息处理器收到后才调用本地 resolve。跨线程请求—应答 Promise 属于 MessageHandler,条目 Promise 属于主线程 PDFObjects,两者不要拼接成一条虚构的 Promise 链。

渲染循环怎样使用两种 get

依赖等待集中在 CanvasGraphics.executeOperatorList 的 OPS.dependency 分支,而不是散落在每个绘画算子里:

for (const depObjId of fnArgs) {
  this.dependencyTracker?.recordNamedData(depObjId, i);
  const objsPool = depObjId.startsWith('g_') ? commonObjs : objs;
 
  // If the promise isn't resolved yet, add the continueCallback
  // to the promise and bail out.
  if (!objsPool.has(depObjId)) {
    objsPool.get(depObjId, continueCallback);
    return i;
  }
}

has 为真时,循环直接越过依赖算子,后续绘画算子通过无回调 get 同步取数据。has 为假时,才用带回调 get 登记 continueCallback,并从当前索引 i 返回。

返回值是 i 而不是 i + 1 很重要。InternalRenderTask 把它保存为 operatorListIdx;对象就绪后的下一轮会重新检查同一个依赖算子,确认所有依赖都能同步读取,再真正执行后续绘画。

continueCallback 在这里是 InternalRenderTask._continueBound。条目回调虽然会传入 obj.data,_continue() 并不使用这个参数;它只把通知当成重新调度的信号。数据仍由后续算子从注册表同步取出。

时序一:对象先解决,渲染后登记依赖

以一张已经由 obj 消息解决到页缓存的图片为例:

顺序发生的动作结果
1消息处理器执行 pageProxy.objs.resolve(id, imageData)条目直接从未登记变为已解决
2渲染循环后来遇到 OPS.dependency,has(id) 为 true不注册等待,不暂停
3图片算子调用 getObject,进入无回调 get同一调用栈内返回 imageData

这条路径的主线程绘制热路径没有等待成本。条目 Promise 虽已履行,但渲染循环甚至不需要碰它,只用 has 与同步 get。

如果其他代码此时显式调用 get(id, callback),getOrInsertComputed 会返回这个已解决条目,callback 仍通过已履行 Promise 的 .then 排入微任务;这不会影响无回调读取的同步性。

时序二:渲染先登记等待,对象后解决

worker 在解码图片前就会向 operatorList 添加依赖,因此下面这条路径同样正常:

顺序发生的动作结果
1canvas 遇到依赖,has(id) 为 false对象可能未登记,也可能已经 pending
2get(id, continueCallback) 复用或创建条目,并注册 .thenget 返回 null,executeOperatorList 返回当前 i
3图片消息稍后到达,api.js 调用 resolve(id, imageData)先写 data,再履行本地 Promise
4Promise 微任务调用 _continueBound渲染任务安排下一轮,而不是在消息回调里直接画
5从索引 i 重试,has(id) 为 true越过依赖,绘画算子同步取得图片

字体的「消息先到但尚未就绪」也归到第二条路径:commonobj 已到达不代表 has 为真,只有字体绑定/fallback 链走到 finally 并调用 resolve 后,等待渲染才会继续。

两条路径能共用同一份代码,靠的就是 getOrInsertComputed 保留条目身份、data 保存同步状态、Promise 只负责通知。谁先谁后只改变状态迁移顺序,不改变最终读取契约。

设计要点

1. 哨兵表达三态,胜过真值判断

如果只用 Map.get(id) 的返回值判断,就无法区分未登记、pending、null 和其他假值。私有 Symbol 把「未解决」从所有合法 payload 中隔离出来,resolve 的默认 null 也因而安全。

2. 稳定条目同时服务生产者和等待者

get 与 resolve 都通过 getOrInsertComputed 获得同一个条目。先注册回调不会挡住后来的数据,先解决数据也不会让后注册回调丢失通知;不需要另外维护等待者数组或消息重放缓存。

3. 异步只出现在依赖边界,绘制保持同步

PDFObjects 没有把 canvas 的每个算子改造成 async。依赖算子负责暂停与恢复,真正绘画时统一同步读取,这让渲染状态机、异常路径与绘制顺序都保持简单。

4. 「就绪」由消费者定义,而不是由消息到达定义

图片可以收到后立即解决;字体必须等主线程绑定或 fallback 完成。PDFObjects 提供的是中立的通知机制,api.js 才把不同资源各自的准备工作纳入同一个 id 就绪契约。

5. 单次解决与 pending 删除保护的是等待关系

禁止重复解决防止最终值被覆盖;禁止删除 pending 防止旧等待者与新条目失联。这两条约束共同保证一个登记生命周期里的通知关系闭合。

6. 缓存作用域与资源释放必须一起设计

文档级和页级两个池避免跨页资源被过早清理,也让页资源可以独立回收。clear 主动关闭 ImageBitmap,迭代器只暴露已解决数据,api.js 再补上清理后的迟到消息防线,才构成完整的资源所有权链。

总结

PDFObjects 不是通用缓存,也不是跨线程 Promise 封装。它是一个只关心渲染对象生命周期的三态注册表:未登记、已登记未解决、已解决;以 INITIAL_DATA 做同步判定,以本地 Promise 做异步通知。

读懂这 116 行,渲染链路里两个看似矛盾的行为就统一了:CanvasGraphics 可以在依赖未就绪时通过回调停等,又能在真正绘制时用 get 同步取对象。commonObjs 与 objs 决定数据能共享多久,resolve 的决定时机决定数据何时真的可用,delete / clear / iterator 则守住等待关系与资源释放边界。

从消息到像素,PDF.js 没有把异步扩散到整个绘制器,而是把它压缩在「依赖未就绪」这一条边上。这正是这个小文件在展示层中的价值。

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

相关文章