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

前言
这是 PDF.js 源码解读系列「字节与流」层的第二篇,介绍 src/core/stream.js(97 行)里的 Stream、StringStream、NullStream 三个类。
再往前看,第 10 篇 XRef 与第 11 篇 Parser 已经把对象取回与语法解析讲完;这两篇默认脚下有一层稳定的字节来源,而这个来源的契约由上一篇 base_stream.js 定义:getByte、getBytes、peekByte、reset、makeSubStream、clone 等方法该做什么、不该做什么。本篇给出最基本的一种实现:在一段已经存在于内存的字节上开一个带游标的窗口。
Stream 是仓库里出现频率最高的流类型。LocalPdfManager 把整个文件包成它;Parser.makeStream 用 makeSubStream 从父流里切出每个 12 0 obj 的流数据;XRef.fetchUncompressed 也用它定位对象。StringStream 与 NullStream 则是两个只有几行的特化子类,分别解决「把字符串当成流解析」和「用空流占位」两个具体问题。
一句话概括:Stream 不负责下载、不解压、不加密,它只维护 start、pos、end 三个指针,并把所有读取动作变成对 Uint8Array 的索引与 subarray 视图。
模块总览
stream.js 由三个类组成,文件很短,职责却分得很清:
| 区块 | 关键方法 | 职责 |
|---|---|---|
Stream 窗口 | constructor、length、isEmpty | 在既有字节上建立逻辑区间与游标 |
| 顺序读取 | getByte、getBytes、getByteRange | 从 pos 读字节,返回视图或结束哨兵 |
| 游标与窗口 | reset、moveStart | 回到起点,或把逻辑起点推到当前位置 |
| 派生 | makeSubStream、clone | 共享底层内存,生成子窗口或独立副本 |
StringStream | constructor | 把 JavaScript 字符串转成可解析的流 |
NullStream | constructor | 用零长度流表示「没有内容」 |
Stream 覆写了 BaseStream 里的大部分抽象成员,但没有覆写 peekByte、peekBytes、getUint16、getInt32、getString、skip。这些方法都建立在 getByte、getBytes 和可写的 pos 之上,父类的默认实现已经足够。
三指针模型
Stream 没有「缓冲区偏移」这种额外状态。整个读取过程只由三个数字描述:start 是逻辑窗口的起点,pos 是当前游标,end 是逻辑窗口的终点。三者都是底层 bytes 数组里的绝对下标,窗口区间是左闭右开的 [start, end)。
constructor 方法
constructor(arrayBuffer, start, length, dict) {
super();
this.bytes =
arrayBuffer instanceof Uint8Array
? arrayBuffer
: new Uint8Array(arrayBuffer);
this.start = start || 0;
this.pos = this.start;
this.end = start + length || this.bytes.length;
this.dict = dict;
}第一段处理输入形态。传入 Uint8Array 时直接使用,不复制;传入 ArrayBuffer 时用 new Uint8Array(arrayBuffer) 包一层视图。两种情况都共享同一块内存,区别只在于调用方是否已经持有类型化数组。
第二段建立三指针。start 用 start || 0 归一化,undefined、NaN、0 都会落到 0;pos 初始等于 start;end 用 start + length || this.bytes.length 计算。只要 length 是 undefined 或 NaN,加法结果就是 NaN,回退到整个字节数组的长度。
这里有一个容易被忽略的边界:length 为 0 且 start 也为 0 时,start + length 是 0,属于假值,同样会回退到 bytes.length。也就是说,在偏移 0 处显式创建零长度流,得到的却是整段数据。仓库里的空流由 NullStream 负责,StringStream 用 NaN 表示未知长度,正常解析路径不会踩到这个组合;但读源码时要知道构造器只做算术,不做语义校验。
dict 直接按引用保存。过滤器链、加密流与子流可能共享同一个字典对象;需要独立字典时,由 clone 负责复制。
| 字段 | 含义 | 约定 |
|---|---|---|
bytes | 底层字节视图 | Uint8Array,可能是整块缓冲或一个子视图 |
start | 逻辑窗口起点 | 绝对下标,moveStart 会修改它 |
pos | 当前读取位置 | 绝对下标,初始等于 start |
end | 逻辑窗口终点 | 绝对下标,不包含在窗口内 |
dict | 流字典 | 可以为 null,默认按引用共享 |
end 并没有被截断到 bytes.length。构造器相信调用方给出的 length;如果长度超过底层数组,getByte 可能读出 undefined。实际路径里,Parser 会用 endstream 扫描修正错误长度,makeSubStream 的起点与长度也来自已经校验过的偏移,所以这个信任是可控的。
一个 Stream 从建立到销毁通常只经历三个阶段:
- 建立窗口。
PDFManager用整个文件建立顶层流;Parser算出某个流对象的startPos与length,再用makeSubStream建立子窗口。此阶段只写start、pos、end,不复制数据。 - 推进游标。
Lexer与Parser通过getByte、getBytes、skip前进;pos单调增长,end保持不变。只有调用getBytes时会创建一个轻量视图对象,字节本身不动。 - 复位或派生。解析同一个流对象的字典与数据时可能需要多次读取,
reset把pos拉回start;写出或编辑时用clone复制窗口描述;需要长期保留某个位置时用makeSubStream开新窗口。
这三个阶段对应三组方法:构造器负责第一阶段,读取与 skip 负责第二阶段,reset、moveStart、makeSubStream、clone 负责第三阶段。它们之间没有隐藏状态传递,任何时候只靠三个数字就能还原流的完整位置。
设计要点:用绝对下标而不是「相对偏移加基址」来描述窗口,读取逻辑就退化成一次比较和一次自增。
getByte不需要先算start + pos,getBytes不需要维护额外的读取偏移;所有派生流也只需要复制三个数字。状态越少,越不容易在嵌套流里错位。
length 与 isEmpty 方法
get length() {
return this.end - this.start;
}
get isEmpty() {
return this.length === 0;
}length 是窗口的总长度,不是剩余长度。读完整个流之后,pos 等于 end,length 仍然是原来的值;此时判断是否读完要看 pos,不能看 length。
isEmpty 同样只看窗口,不看游标。一个长度为 10、已经读到末尾的流不是空流,只是「读完了」;真正的空流是 start 与 end 相等。BaseStream 与 DecodeStream 里反复出现的 isEmpty 判断,语义都建立在这条区分上。
moveStart 会改变 start,因此 length 会随之变小。这个 getter 不是缓存的只读快照,而是对当前窗口的实时计算。
顺序读取与零拷贝
getByte 方法
getByte() {
return this.pos >= this.end ? -1 : this.bytes[this.pos++];
}这是整个流最热的路径。先比较 pos 与 end,到达终点就返回 -1;否则读取 bytes[pos],再让 pos 自增。由于 end 不包含在窗口内,最后一个可读字节的下标是 end - 1。
-1 同时承担「文件结束」和「没有更多数据」两种含义。PDF 的字节范围是 0 到 255,负数不可能成为合法字节,所以这个哨兵不会与真实数据冲突。Lexer 里的 while 循环因此可以只判断 -1,不必再额外查询长度。
方法没有检查 pos 是否超过 bytes.length。它只信任 end,这也是 Stream 比 DecodeStream 快的原因:没有缓冲区扩容、没有解码状态、没有缺失分块检查,只有一次数组索引。
getBytes 方法
getBytes(length) {
const pos = this.pos;
const endPos = !length ? this.end : Math.min(pos + length, this.end);
this.pos = endPos;
return this.bytes.subarray(pos, endPos);
}先保存旧游标,再计算新的终点。!length 为真时取窗口末尾,所以「不传参数」和「传 0」都表示读取剩余全部字节;想读固定长度时必须传正整数。Math.min 负责截断:请求长度超过剩余数据时,只返回到 end 为止的部分。
subarray 是零拷贝的关键。它返回一个新的 Uint8Array 视图,底层 ArrayBuffer 与父数组完全相同,只是 byteOffset 与 length 不同。读取几百 KB 的内容流时,不会产生一次额外的内存分配与复制;Lexer、Parser、bytesToString 都直接消费这个视图。
零拷贝也带来两个必须记住的约束:
- 返回数组的
buffer是整块底层缓冲,不是这段数据的专属缓冲。拿到视图后不能假设buffer.byteLength等于bytes.length,也不能直接按buffer的下标访问这段数据。 - 视图与流共享内存。修改返回数组会改到流的原始字节;需要独立副本时必须显式调用
slice。
pos 在返回之前就已经推进到 endPos,所以调用方即使立刻再次调用 getByte,也会从新位置继续。若请求长度在末尾被截短,游标前进的是实际长度,peekBytes 正是利用这一点保持位置准确。
几种读取方式放在一起对比:
| 调用 | 返回区间 | pos 变化 | 是否复制 | 典型用途 |
|---|---|---|---|---|
getByte() | 当前字节 | 前进 1 | 否 | 词法扫描 |
getBytes() | pos 到 end | 前进到 end | 否 | 一次读完剩余数据 |
getBytes(n) | pos 到 pos + n,受 end 限制 | 前进实际读取量 | 否 | 定长读取 |
getByteRange(a, b) | 绝对区间 [a, b) | 不变 | 否 | 文件指纹、签名校验 |
peekBytes(n) | 同 getBytes(n) | 先前进再退回 | 否 | 试读、判断后续语法 |
设计要点:
subarray把「复制」从读取路径里彻底拿掉。PDF 解析时同一段内容往往要被读多次:Parser先试读,Lexer再正式读,Evaluator又按算子消费。如果每次读取都slice,大文件会产生大量短命数组;返回视图则只付出一个对象头的成本。
getByteRange 方法
getByteRange(begin, end) {
if (begin < 0) {
begin = 0;
}
if (end > this.end) {
end = this.end;
}
return this.bytes.subarray(begin, end);
}它与 getBytes 的区别不在实现复杂度,而在语义:区间是底层 bytes 的绝对下标,与当前 pos 无关,调用后游标不动。
两个裁剪只处理最常见的方向。begin 为负数时拉到 0;end 超过逻辑窗口末尾时截到 this.end。它没有把 begin 限制在 end 之前,也没有让 end 不小于 0;这些情况交给 subarray 自己处理,结果是一个空视图或更短的视图。
注意裁剪的上界是 this.end,不是 bytes.length。这说明它仍然尊重当前逻辑窗口:即使底层还有更多字节,也不会越过 end 返回。反过来说,它也没有把 begin 抬到 this.start;如果传入比窗口起点更早的绝对下标,仍能读到窗口之前的字节。这是「绝对区间」语义的自然结果。
ChunkedStream 覆写了这个方法:先做同样的裁剪,再对尚未下载的区间调用 ensureRange,缺数据时抛 MissingDataException。上层看到的接口不变,但读取可能变成一次网络请求。
游标与窗口
reset 与 moveStart 方法
reset() {
this.pos = this.start;
}
moveStart() {
this.start = this.pos;
}reset 把游标放回逻辑起点,而不是数组下标 0。一个已经 moveStart 到文件头部的流,复位后会回到头部,不会回到文件最开头。它只改 pos,不清空 bytes、不修改 dict,可以反复调用。
moveStart 则把逻辑起点推到当前游标处。pos 不动,start 变大,end 不变,于是 length 变小。PDFDocument.checkHeader 的用法最典型:先 reset,用 find 找到 %PDF-,再 moveStart,把文件开头可能存在的空白、BOM 或垃圾字节排除在逻辑窗口之外;随后 skip 头部签名,读取版本号。
按公开方法看,moveStart 只会让窗口变窄,不能把已经丢弃的前缀重新纳入。若之后再次 reset,游标回到新的 start,也就是头部,而不是原始文件起点。
skip 没有在 Stream 里覆写,用的是 BaseStream 的默认实现:this.pos += n || 1。它不检查边界,负数表示后退;Parser 扫描 JPEG 内联图像时就用 skip(-2) 从错误长度处回退到 marker 起点。由于 skip 直接改 pos,它与 moveStart 配合时要求调用方清楚当前游标的绝对位置。
边界行为与常见误用
Stream 的代码很短,边界语义却不少。下面这些组合在调用方看来都是「同一个方法」,实际结果差异很大:
| 场景 | 实际行为 | 需要注意 |
|---|---|---|
getBytes() 或 getBytes(0) | 从 pos 一直读到 end | 传 0 不是「读零个字节」 |
getBytes(n) 超过剩余数据 | 截到 end,返回更短的视图 | 以返回数组的 length 为准 |
游标已经到 end 后调 getByte | 返回 -1,不抛错 | 适合循环终止,不适合区分错误类型 |
getByteRange 的 begin 为负 | 拉到 0 | 不会抛错 |
getByteRange 的 end 过大 | 截到 this.end | 不会读到逻辑窗口之外 |
getByteRange 的 begin 大于 end | subarray 返回空视图 | 调用方要自己保证区间顺序 |
手动把 pos 推到 end 之后 | getByte 仍是 -1,getBytes 为空 | skip 不校验,越界不会被发现 |
修改 getBytes 返回的视图 | 改到底层 bytes | 需要独立副本时显式 slice |
直接使用返回视图的 buffer | buffer 是整块底层缓冲 | 数据起点是视图的 byteOffset |
start 为 0 且 length 为 0 | end 回退到 bytes.length | 空流请用 NullStream |
对子视图流调用 makeSubStream | 下标从整块缓冲区起点算,不从子视图算 | 主路径的 bytes 从 0 开始,不受影响 |
| 克隆后修改字典的嵌套值 | 顶层键独立,值与引用仍共享 | 需要完全隔离时必须自行深拷贝 |
前几行都是「静默截断」:方法不抛异常,也不返回错误码,只给一个更短的结果。这是流式解析必须的选择——数据边界经常由损坏文件或错误长度决定,抛错会让整份文档无法打开;返回可用部分,至少还能继续扫描。
需要特别区分的是「读到末尾」与「空流」。pos 到达 end 只说明当前窗口读完了,length 仍然非零,isEmpty 为假;只有 start 与 end 相等才是空流。很多解析循环用 getByte 的 -1 终止,而不是用 isEmpty 判断,就是因为两者含义不同。
最后两行涉及所有权。零拷贝视图让「读取」不分配,但也让「修改」具有传染性;需要把字节交给外部修改时,先 slice 出一份独立副本。clone 的浅克隆字典同理:改顶层键安全,改嵌套对象仍然会影响原文档。
派生流:makeSubStream 与 clone
makeSubStream 方法
makeSubStream(start, length, dict = null) {
return new Stream(this.bytes.buffer, start, length, dict);
}这是 Stream 里最重要的一个优化,也是理解 PDF.js 内存模型的关键。它没有复制字节,没有调用 slice,甚至没有复用 this.bytes;它把 this.bytes.buffer 整块交给构造器,让新 Stream 用 new Uint8Array(arrayBuffer) 重新包一层视图。新流与父流共享同一块 ArrayBuffer,只拥有自己的 start、pos、end。
start 是底层缓冲区的绝对下标,不是相对于父流 start 的偏移。XRef.fetchUncompressed 里可以看得很清楚:
const stream = this.stream.makeSubStream(xrefEntry.offset + this.stream.start);xref 条目里的 offset 是相对文件头的偏移,而 makeSubStream 要的是相对底层缓冲区的偏移,所以这里加上 this.stream.start。Parser.makeStream 传入的 startPos 也是 Lexer 游标的绝对位置,而不是相对父窗口的差值。
dict 按引用传递,不做克隆。解析器在切出流数据之后会设置 stream.dict = dict,父子流因此共享同一个字典对象;这与 PDF 语义一致:子流就是那个流对象本身的数据窗口。
省略 length 时,新流构造器的 end 会回退到底层缓冲区的总长度,而不是父流的 end。对顶层文件流来说,这两个值通常相同;如果父流本身已经是一个子窗口,二者可能不同。调用方要么显式传入长度,要么明确知道自己要一直读到缓冲区末尾。
还有一个隐含前提:this.bytes 通常是从缓冲区下标 0 开始的完整视图,此时 bytes.buffer 与 bytes 的坐标系一致。如果 this.bytes 本身是带非零 byteOffset 的 subarray,再用 bytes.buffer 配绝对下标,坐标系就会偏移父视图的起点。仓库里的主路径满足这个前提;从 peekBytes 临时包出来的 Stream 只用于小范围试读,不走这条派生路径。
makeSubStream 的复杂度是 O(1)。一份几十 MB 的 PDF 里可能有成千上万个流对象,每个都复制自己的数据会直接拖垮内存;共享底层缓冲、只建对象头,才能让「解析一个流对象」保持常数成本。
设计要点:子流共享的是字节,不是状态。
bytes.buffer让内存只有一份,start、pos、end让每个流拥有独立窗口和独立游标。同一份内容可以被Parser、Evaluator、字体解析器同时持有,各自复位、各自读取,互不干扰。
clone 方法
clone() {
return new Stream(
this.bytes.buffer,
this.start,
this.length,
this.dict?.clone()
);
}clone 与 makeSubStream 共享同一条优化路线:不复制字节,只新建对象。区别在于它复制的是当前窗口,start 与 length 原样保留,而不是重新指定起点与长度。新流的 pos 由构造器初始化为 start,因此旧流读到一半也不影响副本。
字典的处理是两者最大的差别。makeSubStream 直接复用传入的 dict,clone 调用 this.dict?.clone()。Dict.clone 在 primitives.js 里做的是浅克隆:新建一个 Dict,沿用同一个 xref,把原字典的键值逐条复制过去。字典本身是新的,顶层增删不会影响原对象;值仍然指向原来的对象、数组或引用。
这个设计服务于写出与编辑场景。写出 PDF 时需要修改 Length、Filter、DecodeParms 等条目,或者给流换一个字典;如果直接改原字典,已加载文档的语义就被破坏了。克隆一层字典,既隔离了可变外壳,又避免深拷贝整棵对象图。
可选链 this.dict?.clone() 处理了没有字典的流。NullStream 与临时 Stream 都没有字典,克隆时直接得到 undefined,不会抛错。
与 makeSubStream 一样,clone 也使用 this.bytes.buffer,因此同样隐含「bytes 从缓冲区下标 0 开始」的前提。另一个需要知道的点是:clone 固定返回 Stream,不保留子类。ChunkedStream 继承了这个实现,克隆出来的是普通 Stream,不再有分块管理器与缺失区间检查;它适合数据已经完整、只需要独立游标的场景。
与其它流实现的对照
把 Stream、DecodeStream、ChunkedStream 放在一起,能看出「共享字节、独立游标」在不同数据源上的三种做法:
| 能力 | Stream | DecodeStream | ChunkedStream |
|---|---|---|---|
| 底层存储 | 外部传入的 ArrayBuffer 视图 | 内部按需扩容的解码缓冲 | 预分配整文件长度的 bytes |
| 读取未就绪数据 | 不存在,字节已经在内存里 | 调用 readBlock 解码 | 抛 MissingDataException 请求下载 |
makeSubStream | 用 bytes.buffer 新建窗口 | 先读完整个解码缓冲,再包成 Stream | 原型链子流,共享 bytes 并按需校验 |
clone | 共享缓冲区,字典浅克隆 | 先读完,再包成 Stream | 继承 Stream,得到普通 Stream |
getByteRange | 裁剪后返回视图 | 未覆写 | 先 ensureRange 再返回视图 |
| 游标复位 | pos = start | pos = 0 | 继承 Stream |
这三套实现共同证明了一件事:BaseStream 的抽象不是「同一种存储的不同包装」,而是「同一种读取语义的不同数据来源」。Lexer 只关心 getByte 是否返回下一个字节,完全不关心这个字节是来自本地数组、解压缓冲还是刚下载的分块。
两种特殊流
StringStream 方法
class StringStream extends Stream {
constructor(str, dict = null) {
super(stringToBytes(str), NaN, NaN, dict);
}
}StringStream 把 JavaScript 字符串变成可解析的流。stringToBytes 在 shared/util.js 里逐字符调用 charCodeAt,再按位与 0xff 截到低字节,放进新建的 Uint8Array。它假设字符串已经是 PDF 语法所需的单字节表示,适合内容流片段、外观流与 Latin-1 文本;真正的 Unicode 文本要先经过调用方编码,不能指望它自动完成。
传入两个 NaN 是一个小技巧。start 的 NaN || 0 归零,end 的 NaN + NaN || bytes.length 取整个字节数组长度。两个「未知」分别被构造器的两个回退分支消化,最终得到一个从 0 开始、覆盖全部字符串字节的窗口。
dict 可选,用于给合成出来的流挂字典。annotation.js 生成注释外观流、default_appearance.js 解析 /DA 字符串、writer.js 写 XFA 数据、pdf_editor.js 组装编辑后的内容,都是先构造 StringStream,再交给同一套 Lexer、Parser、Evaluator。调用方不需要为「内存里的字符串」准备一条特殊解析路径。
default_appearance.js 里的用法最直观:一个负责解析默认外观的求值器,构造函数只需要把字符串包成 StringStream:
constructor(str) {
super(new StringStream(str));
}求值器本身并不知道 BaseStream 的具体类型,它只依赖基类提供的读取与复位接口。StringStream 把「字符串」这个数据源伪装成「流」,于是 /DA 字符串与页面内容流走完全相同的解析代码。这正是抽象基类存在的意义:数据来源可以替换,解析器不需要分支。
NullStream 方法
class NullStream extends Stream {
constructor() {
super(new Uint8Array(0));
}
}NullStream 是零长度流。构造器传入一个空数组,start 落到 0,end 因为 start + undefined 是 NaN 而回退到 bytes.length,结果也是 0;于是 length 为 0、isEmpty 为真、getByte 永远返回 -1、getBytes 返回空视图。
它的价值不在读取,而在占位。Parser.makeFilter 遇到 Length 为 0 的流,或者过滤器名称不支持、解码抛错时,会在警告之后返回 NullStream;Page.getContentStream 在页面根本没有内容流时,也返回 NullStream。这样 Evaluator、OperatorList 与上层调用方不需要在每个入口判断 null,空流会自然地产生零个算子。
源码里两个最直接的入口是:
if (maybeLength === 0) {
warn(`Empty "${name}" stream.`);
return new NullStream();
}以及过滤器链的异常兜底:
} catch (ex) {
if (ex instanceof MissingDataException) {
throw ex;
}
warn(`Invalid stream: "${ex}"`);
return new NullStream();
}第一处只把「长度为 0」当成确定为空;注释特意说明,流字典里的 Length 经常不可靠,所以长度为 0 以外的值不能直接当成空流,否则会把非空数据误判。第二处把解码失败降级为空流,但必须先放过 MissingDataException:它不是坏数据,而是网络分块还没到,必须让上层去请求,不能吞掉。
这也是空对象模式在解析器里的具体用法:用一个满足完整 BaseStream 契约的对象表示「没有数据」,比到处散布 if (stream) 更稳定;一旦某个调用方忘了判空,也不会因为 null.getByte 直接崩掉。
共享与复制一览
把文件里所有会创建新对象或新字节的操作放在一张表里,内存关系就清楚了:
| 操作 | 分配新字节 | 共享底层缓冲区 | 独立游标 | 独立字典 |
|---|---|---|---|---|
new Stream(Uint8Array) | 否 | 是 | 是 | 否,按引用 |
new Stream(ArrayBuffer) | 否 | 是 | 是 | 否,按引用 |
getByte / getBytes | 否 | 是 | 否 | 不涉及 |
getByteRange | 否 | 是 | 否 | 不涉及 |
makeSubStream | 否 | 是 | 是 | 否,共享同一份 |
clone | 否 | 是 | 是 | 是,浅克隆 |
new StringStream | 是,字符串转字节 | 否 | 是 | 否,按引用 |
new NullStream | 是,零长度数组 | 否 | 是 | 无 |
唯一真正分配字节的入口是 StringStream;NullStream 只分配一个空数组;其余操作都在既有缓冲上开视图。这个分布决定了 PDF.js 的内存曲线:解析大文件时,内存主要由原始文件与解码缓冲占据,而不是由成千上万个流对象各存一份数据。
设计要点:复制只发生在「数据形态改变」的时候,例如字符串变成字节、压缩数据变成解码结果。只要形态不变,就只复制描述信息,也就是指针。把「分配」与「读取」分开,是流式 PDF 能在大文件上保持低内存的前提。
总结
Stream 是 PDF.js 的内存字节窗口:它把一块 Uint8Array 包装成带 start、pos、end 的流,提供顺序读取、区间读取、复位、派生与克隆。StringStream 与 NullStream 则用最小的代码覆盖「合成内容」与「空内容」两个边界。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 文件入口 | PDFManager | ArrayBuffer 或视图 | Stream |
| 网络入口 | ChunkedStream | 分块下载的数据 | 带缺失区间检测的流 |
| 对象切分 | Parser.makeStream / XRef | 绝对偏移与长度 | 子流视图 |
| 解码 | DecodeStream 子类 | 压缩或加密字节 | 原始字节 |
| 内容合成 | StringStream / NullStream | 字符串或空内容 | 可交给 Lexer 的流 |
用一句话串起来:PDFManager 把文件变成 Stream,Parser 用 makeSubStream 把文件切成对象级窗口,DecodeStream 再把压缩字节变成可读字节,最后 StringStream 与 NullStream 补齐合成内容与空内容的边界。
五条设计原则
- 三指针是唯一真相。
start、pos、end决定窗口、游标与长度,没有隐藏的缓冲区偏移。所有读取方法都从这三个数字推导,派生流也只复制这三个数字。 - 视图优先,复制兜底。
getBytes、getByteRange、makeSubStream都返回或共享subarray视图,只有改变数据形态时才分配新字节。省掉的是每次读取的分配与拷贝,不是可读性。 - 子流共享内存,游标独立。
makeSubStream用bytes.buffer建立 O(1) 子窗口,父子流共享字节但各自维护pos。这让同一份文件可以同时被多个解析器读取。 - 克隆只隔离可变外壳。
clone共享底层缓冲,复制窗口描述,浅克隆字典。字节是只读的,字典是可变的;隔离真正会被修改的那一层,避免深拷贝整棵对象图。 - 特殊流用最少量代码解决。
StringStream与NullStream各自只有几行,却让「字符串内容」与「空内容」完全复用现有解析链路。调用方不需要为边界情况写分支。
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 源码解读: 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日