StreamPDFStreamPDF
返回博客

PDF.js 源码解读: lzw_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: lzw_stream

前言

这是「字节与流」层的一篇文章,对象是 Worker 端 src/core/lzw_stream.js。

「字节与流」层讲的是 PDF 的过滤器(Filter):字典里 /Filter 写下的名字,如何被 Parser.makeFilter 换成一个能按需吐字节的 DecodeStream。上一篇我们已经拆过 /FlateDecode;本篇处理同样属于 Filter 解码器的 LZW 与 ASCII85,其中 ASCII85 的细节放在同批的另一篇,这里聚焦 LZW。

过滤器可以串联。数组 /Filter [/ASCII85Decode /FlateDecode] 的语义是「先做 ASCII85 解码,再把结果交给 Flate」;makeFilter 的循环从数组头开始逐层包装,读取时由最外层向内逐级拉取,实际解码顺序仍是数组顺序。所以 LZWStream 的输入未必是文件里的原始字节,也可能是另一个解码流的输出。

Flate 是现代 PDF 的默认压缩方式,几乎每份文档都有;LZW 则是 PDF 从 TIFF 与 GIF 继承下来的字典压缩,今天只在少数扫描件、旧版生成器与部分图像数据里出现。两者同样属于「熵解码」,但机制完全不同:Flate 靠 Huffman 码表加 LZ77 回溯,LZW 靠一张随解码不断增长的字符串字典,而且码长本身会变化——从 9 位起步,随字典膨胀逐级加宽到 12 位。

LZW 由 Welch 在 1984 年整理成形,PDF 1.0 就把它列进了标准过滤器。它曾因 GIF 的专利争议而广为人知,在 PDF 里则始终是配角:规范的 /LZWDecode 要求实现同时支持 /EarlyChange,而多数现代生成器早已改用 Flate。PDF.js 仍然完整实现它,因为读一份九十年代的扫描件时,这段代码可能正是唯一能解开内容流的路径。

读这份文件的价值不在于「会用 LZW」,而在于它把三个经典问题压缩进了 145 行:变长码如何跨字节读取、字典如何在不分配对象的前提下增长、解码状态如何跨块延续。这三件事在任何流式解码器里都会反复出现。

一句话概括它的职责:把按 9 到 12 位打包的变长码流,通过一张 4096 项的自建字典,还原成连续字节。

模块总览

lzw_stream.js 全文 145 行,只有一个类 LZWStream,代码可以切成四块:

区块关键成员职责
流身份constructor记录底层流、dict,初始化字典与位缓冲
位读取readBitsMSB 优先地从字节流中取出任意位数
解码readBlock每次处理 512 个码,维护字典并输出字节
解码状态lzwState码长、下一个码号、前缀码、三张数组、序列

它在整条链路里的位置:

PDF 字节
  → Parser.makeFilter              (读到 /Filter /LZWDecode)
  → LZWStream(str, maybeLength, earlyChange)
  → PredictorStream                (可选:/DecodeParms 的行预测)
  → DecodeStream.getBytes          (按需拉取,循环调用 readBlock)
  → 上层:图像数据 / 字体文件 / 内联流

继承关系是 BaseStream → DecodeStream → LZWStream。DecodeStream 提供 buffer、bufferLength、eof、ensureBuffer、getByte、getBytes 这些设施;LZWStream 只需要实现一个 readBlock,每次往 buffer 尾部追加一段解码结果。上层按自己需要的长度反复触发 readBlock,解码因此是惰性的:没人读的流永远不会被解。

LZWStream 没有覆盖 getBytes、getByte、ensureBuffer,也没有像 FlateStream 那样提供异步快路径。它把所有复杂度压在 readBlock 里,对外只暴露「一个按需推进的解码块」。这种分工让新增一个过滤器的最小代价很低:想支持一种新编码,只需要再写一个 readBlock。

DecodeStream.getBytes 的协议也值得说明:调用方给出长度,它就从 pos 开始反复 readBlock,直到 bufferLength 覆盖到目标或 eof;返回的是 subarray,不复制。LZWStream 只负责让 bufferLength 往前走,从不移动 pos。读源码时如果弄混这两个游标,就会把「已解码」和「已消费」当成一回事。

