StreamPDFStreamPDF
返回博客

PDF.js 源码解读: object_loader

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: object_loader

前言

src/core/object_loader.js 只有 153 行,却是 PDF.js 在流式加载场景下能否顺畅工作的关键之一。它不做解析,也不做渲染,只做一件事:在真正需要某个资源之前,先沿着对象图把可能缺的字节一次性要回来。

这个问题的根源在 Worker 的同步解析模型。XRef.fetch 是同步的,Dict.get 也是同步的;一旦读到一个还没下载的分块,ChunkedStream 会抛出 MissingDataException,异常里带着缺失的字节区间。如果每个调用点都各自捕获、请求、重试,解析代码会被重试逻辑淹没;而且同一个页面里的字体、图片、颜色空间往往落在相邻区间,逐个请求会产生大量小范围网络往返。

ObjectLoader 把这件事集中处理:从页面资源字典的若干个键出发,深度优先走一遍可达的对象图,把成功取回的对象留在 XRef 缓存里,把缺失的区间收集成一个数组,一次提交给 ChunkedStreamManager。等数据到达后,再从没走通的位置继续。

使用场景调用点目的
页面绘制Page.loadResources 配合 operatorList 键集合让内容流解释时不再缺资源
文本抽取Page.loadResources 配合 textContent 键集合让字体与编码表提前就位
合并资源Page.#getMergedResources覆盖内容流字典里的局部资源
XFA 字体Page.#loadXfaFonts预取 DR 里的 /Font
注释外观Annotation.loadResources预取外观流的资源

模块总览

文件只有两个模块级函数、一个类、三个方法:

组成类型职责
mayHaveChildren模块函数判断一个节点是否可能还有子节点
addChildren模块函数把节点的子节点压入待访问栈
ObjectLoader.refSet实例字段记录本次遍历已经访问过的引用
ObjectLoader.load实例方法从 keys 出发,完成整轮遍历
ObjectLoader.#walk私有方法迭代式深度优先遍历,收集并补齐缺失区间
ObjectLoader.load 静态版静态方法全量数据已就绪时直接跳过

这个类只有一个类字段 refSet 和一个三参数构造函数,也没有跨次调用的状态。每次 ObjectLoader.load(obj, keys, xref) 都新建一个实例,用完即弃。

一次页面加载的时序

以 Page.getOperatorList 为例,资源预加载并不是孤立发生的,它与内容流的获取是并行的:

const contentStreamPromise = this.getContentStream();
const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);

loadResources 内部还会先把页面继承来的资源字典解析出来,再交给 ObjectLoader:

