StreamPDFStreamPDF
返回博客

PDF.js 源码解读: BasePDFStream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: BasePDFStream

前言

本篇完整解读一个源文件:src/shared/base_pdf_stream.js,共 240 行。

此前的网络流文章把这个文件作为 fetch、XHR、Node.js 与 push 传输中的一个子系统介绍。本篇把视角收窄:基类文件本身确立了什么契约、持有什么状态,又有哪些决定被明确留给子类?

文件导出三个类:

  • BasePDFStream 持有字节来源并负责创建 reader;
  • BasePDFStreamReader 描述整份文档的流式读取与响应元数据;
  • BasePDFStreamRangeReader 描述独立请求的字节区间。

这三个类自身都不下载数据。它们的价值在于提供统一接口,让 worker 按需拉取字节,而不必关心主线程的数据究竟来自 fetch、XMLHttpRequest、本地文件流,还是应用自己提供的 buffer。

模块总览

区块行号职责
BasePDFStream22-93保存 source 与 reader 类;创建、取消 reader
BasePDFStreamReader98-201full reader 的元数据、进度与抽象 read() / cancel()
BasePDFStreamRangeReader206-238最小 range reader 接口
导出240只导出上述三个基类

依赖被刻意压到最小:只从 util.js 引入 assert 和 unreachable。模块内没有网络、解析器或 worker 消息代码。

核心对象关系是:

BasePDFStream
├── 至多一个 BasePDFStreamReader
└── Set 中零到多个 BasePDFStreamRangeReader

所以 full 与 range 不是同一个游标上的两种方法,而是各自拥有独立 I/O 状态的 reader 对象,只由 stream 的小型注册表进行协调。

BasePDFStream.constructor():注入具体实现

