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

前言
src/core/pdf_manager.js 只有 244 行,但它决定了 Worker 里「字节从哪里来」这件事。PDFDocument、XRef、Parser、Catalog 全都只认一个东西:一个可以同步读的 Stream。文件可能来自内存里的 ArrayBuffer,也可能来自按范围请求的网络响应;manager 的职责就是把这两种来源抹平成同一个 Stream,再给上层提供统一的异步访问接口。
这层抽象解决的是一个结构性矛盾:
| 层 | 期望的读取方式 | 现实 |
|---|---|---|
XRef / Parser | 同步、随机访问 | 网络分块可能还没到 |
| 网络传输 | 异步、按需 | 一次只能取一段,有延迟 |
PDFDocument | 不关心来源 | 需要同时支持内存与流式两种场景 |
BasePdfManager 把「缺字节」包装成 MissingDataException,把「补字节」包装成 requestRange,再用 ensure 把两者串起来。上层看到的仍然是一次普通的属性读取或方法调用;重试、等待、分块全部发生在 manager 内部。
本篇按当前版本的真实实现讲,包括一个与旧版接口不同的地方:这个文件已经不再转发 numPages、pdfInfo、getData 这类成员,抽象接口被收缩到了真正需要多态的几个方法上。
模块总览
文件由三部分组成:一个 URL 辅助函数、一个基类、两个子类。
| 组成 | 类别 | 职责 |
|---|---|---|
parseDocBaseUrl | 模块函数 | 校验并规范化文档基准 URL |
BasePdfManager | 抽象基类 | 保存选项、提供通用委托、定义必须实现的方法 |
LocalPdfManager | 子类 | 直接持有一个内存 Stream |
NetworkPdfManager | 子类 | 持有 ChunkedStreamManager 与 ChunkedStream |
基类的成员可以按「谁来实现」分成四组:
| 分组 | 成员 | 说明 |
|---|---|---|
| 只读属性 | docId、password、docBaseUrl | 文档身份与安全信息 |
| 通用委托 | ensureDoc、ensureCatalog、initDocument、getPage、fontFallback、cleanup | 直接转发给 PDFDocument 或 Catalog |
| 具体实现 | updatePassword | 更新密码并同步给加密器 |
| 抽象方法 | ensure、requestRange、requestLoadedStream、sendProgressiveData、terminate | 子类必须覆盖,否则触发 unreachable |
两个子类的差别集中在一张表里:
| 维度 | LocalPdfManager | NetworkPdfManager |
|---|---|---|
| 数据来源 | 完整字节数组 | 范围请求或渐进式分块 |
| 底层流 | Stream | ChunkedStream |
ensure 缺字节 | 不会发生 | 捕获异常,请求区间后重试 |
requestRange | 抽象未实现 | 委托 ChunkedStreamManager |
requestLoadedStream | 返回已完成的 Promise | requestAllChunks |
sendProgressiveData | 抽象未实现 | onReceiveData |
terminate | 空实现 | 中止分块管理器 |
选择哪一个由 Worker 在 getPdfManager 里根据传输能力决定:
| 条件 | 选择 | 流类型 |
|---|---|---|
调用方直接给了完整 data | LocalPdfManager | Stream |
| 响应头表明支持范围请求 | NetworkPdfManager | ChunkedStream |
| 不支持范围请求,边下边读 | 先缓存,读完后 LocalPdfManager | Stream |
| 支持范围请求且正在流式下载 | NetworkPdfManager + sendProgressiveData | ChunkedStream |
parseDocBaseUrl 方法
docBaseUrl 是解析文档内相对 URI 的基准。它来自调用方传入的选项,但在进入文档对象之前必须先过一次校验。
function parseDocBaseUrl(url) {
if (url) {
const absoluteUrl = createValidAbsoluteUrl(url);
if (absoluteUrl) {
return absoluteUrl.href;
}
warn(`Invalid absolute docBaseUrl: "${url}".`);
}
return null;
}函数的行为可以归纳成四种输入:
| 输入 | 处理 | 结果 |
|---|---|---|
| 空值或空字符串 | 直接返回 | null |
| 合法的绝对 URL | createValidAbsoluteUrl 解析并校验协议 | 规范化后的 href |
| 相对路径 | 没有 base 可依,解析失败 | 警告并返回 null |
| 协议不被允许 | 协议校验失败 | 警告并返回 null |
它只接受绝对 URL,不会把相对路径拼到当前页面地址上。这是刻意的:Worker 运行在独立线程里,没有 document.baseURI 可用;相对路径的解析责任留在主线程,由调用方在发起加载前自己补全。
非法值不会抛异常,只打印警告并退化成 null。文档仍然能打开,只是后续遇到相对 URI 时无法解析。对一个纯展示型选项来说,这种降级比中断加载更合理。
这个值最终会出现在 Catalog.baseUrl 上:
| 优先级 | 来源 |
|---|---|
| 1 | 目录字典 /URI 里的 /Base,且必须是合法绝对 URL |
| 2 | pdfManager.docBaseUrl |
| 3 | null |
Catalog 用这个基准去解析动作字典、附件与注释里的相对链接;注释层拿到的 baseUrl 也是同一个值。因此 parseDocBaseUrl 虽然只有十行,却是整条 URL 解析链的起点。
设计要点:外部输入在进入核心层之前先归一化。
docBaseUrl只在 manager 构造时解析一次,之后所有读取方拿到的都是已校验的href或null,不需要在每次使用前重复判断合法性。
构造:一个基类,两套实现
constructor 方法
基类构造函数做了三件事:保存通用选项、做一次特性检测、把配置分发给模块级的选项容器。
constructor({
// source,
// disableAutoFetch,
docBaseUrl,
docId,
enableXfa,
evaluatorOptions,
handler,
// length,
password,
// rangeChunkSize,
}) {
if (
(typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
this.constructor === BasePdfManager
) {
unreachable("Cannot initialize BasePdfManager.");
}
this._docBaseUrl = parseDocBaseUrl(docBaseUrl);
this._docId = docId;
this._password = password;
this.enableXfa = enableXfa;参数列表里被注释掉的 source、disableAutoFetch、length、rangeChunkSize 只属于网络实现,基类刻意不接收。这种「基类只声明自己用得到的字段」的写法,让两个子类的构造参数可以共用同一个对象,而不需要基类了解传输细节。
实例化保护只在测试构建里生效。生产构建中 PDFJSDev 会被替换成常量,这段判断会被静态求值掉;测试里如果误用 new BasePdfManager(...),会立刻得到「不能初始化基类」的错误。
docId 会参与全局 ID 的生成。PDFDocument 里有一个全局 ID 工厂,用 g_${docId} 作为文档前缀,再拼上字体序号,得到跨页面共享的字体标识。这个标识会一路传到主线程,用来复用已经创建的 FontFace。
password 是初始密码,之后还可能被 updatePassword 替换。它不参与流的选择,只影响 XRef 解密。
// Check `OffscreenCanvas` and `ImageDecoder` support once,
// rather than repeatedly throughout the worker-thread code.
evaluatorOptions.isOffscreenCanvasSupported &&= FeatureTest.isOffscreenCanvasSupported;
evaluatorOptions.isImageDecoderSupported &&= FeatureTest.isImageDecoderSupported;
this.evaluatorOptions = Object.freeze(evaluatorOptions);这两个 &&= 是「调用方意愿与运行时能力」的与运算:
| 选项 | 与什么做与运算 | 效果 |
|---|---|---|
isOffscreenCanvasSupported | FeatureTest.isOffscreenCanvasSupported | 两者都真才启用 |
isImageDecoderSupported | FeatureTest.isImageDecoderSupported | 两者都真才启用 |
这段代码会直接修改传入对象。调用方传进来的 evaluatorOptions 在进入 manager 之前是可变配置,进入之后被就地补齐并立即 Object.freeze。冻结是浅层的:顶层属性不能再改,但嵌套对象仍然可变。真正的约束来自约定:后续代码只读不写。
选项随后被分发给八个模块级容器:
| 模块 | 收到的选项 | 用途 |
|---|---|---|
ImageResizer | evaluatorOptions | 图片缩放阈值 |
JpegStream | evaluatorOptions | JPEG 解码参数 |
OperatorList | evaluatorOptions | 操作符列表缓存策略 |
IccColorSpace | 选项加 handler | 读取 wasmUrl、useWasm、useWorkerFetch |
CmykICCBasedCS | 选项加 handler | 读取 iccUrl |
PDFFunctionFactory | 选项加 handler | 读取 useWasm |
Pattern | 选项加 handler | 读取 hasGPU |
WasmImage | 选项加 handler | WASM 图片解码 |
后五个收到的是带 handler 的选项对象,但真正使用它的只有 WasmImage:它在 Worker 内加载 WASM 模块时需要跨线程通信能力。其余几个只读取各自的 URL 与开关字段,多传一个 handler 不会造成影响。
这些 setOptions 都是模块级静态方法,在同一个 Worker 里只应被调用一次。PDF.js 的默认部署是「一个文档一个 Worker」,因此不存在多个文档互相覆盖选项的问题;如果复用一个 Worker 打开多个文档,后一个文档的选项会覆盖前一个,这是这套静态容器已知的边界。
设计要点:特性检测只做一次,选项只注入一次,然后把对象冻结。PDF.js 在 Worker 里刻意避免「每次解析都查一遍能力」的成本,把这类全局判断压缩到文档构造阶段。
委托接口:ensureDoc 与 ensureCatalog
基类不直接暴露 pdfDocument 的细节,而是提供两个语义明确的入口:一个读文档属性,一个读目录属性。
ensureDoc 方法
ensureDoc(prop, args) {
return this.ensure(this.pdfDocument, prop, args);
}
ensureCatalog(prop, args) {
return this.ensure(this.pdfDocument.catalog, prop, args);
}两个方法都是一行转发,差别只在目标对象。ensureDoc("numPages") 读的是 PDFDocument 上的 getter;ensureCatalog("numPages") 读的是 Catalog 上的 getter。同一个名字可能出现在两个对象上,语义不同:文档层会考虑线性化与 XFA,目录层只负责页面树。
ensureCatalog 里的 this.pdfDocument.catalog 是一次普通属性访问。Catalog 由 parse 创建,而 initDocument 保证 parse 先完成,因此到这里目录对象一定存在;真正可能缺字节的是后续的方法调用,它们发生在 ensure 的 try 块内部。
传参形式也统一:如果属性值是函数,args 会作为参数数组传进去;否则 args 被忽略,直接返回属性值。
initDocument 方法
文档初始化的五个步骤全部通过 ensureDoc 执行:
async initDocument(recoveryMode) {
await this.ensureDoc("checkHeader");
await this.ensureDoc("parseStartXRef");
await this.ensureDoc("parse", [recoveryMode]);
// Check that at least the first page can be successfully loaded,
// since otherwise the XRef table is definitely not valid.
await this.ensureDoc("checkFirstPage", [recoveryMode]);
// Check that the last page can be successfully loaded, to ensure that
// `numPages` is correct, and fallback to walking the entire /Pages-tree.
await this.ensureDoc("checkLastPage", [recoveryMode]);
}这五步的顺序不是随意的:
| 步骤 | 作用 | 失败意味着 |
|---|---|---|
checkHeader | 校验文件头与版本 | 不是合法 PDF |
parseStartXRef | 定位交叉引用表起点 | 尾部结构损坏 |
parse | 构建 XRef,处理对象流与加密 | 交叉引用不可用 |
checkFirstPage | 至少能取到第一页 | 页面树根节点不可用 |
checkLastPage | 校验页数,必要时回退遍历整棵页面树 | 页数声明错误 |
每一步都可能因为缺字节而失败,而每一步都可能改变内部状态。ensure 的重试发生在单步内部,不会让整个初始化从头再来;只有当某一步抛出非缺字节异常时,才会中断。
recoveryMode 会一路传到 parse、checkFirstPage、checkLastPage。Worker 的处理方式是:先用 false 初始化,如果拿到 XRefParseException,就等 requestLoadedStream 完成,再用 true 重来一遍。第二遍拿到的是完整字节,可以做更激进的修复,比如逐个对象重建交叉引用。
抽象方法一览
基类里五个方法直接调用 unreachable,它们是子类必须覆盖的契约:
| 方法 | 为什么必须由子类实现 | LocalPdfManager | NetworkPdfManager |
|---|---|---|---|
ensure | 决定缺字节时是否等待与重试 | 直接读属性 | 捕获异常后请求区间 |
requestRange | 决定区间如何变成网络请求 | 不会调用 | 委托分块管理器 |
requestLoadedStream | 决定如何拿到完整字节 | 已完成的 Promise | 等待全部分块 |
sendProgressiveData | 决定如何处理渐进式到达的块 | 不会调用 | 写入分块流 |
terminate | 决定如何中止正在进行的传输 | 空实现 | 中止分块管理器 |
unreachable 在测试构建里会抛出明确的错误,在生产构建里被优化成不可达标记。它比「返回 undefined」更安全:一旦某个子类忘了实现,调用点会立刻失败,而不是带着空值继续往下走。
updatePassword 是唯一在基类里给出具体实现的「动作型」方法:
updatePassword(password) {
this._password = password;
this.pdfDocument.xref.encrypt?.setPassword(password);
}它同时更新两处:manager 自己的 _password 供后续读取,以及 XRef 上的加密器。用可选链是因为未加密文档没有 encrypt,这时只需要更新字段。
cleanup、getPage、fontFallback 也都是纯转发,它们不涉及字节来源,因此不需要在子类里重复实现。
设计要点:基类只抽象「与数据来源有关」的方法。页面获取、字体回退、缓存清理这些与传输无关的行为,统一以委托方式实现,避免两个子类各写一份完全相同的代码。
LocalPdfManager:全部在内存里
ensure 方法
class LocalPdfManager extends BasePdfManager {
constructor(args) {
super(args);
const stream = new Stream(args.source);
this.pdfDocument = new PDFDocument(this, stream);
this._loadedStreamPromise = Promise.resolve(stream);
}
async ensure(obj, prop, args) {
const value = obj[prop];
if (typeof value === 'function') {
return value.apply(obj, args);
}
return value;
}
requestLoadedStream(noFetch = false) {
return this._loadedStreamPromise;
}
terminate(reason) {}
}这个实现短得几乎不像一个「管理器」,因为内存场景下没有异步可言:
args.source是一个完整的字节数组,new Stream直接把它包成可随机访问的流。PDFDocument构造时拿到 manager 与流,后续所有解析都在这块内存上同步进行。_loadedStreamPromise立即完成,任何等待完整字节的调用方都不会阻塞。
ensure 没有 try/catch,因为 Stream 不会抛 MissingDataException。越界读取会抛范围错误,那是真正的文档损坏,应该让上层看到,而不是重试。
这里有一个容易忽略的细节:async 函数会把返回值自动展平。如果属性值本身是一个 Promise(例如 XFA 文档的 numPages 可能是 Promise),return value 会让调用方拿到的仍然是 Promise 的最终值。这就是为什么 ensure 声明为 async,即使内存实现里没有任何 await。
terminate 是空实现。没有网络请求需要中止,也没有分块管理器需要关闭;实例被丢弃后,内存随垃圾回收释放。
| 特征 | 内存实现的表现 |
|---|---|
| 首次读取延迟 | 无 |
| 缺字节异常 | 不会出现 |
| 完整字节获取 | 立即完成 |
| 中止成本 | 无 |
| 适用文档规模 | 受内存限制 |
NetworkPdfManager:按需补块
ensure 方法
class NetworkPdfManager extends BasePdfManager {
constructor(args) {
super(args);
this.streamManager = new ChunkedStreamManager(args.source, {
msgHandler: args.handler,
length: args.length,
disableAutoFetch: args.disableAutoFetch,
rangeChunkSize: args.rangeChunkSize,
});
this.pdfDocument = new PDFDocument(this, this.streamManager.getStream());
}
async ensure(obj, prop, args) {
try {
const value = obj[prop];
if (typeof value === "function") {
return await value.apply(obj, args);
}
return value;
} catch (ex) {
if (!(ex instanceof MissingDataException)) {
throw ex;
}
await this.requestRange(ex.begin, ex.end);
return this.ensure(obj, prop, args);
}
}构造函数把四个网络参数交给 ChunkedStreamManager:消息处理器、文件总长度、是否禁用自动预取、分块大小。getStream() 返回的 ChunkedStream 是一个定长数组,已加载的块被写进去,未加载的块保持空白。
ensure 是整个文件里最关键的一段。它只捕获一种异常:MissingDataException。
| 异常类型 | 处理方式 | 原因 |
|---|---|---|
MissingDataException | 请求缺失区间,递归重试 | 数据只是还没到,可以等待 |
| 其它异常 | 原样抛出 | 文档损坏、解密失败、语法错误 |
重试路径是「请求区间,然后重新执行同一次属性读取或方法调用」。这意味着被 ensure 包裹的 getter 必须能在重入时利用已有缓存。PDF.js 大量使用 shadow 缓存:第一次成功计算后把结果写回实例属性,第二次读取直接命中,不会重复解析。如果某个方法每次调用都从头开始,重试就会反复做无用功。
递归调用没有次数上限。安全性来自另一侧:requestRange 返回的 Promise 只有在对应块真正到达后才完成;如果管理器被中止,它会 reject,递归自然终止。只要请求的区间确实能覆盖缺失范围,每一轮都会取得进展。
把它和 ObjectLoader 放在一起看,分工很清楚:
| 场景 | 谁负责 | 请求粒度 |
|---|---|---|
| 文档初始化、单属性读取 | NetworkPdfManager.ensure | 单个缺失区间 |
| 页面资源批量预取 | ObjectLoader | 多区间合并 |
ensure 是兜底机制,保证任何一次同步访问最终都能拿到字节;ObjectLoader 是优化机制,把大量小缺口合并成少数几次请求,避免兜底机制被频繁触发。
requestRange 与 requestLoadedStream 方法
requestRange(begin, end) {
return this.streamManager.requestRange(begin, end);
}
requestLoadedStream(noFetch = false) {
return this.streamManager.requestAllChunks(noFetch);
}requestRange 只做转发。区间如何换算成块号、如何合并、如何通过消息处理器向主线程发 GetRangeReader,全部由 ChunkedStreamManager 决定。manager 不关心这些细节,它只需要一个「等这个区间到齐」的 Promise。
requestLoadedStream 的 noFetch 参数值得单独说明:
noFetch | 行为 | 使用场景 |
|---|---|---|
false | 请求所有缺失块,等全部到齐 | GetData、SaveDocument |
true | 不主动发请求,只等待已经安排的传输完成 | DataLoaded 事件、恢复模式 |
Worker 里 GetData 的实现就是等这个 Promise 完成后返回 stream.bytes;SaveDocument 也先等它,因为写回需要完整的原始字节。noFetch 为真时用于「确认当前下载已经完成」的场景,避免为了一个状态通知再触发一轮请求。
sendProgressiveData 与 terminate 方法
sendProgressiveData(chunk) {
this.streamManager.onReceiveData({ chunk });
}
terminate(reason) {
this.streamManager.abort(reason);
}渐进式数据是「边下边读」模式的入口。Worker 在 getPdfManager 里一边读网络流,一边把每块数据交给 sendProgressiveData;如果 manager 还没建好,块会先进入临时数组,建好后再补发。onReceiveData 接收的对象只带 chunk,没有 begin,因为渐进式下载总是从当前写指针继续,不需要显式偏移。
terminate 把所有挂起的区间请求置为失败。Worker 终止时还会调用单独的取消函数中止底层 XHR,但 manager 这一层只需要保证「等待中的 Promise 不会永远悬着」。
两个实现放在一起对比,可以看清抽象的实际边界:
| 行为 | 内存实现 | 网络实现 |
|---|---|---|
| 读取属性 | 同步取值 | 同步取值,缺字节时等待 |
| 读取方法 | 直接调用 | 直接调用,缺字节时重试 |
| 等待完整字节 | 已就绪 | 请求缺失块 |
| 接收渐进数据 | 不适用 | 写入分块流 |
| 中止 | 无操作 | 中止分块管理器并拒绝挂起请求 |
设计要点:抽象基类只规定「能做什么」,不规定「怎么做」。
ensure在两个子类里的实现差异很大,但对调用方完全透明;这正是PDFDocument可以只写一套解析代码,却同时支持内存与流式两种来源的原因。
文档、XRef 与 manager
manager 本身不解析任何字节,它只负责把流交出去。PDFDocument 在构造时同时保存 manager 与流,并立刻创建 XRef:
this.pdfManager = pdfManager;
this.stream = stream;
this.xref = new XRef(stream, pdfManager);XRef 的构造函数同样保存这两样东西:
constructor(stream, pdfManager) {
this.stream = stream;
this.pdfManager = pdfManager;于是三者形成一条固定的引用链:PDFDocument 持有 xref 与 stream,XRef 也持有 stream 与 pdfManager。任何一层需要补字节,都能顺着 pdfManager 回到 ensure 或 requestRange,不需要额外的全局状态。
不同读取者走的是不同入口:
| 读取者 | 实际读取 | 缺字节时的路径 |
|---|---|---|
PDFDocument.checkHeader | stream.getBytes | ensureDoc 捕获并重试 |
PDFDocument.startXRef | stream.getBytes | ensureDoc 捕获并重试 |
XRef.parse | stream 与 Parser | ensureDoc("parse") |
XRef.fetch | stream.getBytes | 由外层 ensure 捕获 |
ObjectLoader | xref.stream.manager.requestRanges | 自己批量请求,不经过 ensure |
PDFDocument.#getByteRange | stream.getByteRange | 直接调用 requestRange |
Catalog 的各类属性 | 间接读 XRef | ensureCatalog 捕获并重试 |
在内存实现里,这些路径全部退化成普通数组读取。在网络实现里,一次典型的读取会经过六步:
- 上层调用
ensureDoc("parse"),进入NetworkPdfManager.ensure的 try 块。 XRef.parse读流,ChunkedStream发现目标块未加载,抛出带begin与end的异常。ensure捕获异常,调用requestRange(begin, end)并等待。ChunkedStreamManager把区间换算成块,通过消息处理器向主线程发范围请求。- 数据返回后写入分块流,对应 Promise 完成。
ensure递归重跑ensureDoc("parse"),这次读取命中已加载的块;单个 getter 的结果会被shadow缓存,后续访问不再走这条路。
PDFDocument.#getByteRange 是一个例外。它用于签名校验,只读一小段连续字节;与其把异常抛到 ensureDoc,不如在本地捕获后直接调用 requestRange,少一层重入:
async #getByteRange(begin, end) {
try {
return this.stream.getByteRange(begin, end);
} catch (ex) {
if (!(ex instanceof MissingDataException)) {
throw ex;
}
await this.pdfManager.requestRange(begin, end);
return this.#getByteRange(begin, end);
}
}这段代码和 NetworkPdfManager.ensure 的形状几乎一样,说明「捕获缺字节、请求区间、重试」是这一层的通用模式。区别只在重试对象:一个重跑属性读取,一个重跑字节切片。
ensure 没有重试计数,也不需要。它依赖一个更强的保证:请求的区间覆盖异常声明的缺口,区间到齐后同一个读取必然能前进。如果底层传输被中止,Promise 会 reject,递归不会继续。把终止条件交给数据层,比在重试层猜一个上限更可靠。
设计要点:字节读取的重试逻辑只允许出现在两个地方:
ensure与极少数需要精确切片的本地方法。调用点越多,重复请求与状态不一致的风险越大;把入口收敛之后,XRef与Catalog可以保持纯同步的写法。
版本说明:接口边界
不同版本的 PDF.js 里,BasePdfManager 的成员并不完全相同。本版把一部分转发型 getter 删掉了,让 manager 更专注于「数据供给」这一件事。如果你看到的资料里提到下面这些名字,它们在本版中的位置如下:
| 名称 | 本版是否存在 | 实际位置 |
|---|---|---|
pdfDocument | 是 | 子类构造时赋值的实例属性 |
docId | 是 | 基类 getter |
numPages | 否 | PDFDocument.numPages,通过 ensureDoc 访问 |
numPagesWithOffset | 否 | 本版没有该成员 |
pdfInfo | 否 | GetDoc 消息的载荷,内容为 numPages、fingerprints、htmlForXfa |
ensure | 是 | 基类抽象方法 |
ensureDoc | 是 | 基类委托方法 |
ensureCatalog | 是 | 基类委托方法 |
requestRange | 是 | 基类抽象方法,网络实现委托分块管理器 |
getData | 否 | requestLoadedStream,Worker 的 GetData 取 stream.bytes |
requestRanges | 否 | ChunkedStreamManager.requestRanges,由 ObjectLoader 调用 |
cleanup | 是 | 基类委托给 PDFDocument.cleanup |
terminate | 是 | 基类抽象方法 |
updateMetadata | 否 | 本版没有该成员 |
updateDocument | 否 | 保存流程走 SaveDocument 与写出器 |
这些差异说明一件事:抽象接口会随实现演进而收缩。早期版本把 numPages、pdfInfo 这类 getter 也放在 manager 上,调用方少写一层 ensureDoc,但 manager 因此知道了太多文档内部结构;现在这些读取统一走 ensureDoc,manager 只保留与传输强相关的多态点。
读源码时以当前文件为准,比记住某个版本的成员列表更可靠。判断一个成员是否属于抽象接口,可以看它是否在多处被 unreachable 标记,或者是否在两个子类里都有不同实现。
总结
pdf_manager.js 是 PDF.js 的数据供给层:向上给 PDFDocument 与 XRef 提供统一的流与访问接口,向下屏蔽内存字节、范围请求与渐进式下载的差异。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 发起加载 | display/api.js | 文件、URL、范围参数 | GetDocRequest |
| 选择实现 | worker.js 的 getPdfManager | 完整数据或范围能力 | LocalPdfManager 或 NetworkPdfManager |
| 建立流 | manager 构造函数 | 字节数组或网络流 | Stream 或 ChunkedStream |
| 建立文档 | PDFDocument | manager 与流 | pdfDocument |
| 初始化 | initDocument | 恢复模式 | 已解析的 XRef 与 Catalog |
| 属性访问 | ensureDoc / ensureCatalog | 属性名与参数 | 值,或一次重试 |
| 补齐字节 | requestRange / requestLoadedStream | 区间或全量 | 数据到达 |
| 生命周期 | cleanup / terminate | 触发原因 | 缓存释放与请求中止 |
用一句话串起来:manager 把「数据可能不在」这件事关在门内,让 PDFDocument 与 XRef 可以假设字节随时可用。
五条设计原则
- 依赖倒置。
PDFDocument只依赖 manager 的抽象接口,不依赖数据来自内存还是网络;传输方式的差异被限制在两个子类里。 - 把缺字节当控制流。
MissingDataException不是错误,而是「请补数据」的信号;只有它会被ensure捕获并重试。 - 选项一次性注入。特性检测、选项冻结、模块级
setOptions都发生在文档构造阶段,热路径上不再重复判断。 - 两个实现共享同一语义。无论内存还是网络,
ensure都承诺「返回属性值或抛出真实错误」;内存实现只是把等待退化成零成本。 - 生命周期显式化。完整字节通过
requestLoadedStream获取,缓存通过cleanup释放,传输通过terminate中止;每个动作都有明确入口,不依赖隐式回收。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 源码解读: 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日