await (this.#resourcesPromise ??= this.pdfManager.ensure(this, 'resources'));
 
await ObjectLoader.load(this.resources, keys, this.xref);

完整时序可以概括成七步:

  1. getOperatorList 同时发起两个 Promise:取内容流、预加载资源。
  2. loadResources 先 ensure 出 resources,这一步可能因为缺字节而由 manager 补齐。
  3. ObjectLoader 从七个资源键里读原始值,组成初始栈。
  4. #walk 深度优先展开引用、字典、流与数组。
  5. 缺失区间被批量提交给 ChunkedStreamManager,等待分块到达。
  6. 数据到达后重新遍历上一轮失败的位置,直到栈空。
  7. 两个 Promise 都完成,PartialEvaluator 开始同步解释内容流。

第 6 步与第 7 步之间的界线很重要:解释器一旦开始跑,就不再容忍缺字节。预加载把网络等待全部挡在这条线之前,这也是为什么 ObjectLoader 值得单独成文件。

为什么需要预加载

Worker 端的解析是一条同步链:Dict.get 解析引用时调用 XRef.fetch,XRef.fetch 直接读 stream。分块流在读越界时会抛出 MissingDataException,异常对象里带着 begin 与 end 两个字节偏移。

层缺字节时的行为谁能补
ChunkedStream抛 MissingDataException(begin, end)ChunkedStreamManager
XRef.fetch原样上抛,不重试调用方
NetworkPdfManager.ensure捕获异常,请求区间后重试manager 自己
ObjectLoader收集多个区间,批量请求自己

ensure 的重试单位是「一次属性访问」,适合页面初始化这种零散读取。渲染一个页面则不同:一次 getOperatorList 会访问几十个字体、图片与颜色空间,如果每个都触发一次 ensure 重试,就会变成几十次串行往返。ObjectLoader 的价值在于把「逐个重试」换成「一次遍历、一次批量请求」。

它的遍历只覆盖调用方点名的键,不会顺着页面树往上爬到整份文档。类注释特意提醒了这一点:不要从 Catalog 或其它页面开始加载,否则遍历会把整份 PDF 的对象图都走一遍,流式加载就失去意义了。

设计要点:预加载的边界由调用方通过 keys 定义。ObjectLoader 只负责「把这张子图走完」,不负责判断「哪些资源值得提前加载」。这个分工让同一套遍历逻辑既能服务绘制,也能服务文本抽取与 XFA,而不需要为每种用途写一份加载策略。

可遍历对象的判定

PDF 的对象模型里,只有四种东西可能继续往下走:引用、字典、流、数组。其余都是叶子。

mayHaveChildren 方法

function mayHaveChildren(value) {
  return (
    value instanceof Ref ||
    value instanceof Dict ||
    value instanceof BaseStream ||
    Array.isArray(value)
  );
}

判断非常保守:只要是这四类之一就返回真,不关心它当前是否真的有子节点。Ref 被包含进来,是因为它指向的对象类型未知,必须先取回才知道要不要继续。

节点类型是否可能展开说明
Ref是取回后可能是任意类型
Dict是值里可能还有引用或数组
BaseStream是只展开它的字典,不碰数据
数组是元素里可能还有引用
Name、字符串、数字、布尔否叶子

addChildren 方法

function addChildren(node, nodesToVisit) {
  if (node instanceof Dict) {
    node = node.getRawValues();
  } else if (node instanceof BaseStream) {
    node = node.dict.getRawValues();
  } else if (!Array.isArray(node)) {
    return;
  }
  for (const rawValue of node) {
    if (mayHaveChildren(rawValue)) {
      nodesToVisit.push(rawValue);
    }
  }
}

这里有三个刻意的选择:

  1. 用 getRawValues 而不是 get。Dict.get 会把 Ref 解析成对象,解析过程可能抛 MissingDataException;遍历阶段一旦抛异常,整个栈的状态就难以恢复。读原始值只碰字典内部的值表,不触发任何解析。
  2. 流只展开字典。流内容可能有几百 KB 甚至几 MB,遍历阶段不需要读它;数据是否齐全由后面的 getBaseStreams 检查。
  3. 只把可能展开的子节点入栈。字符串、数字、名字直接过滤掉,栈里只留下真正需要访问的节点,减少无意义的 pop。

getRawValues 返回的是字典内部值表的迭代器,顺序是插入顺序,对遍历结果没有影响。数组则直接按元素下标遍历,嵌套数组会被当作独立节点继续展开。

字典之间的直接互相引用在 PDF 语法里不可能出现:一个字典不能包含自身,只能通过间接引用形成环。因此遍历只需要防 Ref 环,不需要给 Dict 做对象身份去重。共享的字典可能被访问多次,但次数有限,不会无限循环。

ObjectLoader 的入口

load 方法

  async load() {
    const { keys, dict } = this;
    // Setup the initial nodes to visit.
    const nodesToVisit = [];
    for (const key of keys) {
      const rawValue = dict.getRaw(key);
      // Skip nodes that are guaranteed to be empty.
      if (rawValue !== undefined) {
        nodesToVisit.push(rawValue);
      }
    }
    await this.#walk(nodesToVisit);
 
    this.refSet = null; // Everything is loaded, clear the cache.
  }

起点不是整个字典,而是调用方给出的键列表。比如绘制一个页面时传入的是 ColorSpace、ExtGState、Font、Pattern、Properties、Shading、XObject 七个键;文本抽取时只需要其中四个。

getRaw 不会解析引用,所以这一步永远不会抛缺字节异常。值为 undefined 的键直接跳过,对应「这个资源类别根本不存在」的常见情况。

遍历完成后把 refSet 置空,注释写得很直白:所有东西都加载完了,缓存可以清掉。RefSet 在大型文档里可能有几万个条目,而这次遍历的结果已经沉淀到 XRef 的对象缓存里,没有必要继续保留访问集合。注意这一步只在成功路径上执行;如果 #walk 抛出非缺字节异常,refSet 会随实例一起被回收。

static load 方法

  static async load(obj, keys, xref) {
    // Don't walk the graph if all the data is already loaded.
    if (xref.stream.isDataLoaded) {
      return;
    }
    // eslint-disable-next-line no-restricted-syntax
    const objLoader = new ObjectLoader(obj, keys, xref);
    await objLoader.load();
  }

这个静态入口承担两个职责:工厂与短路。

短路条件 xref.stream.isDataLoaded 决定了这套机制在什么场景下完全不需要启动。BaseStream.isDataLoaded 默认返回真,内存 Stream 永远为真,只有 ChunkedStream 会在分块未齐时返回假。因此本地文件打开时,所有 ObjectLoader.load 调用都是零成本的;网络文档在整份文件已经下载完之后也会直接跳过。

这里检查的是 xref.stream 这个「原始流」,不是某个解码流。解码流(FlateStream、DecodeStream 等)继承默认的 isDataLoaded 为真,但它们底下的分块流可能还没加载完。这类差异由 #walk 里的 getBaseStreams 处理,不归短路条件管。

迭代式深度遍历

#walk 方法

  async #walk(nodesToVisit) {
    const nodesToRevisit = [];
    const pendingRequests = [];
    // DFS walk of the object graph.
    while (nodesToVisit.length) {
      let currentNode = nodesToVisit.pop();
 
      // Only references or chunked streams can cause missing data exceptions.
      if (currentNode instanceof Ref) {
        // Skip nodes that have already been visited.
        if (this.refSet.has(currentNode)) {
          continue;
        }
        try {
          this.refSet.put(currentNode);
          currentNode = this.xref.fetch(currentNode);
        } catch (ex) {
          if (!(ex instanceof MissingDataException)) {
            warn(`ObjectLoader.#walk - requesting all data: "${ex}".`);
 
            await this.xref.stream.manager.requestAllChunks();
            return;
          }
          nodesToRevisit.push(currentNode);
          pendingRequests.push({ begin: ex.begin, end: ex.end });
        }
      }
      if (currentNode instanceof BaseStream) {
        const baseStreams = currentNode.getBaseStreams();
        if (baseStreams) {
          let foundMissingData = false;
          for (const stream of baseStreams) {
            if (stream.isDataLoaded) {
              continue;
            }
            foundMissingData = true;
            pendingRequests.push({ begin: stream.start, end: stream.end });
          }
          if (foundMissingData) {
            nodesToRevisit.push(currentNode);
          }
        }
      }
 
      addChildren(currentNode, nodesToVisit);
    }
 
    if (pendingRequests.length) {
      await this.xref.stream.manager.requestRanges(pendingRequests);
 
      for (const node of nodesToRevisit) {
        // Remove any reference nodes from the current `RefSet` so they
        // aren't skipped when we revist them.
        if (node instanceof Ref) {
          this.refSet.remove(node);
        }
      }
      await this.#walk(nodesToRevisit);
    }
  }

