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

前言
这是「字节与流」层的第二篇,对象是 Worker 端 src/core/predictor_stream.js。
上一篇的 FlateStream 把 DEFLATE 位流还原成字节序列。但字节序列并不等于可用的数据:图像像素与交叉引用流为了进一步提高压缩率,会先做一次「同一行内、相邻行之间」的差分,再交给 Flate 压缩。解压之后必须把差分还原,这份还原工作就由 PredictorStream 完成。
为什么值得单独做一层?因为位图与交叉引用记录的相邻字节高度相关:同一行里相邻像素往往接近,相邻两行的同列字节也常常相同。先做差分,大量字节会变成 0 或很小的整数,Flate 的 LZ77 与 Huffman 因此更容易命中重复。解码端必须严格按行、按顺序还原,任何一行算错,后续依赖它的行都会跟着错位——这就是预测器必须保持「一次一行」状态的原因。
它在链路里的位置很固定:过滤器链的下游、真正消费者的上游。
原始字节
→ FlateStream / LZWStream (熵解码)
→ PredictorStream (行预测还原, 本文件)
→ 图像解码 / xref 记录解析Parser.makeFilter 里的写法能直接看出这层关系:
case "Fl":
case "FlateDecode":
if (params) {
return new PredictorStream(
new FlateStream(stream, maybeLength),
maybeLength,
params
);
}
return new FlateStream(stream, maybeLength);只要字典里有 /DecodeParms,就会被包一层;没有就直接交给 FlateStream。注意 params 不一定是空的——图像与 xref 流几乎都会带 Predictor、Columns、Colors、BitsPerComponent。
图像 XObject 与交叉引用流的字典里,这组参数通常长这样:
/DecodeParms
<< /Predictor 12
/Columns 4
/Colors 1
/BitsPerComponent 8 >>这表示每一行 4 个 8 位样本,使用 PNG Up 预测。交叉引用流的 /Columns 应等于 /W 数组三个字段之和,也就是一条记录的总字节数;图像则可能是 /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 1024,也就是每行 3072 字节、逐行自适应过滤。
PredictorStream 不理解这些字段的业务含义。它只把 Columns、Colors、BitsPerComponent 当成行宽公式的输入,把 Predictor 当成选择解码器的开关。
一句话概括它的职责:把「一行行的预测残差」还原成「一行行的原始样本」,并保证输出的字节数与原图、原记录一致。
文件只有 234 行,没有查表常量,也没有复杂的状态机。它的全部复杂度都在两组位运算:TIFF 预测器的差分累加,以及 PNG 预测器的五种过滤器。
模块总览
全文可以切成四块:
| 区块 | 关键方法 | 职责 |
|---|---|---|
| 工厂与校验 | constructor | 无预测时直接返回原流;校验 Predictor;算出行宽与像素宽 |
| TIFF 预测 | readBlockTiff | Predictor 2 的水平差分还原,覆盖 1、8、16 位与通用位深 |
| PNG 预测 | readBlockPng | Predictor 10 到 15 的五种行过滤器 |
| 继承设施 | DecodeStream | buffer、bufferLength、eof、ensureBuffer |
它继承 DecodeStream,和 FlateStream 是同一套契约:实现一个 readBlock,每次往 buffer 追加「一行」数据。上层 getBytes 按需触发,直到攒够调用方要的字节数。
设计要点:预测器的读入单位是「行」,而不是「块」或「整个流」。
rowBytes一旦算好,readBlock每次只处理固定长度的数据,内存占用与图像高度无关——再高的图也只多一个上一行的引用。
构造与参数
constructor 方法
PredictorStream 的构造函数同时承担工厂的职责:当参数表示「不需要预测」时,它不返回包装对象,而是把原始流原样交回去。
class PredictorStream extends DecodeStream {
constructor(str, maybeLength, params) {
super(maybeLength);
if (!(params instanceof Dict)) {
return str; // no prediction
}
const predictor = (this.predictor = params.get("Predictor") || 1);
if (predictor <= 1) {
return str; // no prediction
}
if (predictor !== 2 && (predictor < 10 || predictor > 15)) {
throw new FormatError(`Unsupported predictor: ${predictor}`);
}
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;
this.stream = str;
this.dict = str.dict;
const colors = (this.colors = params.get("Colors") || 1);
const bits = (this.bits = params.get("BPC", "BitsPerComponent") || 8);
const columns = (this.columns = params.get("Columns") || 1);
this.pixBytes = (colors * bits + 7) >> 3;
this.rowBytes = (columns * colors * bits + 7) >> 3;
return this;
}三次提前返回
构造函数里有三条出口,语义各不相同:
| 条件 | 返回值 | 含义 |
|---|---|---|
params 不是字典 | str | 没有 /DecodeParms,无需预测 |
Predictor 缺失或小于等于 1 | str | 声明了预测参数,但实际不启用 |
| 参数合法 | this | 启用预测,后续按 readBlock 变体解码 |
JavaScript 里,构造函数返回一个对象会覆盖 new 的结果;在派生类里返回非对象则会抛 TypeError。这里两次 return str 都返回对象,因此合法。代价是 super(maybeLength) 已经执行过,那个被丢弃的实例上算了 minBufferLength、初始化了 buffer 与 bufferLength——这些字段只有几个数字,开销可以忽略,换来的是调用方完全不需要判断「预测器是否启用」。
makeFilter 因此可以无脑写成 new PredictorStream(...),拿到的可能是包装器,也可能是原始流,接口一致。
this.dict = str.dict 也值得留意:包装器不新建字典,直接共享底层流的字典,objId 等身份信息不会丢。上层做缓存或循环引用检测时,看到的仍是同一个对象。
参数校验
predictor 的合法取值只有两种形态:
| 值 | 含义 | 本版处理 |
|---|---|---|
| 1 | 无预测 | 返回原流 |
| 2 | TIFF predictor 2 | readBlockTiff |
| 10 | PNG None | readBlockPng |
| 11 | PNG Sub | readBlockPng |
| 12 | PNG Up | readBlockPng |
| 13 | PNG Average | readBlockPng |
| 14 | PNG Paeth | readBlockPng |
| 15 | PNG optimum | readBlockPng |
| 其他 | 未定义 | 抛 Unsupported predictor |
判断写成 predictor !== 2 && (predictor < 10 || predictor > 15),一次性排除 3 到 9、16 以上与负数。0 在前一行已经被 || 1 折叠成 1,不会走到这里。
规范里 10 到 14 对应固定的 PNG 过滤类型,但编码后的每一行仍然带着一个「过滤类型字节」。本版不校验这个字节与声明的 Predictor 是否一致,而是统一按行读取、按字节分派:
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;这行把「选哪种解码器」从每行一次的分支,变成构造时绑定一次的方法引用。readBlock 是 DecodeStream.getBytes 反复调用的热路径,少一次判断就少一次属性查找。
代价是每个实例都会产生一个指向原型方法的绑定,内存上有微小开销;对每个流只创建一次的对象来说,这个取舍很清楚。
行宽计算
const colors = (this.colors = params.get('Colors') || 1);
const bits = (this.bits = params.get('BPC', 'BitsPerComponent') || 8);
const columns = (this.columns = params.get('Columns') || 1);
this.pixBytes = (colors * bits + 7) >> 3;
this.rowBytes = (columns * colors * bits + 7) >> 3;Colors 是每像素的分量数(灰度 1、RGB 3、CMYK 4),Columns 是一行的像素数,BitsPerComponent 是每个分量的位数。三者相乘得到一行的位数,加 7 再右移 3 位,就是向上取整的字节数。这是免浮点的整除向上取整写法。
params.get("BPC", "BitsPerComponent") 用了字典的两键回退:先找 /BPC,没有再找 /BitsPerComponent。前者是一些生成器使用的缩写,后者是规范名称。
两个宽度的含义不同:
| 字段 | 公式 | 含义 | 8 位 RGB 例子 |
|---|---|---|---|
pixBytes | (colors * bits + 7) >> 3 | 一个像素占多少字节 | 3 |
rowBytes | (columns * colors * bits + 7) >> 3 | 一整行占多少字节 | 3 乘 columns |
pixBytes 只在 PNG 过滤器里当「左邻步长」用。位深小于 8 时它至少为 1,也就是按整字节回退取左邻;这种情况在图像路径上很少见,实际数据几乎都是 8 位或 16 位。
rowBytes 是每次 readBlock 固定读取的长度,也是输出的固定步长。它只在这里算一次,之后所有循环都直接复用。
设计要点:元数据只在构造期解析一次,并立刻折算成两个字节宽度。解码循环里不再出现
colors、bits、columns的乘法,也没有重复的向上取整——这是把「格式解析」与「数据搬运」分开的典型做法。
TIFF 预测
readBlockTiff 方法
TIFF predictor 2 只做水平差分:每个样本加上它左边同分量的样本即可。它不需要上一行,因此实现里没有行缓冲之外的任何状态。
readBlockTiff() {
const rowBytes = this.rowBytes;
const bufferLength = this.bufferLength;
const buffer = this.ensureBuffer(bufferLength + rowBytes);
const bits = this.bits;
const colors = this.colors;
const rawBytes = this.stream.getBytes(rowBytes);
this.eof = !rawBytes.length;
if (this.eof) {
return;
}顺序上有讲究:先 ensureBuffer 扩容,再读源数据。缓冲区扩容可能重新分配 Uint8Array,所以任何对 buffer 的引用都必须在扩容之后建立——后面 PNG 分支的 prevRow 视图也遵守这一点。
rawBytes 的长度可能小于 rowBytes:底层流被截断时会返回短数组。这里只把「一个字节都没有」当作 EOF;短行仍然继续处理,缺失的位置在后面会被类型化数组折叠成 0。这是对损坏文件的宽容处理,而不是严格校验。
1 位灰度:一次前缀异或
最常见的低位深场景被单独优化:
if (bits === 1 && colors === 1) {
// Optimized version of the loop in the "else"-branch
// for 1 bit-per-component and 1 color TIFF images.
for (i = 0; i < rowBytes; ++i) {
let c = rawBytes[i] ^ inbuf;
c ^= c >> 1;
c ^= c >> 2;
c ^= c >> 4;
inbuf = (c & 1) << 7;
buffer[pos++] = c;
}一行 1 位样本就是一个位串,每个位是它前一位的异或累积。要在一行里逐位累加,朴素写法是循环 8 次;这里的 c ^= c >> 1、>> 2、>> 4 是并行前缀异或:三次移位把「前面所有位的异或」传播到每一位,步长依次翻倍,正好覆盖 8 个位。
inbuf 保存跨字节的接续值。上一字节解码后,最低位就是这一行最后一个样本;把 (c & 1) << 7 放进 inbuf 的高位,下一个字节的第 7 位异或时就能接上它。这样整行在字节之间连续,不需要额外的位状态数组。
注意 inbuf 在处理第一个字节前是 0,所以行首样本等于原始值,等价于「左邻为 0」。
输出与输入等长:每个输入字节产出一个解码字节,bufferLength 增加 rowBytes,不会因为位深而膨胀。
8 位:首像素直通,其余累加
} else if (bits === 8) {
for (i = 0; i < colors; ++i) {
buffer[pos++] = rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[pos] = buffer[pos - colors] + rawBytes[i];
pos++;
}第一个像素没有左邻,直接复制 colors 个字节。之后的每个字节都加上「同一行、往前一个像素、同一分量」的解码值。pos - colors 用的是已经解码的值,而不是原始残差——这正是循环必须从行首正向推进的原因。
这里没有 & 0xff。buffer 是 Uint8Array,赋值时自动对 256 取模,加法溢出的高位被丢弃。少一次位运算,是热循环里的实际收益。
i 从 colors 开始而不是 0,也说明这条路径假定 rowBytes 至少等于 colors,即 columns 至少为 1。
16 位:大端加法
} else if (bits === 16) {
const bytesPerPixel = colors * 2;
for (i = 0; i < bytesPerPixel; ++i) {
buffer[pos++] = rawBytes[i];
}
for (; i < rowBytes; i += 2) {
const sum =
((rawBytes[i] & 0xff) << 8) +
(rawBytes[i + 1] & 0xff) +
((buffer[pos - bytesPerPixel] & 0xff) << 8) +
(buffer[pos - bytesPerPixel + 1] & 0xff);
buffer[pos++] = (sum >> 8) & 0xff;
buffer[pos++] = sum & 0xff;
}16 位样本按大端存放:高字节在前、低字节在后。加法要把「当前样本」与「左侧同分量样本」都拼回 16 位整数,相加后再拆回两个字节。
sum 可能超过 65535,但写回时只取低 16 位(先写 sum >> 8 的低 8 位,再写 sum 的低 8 位),高位自然丢弃。这是模 65536 的加法,符合预测器的定义。
四个 & 0xff 看起来多余——Uint8Array 读出来本来就是 0 到 255。但在叠加 << 8 之前做一次掩码是防御性写法,避免把符号位带进整数。
bytesPerPixel 是 colors * 2,与 8 位路径里的 colors 对应。
通用位深:解包、累加、重打包
其余组合走最一般化的路径:
} else {
const compArray = new Uint8Array(colors + 1);
const bitMask = (1 << bits) - 1;
let j = 0,
k = bufferLength;
const columns = this.columns;
for (i = 0; i < columns; ++i) {
for (let kk = 0; kk < colors; ++kk) {
if (inbits < bits) {
inbuf = (inbuf << 8) | (rawBytes[j++] & 0xff);
inbits += 8;
}
compArray[kk] =
(compArray[kk] + (inbuf >> (inbits - bits))) & bitMask;
inbits -= bits;
outbuf = (outbuf << bits) | compArray[kk];
outbits += bits;
if (outbits >= 8) {
buffer[k++] = (outbuf >> (outbits - 8)) & 0xff;
outbits -= 8;
}
}
}这段同时维护两个位缓冲:inbuf 与 inbits 管输入的解包,outbuf 与 outbits 管输出的重打包。compArray[kk] 保存每个分量在当前行的「上一个解码样本」,按分量分开累加,所以 colors 大于 1 时也不会串色。数组长度是 colors + 1,多出的一格实际没有被使用,属于防御性分配。
输入侧:当剩余位数不足一个样本时,读入一个字节拼到 inbuf 低端;取样本用 inbuf >> (inbits - bits),从当前有效位的高端切出 bits 位。inbuf 从不清洗已经消费过的高位,取出的结果靠 bitMask 只保留低 bits 位——陈旧位会被掩码抹掉。inbits 每次加载后才消耗,始终小于「样本位数加 8」,因此最多只需缓冲两个字节。
输出侧:样本按 bits 位宽依次拼进 outbuf,累计满 8 位就写一个字节。因为整个循环按 columns 与 colors 逐样本推进,输出的字节数正好等于 rowBytes。
if (outbits > 0) {
buffer[k++] = (outbuf << (8 - outbits)) + (inbuf & ((1 << (8 - outbits)) - 1));
}行尾若没占满一个字节,把剩余样本左移到字节高位,再把输入缓冲里剩下的低位并进去。这两段位区间互不重叠,+ 与按位或等价。这样输出的最后一个字节与编码端一致,不会因为补零而破坏后续行的对齐。
this.bufferLength += rowBytes; 在分支之外统一提交。不管走哪条路径,一行解码后的长度总是 rowBytes。
四条路径的差异可以并排看:
| 条件 | 循环单位 | 取模方式 | 输出长度 |
|---|---|---|---|
| 1 位单色 | 字节 | 前缀异或 | rowBytes |
| 8 位 | 字节 | Uint8Array 自动截断 | rowBytes |
| 16 位 | 双字节 | 只写低 16 位 | rowBytes |
| 其他 | 样本 | bitMask 掩码 | rowBytes |
设计要点:四条路径是同一件事的不同特化。1 位用前缀异或、8 位用类型化数组的自动截断、16 位手工拆分字节、通用路径解包再重打包。特化的目的不是炫技,而是让最常见的图像格式(1 位扫描件、8 位灰度与 RGB)走最短的循环。
PNG 预测
readBlockPng 方法
PNG 预测器比 TIFF 多一个维度:它可以使用上一行。行首的过滤类型字节决定这一行怎么还原。
readBlockPng() {
const rowBytes = this.rowBytes;
const pixBytes = this.pixBytes;
const predictor = this.stream.getByte();
const rawBytes = this.stream.getBytes(rowBytes);
this.eof = !rawBytes.length;
if (this.eof) {
return;
}
const bufferLength = this.bufferLength;
const buffer = this.ensureBuffer(bufferLength + rowBytes);
let prevRow = buffer.subarray(bufferLength - rowBytes, bufferLength);
if (prevRow.length === 0) {
prevRow = new Uint8Array(rowBytes);
}上一行从哪里来
prevRow 不是单独保存的数组,而是已解码输出缓冲里紧挨着的上一行:buffer.subarray(bufferLength - rowBytes, bufferLength)。因为每行都恰好追加 rowBytes 字节,这个切片永远指向上一行的完整数据。
第一行时 bufferLength 为 0,subarray(-rowBytes, 0) 会被规范化为空视图;代码检查 length === 0 后换成一个全零数组。这样五种过滤器都不需要额外判断「是不是首行」——上一行全零时,Up 自然退化为 None,Paeth 也自然退化为 Sub 的形式。
复用视图而不是复制的好处很直接:Paeth 需要上一行与左上两个值,如果每行都 slice 出上一行,就要多一次整行拷贝;而视图只是两个数字。坏处是对时序有要求——ensureBuffer 必须在建立视图之前调用,否则缓冲区重新分配后视图会指向旧内存。代码把这个顺序固定下来了。
过滤类型字节先于数据读取。如果流在行边界处结束,getByte 返回 -1,随后 getBytes 返回空数组,eof 置位并直接返回;那个 -1 不会被使用。
五种过滤器
predictor 的取值就是行首字节,和图里声明的 Predictor 10 到 15 无关:
| 值 | 名称 | 还原公式 | 首个像素的处理 |
|---|---|---|---|
| 0 | None | 原始值 | 无需处理 |
| 1 | Sub | 原始值加左邻 | 无左邻,直接复制前 pixBytes 字节 |
| 2 | Up | 原始值加上邻 | 全零上一行时等价于 None |
| 3 | Average | 原始值加左邻与上邻的平均(向下取整) | 左邻视为 0,只加上邻的一半 |
| 4 | Paeth | 原始值加左邻、上邻、左上三者中最接近预测值者 | 左邻与左上为 0,等价于加上邻 |
| 其他 | 非法 | 无 | 抛 Unsupported predictor |
下面逐个看实现。
case 0:
for (i = 0; i < rowBytes; ++i) {
buffer[j++] = rawBytes[i];
}
break;None 是整行直通。它依然要走循环,因为输出必须落在 buffer 里,而不是引用 rawBytes。
case 1:
for (i = 0; i < pixBytes; ++i) {
buffer[j++] = rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[j] = (buffer[j - pixBytes] + rawBytes[i]) & 0xff;
j++;
}
break;Sub 与 TIFF 的 8 位路径同构,只是步长从 colors 换成了 pixBytes。前 pixBytes 个字节没有左邻,直接复制;其余按字节累加。
case 2:
for (i = 0; i < rowBytes; ++i) {
buffer[j++] = (prevRow[i] + rawBytes[i]) & 0xff;
}
break;Up 是唯一整行都依赖上一行的过滤器,连首个像素也不例外。首行时 prevRow 全零,结果与 None 完全相同。
case 3:
for (i = 0; i < pixBytes; ++i) {
buffer[j++] = (prevRow[i] >> 1) + rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[j] =
(((prevRow[i] + buffer[j - pixBytes]) >> 1) + rawBytes[i]) & 0xff;
j++;
}
break;Average 取左邻与上邻的中点:(left + up) >> 1 是向下取整。第一段没有左邻,于是只加上邻的一半(prevRow[i] >> 1)。这一段没有 & 0xff,依赖 Uint8Array 的自动取模;第二段显式掩码,写法不完全统一,但结果一致。
case 4:
// we need to save the up left pixels values. the simplest way
// is to create a new buffer
for (i = 0; i < pixBytes; ++i) {
up = prevRow[i];
c = rawBytes[i];
buffer[j++] = up + c;
}
for (; i < rowBytes; ++i) {
up = prevRow[i];
const upLeft = prevRow[i - pixBytes];
const left = buffer[j - pixBytes];
const p = left + up - upLeft;
let pa = p - left;
if (pa < 0) {
pa = -pa;
}
let pb = p - up;
if (pb < 0) {
pb = -pb;
}
let pc = p - upLeft;
if (pc < 0) {
pc = -pc;
}
c = rawBytes[i];
if (pa <= pb && pa <= pc) {
buffer[j++] = left + c;
} else if (pb <= pc) {
buffer[j++] = up + c;
} else {
buffer[j++] = upLeft + c;
}
}
break;Paeth 是全篇唯一需要三个邻居的过滤器。预测值 p 等于左邻加上邻再减左上,然后比较三者到 p 的距离,选最近的那个。pa、pb、pc 分别对应左邻、上邻、左上;Math.abs 被展开成两个分支,是因为这个比较在每行每字节上都会执行,避免函数调用。
选择顺序是 pa、pb、pc 依次由小到大,平局时优先左邻,其次上邻。这与 PNG 规范一致。
首行的前 pixBytes 字节把左邻与左上视为 0,此时 p 等于上邻,pb 为 0 最小,必然选中上邻,所以代码直接写成 up + c。这是一次正确的特化。
那条注释说「需要一个新缓冲区」,实际上代码并没有新建——prevRow 已经是输出缓冲里上一行的视图,写当前行不会覆盖它。注释是历史残留,读的时候以代码为准。
default:
throw new FormatError(`Unsupported predictor: ${predictor}`);这里的 default 兜的是行内过滤字节,而不是构造时校验过的 Predictor。构造期已经限制了 10 到 15,但每行的首字节来自数据本身,可能是 5 到 255 或 -1。这里不宽容,直接抛错——过滤类型未知意味着这一行的还原公式无从谈起,继续输出只会得到错误的像素。
this.bufferLength += rowBytes; 收尾,与 TIFF 路径一致。
设计要点:
prevRow复用输出缓冲里的上一行,使「上一行」这一维度的成本降到一个视图对象。同时必须保证「扩容在前、建视图在后」,否则视图会绑定到被替换掉的旧数组。这类顺序约束不会由类型系统保证,只能靠代码位置固定。
TIFF 与 PNG 的差异
把两条路径放在一起对照,差异比想象中更有结构:
| 维度 | TIFF predictor 2 | PNG predictor 10 到 15 |
|---|---|---|
| 行首字节 | 无 | 每行 1 字节过滤类型 |
| 预测方向 | 仅左邻 | 左邻、上邻、左上及其组合 |
| 是否需要上一行 | 不需要 | Up、Average、Paeth 需要 |
| 首行特判 | 无(左邻视为 0) | prevRow 全零,无需分支 |
| 位深支持 | 1、8、16 与通用位深 | 按字节处理,步长为 pixBytes |
| 输入行长度 | rowBytes | rowBytes 加 1 个过滤字节 |
| 代码组织 | 按位深分四支 | 按过滤类型分五支 |
最关键的一条:TIFF 的预测只沿一行展开,所以恢复第一个像素后可以一路向右推进;PNG 的 Up、Average、Paeth 需要「上一行同一列」,因此必须保留上一行的解码结果。这也是为什么 TIFF 路径里根本没有 prevRow,而 PNG 路径一进来就要把它准备好。
另一条容易忽略:PNG 的输入比输出多一个字节。getBytes(rowBytes) 之前先 getByte() 读掉过滤类型,这个字节不会进入输出缓冲,也不会影响 bufferLength 的推进。
容错与边界
这一层没有复杂的恢复机制,边界处理集中在三处。
第一,截断即 EOF。this.eof = !rawBytes.length 只在完全读不到数据时置位。如果只读到半行,循环仍会跑完 rowBytes 次,缺失位置上的 rawBytes[i] 是 undefined,参与算术会得到 NaN,写进 Uint8Array 后变成 0。对损坏文件来说,这比抛异常更实用:半行数据总比整页渲染失败好。但同时也意味着,短行不会被识别为错误,只能靠上层的图像解码器在尺寸不符时发现。
第二,过滤字节非法直接抛错。default 分支不吞异常,因为未知过滤器没有可用的还原公式。这与 FlateStream 的分层策略一致:结构性问题(读不到数据)可以容忍,语义性问题(数据格式不认识)必须暴露。
第三,参数非法在构造期失败。Predictor 3 到 9、16 以上都会被 Unsupported predictor 拦下,不会进入解码循环。这样 readBlockPng 里的 switch 只需要处理 0 到 4,不需要再考虑「声明为 2 却走了 PNG 路径」之类的组合。
还有一个隐性的边界:pixBytes 与 rowBytes 都来自声明值。若 PDF 里 Columns 与实际数据不符,解码不会卡死,但输出长度会与数据错位——这类错误只能由上层根据图像宽高或 xref 记录长度来发现。PredictorStream 本身不做一致性校验,它只保证「按声明的行宽推进」。
设计要点:预测器不猜测、不重试、不修复。它把「声明参数」当作唯一事实来源,只负责把字节按公式变换。参数错了,输出自然错;判断对错的责任留给拥有更多上下文的上层。
总结
PredictorStream 是 PDF.js 的行预测还原层:把过滤器链输出的差分字节还原成原始样本,向上供图像解码与 xref 记录解析使用。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | Stream / ChunkedStream | 文件切片、网络分块 | 压缩字节 |
| 熵解码 | FlateStream / LZWStream | zlib、LZW 流 | 预测残差 |
| 行预测 | PredictorStream | Predictor、Colors、BPC、Columns | 原始样本行 |
| 上层消费 | 图像解码 / xref 解析 | 行字节 | 像素、字段记录 |
用一句话串起来:Flate 负责把「变长的位」变成「定长的字节」,Predictor 负责把「相邻相关的残差」变成「各自独立的样本」。
四条设计原则
- 无预测就零包装。构造函数直接返回原流,调用方不需要判断「有没有预测器」,也不会为一个空壳对象付出转发成本。
- 元数据只算一次。
pixBytes与rowBytes在构造期折算完成,解码循环只用这两个整数,格式解析与数据搬运彻底分开。 - 解码器选择一次完成。
readBlock在构造时被绑定到 TIFF 或 PNG 变体,热路径上不再重复判断。 - 上一行用视图,不复制。
prevRow指向输出缓冲里紧邻的上一行,把 PNG 预测的空间成本压到最低;代价是必须固定「扩容在前、建视图在后」的顺序。
至此,「流解压之后还要做什么」这一环补齐了。过滤器链的两端都已展开:FlateStream 处理位流,PredictorStream 处理行差分。再往下,就该看这些字节如何被解释成图像像素、字体字形与交叉引用记录了。
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 源码解读: 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日