代价则落在状态管理上。DecodeStream 只认识 buffer 与 eof,不理解「码长」「字典」这些概念,所以 LZWStream 必须自己保证:块与块之间码长一致、位缓冲的残余位不丢、字典不重建。lzwState 就是为这三件事存在的。

设计要点:readBlock 的粒度是「512 个码」,不是「整个流」,更不是「固定字节数」。LZW 的每个码解出的字节数并不固定,所以这里只能按码的个数切块;块与块之间靠 lzwState 传递码长、nextCode、prevCode 和当前序列。切块让单次调用的工作量有上界,同时保留了跨块延续字典的能力。

LZW 的码值约定

PDF 的 LZW(LZWDecode)与 TIFF 的 LZW 算法同源,但打包方式不同:PDF 规定高位在前(MSB first),而 TIFF 是低位在前。这一点在 readBits 里体现得很清楚。

码值空间被切成四段:

码值区间含义本文件的处理
0 到 255单字节字面量直接作为长度为 1 的序列
256Clear码长重置为 9,nextCode 回到 258,序列长度清零
257EOD置 eof,删除字典状态
258 起动态字典项查表还原,或在特例里补首字节

258 是第一个可以自由分配的码号。字典初始只有 0 到 255 这 256 个字面量项,258 之后每读一个码就新增一项,因此 nextCode 从 258 开始递增。

MSB 优先还有一层实际影响:一个 9 位码可能横跨两个字节,先出现的高位字节在上,后出现的字节在下。readBits 里 cachedData = (cachedData << 8) | c 与 cachedData >>> bitsCached 这一进一出,正是为这个顺序服务的。如果照搬 TIFF 的 LSB 优先实现来读 PDF 数据,得到的会是一堆看似随机的字节,而且往往直到第一个字典码才暴露问题——前面的字面量码只有 8 位,位序错了也看不出来。

EarlyChange 是 PDF 规范里由 /DecodeParms 控制的开关:它决定编码器提前一个码还是按需扩宽码长。Parser.makeFilter 读取它并在缺省时取 1:

        case "LZW":
        case "LZWDecode":
          let earlyChange = 1;
          if (params) {
            if (params.has("EarlyChange")) {
              earlyChange = params.get("EarlyChange");
            }
            return new PredictorStream(
              new LZWStream(stream, maybeLength, earlyChange),
              maybeLength,
              params
            );
          }
          return new LZWStream(stream, maybeLength, earlyChange);

注意两件事。第一,earlyChange 是解码器必须知道的参数:它不改变字典内容,只改变码长的切换时机,读错就会从扩宽那一刻起整段错位。第二,只要 /DecodeParms 存在,就会无条件套一层 PredictorStream,即使里面只有 EarlyChange 而没有任何 Predictor 字段;PredictorStream 对缺省参数是宽容的,这一层不会改变字节。

与 Flate 不同,LZWStream 没有对 maybeLength 做任何调整,直接把 Length 当作缓冲区的初始提示交给 DecodeStream。

字典的表示

constructor 方法

  constructor(str, maybeLength, earlyChange) {
    super(maybeLength);
 
    this.stream = str;
    this.dict = str.dict;
    this.cachedData = 0;
    this.bitsCached = 0;
 
    const maxLzwDictionarySize = 4096;
    const lzwState = {
      earlyChange,
      codeLength: 9,
      nextCode: 258,
      dictionaryValues: new Uint8Array(maxLzwDictionarySize),
      dictionaryLengths: new Uint16Array(maxLzwDictionarySize),
      dictionaryPrevCodes: new Uint16Array(maxLzwDictionarySize),
      currentSequence: new Uint8Array(maxLzwDictionarySize),
      currentSequenceLength: 0,
    };
    for (let i = 0; i < 256; ++i) {
      lzwState.dictionaryValues[i] = i;
      lzwState.dictionaryLengths[i] = 1;
    }
    this.lzwState = lzwState;
  }

构造函数做了三件事:保存底层流、初始化位缓冲、建字典。

字典不是 Map,也不是字符串数组,而是四张定长 TypedArray,外加一个长度游标。这是这份实现最重要的设计选择:

