StreamPDFStreamPDF
返回博客

PDF.js 源码解读: predictor_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: predictor_stream

前言

这是「字节与流」层的第二篇,对象是 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 预测readBlockTiffPredictor 2 的水平差分还原,覆盖 1、8、16 位与通用位深
PNG 预测readBlockPngPredictor 10 到 15 的五种行过滤器
继承设施DecodeStreambuffer、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 缺失或小于等于 1str声明了预测参数,但实际不启用
参数合法this启用预测,后续按 readBlock 变体解码

JavaScript 里,构造函数返回一个对象会覆盖 new 的结果;在派生类里返回非对象则会抛 TypeError。这里两次 return str 都返回对象,因此合法。代价是 super(maybeLength) 已经执行过,那个被丢弃的实例上算了 minBufferLength、初始化了 buffer 与 bufferLength——这些字段只有几个数字,开销可以忽略,换来的是调用方完全不需要判断「预测器是否启用」。

makeFilter 因此可以无脑写成 new PredictorStream(...),拿到的可能是包装器,也可能是原始流,接口一致。

this.dict = str.dict 也值得留意:包装器不新建字典,直接共享底层流的字典,objId 等身份信息不会丢。上层做缓存或循环引用检测时,看到的仍是同一个对象。

参数校验

predictor 的合法取值只有两种形态:

值含义本版处理
1无预测返回原流
2TIFF predictor 2readBlockTiff
10PNG NonereadBlockPng
11PNG SubreadBlockPng
12PNG UpreadBlockPng
13PNG AveragereadBlockPng
14PNG PaethreadBlockPng
15PNG optimumreadBlockPng
其他未定义抛 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 无关:

值名称还原公式首个像素的处理
0None原始值无需处理
1Sub原始值加左邻无左邻,直接复制前 pixBytes 字节
2Up原始值加上邻全零上一行时等价于 None
3Average原始值加左邻与上邻的平均(向下取整)左邻视为 0,只加上邻的一半
4Paeth原始值加左邻、上邻、左上三者中最接近预测值者左邻与左上为 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 2PNG predictor 10 到 15
行首字节无每行 1 字节过滤类型
预测方向仅左邻左邻、上邻、左上及其组合
是否需要上一行不需要Up、Average、Paeth 需要
首行特判无(左邻视为 0)prevRow 全零,无需分支
位深支持1、8、16 与通用位深按字节处理,步长为 pixBytes
输入行长度rowBytesrowBytes 加 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 / LZWStreamzlib、LZW 流预测残差
行预测PredictorStreamPredictor、Colors、BPC、Columns原始样本行
上层消费图像解码 / xref 解析行字节像素、字段记录

用一句话串起来:Flate 负责把「变长的位」变成「定长的字节」,Predictor 负责把「相邻相关的残差」变成「各自独立的样本」。

四条设计原则

  1. 无预测就零包装。构造函数直接返回原流,调用方不需要判断「有没有预测器」,也不会为一个空壳对象付出转发成本。
  2. 元数据只算一次。pixBytes 与 rowBytes 在构造期折算完成,解码循环只用这两个整数,格式解析与数据搬运彻底分开。
  3. 解码器选择一次完成。readBlock 在构造时被绑定到 TIFF 或 PNG 变体,热路径上不再重复判断。
  4. 上一行用视图,不复制。prevRow 指向输出缓冲里紧邻的上一行,把 PNG 预测的空间成本压到最低;代价是必须固定「扩容在前、建视图在后」的顺序。

至此,「流解压之后还要做什么」这一环补齐了。过滤器链的两端都已展开:FlateStream 处理位流,PredictorStream 处理行差分。再往下,就该看这些字节如何被解释成图像像素、字体字形与交叉引用记录了。

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

相关文章