constructor(source, PDFStreamReader, PDFStreamRangeReader) {
  if (
    (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
    this.constructor === BasePDFStream
  ) {
    unreachable("Cannot initialize BasePDFStream.");
  }
  this._source = source;
 
  this.#PDFStreamReader = PDFStreamReader;
  this.#PDFStreamRangeReader = PDFStreamRangeReader;
}

基类不负责选择传输方式。具体 stream 通过 super 注入两个 reader 构造函数:

super(source, PDFFetchStreamReader, PDFFetchStreamRangeReader);

这是不依赖注册表或工厂框架的构造函数注入。BasePDFStream 决定何时可以创建 reader;被注入的类决定自己的 read() 与 cancel() 如何工作。私有字段防止之后替换这些类。

source 被保存为 _source,子类从中读取传输相关选项:URL、请求头、range 大小、disableRange、disableStream 或 PDFDataRangeTransport。基类刻意不为这个对象规定 schema。

直接实例化保护在测试或未构建源码中生效:它会拦住 new BasePDFStream(...),但允许子类调用。后文的抽象方法保护,才是防止 reader 实现缺失的更重要一道约束。

BasePDFStream._progressiveDataLength:衡量 full stream 的覆盖范围

get _progressiveDataLength() {
  return this._fullReader?._loaded ?? 0;
}

这个 getter 只回答一个问题:full reader 当前报告已经加载了多少字节?

在 getFullReader() 之前,或任何字节到达之前,值都是 0。它不是文件长度、解析器已消费的字节数,也不是 range 流量之和;只有 full reader 的 _loaded 字段参与计算。

这个区别解释了它唯一的用途:当请求区间已经完全处于渐进加载的覆盖范围内时,避免再发 range 请求。

BasePDFStream.getFullReader():唯一的整文档 reader

getFullReader() {
  assert(
    !this._fullReader,
    "BasePDFStream.getFullReader can only be called once."
  );
  return (this._fullReader = new this.#PDFStreamReader(this));
}

每个 stream 的 full reader 都是单例。构造与注册同时完成,因此之后 _progressiveDataLength 和 cancelAllRequests() 都能访问同一个对象。

断言约束的是生命周期,而不是 I/O:

  1. 第一次调用构造具体 full reader;
  2. 实例保存到 _fullReader;
  3. 第二次调用直接失败,不会替换或复制 reader。

取消不会重置 _fullReader,所以 stream 被取消后也不能再取得第二个 full reader。这符合调用方一次文档加载对应一个生命周期的使用方式。

是否启动请求由子类在自己的 reader 构造函数中决定;基类工厂只负责实例化。

BasePDFStream.getRangeReader():带条件的工厂

getRangeReader(begin, end) {
  if (end <= this._progressiveDataLength) {
    return null;
  }
  const reader = new this.#PDFStreamRangeReader(this, begin, end);
  this._rangeReaders.add(reader);
  return reader;
}

这一层的 range 区间采用半开形式 [begin, end)。具体传输会把它转换成各自 API 所需的形式,例如 HTTP 使用闭区间,所以终点要写成 end - 1。

方法有两个互斥结果:

条件结果含义
end <= _progressiveDataLengthnullfull reader 已加载整个请求区间
其他情况新的 range reader区间仍需独立的 range 路径

判断只看 end。如果 begin 小于 _loaded、但 end 大于 _loaded,仍会创建 range reader;基类不会裁掉已经加载的前缀。

只有构造成功后才会注册 reader。与 full reader 不同,range reader 明确允许多实例,因为解析器可能同时需要互不相关的区间。

源码注释还规定了一个时序契约:调用方应当在 full reader 的 headersReady promise resolve 之后调用此方法。基类方法本身不会 await 或断言这个顺序,而是依赖调用方先确立 range 与 streaming 能力。

为什么 null 是契约的一部分

null 不表示 PDF 字节不可用,而表示这次 range 请求没有必要,因为 full stream 已经覆盖这些字节。

display/api.js 中真实的 GetRangeReader handler 显式处理了这个分支:

const rangeReader = this.#networkStream.getRangeReader(data.begin, data.end);
 
if (!rangeReader) {
  sink.close();
  return;
}

关闭这个 range sink 并不会丢弃文档数据。已经在运行的 full reader 会继续向 worker 传递数据,再发一次请求只会重复工作。

BasePDFStream.cancelAllRequests():取消分发,而不是状态清零

cancelAllRequests(reason) {
  this._fullReader?.cancel(reason);
 
  // Always create a copy of the rangeReaders.
  for (const reader of new Set(this._rangeReaders)) {
    reader.cancel(reason);
  }
}

取消操作有三项精确语义:

  • full reader 存在时取消其读取;
  • 每个已注册 range reader 都收到同一个 reason;
  • range 遍历使用 Set 的副本。

副本很重要,因为具体 cancel() 实现可能通过 onClosed 或 onDone 回调,同步把自己从原 Set 删除。遍历快照可以避免这种修改影响本轮取消分发。

这个方法不会等待异步关闭完成,也不会清空 _rangeReaders,更不会重置 _fullReader。它只负责取消分发,不是把 stream 恢复到初始状态的析构器。

基类也不会在 EOF 时自动移除 reader。XHR 与 push stream 自己安排移除回调;fetch 与 Node.js 则把这一生命周期细节留给具体实现和所属 stream 的生命周期。

BasePDFStreamReader.constructor():full reader 的共享状态

full reader 基类持有元数据与进度状态,但不持有任何具体字节队列:

onProgress = null;
_contentLength = 0;
_filename = null;
_headersCapability = Promise.withResolvers();
_isRangeSupported = false;
_isStreamingSupported = false;
_loaded = 0;
_stream = null;
 
constructor(stream) {
  // Direct construction is rejected in testing/unbuilt code.
  this._stream = stream;
}

每个 reader 都有自己独立的 Promise.withResolvers() capability。默认值刻意保持保守:未知长度是 0,缺失文件名是 null,可选的 range/streaming 能力在子类确认前都是 false。

_stream 是指回所属 BasePDFStream 的引用。具体实现通过它访问 _source、共享请求头、响应来源和 reader 注册表回调,而不必把这些传输细节塞进基类。

_loaded 有双重作用:具体实现用它统计进度,BasePDFStream 用它判断是否可以省略 range 请求。

headersReady 与元数据 getter

get headersReady() {
  return this._headersCapability.promise;
}
 
get filename() {
  return this._filename;
}
 
get contentLength() {
  return this._contentLength;
}
 
get isRangeSupported() {
  return this._isRangeSupported;
}
 
get isStreamingSupported() {
  return this._isStreamingSupported;
}

这些 getter 共同构成一道元数据屏障。按照文档,它们的确定值要在 headersReady resolve 后读取:

Getterready 后的含义
filename从 content disposition 解析出的文件名,或 null
contentLengthPDF 字节长度,未知时为 0
isRangeSupported是否可以使用独立 range 读取
isStreamingSupported是否可以渐进消费完整响应

响应建立失败或 reader 被取消时,headersReady 可能 reject。子类只有在写完希望消费者看到的元数据后,才应该 resolve 它。

这个 promise 不表示文档正文已经下载完成。对 fetch 而言,它通常只表示响应头已经到达;对 push 传输而言,它可以依据 PDFDataRangeTransport 已经提供的属性立即 resolve。

display/api.js 中面向 worker 的 ReaderHeadersReady handler 正是等待这个 promise,然后返回 contentLength、isStreamingSupported 和 isRangeSupported。正是这个真实调用方,把源码注释中的 range 时序约定变成了实际的顺序保证。

BasePDFStreamReader._callOnProgress():统一通知形态

_callOnProgress() {
  this.onProgress?.({ loaded: this._loaded, total: this._contentLength });
}

这个辅助方法是同步且可选的。没有安装回调时什么也不做;否则每次创建一个新对象,报告当前的 _loaded 与 _contentLength,不保存进度历史。

fetch、Node.js 与 push reader 在数据到达时调用它。XHR 则直接转发原生 progress 事件,并在事件总长度可计算时使用事件中的 total。因此,基类统一的是回调形态,而不是测量进度的机制。

文档特别强调:当 streaming 不受支持时,进度回调尤其有用。此时消费者可能在等待一个完整响应,而不是持续收到正文 chunk。

BasePDFStreamReader.read() 与 cancel():full reader 的抽象操作

async read() {
  unreachable("Abstract method `read` called");
}
 
cancel(reason) {
  unreachable("Abstract method `cancel` called");
}

每个 full reader 子类都必须实现这两个操作。

按照文档,read() 的返回形态与标准 stream reader 一致:

{ value, done: false } // value contains the next binary chunk
{ value, done: true }  // end of input

没有 chunk 可用时,pending read 会等待。文档中的取消契约要求被取消的读取以 done: true 完成,而 I/O 失败会让 pending 操作 reject。

尽管返回形态相似,BasePDFStreamReader 本身并不是 WHATWG ReadableStream 或其 reader。它没有标准的锁定、排队策略或 tee()。这种相似是刻意的:display/api.js 可以直接把它放在 MessageHandler sink 的 onPull 回调下面。

在基类 JSDoc 之外还有一层更具体的边界。主线程的 GetReader 与 GetRangeReader handler 会断言每个非结束态 value 都是 ArrayBuffer,再包成 Uint8Array 并把 buffer transfer 给 worker。因此,主线程具体 reader 实际兑现的是比“二进制数据”更严格的载荷契约。

cancel(reason) 负责启动当前 reader 的拆除。基类接口不返回 promise,也不规定实现该用 AbortController、xhr.abort()、web-stream reader,还是本地状态迁移。

BasePDFStreamRangeReader.constructor():只共享所属 stream

constructor(stream, begin, end) {
  if (
    (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
    this.constructor === BasePDFStreamRangeReader
  ) {
    unreachable("Cannot initialize BasePDFStreamRangeReader.");
  }
  this._stream = stream;
}

构造函数虽然收到 begin 和 end,却只保存 _stream。这并不意味着具体请求拿不到区间:参数仍可被每个子类构造函数立即使用,或由子类自行保存。

不同实现的选择正好说明了这一点:

  • fetch 立即用两个值构造 Range 请求头;
  • Node.js 把它们映射为 fs.createReadStream 选项;
  • XHR 把它们交给自己的请求辅助方法;
  • push 模式保存 _begin,用于把后续到达的 chunk 匹配到正确 reader,而 end 在发起 range 请求时使用。

因此基类没有必要强迫所有实现都携带 _begin 和 _end 字段。

range reader 的 read() 与 cancel(reason) 遵循和 full reader 相同的抽象契约。它刻意没有 headersReady、onProgress、content length 或能力 getter:响应级元数据属于唯一的 full reader,而不是每个片段。

fetch、XHR、Node.js 与 push 如何兑现契约

基类之所以很小,是因为每种传输都在相同方法背后提供自己的状态机。

实现full reader ready 与元数据read() 机制range 实现
PDFFetchStreamfetch 响应校验后 resolve;记录长度、range 能力、文件名与响应来源等待 ready,再拉取 response.body.getReader();更新 _loaded发送以 end - 1 为包含式终点的 Range 请求头,校验响应来源,再拉取另一个 web reader
PDFNetworkStream解析 XHR 响应头;支持 range 时可能中止完整 XHRresolve 已排队的 read capability,或返回完整 arraybuffer chunk发起另一个 XHR,缓存响应并校验 partial-content 元数据
PDFNodeStream用 fs.lstat 取得大小并决定本地 range 策略用 Readable.toWeb() 转换 Node 文件流,再拉取 reader使用 createReadStream 的 start: begin 与 end: end - 1
PDFDataTransportStream根据 PDFDataRangeTransport 属性立即 ready消费 push 进来的 chunk;多余数据排队,无数据时挂起 read capability调用 requestDataRange(begin, end),再按 _begin 把 push 数据路由回来

fetch:两个不同的 ready promise

fetch full reader 等待继承来的 _headersCapability;每个 range reader 则有自己的 _readCapability。两者都用自己的 promise 拦住 read(),直到底层 response.body reader 已经存在。

full reader 还独占渐进计数与响应元数据。当 streaming 被禁用且 range 可用时,它会利用响应头完成能力判断后取消初始正文,再 resolve headersReady。所以取消初始请求可能是能力协商的结果,并不代表文档加载失败。

XHR:reader 形态下的缓冲响应

PDFNetworkStreamReader 永远不会启用 _isStreamingSupported。它的 read() 仍返回标准 { value, done } 形态,但内部会在已缓存 chunk、完成状态和新建 pending promise 之间选择。

确认支持 range 后,XHR 会中止完整请求,改为用独立请求读取片段。full 与 range reader 也说明了为什么取消必须结束 pending read:它们共享的 endRequests helper 会以 done: true resolve 所有停放的 capability。

XHR 与 push reader 会主动把已完成或已取消的 range reader 从 _rangeReaders 删除。这种清理由具体传输负责,不是基类注册表自带的行为。

Node.js:用文件系统元数据替代 HTTP 头

PDFNodeStreamReader 把 lstat(url) 当作元数据阶段。文件大小成为 contentLength;range 能力由 disableRange 和文件是否大于两个 range chunk 共同决定。这里没有可提取的 content-disposition 文件名。

full 与 range reader 都通过 web 兼容 reader 暴露文件数据。与 fetch 一样,range 终点会从基类的排他 end 转为文件系统 API 的包含式 end - 1。

push:应用自己成为字节来源

PDFDataTransportStream 把 I/O 方向反转。transport 构造函数用 initialData 与 progressiveDone 初始化队列和完成状态;full reader 从 PDFDataRangeTransport 读取 length 与 contentDispositionFilename,通过 stream 继承那份队列状态,随后立即 resolve headersReady。

因此一次 read() 有三种完成方式:取出已排队 chunk、观察到 _done,或进入 _requests 等待应用继续 push 数据。即使当前没人读取,_enqueue() 也会更新 _loaded,从而让 _progressiveDataLength 持续有效。

对 range 而言,stream 既创建 reader,也调用 requestDataRange(begin, end)。后续 range chunk 带有 begin 偏移,stream 据此找到 _begin 匹配的已注册 reader。在这里,基类的 Set 不只是取消列表,也是数据分发表。

最后,push 模式覆写 cancelAllRequests(reason):完成基类取消分发后,还会调用 pdfDataRangeTransport.abort(),把取消继续传播给应用持有的数据源。

worker 侧适配器复用同一个基类

core/worker_stream.js 还提供了另一个实现 PDFWorkerStream。它的 reader 用 MessageHandler.sendWithStream() 包装 GetReader 与 GetRangeReader,再把 transfer 过来的 Uint8Array 拆回 ArrayBuffer。

至此设计形成闭环:主线程传输在真实 I/O 上兑现基类契约,display/api.js 把它们的 read 结果泵入消息流,worker 再消费同一套 full/range 抽象,而不必知道字节究竟来自哪种传输。

总结

base_pdf_stream.js 不是网络实现,而是所有 PDF 字节来源共享的 reader 生命周期与能力契约。

最重要的约束有六条:

  1. 每个 stream 只有一个 full reader。 getFullReader() 只注册一次;取消不会重新打开工厂。
  2. range 创建是有条件的。 full reader 的 _loaded 已覆盖 end 时,getRangeReader() 返回 null,调用方必须处理这个“成功省略请求”的分支。
  3. 元数据有时序屏障。 contentLength、filename、isRangeSupported 与 isStreamingSupported 要在 headersReady resolve 后读取。
  4. reader API 形似 stream,但不是 ReadableStream。 子类实现异步 read() 与 cancel();主线程消费者还要求 chunk 是 ArrayBuffer。
  5. 基类负责协调,不负责传输策略。 请求头校验、请求发起、缓冲、range 匹配和物理取消,都属于 fetch、XHR、Node.js、push 或 worker 适配器。
  6. 取消是能感知注册表修改的分发。 遍历 range Set 副本,让子类回调可以安全注销 reader,但基类方法本身不会清空全部生命周期状态。

划清这些边界后,再读具体 stream 文件,看到的就不再是四套互不相关的网络写法,而是同一份精确契约的不同实现。

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

相关文章