字段类型作用
dictionaryValuesUint8Array每个条目的最后一个字节
dictionaryLengthsUint16Array每个条目的字节长度
dictionaryPrevCodesUint16Array每个条目的前缀条目号
currentSequenceUint8Array当前码解出的字节序列,复用为工作区
currentSequenceLength数字currentSequence 的有效长度

前三张数组的下标就是码号,最大 4096 项,对应 12 位码的上限。之所以用 TypedArray 而不是对象数组,是因为 LZW 热路径上每个码都要读写这几张表:定长、连续、无装箱的数组比 Map 或字符串拼接快一个量级,也不会为每个字典项分配对象。

初始化只填 0 到 255:字面量码 i 的值就是 i,长度都是 1,前缀码留 0 表示「没有前缀」。currentSequence 此时全是 0,它不参与初始化,只在解码时被逐字节填充。

有一处容易读错:dictionaryValues 存的不是条目的首字节,而是末字节。这一点在后面的字典更新与回溯重建里会反复出现,先记住结论:新条目等于「前一个码的序列」加上「当前序列的首字节」,所以当前序列的首字节,恰好就是新条目的最后一个字节。

prevCode 在 lzwState 里没有初始化,读取时是 undefined。这不会出问题,因为 readBlock 用 currentSequenceLength 是否大于 0 来判断「有没有前一个序列」,第一次读码时该值为 0,字典更新会被跳过。

序列工作区的复用

currentSequence 是四张数组里唯一会被反复覆写的。每解一个码,它先被填满,再整段拷进输出缓冲。容量 4096 足以容纳任何字典项:第一个动态条目(258)长度是 2,此后每新增一项加 1,到 4095 时长度约为 3839,仍在 4096 之内。

复用同一块工作区,而不是为每个码新建数组,是热路径上省分配的第二个手段(第一个是 TypedArray 本身)。KwKwK 分支直接在这块工作区上追加一个字节,不需要任何临时变量;查表分支则先按长度定位尾部,再逐字节往前填。两个分支都不产生垃圾对象,这是 LZW 解码能在 Worker 里稳定跑下去的关键。

位读取

readBits 方法

  readBits(n) {
    let bitsCached = this.bitsCached;
    let cachedData = this.cachedData;
    while (bitsCached < n) {
      const c = this.stream.getByte();
      if (c === -1) {
        this.eof = true;
        return null;
      }
      cachedData = (cachedData << 8) | c;
      bitsCached += 8;
    }
    this.bitsCached = bitsCached -= n;
    this.cachedData = cachedData;
    return (cachedData >>> bitsCached) & ((1 << n) - 1);
  }

这是一个标准的 MSB 优先位缓冲:

  1. 需要的位数不够,就从底层流补一个字节;
  2. 补字节时把已有位左移 8 位,再把新字节放到最低 8 位;
  3. 取数时把缓冲右移到「剩余位数」的位置,再用掩码截出低 n 位;
  4. 剩余位留在 bitsCached 里,跨调用、跨块继续使用。

cachedData 从不主动裁掉已经消费的高位,它一直带着历史位向左生长。这样做是安全的:读取时只关心 bitsCached 指定的低窗口,高位是残留,掩码会把它们丢掉;JS 的位运算按 32 位截断,也不会因为位太多而溢出。跨块时 bitsCached 最多 7,加上本次补的 8 位,够覆盖 12 位码。

举个例子:缓冲里已有 5 位(bitsCached 为 5),本次要读 9 位。循环补进一个字节,bitsCached 变成 13,cachedData 的低 13 位就是「5 位旧数据 + 8 位新数据」。返回值为 (cachedData >>> 4) & 0x1ff,取的是从第 4 位往上的 9 位;剩下的低 4 位仍留在缓冲里,等下一次读取。整个过程中没有逐位移位,只有一次移位和一次掩码。

c === -1 表示底层流读完,此时置 eof 并返回 null。注意这里返回的不是 0,也不是抛异常,而是 null;调用方如何处理这个值,是后文「边界」一节要展开的一个细节。