整段可以拆成四个动作:取栈顶、处理引用、检查流、压入子节点。循环结束之后,如果这一轮收集到了缺失区间,就批量请求并重入。

取栈顶。nodesToVisit.pop() 让遍历呈深度优先:最后压入的节点最先访问。这里没有使用递归函数调用,而是显式维护一个数组栈。PDF 的对象图可以很深,页面树、嵌套数组、资源字典层层嵌套;损坏文件还可能构造出超深结构。显式栈把「深度」放在堆上,不会因为调用栈溢出而整个 Worker 崩溃。

处理引用。引用是唯一会主动解析的节点。访问之前先查 refSet;已经访问过就跳过,这是环检测的核心。refSet.put 放在 fetch 之前而不是之后,原因有两个:一是防止自引用或互引用在 fetch 内部递归时再次进入同一节点;二是让同一轮里重复出现的引用只解析一次。

RefSet 的实现很轻:内部是一个 Set,元素是 Ref.toString() 的结果,也就是 12R 这种紧凑字符串(生成号非零时写作 12R1)。has、put、remove 三个操作每次都要做一次字符串转换。对单个引用来说这点开销可以忽略;对整页资源来说,它换来的是 O(1) 的环检测,比给 Dict 或数组做对象身份比较便宜得多。

一个典型的环是这样形成的:页面资源里的某个 XObject 字典引用了页面自身,页面又通过 Resources 指回这个 XObject。第一次访问页面引用时把它放进 refSet;顺着资源走回同一个引用,has 返回真,直接跳过。没有这一步,遍历会在两个对象之间来回走,永远不会结束。

fetch 抛 MissingDataException 时,当前节点仍然是那个 Ref,它被放进 nodesToRevisit,缺失区间被记进 pendingRequests。由于引用没有被替换成对象,后面的流检查与 addChildren 都不会展开它,这一轮到此为止,等数据到了再重来。

fetch 抛其它异常时,处理方式完全不同:记录一条警告,调用 requestAllChunks() 把所有缺的分块都要回来,然后直接 return。这是一条兜底路径。非缺字节异常通常意味着文件本身有问题,或者缺字节被包装成了别的错误;继续做精细的区间请求没有意义,不如把整份文件拉完,让后续的同步解析自己决定怎么容错。注意这里没有把节点放进重访列表,这次预加载到此结束,失败的对象会在真正被访问时再次尝试。

