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

前言
这是 PDF.js 源码解读系列进入「字节与流」层的第一篇,介绍 src/core/base_stream.js(168 行)里的 BaseStream 类。
第 10 篇 XRef 已经讲清楚对象怎么被定位与取回,第 11 篇 Parser 已经讲清楚字节怎么被分词、又怎么被组装成 Dict、数组与流对象。这两篇站在「对象」的高度,默认脚下已经有一层稳定的字节来源。本篇向下补上这个默认前提:一个流到底向解析器承诺了什么。
BaseStream 本身几乎不做实际读取工作。它是一个用普通 JavaScript 写出来的抽象基类:定义 getByte、getBytes、reset、makeSubStream 等方法名与语义,具体实现交给三类子类:
Stream:在一段已经存在于内存的ArrayBuffer上开窗口;DecodeStream:把压缩或加密的字节一边解码一边缓冲;ChunkedStream:面向网络分块加载,读到未下载区间时抛MissingDataException。
一句话概括它的职责:它是所有字节源的统一门面,让 Lexer、Parser、XRef、PartialEvaluator 不需要知道自己拿到的是本地文件、网络分块还是 Flate 解压结果。
设计要点:PDF.js 没有用 TypeScript 的抽象类,而是让抽象方法直接调用
unreachable抛错。这样契约不是文档里的约定,而是运行时会立刻暴露的失败点;子类漏实现某个方法时,不会静默返回undefined把错误带到下游。
模块总览
base_stream.js 只有 168 行,按职责可以切成八块:
| 区块 | 关键方法 | 职责 |
|---|---|---|
| 构造与抽象契约 | constructor、unreachable | 阻止直接实例化,声明必须覆写的接口 |
| 尺寸与状态 | length、isEmpty、isDataLoaded | 报告有效字节数与加载状态 |
| 原始读取 | getByte、getBytes、getByteRange、getString | 按字节、按长度、按区间取数据 |
| 前瞻与整数 | peekByte、peekBytes、getUint16、getInt32 | 不移动或移动游标地读取定长字段 |
| 游标控制 | skip、reset、moveStart | 前进、回卷、重设逻辑起点 |
| 派生与克隆 | makeSubStream、clone | 生成共享字节的子流或独立副本 |
| 异步与图像 | getImageData、asyncGetBytes、isAsync 等五个能力 getter | 能力协商与异步解码入口 |
| 流链与溯源 | getBaseStreams、getOriginalStream | 找到真正需要下载或真正持有字节的流 |
这些方法里只有 peekByte、peekBytes、getUint16、getInt32、getString、skip、getImageData 和若干 getter 有默认实现;其余都是抽象方法。默认实现全部建立在两个更小的原语上:getByte、getBytes,以及一个可写的 pos 字段。
构造与抽象契约
BaseStream 没有声明 pos,却在默认实现的 peekByte、skip 里直接使用 this.pos。这是一个隐式契约:子类必须提供一个从 0 开始、可读可写的整数游标。JavaScript 不需要字段声明,但读源码时要把这一点记在心里。
constructor 方法
constructor() {
if (
(typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
this.constructor === BaseStream
) {
unreachable("Cannot initialize BaseStream.");
}
}PDFJSDev 是构建期注入的常量,这段条件在测试语境下才成立;this.constructor === BaseStream 则区分「直接 new 基类」与「子类调用 super()」。直接实例化会立刻抛出错误,子类构造不受影响。
unreachable 的实现只有一行,就是 throw new Error(msg)。它同时承担两个作用:向读代码的人标记「这里不应该被执行」,以及在测试或异常路径里留下明确堆栈。生产环境里这段构造检查会被预处理掉,不留下每次创建流的额外判断。
设计要点:抽象基类最怕的不是「没实现」,而是「没实现却返回了一个看起来能用的值」。
undefined参与算术会变成NaN,参与比较会静默为假,错误往往在几百行之后才爆出来。这里选择在调用点立即抛错,把契约破坏压缩到最短距离。
length 与 isEmpty 方法
get length() {
unreachable("Abstract getter `length` accessed");
}
get isEmpty() {
unreachable("Abstract getter `isEmpty` accessed");
}length 表示流的总有效字节数,不是「还剩多少」。Stream 用 end - start 实现它;DecodeStream 没有覆写这个 getter,因为解码流并不承诺一开始就知道最终长度,调用方只能按需读取。
isEmpty 表示「这个流整体是否为空」,与游标当前位置无关。Stream 直接看 length 是否为零;DecodeStream 的实现更主动:只要还没到文件末尾且缓冲区为空,就继续调用 readBlock,直到拿到数据或确认结束。
两个 getter 都带 // eslint-disable-next-line getter-return,因为函数体只有抛错、没有返回值。这不是疏忽,而是有意把「没有实现」和「返回空」区分开。
原始读取接口
getByte 与 getBytes 方法
getByte() {
unreachable("Abstract method `getByte` called");
}
getBytes(length) {
unreachable("Abstract method `getBytes` called");
}getByte 的契约是:返回一个 0 到 255 的字节,或者在流结束时返回 -1。它不抛异常,也不使用 null 表示结束;解析器里的循环因此可以写成「读到 -1 就停」,不需要额外判断。
getBytes(length) 返回 Uint8Array,并推进游标。length 是可选参数,语义由子类决定,但仓库里的实现保持一致:不传或传 0 表示读到 end,传入正数表示最多读这么多,靠近末尾时会自动截短。调用方必须看返回数组的 length,不能假设一定等于请求值。
另一个没有写进签名的契约是「返回值可能是视图」。Stream 直接返回 bytes.subarray(...),零拷贝;DecodeStream 返回内部解码缓冲的 subarray。这意味着调用方不应随意修改返回的数组,否则会改到流的底层数据。
getImageData 与 asyncGetBytes 方法
async getImageData(length, decoderOptions) {
return this.getBytes(length, decoderOptions);
}
async asyncGetBytes() {
unreachable("Abstract method `asyncGetBytes` called");
}getImageData 是一个异步门面,默认只是把同步的 getBytes 包成 Promise。注释特意说明:它只能用于「保证已经完整加载」的图像数据,否则读到一半的网络流可能给出间歇性错误,ObjectLoader 就是为这种场景准备的前置加载器。
decoderOptions 会继续往 getBytes 传。BaseStream 的 getBytes 签名只写了 length,多出来的参数在 JavaScript 里不会报错;DecodeStream 覆写时正好利用它,把选项交给具体的图像解码器。
asyncGetBytes 默认是抽象方法。只有能利用平台异步解码能力的流才覆写它,例如使用原生 DecompressionStream 的 FlateStream 与 BrotliStream。它返回 null 时表示「异步路径失败,请回退到同步解码」;调用方不能把 null 当成空数据。
getByteRange 方法
getByteRange(begin, end) {
unreachable("Abstract method `getByteRange` called");
}它按 [begin, end) 取一段字节,不移动游标。与 getBytes 不同,区间是底层缓冲区里的绝对位置,不是从当前 pos 起算的相对位置。
Stream 的实现会把负的 begin 拉到 0,把超过 this.end 的 end 参数截到流末尾,然后返回 subarray。ChunkedStream 还会先调用 ensureRange,确认这段区间已经下载,否则抛 MissingDataException 让上层去请求数据。
这个方法主要服务于两类场景:文档指纹只读文件开头固定长度的字节;数字签名校验按签名字典里的 ByteRange 分段读取原文。它们都不应该改变当前解析游标。
读取方法的语义对比
BaseStream 的默认实现把多个方法串成一条链,理解每个方法是否移动游标,比记住实现更重要:
| 方法 | 是否推进 pos | 返回内容 | 结束或越界行为 |
|---|---|---|---|
getByte | 是 | 单个数字 | 返回 -1 |
getBytes | 是 | Uint8Array,通常是视图 | 自动截到 end |
getByteRange | 否 | Uint8Array,通常是视图 | 截到流末尾,不改游标 |
getString | 是 | 字符串 | 依赖 getBytes 的截断行为 |
peekByte | 否 | 单个数字 | 返回 -1 |
peekBytes | 否 | Uint8Array | 自动截到 end |
getString(length) 的实现只有一行:bytesToString(this.getBytes(length))。bytesToString 内部按 8192 个参数一段调用 String.fromCharCode,避免超长字节数组触发参数个数上限。它是按字节到字符的低位映射,不是通用的 Unicode 解码器。
前瞻与多字节读取
peekByte 方法
peekByte() {
const peekedByte = this.getByte();
if (peekedByte !== -1) {
this.pos--;
}
return peekedByte;
}前瞻没有另起一套读取逻辑,而是「读一个,再退一格」。这样所有子类只要正确实现 getByte 和可写的 pos,就自动获得前瞻能力。
只有在读到真实字节时才回退。若已经到 end,getByte 返回 -1,pos 本来就没有前进,再减一反而会跑到末尾之前,因此这里必须判断 -1。
这个实现也解释了为什么 pos 是隐式契约的一部分:前瞻、跳过、复位都直接操作它,而不是通过额外的 tell、seek 方法。
peekBytes 方法
peekBytes(length) {
const bytes = this.getBytes(length);
this.pos -= bytes.length;
return bytes;
}这里回退的是实际返回的字节数,而不是请求的 length。靠近文件末尾时 getBytes 会截短,bytes.length 正好等于游标实际前进的距离,因此前瞻后位置仍然准确。
它同样依赖「getBytes 返回的内容与游标前进量一致」这条契约。仓库里的 Stream、DecodeStream、ChunkedStream 都满足它;如果某个子类返回了比游标前进量更短的数组,前瞻就会错位。
Parser 用它做两件典型的事:读取内联图像结束符后的若干字节,判断后面是否还是可见 ASCII;以及把一小段字节包成临时 Stream 交给新的 Lexer 试读。两者都只看不改,正适合前瞻。
getUint16 方法
getUint16() {
const b0 = this.getByte();
const b1 = this.getByte();
if (b0 === -1 || b1 === -1) {
return -1;
}
return (b0 << 8) + b1;
}它按大端序拼出 16 位无符号整数:前一个字节在高 8 位,后一个在低 8 位。PDF、JPEG、TrueType 里大量长度字段都是这个字节序,因此没有再提供小端变体。
失败时返回 -1,与单字节读取的结束哨兵一致。要注意:一旦第一个字节读到了、第二个字节到了末尾,游标已经前进了 1,方法不会回滚。调用方看到 -1 后通常直接停止解析,所以这个副作用多数情况下不重要,但它确实存在。
Parser 在扫描 JPEG 内联图像时会用 stream.getUint16() 读取 marker segment 的长度,再根据长度 skip 到下一个 marker,这是该方法的典型用法。
getInt32 方法
getInt32() {
const b0 = this.getByte();
const b1 = this.getByte();
const b2 = this.getByte();
const b3 = this.getByte();
return (b0 << 24) + (b1 << 16) + (b2 << 8) + b3;
}它按大端序拼出 32 位有符号整数。JavaScript 的位运算先把操作数转成 32 位整数,所以最高字节左移 24 位后会自然带符号;四个字节全为 0xff 时结果是 -1。
与 getUint16 不同,它没有 EOF 检查。原因很实际:-1 本身就是一个合法的 32 位结果,没法再拿它当结束哨兵。调用方必须先用 getByteRange 或分块加载保证四个字节可读,或者像 fonts.js 读取表目录那样,在拿到结果后立刻转换成无符号偏移。
游标控制与派生流
skip 与 reset 方法
skip(n) {
this.pos += n || 1;
}
reset() {
unreachable("Abstract method `reset` called");
}skip 默认前进 1 个字节,n 为负数时就是后退。它不做边界检查,也不管跳过的是空白、分隔符还是二进制数据;调用方必须自己保证落点合法。另一个细节是 n 为 0 时也会前进 1,因为 0 || 1 取到了 1。仓库里没有用 skip(0) 表达「不动」的调用,读源码时不要把它当成安全的空操作。
reset 把游标放回逻辑起点。Stream 回到 start;DecodeStream 回到 0。复位只改游标,不清空已经解码的缓冲区,也不重置 eof,所以它适合「同一段已读数据再解析一次」,不适合「重新解码」。
moveStart 方法
moveStart() {
unreachable("Abstract method `moveStart` called");
}moveStart 是 BaseStream 里最少见的抽象方法,只有 Stream 实现:this.start = this.pos。它把逻辑窗口的起点推到当前游标处,pos 本身不动,length 随之变短。
真实用途在 PDFDocument.checkHeader:文件开头可能混入空白、BOM 或垃圾字节,find 找到 %PDF- 之后调用 moveStart,让后续所有偏移都相对于真正的头部计算。之后的 skip(PDF_HEADER_SIGNATURE.length) 才是在头部内部前进。
DecodeStream 没有实现这个方法,因为解码流的 start 语义并不存在;它只有从 0 开始的解码缓冲区。
makeSubStream 与 clone 方法
makeSubStream(start, length, dict = null) {
unreachable("Abstract method `makeSubStream` called");
}
clone() {
unreachable("Abstract method `clone` called");
}两个方法都返回新的 BaseStream,但目的不同。makeSubStream 在同一个字节源上开一个子窗口,start 是底层缓冲区里的绝对偏移,不是相对于当前流起点的偏移;length 可以省略,表示一直到父流末尾;dict 默认 null。
Parser.makeStream 解析出 stream 关键字之后,计算数据起点与长度,再调用 makeSubStream 把原始字节切出来;XRef.fetchUncompressed 更直接,传入的是 xrefEntry.offset + this.stream.start,因为偏移量是相对文件头,而 makeSubStream 要的是相对底层缓冲区。
clone 则复制「同一个逻辑窗口」:起点、长度不变,游标独立,字典做一次克隆。它用于需要保留原流、又要单独移动游标的场景,例如写出 PDF 时先复位、读完原始字节,再复用字典信息。
各子类的实现差异
抽象方法只是名字,真正决定行为的是子类。把三个主要实现放在一起看,BaseStream 的契约边界会更清楚:
| 方法 | Stream | DecodeStream | ChunkedStream |
|---|---|---|---|
length | end - start | 未覆写,访问会抛错 | 继承 Stream |
isEmpty | length === 0 | 循环 readBlock 直到有数据或结束 | 继承 Stream |
getByte | 读 bytes,末尾返回 -1 | 按需解码到缓冲区,再读 | 先 ensureByte,再读 |
getBytes | subarray 视图,截到 end | 解码到内部 buffer,返回视图 | 先 ensureRange,再返回视图 |
getByteRange | 截断后返回视图 | 未覆写 | 先 ensureRange,再返回视图 |
reset | pos = start | pos = 0 | 继承 Stream |
moveStart | start = pos | 未覆写 | 继承 Stream |
makeSubStream | 用 bytes.buffer 新建 Stream | 解码完再包成 Stream | 原型链子流,共享 bytes |
clone | 共享缓冲区,字典克隆 | 解码完再包成 Stream | 继承 Stream,得到普通 Stream |
getBaseStreams | 默认 null | 委托给 this.stream | 返回 [this] |
这张表里最值得注意的是 makeSubStream 的三套策略:Stream 零拷贝开窗口,DecodeStream 先把数据解码完整再开窗口,ChunkedStream 先用原型链复制状态、再按需校验缺失分块。它们对上层暴露同一组方法,代价却完全不同。
设计要点:
BaseStream只规定「读得到、能复位、能派生」,不规定「数据在哪里」。Stream的字节在ArrayBuffer里,DecodeStream的字节在解码缓冲区里,ChunkedStream的字节在按块填充的预分配数组里。正是这层抽象让Lexer可以完全不知道网络分块的存在。
加载状态、异步与流链
isDataLoaded 方法
get isDataLoaded() {
return shadow(this, "isDataLoaded", true);
}默认实现假设「拿到这个流时数据已经完整」。shadow 会在实例上定义一个不可写、可配置的自有属性,第一次读取后就把 true 固定下来;测试构建里还会断言原型上确实存在同名属性,避免拼写错误。
ChunkedStream 覆写这个 getter,用已加载分块数与总分块数比较。ObjectLoader 用它决定是否值得遍历对象图:如果主流的 isDataLoaded 已经为 true,整棵资源树都不需要提前请求。
异步与图像能力 getter
get isAsync() {
return false;
}
get isAsyncDecoder() {
return false;
}
get isImageStream() {
return false;
}
get canAsyncDecodeImageFromBuffer() {
return false;
}
async getTransferableImage(width, height) {
return null;
}这五个成员不是「读取数据」,而是「声明能力」。默认全部为否,子类按需覆写,上层通过一次属性读取决定走哪条路径,不需要 instanceof 判断,也不需要试探性调用。
isAsync:整个流能否通过asyncGetBytes一次性给出字节。FlateStream、BrotliStream默认以原生DecompressionStream的异步路径启动,失败后回退到同步解码并把标志置为false;Page.getContentStream看到它为true时,就会先等待异步字节,再包成普通Stream。isAsyncDecoder:解码器本身是否是异步的。JpxStream、Jbig2Stream、CCITTFaxStream为true;DecodeStream.getImageData据此调用各自的decodeImage。isImageStream:这是图像流,不是页面内容流。StreamsSequenceStream构造时会过滤掉它,避免把图像字节拼进内容序列;Page.getContentStream也用它排除图像。canAsyncDecodeImageFromBuffer:底层流能否异步提供完整字节,让图像解码器直接吃缓冲区。JPEG、JPX、JBIG2 的实现会转发this.stream.isAsync。getTransferableImage:尝试返回可直接交给渲染层的图像对象,默认null。JpegStream覆写后用浏览器ImageDecoder解码,失败时仍返回null,让image.js回退到普通的像素解码。
设计要点:能力查询把「是否支持」和「怎么使用」分开。上层只问
isAsync,不需要知道对方是 Flate 还是 Brotli;只问isAsyncDecoder,不需要知道对方是 JPX 还是 JBIG2。新增一种解码器时,只要覆写对应的布尔 getter,调用方的分支不需要改。
getBaseStreams 方法
getBaseStreams() {
return null;
}返回值有两种含义:null 表示「这个流没有需要额外下载的底层流」,数组表示「这些底层流可能还缺数据,请按它们的 start 与 end 请求区间」。
DecodeStream 把调用委托给 this.stream;ChunkedStream 返回 [this];StreamsSequenceStream 把所有子流的结果合并。ObjectLoader 遍历对象图时,就是靠这个接口找到真正需要网络数据的流,而不是盲目请求整个文件。
getOriginalStream 方法
getOriginalStream() {
return this.stream?.getOriginalStream() || this;
}过滤器链是一层套一层的:PredictorStream 包 FlateStream,FlateStream 包 Stream,加密流再包一层 DecryptStream。这个方法沿着 .stream 递归向下,直到某个流没有 stream 字段,返回最底层持有原始字节的对象。
默认实现对自己也成立:普通 Stream 没有 stream 字段,直接返回自身。DecryptStream 特意覆写为返回 this,因为它的下游是尚未解密的密文,写回 PDF 时需要的却是解密后的字节。
Writer.writeStream 与编辑器的 #rawStreamBytes 都先调用 getOriginalStream(),再 reset()、getBytes(),目的就是绕开所有解码器,拿到流的原始编码数据,连同字典一起重新写出去。
总结
BaseStream 是 PDF.js 的字节接口层:向下屏蔽本地内存、网络分块、压缩解码三种来源,向上给 Lexer、Parser、XRef、PartialEvaluator 提供统一的读取、前瞻、复位与派生能力。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | PDFManager / ChunkedStream | 文件、网络分块 | Stream / ChunkedStream |
| 语法解析 | Lexer / Parser | BaseStream | token、Dict、Stream |
| 对象取回 | XRef | Ref | Dict / BaseStream |
| 内容求值 | PartialEvaluator | 页面内容流 | operatorList |
| 图像解码 | DecodeStream 子类 | 压缩图像字节 | 像素数据或可转移图像 |
用一句话串起来:PDFManager 提供字节,BaseStream 把字节包装成可读游标,Parser 把游标上的内容变成对象,XRef 与 Evaluator 再把这些对象组织成页面与绘制指令。
五条设计原则
- 抽象失败要显式。抽象方法不返回
undefined,而是调用unreachable抛错。子类漏实现时,错误发生在第一次调用,不会伪装成一个合法值继续传播。 - 只承诺读取,不承诺存储形态。
getBytes可以返回视图,也可以返回副本;可以一次给全,也可以在末尾截短。上层只看返回数组的length,不假设底层是ArrayBuffer、解码缓冲还是分块数组。 - 前瞻由读取加回退构成。
peekByte、peekBytes没有独立实现,而是复用getByte、getBytes再调整pos。子类只需要把顺序读取做对,前瞻自动成立。 - 子流共享字节,游标独立。
makeSubStream与clone都返回新对象,但共享底层存储;新对象有自己的pos,因此可以在同一份数据上并行解析多个对象。 - 能力查询优先于类型判断。
isAsync、isAsyncDecoder、isImageStream等布尔 getter 让上层按能力分支,而不是按具体类分支。新增流类型时,调用方无需修改。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 深入浅出 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日