位缓冲放在实例上而不是 readBlock 的局部变量里,是因为码可能跨块:一块恰好在码中间结束,剩下的位必须留给下一次 readBlock。这也是 readBits 不能简单包装 getBits 的原因——DecodeStream 没有位级接口。

位缓冲的上界

bitsCached 的值在每次调用后都会减少 n,所以调用之间最多残留 11 位(读取 12 位码之后)。进入循环时若不足,补一次 8 位就够了:11 加 8 等于 19,覆盖 12 位绰绰有余。while 写成循环而不是 if,是因为它不假设调用方一定只读 12 位以内;即便有人读 20 位,也能正确补齐(不过 32 位掩码会失效,本文件不会这样用)。

1 << n 在 n 为 32 时会变成 0,掩码失效;n 为 12 时结果 4096,一切正常。这也是实现能把码长上限钉在 12 的另一个隐含前提——位运算的宽度足够。

再看 cachedData 的生命期:它从不被清空,只被左移和新字节填充。JS 的位运算会自动把操作数转成 32 位有符号整数,所以左移超过 32 位时高位被丢弃,低位的 bitsCached 窗口始终完好。代价是位宽浪费,收益是不需要维护「缓冲区里有几位有效」之外的任何簿记。

解码主循环

readBlock 方法