检查流。流节点不需要 fetch,但它的数据可能还没到位。getBaseStreams() 会一路穿透解码流,找到底下真正的分块流:ChunkedStream 返回自身,DecodeStream 委托给内部流,StreamsSequenceStream 把所有子流的结果拼起来,普通 Stream 返回 null。任何一个基流没有加载完,就把它的完整区间记进请求列表,并把当前流节点放进重访列表。

这里请求的是 stream.start 到 stream.end 的完整区间,不是「还差多少」。分块管理器按固定块大小取整,请求整段与请求缺口在多数情况下会落到同一批块上,但整段写法更简单,也避免计算缺口时引入新的边界错误。

压入子节点。无论当前节点是刚取回的字典、还是检查过的流,最后都会调用 addChildren。流只贡献它的字典,字典贡献它的原始值,数组贡献它的元素。

批量请求与重入。循环结束后,只有收集到区间才进入异步阶段。requestRanges 属于 ChunkedStreamManager,它会把每个区间换算成块号,去重、排序,把连续块合并成若干组后并发发出。数据到达后,重访列表里的 Ref 要从 refSet 移除,否则下一轮一进来就会被「已访问」判断跳过。流节点不需要移除,它们不在 refSet 里。

重入调用的是 #walk 自身,但递归深度等于「需要补几轮数据」,与对象图深度无关。每一轮请求的都是上一轮真实缺失的区间,正常情况下下一轮就能取回对象;只有对象内部又引用了新的未加载区间时才会再走一轮。

批量请求与重试

pendingRequests 是一个局部数组,每轮结束就提交、丢弃。它的元素只有起始与结束两个字段:

字段来源含义
beginMissingDataException.begin 或 stream.start缺失区间起点
endMissingDataException.end 或 stream.end缺失区间终点

一次页面加载可能产生几十个区间,但真正需要发的请求往往只有几个:相邻资源通常落在同一段字节里,排序之后会被合并到连续的块范围。这就是「批量」带来的直接收益。

默认的分块大小是 64 KiB(2 ** 16),一个 100 字节的缺口也会让管理器拉取整块。因此「请求完整流区间」与「只请求缺口」在分块层面往往落到同一批块上,前者的写法更简单,也避免计算缺口时引入边界错误。对小文件来说,一次 requestRanges 就可能把整份文档拉完,之后 isDataLoaded 变真,后续页面的预加载全部短路。

ObjectLoader 与 NetworkPdfManager.ensure 都处理缺字节,但粒度不同:

维度ensureObjectLoader
触发单位单个属性访问一次资源键遍历
请求次数每个缺口一次多缺口合并成一次
等待方式单个 Promise整轮遍历完成
适用阶段文档初始化、零散读取页面绘制、文本抽取
失败策略有限次重试非缺字节异常直接请求全量

两者不是替代关系。ensure 负责「第一次拿到字典」,ObjectLoader 负责「把字典背后的资源一次性备齐」。loadResources 先 ensure 出 resources,再交给 ObjectLoader,正好是这条分工的体现。

边界情况行为
keys 里的键不存在getRaw 返回 undefined,直接跳过
资源字典为空初始栈为空,循环不执行,立即完成
同一字典被多处引用会被访问多次,但每次展开都是有限深度
引用形成环refSet 命中后跳过,不会重复进入
流区间跨越文件末尾区间按块边界换算,越界部分不影响遍历结果
非缺字节异常请求全部数据后结束本轮,不再继续遍历
遍历中途 Worker 终止Promise 被 reject,异常交给上层页面任务处理

失败的区间请求不会在 #walk 里被捕获。requestRanges 返回的 Promise 如果 reject(例如 Worker 被终止),异常会一路传给 ObjectLoader.load 的调用方,由上层决定是中止页面还是忽略。ObjectLoader 不吞异常,也不做重试计数,它假设「数据要么能拿到,要么上层会终止」。

版本说明:旧接口去了哪里

如果你读过早期 PDF.js 的 ObjectLoader,可能会记得 has(objId)、get(objId, callback)、实例属性 pendingRequests 与 onProgress 回调。这一版里这四个东西都不存在,全文只有 refSet、load 与 #walk。

