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

前言
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);完整时序可以概括成七步:
getOperatorList同时发起两个 Promise:取内容流、预加载资源。loadResources先ensure出resources,这一步可能因为缺字节而由 manager 补齐。ObjectLoader从七个资源键里读原始值,组成初始栈。#walk深度优先展开引用、字典、流与数组。- 缺失区间被批量提交给
ChunkedStreamManager,等待分块到达。 - 数据到达后重新遍历上一轮失败的位置,直到栈空。
- 两个 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);
}
}
}这里有三个刻意的选择:
- 用
getRawValues而不是get。Dict.get会把Ref解析成对象,解析过程可能抛MissingDataException;遍历阶段一旦抛异常,整个栈的状态就难以恢复。读原始值只碰字典内部的值表,不触发任何解析。 - 流只展开字典。流内容可能有几百 KB 甚至几 MB,遍历阶段不需要读它;数据是否齐全由后面的
getBaseStreams检查。 - 只把可能展开的子节点入栈。字符串、数字、名字直接过滤掉,栈里只留下真正需要访问的节点,减少无意义的
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 是一个局部数组,每轮结束就提交、丢弃。它的元素只有起始与结束两个字段:
| 字段 | 来源 | 含义 |
|---|---|---|
begin | MissingDataException.begin 或 stream.start | 缺失区间起点 |
end | MissingDataException.end 或 stream.end | 缺失区间终点 |
一次页面加载可能产生几十个区间,但真正需要发的请求往往只有几个:相邻资源通常落在同一段字节里,排序之后会被合并到连续的块范围。这就是「批量」带来的直接收益。
默认的分块大小是 64 KiB(2 ** 16),一个 100 字节的缺口也会让管理器拉取整块。因此「请求完整流区间」与「只请求缺口」在分块层面往往落到同一批块上,前者的写法更简单,也避免计算缺口时引入边界错误。对小文件来说,一次 requestRanges 就可能把整份文档拉完,之后 isDataLoaded 变真,后续页面的预加载全部短路。
ObjectLoader 与 NetworkPdfManager.ensure 都处理缺字节,但粒度不同:
| 维度 | ensure | ObjectLoader |
|---|---|---|
| 触发单位 | 单个属性访问 | 一次资源键遍历 |
| 请求次数 | 每个缺口一次 | 多缺口合并成一次 |
| 等待方式 | 单个 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、xref | Promise |
| 遍历 | ObjectLoader.#walk | 待访问栈 | 已取回对象、缺失区间 |
| 补字节 | ChunkedStreamManager.requestRanges | 区间数组 | 分块数据 |
| 重入 | #walk 递归 | 重访列表 | 遍历完成 |
| 后续解析 | PartialEvaluator | 已加载的对象 | operatorList 或文本内容 |
用一句话串起来:ObjectLoader 把「哪里会缺字节」提前算出来,把「一次要多少字节」合并起来,让后面的同步解析可以假装网络不存在。
五条设计原则
- 预加载优先于到处重试。缺字节的处理集中在一处,业务代码不需要在每个
get后面写 catch 与重试。 - 迭代优先于递归。显式数组栈让遍历深度不再受调用栈限制,对损坏文件与超深对象图更稳。
- 遍历只读原始值。
getRawValues不触发引用解析,遍历阶段不会因为缺字节而中断,栈状态始终可控。 - 只对引用去环。
RefSet记录已访问的引用;字典与数组不可能形成真正的环,因此不做对象身份比较,省下每次访问的哈希成本。 - 批量请求,分轮推进。缺失区间先收集再提交,由分块管理器去重排序;每一轮只请求真实缺口,递归深度由数据轮次决定。
下一篇会顺着这条数据供给链往下走,看 pdf_manager.js 如何在本地字节与网络分块之间选择,以及 PDFDocument 与 XRef 怎样通过 manager 拿到自己需要的字节。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: pdf_manager2026年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 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日