先看进入循环前的准备:

  readBlock() {
    const blockSize = 512,
      decodedSizeDelta = blockSize;
    let estimatedDecodedSize = blockSize * 2;
    let i, j, q;
 
    const { lzwState } = this;
    const earlyChange = lzwState.earlyChange;
    let nextCode = lzwState.nextCode;
    const dictionaryValues = lzwState.dictionaryValues;
    const dictionaryLengths = lzwState.dictionaryLengths;
    const dictionaryPrevCodes = lzwState.dictionaryPrevCodes;
    let codeLength = lzwState.codeLength;
    let prevCode = lzwState.prevCode;
    const currentSequence = lzwState.currentSequence;
    let currentSequenceLength = lzwState.currentSequenceLength;
 
    let decodedLength = 0;
    let currentBufferLength = this.bufferLength;
    let buffer = this.ensureBuffer(this.bufferLength + estimatedDecodedSize);

所有跨块状态先取到局部变量:码长、下一个码号、前缀码、三张数组、当前序列。这一步不是风格问题,而是热路径优化——循环内每个码都要用到这些值,如果每次都穿过 this.lzwState 再取属性,开销会明显放大。循环结束时再一次性写回。

blockSize 定为 512,estimatedDecodedSize 先按 1024 预留。ensureBuffer 按 2 的幂扩容,所以这里的估算只是「至少够用」的提示。

每个码进来后分成四路:

    for (i = 0; i < blockSize; i++) {
      const code = this.readBits(codeLength);
      const hasPrev = currentSequenceLength > 0;
      if (code < 256) {
        currentSequence[0] = code;
        currentSequenceLength = 1;
      } else if (code >= 258) {
        if (code < nextCode) {
          currentSequenceLength = dictionaryLengths[code];
          for (j = currentSequenceLength - 1, q = code; j >= 0; j--) {
            currentSequence[j] = dictionaryValues[q];
            q = dictionaryPrevCodes[q];
          }
        } else {
          currentSequence[currentSequenceLength++] = currentSequence[0];
        }
      } else if (code === 256) {
        codeLength = 9;
        nextCode = 258;
        currentSequenceLength = 0;
        continue;
      } else {
        this.eof = true;
        delete this.lzwState;
        break;
      }

字面量:code < 256 时,当前序列就是这一个字节,长度置 1。注意 currentSequence[0] 在后面的字典更新里还会被当作「当前序列的首字节」,所以这里不能省。

字典项:code >= 258 时先判断 code < nextCode。成立说明这个码已经建过,直接查表:先取长度,再从尾到头沿前缀链填回 currentSequence。循环里 j 从长度减 1 递减,q 从码号沿 dictionaryPrevCodes 回溯,每一步把 dictionaryValues[q] 写到当前尾部,直到回到 0 到 255 的基础码。因为 dictionaryValues 存的是条目末字节,倒着填正好拼出正序的字节序列。

KwKwK 特例:code === nextCode 是 LZW 的经典边界。它意味着「这个码还没建出来,但编码器已经在用了」。此时无法查表,但可以确定它的内容:它等于前一个码的序列,再加上当前序列的首字节——而当前序列此时还没更新,正好还保留着前一个码解出的结果,于是 currentSequence[currentSequenceLength++] = currentSequence[0] 一行就完成追加。实现里用的是 else 兜底,所以 code 大于 nextCode 的非法值也会走到这里,结果同样是追加首字节;规范编码器不会产生这种码。

Clear:code === 256 时把码长、nextCode、序列长度重置,然后 continue。continue 很关键:它同时跳过了后面的字典更新和字节输出。字典数组本身不清零,但 nextCode 回到 258 后,旧条目会被新条目从 258 开始逐个覆盖;查表时又有 code < nextCode 的前置判断,陈旧的 4096 项不会造成误读。prevCode 也不重置,不过 currentSequenceLength 为 0 让下一次的 hasPrev 为假,陈旧前缀不会被使用。

EOD:256 与 257 之外的最后一个分支其实只剩 257。它把 eof 置位,删除 this.lzwState 释放四张数组,然后 break 跳出循环。这里有个值得注意的细节:lzwState 已经在方法顶部被解构为局部变量,delete this.lzwState 只是断开实例与对象的引用;循环外那几行写回仍然能作用在这个「孤儿」对象上,不会报错。真正被释放的是实例持有的那份引用,数组随之可回收。

解码轨迹示例

把上面几条规则串起来,用五个码走一遍。输入码依次是 65、66、258、259、66:

步骤读到的码当前序列新建条目输出
165A无(没有前一个序列)A
266B258 = A + B = ABB
3258AB259 = B + A = BAAB
4259BA260 = AB + B = ABBBA
566B261 = BA + B = BABB

五步输出拼起来是 ABABBAB。

这张表能说明三件事。第一,第 2 步新建 258 时,前缀是「上一个码 65 的序列 A」,而不是当前码的序列;这是 prevCode 的作用。第二,第 3 步读到 258 时,它已经在第 2 步建好,258 < nextCode 成立,于是走查表分支,沿前缀链从 258 回溯到 65,倒着填出 AB。第三,每个新条目的最后一个字节,都是当前序列的首字节:258 的末字节是 B,259 的末字节是 A,260 的末字节是 B,这正好解释了 dictionaryValues[nextCode] = currentSequence[0] 的写法。

如果第 3 步读到的不是 258 而是 259——也就是 code === nextCode——就进入了 KwKwK:259 还没建出来,但按规则它一定等于「258 的序列 AB」加上「当前序列的首字节」,而当前序列此时仍是上一步的 B,追加后得到 ABB。实现里那行单字节追加,省掉的是一次完整的查表。

字典的维护发生在码处理完之后:

if (hasPrev) {
  dictionaryPrevCodes[nextCode] = prevCode;
  dictionaryLengths[nextCode] = dictionaryLengths[prevCode] + 1;
  dictionaryValues[nextCode] = currentSequence[0];
  nextCode++;
  codeLength =
    (nextCode + earlyChange) & (nextCode + earlyChange - 1)
      ? codeLength
      : Math.min(Math.log(nextCode + earlyChange) / 0.6931471805599453 + 1, 12) | 0;
}
prevCode = code;

新条目由三部分组成:前缀是 prevCode,长度是前一条长度加 1,值是当前序列的首字节。prevCode 是上一个被读到的码,currentSequence[0] 是当前码解出的首字节,两者拼接正好就是 LZW 的标准建表规则。

hasPrev 在每次读码之前就算好了,它表示「上一步是否留下了一个有效序列」。第一个码、以及 Clear 之后的第一个码,都没有前一个序列可用于建表,必须跳过;否则会拿一个空的或陈旧的 prevCode 去填表。

码长的增长与 earlyChange

码长更新那一行是全文件最密集的表达式。拆开看:

  • (nextCode + earlyChange) & (nextCode + earlyChange - 1):判断这个数是否为 2 的幂。按位与在「只有一位为 1」时结果为 0,条件取反后就表示「是 2 的幂」。
  • Math.log(x) / 0.6931471805599453 + 1:0.693... 是 Math.LN2 的字面值,这个式子就是 log2(x) + 1,再取整得到表示 x 需要的位数。
  • Math.min(..., 12):码长最多 12 位,对应 4096 项字典。
  • | 0:把浮点结果截成整数。

earlyChange 只影响什么时候触发扩宽:

earlyChange首次切换到 10 位的 nextCodenextCode + earlyChange结果码长
051251210
1(PDF 默认)51151210

earlyChange 为 0 时是标准 LZW:下一个码号真正达到 512,9 位装不下,才切到 10 位。为 1 时提前一个码:nextCode 刚到 511 就切到 10 位,因为编码器那边也会提前扩宽。解码器与编码器必须使用同一个值,否则从切换点开始,后续所有位都会被错误地重新切分。

同样的规则继续作用于 11 位与 12 位:earlyChange 为 1 时分别在 nextCode 为 1023、2047 时切换;为 0 时对应 1024、2048。12 位是终点,再往上没有 13 位码——字典上限就是 4096 项,Math.min 把码长钉在 12,不在 2 的幂判断上再做分支。

三个台阶连起来看:9 位能表示 0 到 511,10 位能表示 0 到 1023,11 位能表示 0 到 2047,12 位能表示 0 到 4095。切换点恰好是「码号即将超出当前位宽能表示的范围」的那一刻,earlyChange 只是把这个时刻整体前移一位。

还有一个没有被显式处理的边界:字典数组长度是 4096,合法下标是 0 到 4095。如果 nextCode 一路涨到 4096,写 dictionaryPrevCodes[4096] 就超出范围了。TypedArray 对越界写入是静默丢弃,不会抛错;而规范编码器在字典写满之前就会发 Clear,所以这一行实际上是一层隐式保护,而不是漏洞。

输出与缓冲区增长

码处理完、字典更新完之后,才把当前序列写进输出缓冲:

      decodedLength += currentSequenceLength;
      if (estimatedDecodedSize < decodedLength) {
        do {
          estimatedDecodedSize += decodedSizeDelta;
        } while (estimatedDecodedSize < decodedLength);
        buffer = this.ensureBuffer(this.bufferLength + estimatedDecodedSize);
      }
      for (j = 0; j < currentSequenceLength; j++) {
        buffer[currentBufferLength++] = currentSequence[j];
      }
    }
    lzwState.nextCode = nextCode;
    lzwState.codeLength = codeLength;
    lzwState.prevCode = prevCode;
    lzwState.currentSequenceLength = currentSequenceLength;
 
    this.bufferLength = currentBufferLength;

decodedLength 是本块已经解出的字节数,估算值不够时按 512 的步长往上加,直到覆盖它,再让 ensureBuffer 扩容。这里不能用「码数乘固定长度」来估算,因为每个码的长度从 1 一路涨到几千字节;按实际解码长度累加是唯一可靠的做法。

扩容请求写的是 this.bufferLength + estimatedDecodedSize,即始终相对于块起点;currentBufferLength 则是块内游标,逐字节递增。块结束时只提交一次 this.bufferLength,块内不碰对象属性。这与 Flate 的主循环是同一个套路。

循环固定跑 512 次,除非提前遇到 EOD。哪怕某个码解出几千字节,块也照样在 512 个码处结束;哪怕 512 个码只解出几十字节,也不会多读。这种「按码定量」的切块方式保证了单次 readBlock 的码读取次数有上界,而输出量由数据本身决定。

buffer 是 ensureBuffer 返回的引用;扩容时它已经把新数组写回 this.buffer,局部的 buffer 只是为了本块后续写入不再穿过属性。因此块结束时不需要再提交 buffer,只提交 bufferLength。

为什么是 512

512 这个数字没有规范含义,是这份实现选定的批量大小。太小会让 readBlock 的调用次数变多,函数顶部与末尾的解构、写回开销会摊到每个码上;太大则单次调用可能解出上百 KB,破坏「按需解码」的粒度——调用方只要 64 字节,也会被迫解完一大批码。初始估算 1024 与扩容步长 512 遵循同一思路:既不为每个码调用 ensureBuffer,也不让预留空间过度膨胀。

设计要点:字典、码长、前缀码、当前序列全部是跨块状态,但它们在 readBlock 里都退化成了局部变量。局部化带来两个好处:热循环里没有属性访问;写回发生在块尾,状态不会在块中途被外部观察到。代价是函数顶部和末尾各有一组样板式的解构与赋值,但这是为了性能刻意付出的可读性成本。

对比: LZW 与 Flate

两者都是 DecodeStream 的子类,接口完全一样,内部却是两条路:

维度FlateStreamLZWStream
压缩机制Huffman 编码 + LZ77 回溯自建字符串字典
位序DEFLATE 规定低位在前PDF 规定高位在前
码长固定或动态 Huffman,逐符号不等9 到 12 位,随字典规模增长
控制码块头 BFINAL / BTYPE256 Clear、257 EOD
字典每次建码表,块结束后丢弃4096 项,跨块延续,Clear 时重建
分块单位一个 DEFLATE 块512 个码
特有参数无/EarlyChange
容错截断时保留前缀,部分错误直接抛截断时置 eof,EOD 时释放字典

一个直观区别是「状态生命期」。Flate 的 Huffman 码表属于当前块,块结束就作废;LZW 的字典属于整个流,除非 Clear,否则一直增长。这也是 LZW 必须把四张数组挂在实例上的原因。

压缩率上,LZW 通常不如 Flate,尤其在文本与重复模式较多的内容流上;它的优势是解码器简单、内存占用固定(4096 项),既不需要建 Huffman 码表,也不依赖块边界。PDF.js 保留它,主要是为了兼容旧文档,而不是因为它更快。单码成本也更高:LZW 每个码都要穿过 readBits、分支判断与字典写入,而 Flate 的固定码表路径只有一次查表与一次数组写入。

一个有意思的细节是驱动方式:Flate 的 readBlock 由块头驱动,LZW 的 readBlock 由码数驱动。前者每次处理的数据量由压缩文件决定,后者由实现常量(512)决定。这也解释了它们置 eof 时机的差别——Flate 在块头看到 BFINAL 就能预知结束,LZW 必须真的读到 EOD 或流尾才知道。

两者也有共同点:都只依赖 DecodeStream 的缓冲协议,都不解析 PDF 语法,都不关心数据最终是像素、字形还是内容流。过滤器层的边界因此非常干净——输入是字节,输出是字节,出错只会以 eof 或异常的形式体现。

边界情况

EOD 缺失。readBits 在底层流耗尽时返回 null,readBlock 没有对 null 单独判断。JS 里 null 参与数值比较会被转成 0,于是 code < 256 成立,走字面量分支,把 0 写进当前序列。结果是:截断的流在最后一块里会被补出若干 0 字节,而不是立刻停止。eof 此时已经置位,上层不会再调用新的 readBlock,所以最多影响当前这一块(最多 512 个码)。这是读这份源码时值得留意的一处宽松行为。

EOD 提前出现。delete this.lzwState 之后实例上不再有字典,后续任何 readBlock 都会在解构时失败;但 eof 已经置位,DecodeStream.getBytes 的条件是「未到 eof 才继续读」,因此不会再进来。删除动作的实际收益是让四张数组尽早可回收。

Clear 出现在任意位置。Clear 不要求对齐任何边界,码流里随时可以出现。实现里 continue 跳过本轮的字典更新与输出,状态重置是完整的:码长、nextCode、序列长度三项一致回到初始值。

Clear 之后的第一码。它没有前一个序列,因此不会建表;从第二个码开始,字典重新从 258 增长。编码器可以在任意位置发 Clear,解码器不需要等待任何对齐。

字典写满。如前所述,nextCode 到达 4096 后越界写入被 TypedArray 忽略,码长固定在 12,解码仍可继续,直到编码器发来 Clear。规范要求编码器在写满前 Clear,所以正常文件不会走到这里。

与 Predictor 的组合。Parser.makeFilter 在 /DecodeParms 存在时总会把 LZWStream 包进 PredictorStream。因此 LZWStream 的输出不一定是最终数据:参数里有 Predictor 时,还要先按行做 PNG 或 TIFF 预测还原。LZWStream 只负责把码流还原成正确的字节序列,行预测属于另一层的职责。

eof 的语义。它只在两个时刻被置位:读到 EOD,或底层流耗尽。DecodeStream.getBytes 看到 eof 后不再调用 readBlock,但已经写进 buffer 的字节仍可被当前请求取走。这与 Flate 的「块读完再返回」一致:eof 表示「不要再解码」,而不是「之前解出的都不算」。

三个常见误读

第一,把 dictionaryValues 当成条目的首字节。它存的是末字节;首字节要靠前缀链一路回溯到基础码才能得到。理解反了,倒序填充的循环就看不懂。

第二,把 earlyChange 当成字典参数。它不改变任何条目内容,只改变码长切换的时机;两边取值不一致时,字典本身仍然「正确」,但位流从切换点起被错误切分。

第三,以为 Clear 会清空四张数组。实现只重置 codeLength、nextCode 与序列长度;旧条目靠 code < nextCode 的前置判断失效,后续新条目从 258 开始覆盖。省掉一次 4096 项的清零,也避免了在热路径上做大块内存写。

如果要把截断处理改得更严格,最小改动是在循环开头判断 code === null 并 break;现有实现选择了「补零收尾」,与 PDF.js 在其他过滤器里的宽容策略一致:宁可给出带尾巴的结果,也不让整页渲染因为一个缺失的 EOD 而失败。

总结

LZWStream 是 PDF.js 的字典熵解码层:把 9 到 12 位的变长码还原成字节序列,向上供 PredictorStream、图像解码器、字体解析器使用。

它在整条链路中的位置

阶段模块输入输出
字节供给Stream / ChunkedStream文件切片、网络分块压缩字节
滤镜分派Parser.makeFilter/Filter、/DecodeParms过滤器链
熵解码LZWStream9 到 12 位码 + 自建字典未预测的字节序列
行预测PredictorStreamPredictor、Columns、BPC行对齐的样本字节
上层消费图像解码 / 字体 / 内联流行字节像素、字形、内容

用一句话串起来:LZWStream 把「码」翻译成「串」,PredictorStream 再把「预测后的行」还原成「原始的行」。

从工程角度看,这份实现是「用固定内存换确定性」的典型:四张 4096 项的数组在构造时一次分配,解码过程中不再申请任何对象;位缓冲只有两个数字;输出缓冲按块增长。对可能同时处理上百份文档的 Worker 来说,这种可预测的内存曲线比极限压缩率更重要。

五条设计原则

  1. 状态局部化,块尾统一提交。码长、nextCode、prevCode、序列长度全部在函数顶部取到局部变量,循环里只碰局部变量,块结束时一次性写回。跨块延续与热路径性能在这里同时成立。
  2. 字典用定长数组,不用映射或字符串。三个 4096 项的 TypedArray 用码号直接寻址,建表与查表都是常数时间,且没有为每个条目分配对象。对于动辄上万个码的流,这是数量级的差别。
  3. 倒序填表,正序输出。dictionaryValues 只存每个条目的末字节,重建时从长度减 1 往前填,前缀链天然给出正序序列。存末字节而不是整串,把每个条目的存储压到 3 个数字。
  4. 码长切换严格对齐编码器。(nextCode + earlyChange) 是 2 的幂才扩宽,earlyChange 默认取 1。这个参数不改变字典内容,只改变位切分点,读错一个码就会让后续整段错位。
  5. 按码切块,按实际长度扩容。LZW 的码长可变,块大小只能按码计数(512);输出缓冲则按本块真实解出的字节数分步增长。两类「量」分开管理,估算永远不会低估。

把这五条放在一起,会发现它们服务于同一个目标:让「位切分」与「字节还原」互不干扰。码长负责切位,字典负责翻译,缓冲负责输出,三者只通过 lzwState 交接。读这份实现的最短路径因此是:先确认码长,再看 nextCode,最后才看字典内容。

至此,「字典压缩怎么变回原始字节」这一环补齐了。同一层的 ASCII85 走的是另一条路:它不做统计压缩,只把五个可打印字符还原成四个字节,具体拆解放在同批的另一篇。

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

相关文章