旧版概念本版对应物语义差别
has(objId)refSet.has(ref)表示本次遍历访问过,不表示对象已加载
get(objId, callback)#walk 内部的 xref.fetch不对外暴露单对象等待接口
实例属性 pendingRequests#walk 的局部数组每轮提交后丢弃,不跨调用存活
onProgress 回调await ObjectLoader.load(...)只有完成信号,没有中间进度

旧接口的设计目标是「按对象号等待单个对象」,调用方拿到一个对象号就能问「好了没有」、「好了就回调我」。本版把加载单位改成「一次键遍历」,等待单位改成「整个 Promise」,内部不再维护对象号到回调的映射。这样做的结果是接口更小,但调用方必须一次性说清需要哪些资源键。

不过「懒加载」的语义没有变。ObjectLoader 只预加载调用方点名的子树;页面树、其它页面的资源、目录对象都不在遍历范围内。真正取对象仍然由 Dict.get 在访问时触发,预加载只是把「缺字节」这个障碍提前清掉,让后续的同步访问命中 XRef 缓存。

getImageData 的约束

BaseStream 上有一条容易被忽略的注释:

  /**
   * NOTE: This method can only be used to get image-data that is guaranteed
   *       to be fully loaded, since otherwise intermittent errors may occur;
   *       note the `ObjectLoader` class.
   */
  async getImageData(length, decoderOptions) {
    return this.getBytes(length, decoderOptions);
  }

这条约束与 ObjectLoader 直接相关。图片解码不是逐字节的容错读取:解码器往往一次性拿到整段图像数据,再交给 JPEG、JPX、JBIG2 等解码器。如果数据只加载了一部分,有的路径会抛 MissingDataException,有的路径会拿着截断的字节继续解,得到错误像素或时好时坏的渲染结果。

ObjectLoader 通过「检查流」这一步提供保证:页面资源键里包含 XObject,图片流会作为流节点被访问;它的基流区间被完整请求;等 load 完成时,这些流的字节已经在内存里。之后 PartialEvaluator 同步解析图片字典、异步解码图片数据时,就不会再遇到半截数据。

读取方式缺字节时的表现适用场景
getBytes抛 MissingDataException同步解析
getImageData可能抛错,也可能解出错图已完全加载的图像数据
asyncGetBytes等待更多数据异步解码路径

注释说的是「guaranteed to be fully loaded」,而不是「永远不会缺字节」。这是一条契约:调用方必须先通过预加载或整份下载保证数据齐全,才能调用它。ObjectLoader 存在的意义之一,就是让这条契约在流式加载场景下仍然能被满足。

设计要点:ObjectLoader 不负责解析,也不负责解码,它只负责把「数据可用性」这件横切关注点从事务性代码里抽出来。抽出来之后,解析层可以保持同步、保持简单;数据加载层可以独立做批量、做去重、做重试。两者之间唯一的契约就是:预加载完成之后,被点名的子树不再缺字节。

总结

ObjectLoader 是 PDF.js 的对象图预取层:向上服务 Page、Annotation 与 XFA 的资源加载,向下驱动 XRef.fetch 与 ChunkedStreamManager.requestRanges。

它在整条链路中的位置

阶段模块输入输出
资源键core_utils.js用途(绘制或文本)keys 数组
入口Page.loadResources资源字典、keys、xrefPromise
遍历ObjectLoader.#walk待访问栈已取回对象、缺失区间
补字节ChunkedStreamManager.requestRanges区间数组分块数据
重入#walk 递归重访列表遍历完成
后续解析PartialEvaluator已加载的对象operatorList 或文本内容

用一句话串起来:ObjectLoader 把「哪里会缺字节」提前算出来,把「一次要多少字节」合并起来,让后面的同步解析可以假装网络不存在。

五条设计原则

  1. 预加载优先于到处重试。缺字节的处理集中在一处,业务代码不需要在每个 get 后面写 catch 与重试。
  2. 迭代优先于递归。显式数组栈让遍历深度不再受调用栈限制,对损坏文件与超深对象图更稳。
  3. 遍历只读原始值。getRawValues 不触发引用解析,遍历阶段不会因为缺字节而中断,栈状态始终可控。
  4. 只对引用去环。RefSet 记录已访问的引用;字典与数组不可能形成真正的环,因此不做对象身份比较,省下每次访问的哈希成本。
  5. 批量请求,分轮推进。缺失区间先收集再提交,由分块管理器去重排序;每一轮只请求真实缺口,递归深度由数据轮次决定。

下一篇会顺着这条数据供给链往下走,看 pdf_manager.js 如何在本地字节与网络分块之间选择,以及 PDFDocument 与 XRef 怎样通过 manager 拿到自己需要的字节。

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

相关文章