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

前言
这是「字节与流」层里最短的两个过滤器之一,对象是 Worker 端 src/core/run_length_stream.js(59 行)中的 RunLengthStream。
上一篇 ascii_hex_stream 处理的是「文本换二进制」:两位十六进制字符拼成一个字节,解码器只需要记住有没有半个字节。本篇处理另一种朴素编码——行程编码(run-length encoding)。它不改变字符集,而是把「连续重复的字节」压成「一个头字节加一个样本字节」,把「一段不重复的字节」压成「一个头字节加若干原样字节」。
这种编码在 PDF 里并不承担通用压缩的职责。规范把它列在 RunLengthDecode 名下,主要服务两类数据:早期从 TIFF、传真、扫描仪转过来的位图,以及大片同色区域的掩码图像。这类数据的特点是「重复极多」,一行里常常有几百个相同的 0 或 255,行程编码因此非常划算。反过来,对已经用 Flate 压过的文本再套一层 RLE 几乎没有意义,所以它很少出现在现代生成器的输出里。
它的入口同样只有一行:
case "RL":
case "RunLengthDecode":
return new RunLengthStream(stream, maybeLength);来自 Parser.makeFilter。与 ASCIIHex 不同,这里没有单独的「扫描内联图像」分支:RLE 的结束标记是一个字节值,无法像大于号那样用文本扫描的方式安全定位,因此内联图像如果真用了 RunLengthDecode,会走通用的结束符查找。
它的编码单位是「run」,一个 run 最多产出 128 字节,最少产出 1 字节。解码器每次 readBlock 只展开一个 run,不为调用方的请求量做任何循环——循环在基类里。
一句话概括它的职责:读取一个头字节和紧随其后的样本,按 run 类型把样本复制或重复到输出缓冲,遇到结束字节立即停止。
模块总览
run_length_stream.js 只有 59 行,去掉版权头与 import,实际只有两个方法:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 构造 | constructor | 接管上游流与字典,不缩放尺寸提示 |
| 解码 | readBlock | 读两字节头、按类型展开一个 run、写入缓冲 |
| 继承设施 | DecodeStream | 缓冲、bufferLength、eof、ensureBuffer |
| 对外读取 | getByte、getBytes | 按需驱动 readBlock,返回连续字节 |
数据路径可以写成:
编码字节(头字节 + 数据字节)
→ RunLengthStream.readBlock (本文件,一次一个 run)
→ DecodeStream.buffer (输出缓冲,每次追加 1 到 128 字节)
→ getBytes / getByte (循环驱动,直到凑够调用方要的数量)
→ 图像解码器 / Lexer
与 ASCIIHex 相比,这里没有跨块保存的输入状态。每个 run 的头部、样本、数据都在同一次 readBlock 里读完;块与块之间只共享输出缓冲。这个差异直接决定了两个类一个需要 firstDigit 字段,另一个什么都不需要。
readBlock 同样忽略 decoderOptions。RLE 没有任何可调参数,所有信息都在头字节里。
构造:为什么不缩放尺寸提示
constructor 方法
class RunLengthStream extends DecodeStream {
constructor(str, maybeLength) {
super(maybeLength);
this.stream = str;
this.dict = str.dict;
}构造函数只有三行有效代码,和 ASCIIHex 的构造相比少了两样东西:尺寸缩放与状态字段。
先看尺寸提示为什么不缩放。ASCIIHex 的输出一定不超过输入的一半,ASCII85 约为五分之四,所以它们把 /Length 乘以一个系数,让首次分配更贴近真实输出。RLE 没有固定比例:
| 编码形态 | 输入消耗 | 输出产出 | 输出与输入之比 |
|---|---|---|---|
| 复制型 run,n 等于 0 | 2 字节 | 1 字节 | 0.5 |
| 复制型 run,n 等于 127 | 129 字节 | 128 字节 | 约 0.99 |
| 重复型 run,头字节 255 | 2 字节 | 2 字节 | 1 |
| 重复型 run,头字节 129 | 2 字节 | 128 字节 | 64 |
同一个文件里,膨胀率可以从 0.5 一路跳到 64。任何固定系数都会在某一端失效:乘 0.5 会让重复型流反复扩容,乘 2 又会让复制型流一开始就多占一倍内存。既然没有可靠的先验,就不猜。
maybeLength 于是只作为 DecodeStream 的起始容量提示,被抬到不小于它的 2 的幂。重复型数据真正需要的容量可能远远超过它,但那由 ensureBuffer 的几何扩容兜底。这与 ASCIIHex 的做法在原则上是同一个:提示只影响首次分配,正确性靠扩容。
再看状态字段为什么不需要。ASCIIHex 的输入可能在一个字符中间被切断,因此必须留半个字节。RLE 的输入以 run 为单位,一个 run 的所有字节在同一次 readBlock 里读完;上游 getBytes 本身会透明地跨块,所以解码器看不到「run 被切开」这种情况。没有需要跨调用保存的东西,构造函数自然只剩两个赋值。
this.dict = str.dict 依旧是必要的。RLE 常用于图像,下游解码器需要 /Width、/Height、/BitsPerComponent 等条目;字典在过滤器链里必须一路传递,否则图像无法解释。
设计要点:尺寸提示的价值取决于编码的确定性。输出上限明确的编码可以缩放提示,输出上限与数据强相关的编码不缩放。这里的选择不是省略,而是承认「不知道」,把不确定性交给扩容策略。
readBlock:一次只展开一个 run
readBlock 方法
readBlock() {
// The repeatHeader has following format. The first byte defines type of run
// and amount of bytes to repeat/copy: n = 0 through 127 - copy next n bytes
// (in addition to the second byte from the header), n = 129 through 255 -
// duplicate the second byte from the header (257 - n) times, n = 128 - end.
const repeatHeader = this.stream.getBytes(2);
if (repeatHeader.length < 2 || repeatHeader[0] === 128) {
this.eof = true;
return;
}
let buffer;
let bufferLength = this.bufferLength;
let n = repeatHeader[0];方法开头的注释就是完整规范:头字节决定 run 的类型与长度,第二个字节是样本。读懂这段注释,剩下的代码几乎是逐句直译。
第一个动作是 this.stream.getBytes(2)。为什么一次读两个字节,而不是先读头、再按需读样本?因为第二个字节在两种 run 里都必须用到:
- 复制型 run 里,它是第一个被复制的字节,头字节的
n只计算它之后的额外字节。 - 重复型 run 里,它是被重复的那一个值。
既然两种情况下都要取,一次读两字节就把「取头」与「取样本」合并,少一次上游调用,也少一次截断检查。
repeatHeader.length < 2 覆盖两种情况:上游已经完全耗尽,或者只剩一个字节。无论剩下的那个字节是不是结束标记,都无法构成一个完整的 run,因此统一归到 eof,不产出任何数据。
repeatHeader[0] === 128 是规范里唯一的结束值。判断放在分配缓冲之前,因为结束 run 不需要任何输出;如果先 ensureBuffer 再判断,会为一个不存在的 run 白白扩容。
buffer 声明为 let 而不是 const,是因为它由 ensureBuffer 返回,可能是一个新数组。bufferLength 复制到局部变量,减少方法内的属性访问,这一点和 ASCIIHex 的做法一致。
n 先取头字节原值,后面按类型分别解释。这里没有对 n 做任何合法性检查:0 到 255 的每个值都有确定语义,不存在非法头。
readBlock 每次只处理一个 run,不会为了填满缓冲而循环。理由是基类已经负责循环:DecodeStream.getBytes 会反复调用,直到攒够调用方要的字节数或遇到 eof。把循环放在基类,子类就只需要保证「一次调用要么产出至少一个字节,要么置 eof」。
设计要点:一个方法只做一件事。
readBlock的语义被压缩成「展开一个 run」,因此它不需要知道调用方要多少字节,也不需要处理「半个 run」的残留。所有跨调用的事情都交给输出缓冲。
头字节语义
头字节把一个字节的空间切成三段,每段连续、无空隙:
头字节 n | 类型 | 输出长度 | 输入消耗 | 数据来源 |
|---|---|---|---|---|
0 到 127 | 复制型 | n + 1(1 到 128) | n + 2 字节 | 头后的样本字节,再加后续 n 个字节 |
128 | 结束 | 0 | 1 到 2 字节 | 无 |
129 到 255 | 重复型 | 257 - n(2 到 128) | 2 字节 | 头后的样本字节重复若干次 |
复制型的 n 表示「样本之外的额外字节数」。n 等于 0 时,一个 run 只产出一个字节,就是紧跟头部的那个样本;n 等于 127 时,产出 128 字节。复制型的输入消耗比输出长度多一个字节,因为多了一个头字节。
重复型的长度是 257 减头字节。头字节 255 得到 2,头字节 129 得到 128。这个反向计数不是随意设计,而是把「有符号的负数」映射到无符号字节上的结果:PackBits 用 -1 到 -127 表示重复长度 2 到 128,把这些有符号数按补码读出,正好是 255 到 129。PDF 规范沿用了这套映射,于是头字节的数值越小,重复次数越多。
128 被留作结束,正好填补两段之间的空位。按 257 - 128 计算它会得到 129,超过单 run 的上限 128,所以这个值本来也无法表示合法的重复长度。把它定义为结束标记,既没有浪费编码空间,也让「头字节等于 128 就停」成为一条无需查表的规则。
两个类型的长度上限都是 128。这个上限不是巧合:它保证任何一个 run 都不需要用两个字节来表示长度,头字节的所有取值都能被有效利用。代价是长行程必须拆成多个 run,例如 300 个相同的字节会被拆成 128、128、44 三段。
设计要点:一个字节同时编码「类型」与「长度」,靠区间划分而不是标志位。复制型用正向计数,重复型用补码式的反向计数,两者在数值上首尾相接,中间只留下一个结束值。这种布局让解码器的判断只剩一次比较。
复制型 run:n 小于 128
if (n < 128) {
// copy n bytes
buffer = this.ensureBuffer(bufferLength + n + 1);
buffer[bufferLength++] = repeatHeader[1];
if (n > 0) {
const source = this.stream.getBytes(n);
buffer.set(source, bufferLength);
bufferLength += n;
}
}复制型的展开几乎是内存拷贝。
ensureBuffer(bufferLength + n + 1) 一次把容量扩到「已有输出加本 run 的输出」。输出正好是 n + 1 字节:样本一个,加上后续 n 个。扩容只在这一处发生,后面的写入不再检查容量。
先写样本字节:buffer[bufferLength++] = repeatHeader[1]。此时 buffer 已经是 ensureBuffer 的返回值,即使扩容换了数组,写入的也是新数组。这个顺序不能反:若先取旧引用再扩容,写进去的字节会落在被丢弃的旧缓冲里。
if (n > 0) 是一个小优化。n 等于 0 时不需要再向上游要数据,直接结束本 run;省掉一次 getBytes(0) 调用。getBytes(0) 在 DecodeStream 里的语义是「读到末尾」,如果不加这个判断,会把整个剩余流读进来,语义完全错位。这个分支不是可有可无的防御,而是必须的。
buffer.set(source, bufferLength) 把上游返回的视图整段复制到输出缓冲的指定位置。source 可能是一个 subarray,指向上游缓冲;set 把它逐字节拷进输出,两个数组互不影响。这里没有用循环,也没有用展开运算符,原生批量拷贝比 JS 逐字节快得多。
bufferLength += n 用的是请求量 n,而不是 source.length。正常情况下 getBytes(n) 会返回正好 n 个字节;如果上游提前耗尽,它可能返回更少。此时按 n 推进会越过实际写入的数据,尾部留空。
这是本方法唯一一处「乐观」的地方。按 n 推进会越过实际写入的数据,多出来的位置保持 0:缓冲在分配时是零填充的,而 bufferLength 只增不减,这些位置从未被写过。于是截断的输入不会报错,而是产出一段「长度按 run 计算、尾部全为零」的字节。下一次 readBlock 调用 getBytes(2) 只能拿到零个或一个字节,立刻置 eof,读取循环随之停止。真正的损坏发生在被截断的文件里,此时任何解码策略都无法还原数据,尾部是否正确已经没有意义。
设计要点:复制型 run 的本质是 memcpy。解码器只做两次写入:一次单个字节,一次批量复制。没有逐字节循环,因此即使是最坏情况下的 128 字节 run,JS 层的开销也只是一个常数。
重复型 run:n 大于等于 128
} else {
n = 257 - n;
buffer = this.ensureBuffer(bufferLength + n + 1);
buffer.fill(repeatHeader[1], bufferLength, bufferLength + n);
bufferLength += n;
}
this.bufferLength = bufferLength;重复型只有三行,但信息密度很高。
n = 257 - n 把反向计数还原成重复次数。头字节 129 变成 128,255 变成 2。这个减法也顺带把 n 从「头字节语义」切换到「输出长度语义」,后面两行不再需要区分类型。
ensureBuffer(bufferLength + n + 1) 沿用了复制型的表达式,多分配一个字节。重复型实际只需要 n 字节,多出的一个位置不会被写入。这是为了让两个分支的扩容表达式保持一致,代价最多一字节,换来的是读代码时不需要在两种公式之间切换。
buffer.fill(repeatHeader[1], bufferLength, bufferLength + n) 是核心。Uint8Array.fill 的区间是左闭右开:起点包含,终点不包含,因此正好填满 n 个位置。这是原生的内存填充,行为等价于 memset,不需要在 JS 里写循环。
举两个具体的头字节:
| 输入字节 | 头字节解释 | 输出 |
|---|---|---|
0x81 0x00 | 重复 128 次 | 128 个 0x00 |
0x81 0xFF | 重复 128 次 | 128 个 0xFF |
0xFF 0x41 | 重复 2 次 | 两个 A |
0x80 | 结束 | 无 |
第一行就是「大片同色」的典型形态:两个字节换 128 个字节,膨胀 64 倍。一张全白的 1 位掩码图,压缩后可能只有几十个字节。
bufferLength += n 之后,方法末尾统一写回 this.bufferLength。两个分支都在这里汇合,避免在分支内部重复赋值。
设计要点:重复型 run 不复制样本,只填充。
fill与set覆盖了两种 run 的全部写入,解码器没有任何按字节推进的路径。这类「把工作交给原生数组方法」的写法,是短小过滤器能跑得足够快的主要原因。
一段数据的完整展开
把编码字节逐个走一遍,比抽象描述更容易看清 run 的边界。假设上游提供这样八个字节:
03 41 42 43 44 81 00 80
第一次 readBlock:
getBytes(2)取到03与41。- 头字节 3 小于 128,是复制型,输出长度是 3 加 1,也就是 4。
- 先写样本
0x41,再getBytes(3)取到42 43 44,整段复制。 bufferLength从 0 变成 4,上游位置到 5。
第二次调用:
getBytes(2)取到81与00。- 头字节 129 大于等于 128,是重复型;
257 - 129等于 128。 fill把0x00写到输出缓冲的 128 个位置。bufferLength从 4 变成 132,上游位置到 7。
第三次调用:
- 上游只剩
80一个字节,getBytes(2)返回长度为 1 的视图。 repeatHeader.length < 2先命中,直接置eof,不产出。bufferLength保持 132。
整个过程可以列成一张表:
| 调用 | 上游读取 | 头字节 | 展开方式 | 本次产出 | 输出累计 | 上游位置 |
|---|---|---|---|---|---|---|
| 1 | 03 41 42 43 44 | 3 | 复制 4 字节 | 4 | 4 | 5 |
| 2 | 81 00 | 129 | 重复 128 次 | 128 | 132 | 7 |
| 3 | 80 | 128 | 结束 | 0 | 132 | 8 |
输出缓冲的前八个字节是 41 42 43 44 00 00 00 00,后面继续跟着 124 个 00。
这张表还解释了「按需读取」的含义。如果调用方只请求 4 个字节,getBytes(4) 在第一次 readBlock 之后就满足条件,第二、三个 run 完全不会被展开;剩下的字节留在上游,等下一次读取时再处理。如果调用方一次请求 200 字节,则要跑完三次调用,最后返回 132 字节,而不是报错或补零。
第三个调用还有一个容易被忽略的顺序细节:结束标记只占一个字节,但代码总是请求两个。当它恰好是文件末尾时,「不足两字节」的判断比「等于 128」更早生效。两条路径的结果都是 eof,所以这个顺序不影响正确性,但它说明头部读取是「按对」而不是「按语义」进行的。
设计要点:解码进度由 run 的边界决定,而不是由调用方请求的长度决定。请求 4 字节只展开一个 run,请求 200 字节展开到
eof;同一份数据可以按任意粒度被消费,状态始终一致。
解码器与图像消费者的边界
RunLengthDecode 在 PDF 里几乎只用于图像。这意味着它输出的字节不是交给 Lexer 去分词,而是交给图像解码器去解释成像素。这条边界决定了本类不需要理解行、列或颜色空间。
this.dict = str.dict 是这条边界上的关键一行。RLE 本身不读字典,但它把字典完整传给下游,图像解码器才能拿到 /Width、/Height、/BitsPerComponent、/ColorSpace。如果这一层把字典丢掉,过滤器链虽然还能解码,像素却无法被正确组装。
DecodeParms 对 RLE 没有意义。Parser.makeFilter 的 RL 分支不接收 params,因为规范没有为 RunLengthDecode 定义任何参数。对比同一个 switch 里的 FlateDecode,后者会把 params 交给 PredictorStream;RLE 没有任何类似的横向差分或行宽配置。
还有一个容易误解的点:RLE 的 run 可以跨越图像的行边界。编码是按字节流进行的,编码器完全可以把一行的末尾与下一行的开头合并成一个 run;解码器也不需要知道一行有多宽,它只负责连续展开。分行是图像解码器的事,位置由 /Width 与 /BitsPerComponent 决定。
内存上,本类始终只持有「已解码输出」。图像解码器通常请求整张图,getBytes 会反复驱动 readBlock,缓冲一路增长到完整像素大小;扩容是按几何倍数发生的,因此总复制量仍是线性的。对于一张以大片同色为主的图,输入很小、输出很大,这个增长过程会经历多次翻倍,但每次翻倍的代价都被后续的大量读取摊薄。
设计要点:过滤器只负责字节,不负责语义。RLE 不知道什么是行、什么是像素、什么是颜色空间;它把字典原样传递,把字节连续输出,把解释权留给下游。这条边界让同一个解码器可以服务任意图像类型。
输出累积与跨块
readBlock 一次只产出一个 run,但调用方要的往往是一整段数据。累积靠的是两个东西:this.bufferLength 与 ensureBuffer。
this.bufferLength 在对象上持续存在,每次 readBlock 从它开始写,结束时更新它。因此第二次调用看到的是上一次的终点,输出自然首尾相接。ensureBuffer 只在容量不够时分配新数组,并把旧内容整体复制过去;逻辑长度不受影响。
repeatHeader 是上游缓冲的视图,不因为输出缓冲扩容而失效。它指向的是另一块内存,与 this.buffer 无关。这也是「先读头再扩容」这种顺序能成立的原因:读到的两个字节在整个方法里都有效。
一次 getBytes(1000) 需要多少次 readBlock,取决于数据形态:
| 数据形态 | 单次产出 | 调用次数 | 说明 |
|---|---|---|---|
| 全为同一字节 | 128 字节 | 约 8 次 | 重复型最大 run |
| 随机字节,长行程 | 128 字节 | 约 8 次 | 复制型最大 run,n 等于 127 |
| 大量单字节 run | 1 字节 | 最多 1000 次 | n 等于 0,每次只出一个字节 |
| 截断的尾部 | 0 字节 | 1 次 | 头部不足两字节,立即 eof |
最坏情况是「每个 run 只产出一个字节」,此时每读一个字节要调用一次 readBlock,每次还要向上游要两个字节。这种输入在现实中很少见:行程编码存在的意义就是把连续重复压短,如果数据本身没有行程,编码器通常不会选择它。即便如此,代价也只是函数调用开销,不会退化成平方复杂度。
另一个值得注意的点是:readBlock 从不「预读」下一个 run。它不知道后面还有没有数据,也不需要知道。头字节是否等于 128、上游是否还有两个字节,都要等下一次调用才能确定。这种「一次一个」的节奏让内存占用始终等于「已解码输出加一个 run」,与流的长度无关。
设计要点:跨块只共享输出,不共享输入。RLE 的每个 run 都在单次调用内完成,因此解码器没有任何「待续状态」。这一点与 ASCIIHex 正好相反:那边块边界落在字符之间,必须留半个字节;这边块边界永远落在 run 之间。
结束、截断与容错
RLE 没有独立的结束标记章节,因为结束就是头字节的一个取值。把四种收尾放在一起看:
| 情况 | 检测点 | 行为 |
|---|---|---|
| 正常结束 | 头字节等于 128 | 置 eof,不产出 |
| 上游耗尽 | getBytes(2) 返回少于 2 字节 | 置 eof,不产出 |
| 复制型数据不足 | getBytes(n) 返回更短 | 写入已有部分,下一次调用补上 eof |
| 重复型数据不足 | 不可能 | 只需要第二个字节,已在头部读取 |
第一种情况有一个细节:结束字节只有一个字节,但代码读了两个。如果文件恰好在这里结束,getBytes(2) 返回一个字节,length < 2 先命中,repeatHeader[0] === 128 根本不会被求值。两条路径的结果相同,都是 eof。结束标记后面如果还有字节,那两个字节都会被读进来,第二个字节被丢弃——它本来也不属于这个流。
第二种情况是真正的截断。一个头字节加零个样本,无法构成任何 run,只能停止。
第三种情况是部分数据。getBytes(n) 会尽量返回,靠近末尾时截短。代码仍然按 n 推进逻辑长度,这一点前面已经讨论过:输出尾部会被补成零字节,而不是回退长度。下一次调用立刻结束,调用方拿到的是一段「声明长度按 run 计算、尾部为零」的数据。
重复型没有第三种情况,因为它只需要两个字节就能完成一个 run,而这两个字节在开头已经一起读到了。
解码器不做任何头字节校验。每个 0 到 255 的取值都有确定语义,没有「非法头」这种东西;损坏的数据只会被解释成某种 run,产出看起来合法但内容错误的字节。这是有意的取舍:RLE 在 PDF 里几乎只用于图像,图像损坏时上层的图像解码器会报错或降级,解码器本身没有必要再维护一套错误码。
设计要点:头字节即真相。解码器不保存「待补数据」,也不回头验证 run 是否合理;所有异常都收敛到同一个
eof。对一种只服务图像的简单编码来说,这样的容错粒度已经足够。
与 ASCIIHex 的对照
两个过滤器都很短,但它们的差异恰好覆盖了「块边界」的两种处理方式:
| 维度 | ASCIIHexDecode | RunLengthDecode |
|---|---|---|
| 数据形态 | 文本 | 二进制 |
| 跨块状态 | 半个字节(firstDigit) | 无 |
| 结束标记 | 大于号 | 0x80 |
| 单次产出 | 最多 4000 字节 | 1 到 128 字节 |
| 输入消耗 | 每次最多 8000 字节 | 每次 2 到 129 字节 |
| 最坏膨胀 | 0.5 | 64 |
| 尺寸提示 | 乘以 0.5 | 不缩放 |
| 写入方式 | 逐字节写 | set 或 fill 批量写 |
ASCIIHexDecode 的边界落在字符之间,所以必须保存半个字节;RunLengthDecode 的边界落在 run 之间,所以什么都不用保存。两者都用「带内标记」结束:一个用大于号,一个用特定字节值。区别在于,大于号在文本里几乎不会自然出现,可以安全扫描;0x80 在二进制里完全可能出现在数据中,所以只能按 run 语义顺序读取,不能事先扫描。
这也是为什么内联图像只给 ASCIIHex 和 ASCII85 写了专用的结束扫描函数,而 RLE 走通用路径。能否「先扫描后切分」,取决于标记是否与数据空间可区分。
总结
RunLengthStream 是 PDF.js 的行程展开过滤器:读取一个头字节与一个样本,把样本复制或重复到输出缓冲,再由基类的读取循环把一个个 run 拼成连续字节。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 过滤器选择 | Parser.makeFilter | RL、/Length | RunLengthStream |
| 展开 | RunLengthStream.readBlock | 头字节与样本 | 1 到 128 字节 |
| 累积 | DecodeStream | 多次 readBlock 的产出 | 连续输出缓冲 |
| 读取 | DecodeStream.getBytes | 调用方需要的长度 | 字节视图 |
| 消费 | 图像解码器、Lexer | 字节 | 像素、token |
用一句话串起来:上游提供编码字节,readBlock 每次展开一个 run,getBytes 把多个 run 缝成连续数据,图像解码器与解析器只看结果。
五条设计原则
- 头字节自带类型与长度。复制型用 0 到 127,结束用 128,重复型用 129 到 255;判断只需要一次比较,不需要额外的标志位。
- 一个 run 一次调用。
readBlock不在内部循环凑长度,也不跨调用保存输入状态。跨块由基类的读取循环与持续累积的输出缓冲共同解决。 - 先读头部两字节,再按需读数据。两种 run 都必然用到第二个字节,合并读取减少一次上游调用与一次截断判断;结束与截断也在同一处被发现。
- 批量写入交给原生方法。复制用
set,重复用fill,JS 层只做常数级调度。这是短小过滤器仍能高效处理大图的原因。 - 不确定的比例不做提示。输出与输入之比在 0.5 到 64 之间浮动,因此不缩放
maybeLength,把容量问题交给几何扩容。
与上一篇 ascii_hex_stream 放在一起看,这两个类构成过滤器链里最短的一对:一个把文本拆成半字节再拼字节,一个把字节按 run 展开。它们都不需要字典、码表或窗口,却完整展示了 DecodeStream 契约的两个极端——一个有跨块状态,一个完全没有。
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 源码解读: 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日