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

前言
这是「字节与流」层的一篇文章,对象是 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,初始化字典与位缓冲 |
| 位读取 | readBits | MSB 优先地从字节流中取出任意位数 |
| 解码 | 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 的序列 |
| 256 | Clear | 码长重置为 9,nextCode 回到 258,序列长度清零 |
| 257 | EOD | 置 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,外加一个长度游标。这是这份实现最重要的设计选择:
| 字段 | 类型 | 作用 |
|---|---|---|
dictionaryValues | Uint8Array | 每个条目的最后一个字节 |
dictionaryLengths | Uint16Array | 每个条目的字节长度 |
dictionaryPrevCodes | Uint16Array | 每个条目的前缀条目号 |
currentSequence | Uint8Array | 当前码解出的字节序列,复用为工作区 |
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 优先位缓冲:
- 需要的位数不够,就从底层流补一个字节;
- 补字节时把已有位左移 8 位,再把新字节放到最低 8 位;
- 取数时把缓冲右移到「剩余位数」的位置,再用掩码截出低
n位; - 剩余位留在
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:
| 步骤 | 读到的码 | 当前序列 | 新建条目 | 输出 |
|---|---|---|---|---|
| 1 | 65 | A | 无(没有前一个序列) | A |
| 2 | 66 | B | 258 = A + B = AB | B |
| 3 | 258 | AB | 259 = B + A = BA | AB |
| 4 | 259 | BA | 260 = AB + B = ABB | BA |
| 5 | 66 | B | 261 = BA + B = BAB | B |
五步输出拼起来是 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 位的 nextCode | nextCode + earlyChange | 结果码长 |
|---|---|---|---|
| 0 | 512 | 512 | 10 |
| 1(PDF 默认) | 511 | 512 | 10 |
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 的子类,接口完全一样,内部却是两条路:
| 维度 | FlateStream | LZWStream |
|---|---|---|
| 压缩机制 | Huffman 编码 + LZ77 回溯 | 自建字符串字典 |
| 位序 | DEFLATE 规定低位在前 | PDF 规定高位在前 |
| 码长 | 固定或动态 Huffman,逐符号不等 | 9 到 12 位,随字典规模增长 |
| 控制码 | 块头 BFINAL / BTYPE | 256 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 | 过滤器链 |
| 熵解码 | LZWStream | 9 到 12 位码 + 自建字典 | 未预测的字节序列 |
| 行预测 | PredictorStream | Predictor、Columns、BPC | 行对齐的样本字节 |
| 上层消费 | 图像解码 / 字体 / 内联流 | 行字节 | 像素、字形、内容 |
用一句话串起来:LZWStream 把「码」翻译成「串」,PredictorStream 再把「预测后的行」还原成「原始的行」。
从工程角度看,这份实现是「用固定内存换确定性」的典型:四张 4096 项的数组在构造时一次分配,解码过程中不再申请任何对象;位缓冲只有两个数字;输出缓冲按块增长。对可能同时处理上百份文档的 Worker 来说,这种可预测的内存曲线比极限压缩率更重要。
五条设计原则
- 状态局部化,块尾统一提交。码长、
nextCode、prevCode、序列长度全部在函数顶部取到局部变量,循环里只碰局部变量,块结束时一次性写回。跨块延续与热路径性能在这里同时成立。 - 字典用定长数组,不用映射或字符串。三个 4096 项的 TypedArray 用码号直接寻址,建表与查表都是常数时间,且没有为每个条目分配对象。对于动辄上万个码的流,这是数量级的差别。
- 倒序填表,正序输出。
dictionaryValues只存每个条目的末字节,重建时从长度减 1 往前填,前缀链天然给出正序序列。存末字节而不是整串,把每个条目的存储压到 3 个数字。 - 码长切换严格对齐编码器。
(nextCode + earlyChange)是 2 的幂才扩宽,earlyChange默认取 1。这个参数不改变字典内容,只改变位切分点,读错一个码就会让后续整段错位。 - 按码切块,按实际长度扩容。LZW 的码长可变,块大小只能按码计数(512);输出缓冲则按本块真实解出的字节数分步增长。两类「量」分开管理,估算永远不会低估。
把这五条放在一起,会发现它们服务于同一个目标:让「位切分」与「字节还原」互不干扰。码长负责切位,字典负责翻译,缓冲负责输出,三者只通过 lzwState 交接。读这份实现的最短路径因此是:先确认码长,再看 nextCode,最后才看字典内容。
至此,「字典压缩怎么变回原始字节」这一环补齐了。同一层的 ASCII85 走的是另一条路:它不做统计压缩,只把五个可打印字符还原成四个字节,具体拆解放在同批的另一篇。
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 源码解读: 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日