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

前言
这是「字节与流」层的一篇文章,对象是 Worker 端 src/core/flate_stream.js。
Flate 是 PDF 里出现频率最高的过滤器,Predictor 则总是跟在它后面,针对图像与交叉引用流做行预测。本文只讲前者,后者留给下一篇。
PDF 里几乎所有的重量级数据都躲在一个压缩流后面:交叉引用流、对象流、页面内容流、内嵌字体、图像的像素数据。字典里那句 /Filter /FlateDecode 一出现,Parser.makeFilter 就会把原始字节包装成 FlateStream。再往下一层,PredictorStream 可能又套在它外面,处理图像与交叉引用流的行预测。
所以这一篇的位置很明确:它是「字节到字节」链路里最靠近压缩数据的一环。输入是带 zlib 头的 DEFLATE 数据,输出是解密后、预测前的原始字节序列。它不理解 PDF 语法,也不关心图像色彩空间,只做一件事——把位流还原成字节。
flate_stream.js 全文 457 行,其中约 120 行是查表常量。文件头的版权注释写得很坦诚:这个实现是 XPDF 的 JavaScript 移植。因此读它时不要期待「教科书式的 DEFLATE 讲解」,它是一份为性能与容错打磨过的工程实现:查表优先、按块处理、截断时尽量给出部分结果。
一句话概括它的职责:把 zlib 包装的 DEFLATE 位流,还原成调用方可以顺序读取的字节缓冲。
模块总览
全文可以切成八块:
| 区块 | 关键方法 | 职责 |
|---|---|---|
| 流身份 | constructor | 校验 zlib 头,初始化位缓冲 |
| 位读取 | getBits | 从底层字节流取出任意位数 |
| 码表解码 | getCode | 用展开表一次查表得到符号 |
| 建表 | generateHuffmanTable | 由码长数组生成 canonical 码表 |
| 常量表 | fixedLitCodeTab、fixedDistCodeTab、lengthDecode、distDecode | 固定码与长度、距离的基值表 |
| 块解码 | readBlock | 分派未压缩块、固定块、动态块,执行 LZ77 复制 |
| 容错 | #endsStreamOnError | 截断时以 eof 收尾,保留已解出的部分 |
| 异步 | asyncGetBytes、getImageData | 优先走浏览器原生 DecompressionStream,失败回退 |
它在整条链路里的位置:
PDF 字节
→ Parser.makeFilter (读到 /Filter /FlateDecode)
→ FlateStream(str, maybeLength) (本文件: zlib 头 + DEFLATE 解码)
→ PredictorStream (可选: /DecodeParms 的行预测)
→ DecodeStream.getBytes (按需拉取,readBlock 循环)
→ 上层: 图像数据 / 字体文件 / 内容流 / xref 流继承关系是 BaseStream → DecodeStream → FlateStream。DecodeStream 提供 buffer、bufferLength、eof、ensureBuffer、getByte、getBytes 这些设施;FlateStream 只需要实现一个 readBlock,每次调用往 buffer 尾部追加一段解码结果。上层按自己需要的长度反复触发 readBlock,解码因此是惰性的:没人读的流永远不会被解压。
设计要点:
readBlock的粒度是「一个 DEFLATE 块」,而不是「一整个流」。一个块解完就返回,控制权交回DecodeStream.getBytes;调用方只要 64 字节,就只解到够 64 字节为止。这让首屏渲染不必等整张图片解压完。
zlib 头: 校验与身份
PDF 的 Flate 数据不是裸 DEFLATE,而是 RFC 1950 的 zlib 包装:2 字节头 + DEFLATE 数据 + 4 字节 Adler-32 尾。构造函数只处理头部这 2 个字节,尾部校验和直接忽略。
constructor 方法
constructor(str, maybeLength) {
super(maybeLength);
this.stream = str;
this.dict = str.dict;
const cmf = str.getByte();
const flg = str.getByte();
if (cmf === -1 || flg === -1) {
throw new FormatError(`Invalid header in flate stream: ${cmf}, ${flg}`);
}
if ((cmf & 0x0f) !== 0x08) {
throw new FormatError(
`Unknown compression method in flate stream: ${cmf}, ${flg}`
);
}
if (((cmf << 8) + flg) % 31 !== 0) {
throw new FormatError(`Bad FCHECK in flate stream: ${cmf}, ${flg}`);
}
if (flg & 0x20) {
throw new FormatError(`FDICT bit set in flate stream: ${cmf}, ${flg}`);
}
this.codeSize = 0;
this.codeBuf = 0;
}两个字节里塞了四层信息:
| 字段 | 位置 | 含义 | 本版是否校验 |
|---|---|---|---|
| CM | CMF 低 4 位 | 压缩方法,8 表示 deflate | 是 |
| CINFO | CMF 高 4 位 | 窗口大小,2 的 CINFO 加 8 次方 | 否 |
| FCHECK | FLG 低 5 位 | 校验位,让 16 位头能被 31 整除 | 是 |
| FDICT | FLG 第 5 位 | 是否带预设字典 | 是,置位即报错 |
| FLEVEL | FLG 高 2 位 | 压缩等级提示 | 否 |
几个细节值得说清。
第一,str.getByte() 返回 -1 表示流已结束。头部只有 1 字节时,第二个 getByte 返回 -1,-1 参与位运算会得到全 1,如果不单独拦截,后面的判断会给出误导性的错误信息。所以先做一次整体检查。
第二,(cmf & 0x0f) !== 0x08 只检查低 4 位。zlib 规范要求 CINFO 不超过 7,但这里没有校验高 4 位——因为 DEFLATE 解码器本身不依赖窗口大小,它只用距离码,而距离码的上限已经是 32768。CINFO 写错最多说明编码器用了更小的窗口,不影响正确解码。
第三,((cmf << 8) + flg) % 31 !== 0 就是 FCHECK。等价写法是 % 31 === 0 而不是逐位重算 FCHECK。最常见的合法头是 0x78 0x9C、0x78 0xDA、0x78 0x01,分别对应默认、最高、最快三档。
第四,flg & 0x20 是 FDICT。带预设字典的 zlib 流需要字典才能解压,PDF 规范不允许 /FlateDecode 使用它,所以这里直接抛错,而不是去读 4 字节字典 ID。
构造函数最后把位缓冲清零。codeSize 是缓冲里还有多少可用位,codeBuf 是这些位本身。底层流的位置已经被推进了 2 字节,这正是 asyncGetBytes 回退时重建流从偏移 2 开始的原因。
设计要点:头部校验放在构造函数里,而不是第一次
readBlock时才做。这样损坏的流会在makeFilter的try里立刻暴露,上层可以用一个PixelError或NullStream兜住,而不会在渲染到一半时突然失败。
位读取与码表解码
getBits 方法
DEFLATE 是位流:块头 3 位、动态块里 5 位、4 位、3 位交错出现,Huffman 码本身还是变长的。getBits 负责「按位取数」,并且把跨字节的残余保存在实例上。
getBits(bits) {
const str = this.stream;
let codeSize = this.codeSize;
let codeBuf = this.codeBuf;
let b;
while (codeSize < bits) {
if ((b = str.getByte()) === -1) {
throw new FormatError("Bad encoding in flate stream");
}
codeBuf |= b << codeSize;
codeSize += 8;
}
b = codeBuf & ((1 << bits) - 1);
this.codeBuf = codeBuf >> bits;
this.codeSize = codeSize -= bits;
return b;
}值得注意的只有三处。
第一,字节是低位先入。codeBuf |= b << codeSize 把新读到的字节放在已有位的上方;取数时用 codeBuf & ((1 << bits) - 1) 从低位取走。所以缓冲区里的第 0 位永远是「下一个要消费的位」。这与 DEFLATE 的位序一致——RFC 1951 规定除 Huffman 码以外的字段都是低位在前。Huffman 码本身是高位在前,这个差异在 generateHuffmanTable 里用一次位反转抹平。
第二,while (codeSize < bits) 决定要读几个字节。如果缓冲里已经有 5 位、这次要 13 位,就再读一个字节,凑到 13 位为止;多余的高位留在缓冲里,下次继续用。读字节的次数是「补到够用」,不是固定值。
第三,取完立刻写回:codeBuf >> bits 丢掉已消费的低位,codeSize -= bits 更新计数。局部变量加写回,是为了在这一对读写之间不反复访问 this,在这个每字节都要调用的函数里是实打实的收益。
这里不需要担心 32 位溢出。readBlock 里最大的单次请求是距离的 13 位额外位,加上缓冲里最多 7 位残余,总共不超过 20 位;JS 的位运算按 32 位有符号整数执行,20 位远在安全范围内。真实解码器里不会出现「一次要 32 位」的调用。
设计要点:位缓冲把「字节流的位置」与「位流的位置」彻底解耦。
str.getByte()只会在需要时被调用,调用方看到的是getBits(bits)这样一个纯函数式接口,字节边界完全透明。
getCode 方法
有了位缓冲,变长 Huffman 码只需要一次查表。getCode 的入参是一张「展开表」:table[0] 是码值数组,table[1] 是最大码长。
getCode(table) {
const str = this.stream;
const codes = table[0];
const maxLen = table[1];
let codeSize = this.codeSize;
let codeBuf = this.codeBuf;
let b;
while (codeSize < maxLen) {
if ((b = str.getByte()) === -1) {
// premature end of stream. code might however still be valid.
// codeSize < codeLen check below guards against incomplete codeVal.
break;
}
codeBuf |= b << codeSize;
codeSize += 8;
}
const code = codes[codeBuf & ((1 << maxLen) - 1)];
const codeLen = code >> 16;
const codeVal = code & 0xffff;
if (codeLen < 1 || codeSize < codeLen) {
throw new FormatError("Bad encoding in flate stream");
}
this.codeBuf = codeBuf >> codeLen;
this.codeSize = codeSize - codeLen;
return codeVal;
}关键在于表项被压缩进一个 32 位整数:高 16 位存码长,低 16 位存符号值。查表时用缓冲区里最低的 maxLen 位做下标,一次命中;命中之后再从表项里取出真正要消耗的位数 codeLen。
为什么可以「超前读满 maxLen 位」?因为表是按最大码长铺满的:长度为 len 的码,会把所有低 len 位等于该码的槽位都填上同一个符号。所以哪怕当前码只有 3 位,只要缓冲区里有 3 位就能定位;多读进来的位不会影响低 3 位的匹配结果。读进来但没用掉的位通过 codeBuf >> codeLen 留到下一次。
if (codeSize < codeLen) 是针对流末尾的保护:getByte 返回 -1 时跳出循环,缓冲区里可能只有 2 位,而表项说需要 5 位,说明这个码不完整,必须报错。注释里写得很清楚:codeSize < codeLen 是唯一的守卫。
codeLen < 1 则覆盖另一种情况:非法或过订阅的码表会在某些槽位留下 0,解码到这些槽位时表项高 16 位是 0,直接判错。这条判断让「坏码表」在运行时变成明确异常,而不是返回一个莫名其妙的符号。
this.codeSize = codeSize - codeLen 与 getBits 里的 codeSize -= bits 写法不统一,但语义一致,都是把剩余的位数写回。
设计要点:这是典型的「定长索引、变长消耗」结构。解码复杂度从「沿树走
len步」降到「一次数组访问」,代价是每张表要占 2 的 maxLen 次方个整数。动态块每次都要新建两张表,最坏情况 maxLen 为 15,单表 32768 项,约 128 KB——这是用内存换 CPU 的明确取舍,在浏览器里仍然划算,因为省下的是每个符号的分支预测失败。
generateHuffmanTable 方法
getCode 依赖的表由 generateHuffmanTable 现场生成。输入是每个符号的码长数组,输出是「展开表 + 最大码长」。
generateHuffmanTable(lengths) {
const n = lengths.length;
// find max code length
let maxLen = 0;
let i;
for (i = 0; i < n; ++i) {
if (lengths[i] > maxLen) {
maxLen = lengths[i];
}
}
// build the table
const size = 1 << maxLen;
const codes = new Int32Array(size);
for (
let len = 1, code = 0, skip = 2;
len <= maxLen;
++len, code <<= 1, skip <<= 1
) {
for (let val = 0; val < n; ++val) {
if (lengths[val] === len) {
// bit-reverse the code
let code2 = 0;
let t = code;
for (i = 0; i < len; ++i) {
code2 = (code2 << 1) | (t & 1);
t >>= 1;
}
// fill the table entries
for (i = code2; i < size; i += skip) {
codes[i] = (len << 16) | val;
}
++code;
}
}
}
return [codes, maxLen];
}分成三步来读。
第一步,求最大码长。 表的大小是 1 << maxLen,这决定了内存占用与查表宽度。空码长数组会得到 maxLen = 0、size = 1,表里唯一的槽位是 0,getCode 会因 codeLen < 1 报错——空表在语义上就是非法表。
第二步,按 canonical Huffman 分配码字。 外层循环按码长从 1 到 maxLen 递增,内层按符号值递增。同一长度内,先出现的符号拿到更小的码;换长度时 code <<= 1,因为更长的码是在上一层的码后面补 0。这正是 RFC 1951 规定的 canonical 顺序,不需要额外排序。
skip 与 code 同步左移:skip 是 2^len,代表「当前长度下,每个码在 maxLen 位索引空间里出现的步长」。长度为 1 的码每隔 2 个槽出现一次,长度为 2 的每隔 4 个,依此类推。
第三步,位反转后铺表。 这是全函数最容易被忽略的一行:
code2 = (code2 << 1) | (t & 1);canonical Huffman 的码字是「高位在前」书写的,而位缓冲是「低位在前」存放的。如果不反转,codeBuf 的低位对应码的最高位,用掩码取出的下标就完全对不上。把码字反转之后,「前缀匹配」变成「后缀匹配」,于是可以写成「从 code2 开始、每隔 skip 填一项」的简单循环。
举个最小的例子。三个符号,码长分别是 [1, 2, 2]:
| 符号 | 码长 | canonical 码 | 反转后 | 填充的槽位(maxLen 为 2) |
|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 0、2 |
| 1 | 2 | 10 | 01 | 1 |
| 2 | 2 | 11 | 11 | 3 |
符号 0 的码长是 1,所以它占两个槽位;符号 1、2 各占一个。查表时无论缓冲区低 2 位是 00 还是 10,都会命中符号 0,随后 codeLen 告诉 getCode 只消耗 1 位。
注意这里没有做「过订阅」或「不完整码表」的校验。合法的 DEFLATE 流不会出现这两类表;非法的表会在查表时以 0 表项的形式暴露,由 getCode 的 codeLen < 1 兜住。这是有意把校验推迟到使用点的做法,避免在热路径的建表阶段付出额外遍历。
设计要点:canonical 分配保证了「码长数组」是唯一输入,不需要传输码字本身;位反转则把「前缀码」适配到「低位先入」的位缓冲。两者合起来,才能让变长解码退化成一次定长数组访问。
固定表与动态码表
固定码表: 预计算的 288 个码
DEFLATE 的固定 Huffman 块使用一套规范里写死的码表。PDF.js 没有在运行时生成它,而是把整个展开表作为常量写在文件顶部。
const fixedLitCodeTab = [
new Int32Array([
0x70100, 0x80050, 0x80010, 0x80118, 0x70110, 0x80070, 0x80030, 0x900c0, 0x70108, 0x80060,
0x80020, 0x900a0, 0x80000, 0x80080, 0x80040, 0x900e0, 0x70104, 0x80058, 0x80018, 0x90090,
0x70114, 0x80078, 0x80038, 0x900d0, 0x7010c, 0x80068, 0x80028, 0x900b0, 0x80008, 0x80088,
0x80048, 0x900f0, 0x70102, 0x80054, 0x80014, 0x8011c, 0x70112, 0x80074, 0x80034, 0x900c8,
0x7010a, 0x80064, 0x80024, 0x900a8, 0x80004, 0x80084, 0x80044, 0x900e8, 0x70106, 0x8005c,
0x8001c, 0x90098, 0x70116, 0x8007c, 0x8003c, 0x900d8, 0x7010e, 0x8006c, 0x8002c, 0x900b8,
0x8000c, 0x8008c, 0x8004c, 0x900f8,
// 中间省略, 完整的 512 项见源码
0x7010f, 0x8006f, 0x8002f, 0x900bf, 0x8000f, 0x8008f, 0x8004f, 0x900ff,
]),
9,
];用「高 16 位码长、低 16 位符号」的规则去读这些十六进制数,含义立刻清楚:
| 表项 | 码长 | 符号 | 说明 |
|---|---|---|---|
0x70100 | 7 | 256 | 块结束符,固定码里最短的一档 |
0x80050 | 8 | 80 | 字面量字符 P |
0x80118 | 8 | 280 | 长度符号 280 |
0x900c0 | 9 | 192 | 字面量字符 192 |
固定字面量码表的规范是:0 到 143 用 8 位,144 到 255 用 9 位,256 到 279 用 7 位,280 到 287 用 8 位。表里 512 项 = 2 的 9 次方个槽位,正好覆盖最大码长 9。所有 288 个符号各占若干槽位,查表宽度固定为 9。
距离表更小:
const fixedDistCodeTab = [
new Int32Array([
0x50000, 0x50010, 0x50008, 0x50018, 0x50004, 0x50014, 0x5000c, 0x5001c, 0x50002, 0x50012,
0x5000a, 0x5001a, 0x50006, 0x50016, 0x5000e, 0x00000, 0x50001, 0x50011, 0x50009, 0x50019,
0x50005, 0x50015, 0x5000d, 0x5001d, 0x50003, 0x50013, 0x5000b, 0x5001b, 0x50007, 0x50017,
0x5000f, 0x00000,
]),
5,
];它有 32 项、最大码长 5,但只有 30 个有效距离符号(0 到 29)。符号 30 和 31 在规范里保留,对应的槽位填 0x00000——码长 0、符号 0。一旦动态或损坏的流解到这些槽位,getCode 的 codeLen < 1 就会抛出 Bad encoding in flate stream。
固定表用常量换来两件事:每个固定块省掉一次建表;也避免建表函数在固定块上白跑一遍。对于小内容流(比如几百字节的 Form XObject),固定块非常常见,这笔预计算很值。
设计要点:表项把「码长」与「符号」打包进一个整数,避免了两个并行数组或对象数组。查表得到的是单个 32 位值,
getCode用两次位运算拆开,几乎没有额外开销。
动态码表: 码长码与三种重复
动态 Huffman 块把「码表」也编码进了位流。它先传一张「码长码表」,再用这张表去解出真正的字面量与距离码长。
} else if (hdr === 2) {
// compressed block, dynamic codes
const numLitCodes = this.getBits(5) + 257;
const numDistCodes = this.getBits(5) + 1;
const numCodeLenCodes = this.getBits(4) + 4;
// build the code lengths code table
const codeLenCodeLengths = new Uint8Array(codeLenCodeMap.length);
let i;
for (i = 0; i < numCodeLenCodes; ++i) {
codeLenCodeLengths[codeLenCodeMap[i]] = this.getBits(3);
}
const codeLenCodeTab = this.generateHuffmanTable(codeLenCodeLengths);三个计数的编码方式各不相同:字面量码数存的是「实际数量减 257」,范围 257 到 288;距离码数存「减 1」,范围 1 到 32;码长码数存「减 4」,范围 4 到 19。上限都不是 2 的整数次幂,所以不能直接按位宽取值。
码长码并不是按符号 0、1、2 的顺序传输的,而是按一张固定置换表:
const codeLenCodeMap = new Int32Array([
16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15,
]);前三个是特殊码 16、17、18,紧接着是几乎不用的 0,然后从 8 开始递减、交替排列。这个顺序是 RFC 1951 定的,把最常出现的码长放在前面,好让「只传前几项」时覆盖率最高。
codeLenCodeLengths 的长度是置换表本身的长度 19,写入时按 codeLenCodeMap[i] 定位,所以真正参与建表的数组始终是 19 项,未传到的符号码长为 0。
接下来用这张表解出字面量与距离的码长,三种特殊码负责游程压缩:
| 特殊码 | 额外位数 | 基值 | 含义 |
|---|---|---|---|
| 16 | 2 | 3 | 复制上一个码长 3 到 6 次 |
| 17 | 3 | 3 | 重复 0 长度 3 到 10 次 |
| 18 | 7 | 11 | 重复 0 长度 11 到 138 次 |
len = 0;
i = 0;
const codes = numLitCodes + numDistCodes;
const codeLengths = new Uint8Array(codes);
let bitsLength, bitsOffset, what;
while (i < codes) {
const code = this.getCode(codeLenCodeTab);
if (code === 16) {
bitsLength = 2;
bitsOffset = 3;
what = len;
} else if (code === 17) {
bitsLength = 3;
bitsOffset = 3;
what = len = 0;
} else if (code === 18) {
bitsLength = 7;
bitsOffset = 11;
what = len = 0;
} else {
codeLengths[i++] = len = code;
continue;
}
let repeatLength = this.getBits(bitsLength) + bitsOffset;
while (repeatLength-- > 0) {
codeLengths[i++] = what;
}
}len 是「上一个非特殊码的码长」,跨迭代保留。码 16 复制它;码 17 和 18 复制 0,且顺手把 len 也置 0——这是一个容易忽略的细节:连续的 17 之后再出现 16,复制的必须是 0 而不是 17 之前的旧长度。规范要求如此,代码用 what = len = 0 一行同时完成两件事。
重复次数是「读到的额外位 + 基值」。以 18 为例,读 7 位得到 0 到 127,加 11 就是 11 到 138。这样 32 个全零码长只需要几个符号就能表达,动态块的头开销因此可控。
while (repeatLength-- > 0) 没有任何越界检查。如果损坏的流声明了超过剩余槽位的重复次数,i 会越过 codes,而 codeLengths 是 Uint8Array,越界赋值会被静默丢弃,不会破坏内存;循环之外的 while (i < codes) 也会在越界后退出。这种「靠类型化数组兜底」的写法不算漂亮,但足够安全。
建完两张表之后立刻按边界切开:
litCodeTable = this.generateHuffmanTable(codeLengths.subarray(0, numLitCodes));
distCodeTable = this.generateHuffmanTable(codeLengths.subarray(numLitCodes, codes));subarray 是视图,不复制数据,因此这一步没有额外分配,只是把同一块缓冲区切成两段交给建表函数。
设计要点:码长码表只有 19 项,却要解码最多 320 项码长,游程编码是唯一的出路。把「上一个长度」保存在
len里、并要求 17 与 18 将其清零,是这套格式里最细的语义约束,实现时少写一句就会在连续游程上解出错误码表。
readBlock 方法
readBlock 一进来就读 3 位块头:最低位是 BFINAL,其余两位是 BTYPE。
let buffer, hdr, len;
const str = this.stream;
// read block header
try {
hdr = this.getBits(3);
} catch (ex) {
this.#endsStreamOnError(ex.message);
return;
}
if (hdr & 1) {
this.eof = true;
}
hdr >>= 1;三种块类型
| BTYPE | 名称 | 头部开销 | 数据形式 | 本版处理 |
|---|---|---|---|---|
| 0 | 未压缩块 | 4 字节 LEN 与 NLEN | 原样字节 | 直接拷贝到输出缓冲 |
| 1 | 固定 Huffman | 无 | LZ77 + 固定码表 | fixedLitCodeTab 查表 |
| 2 | 动态 Huffman | 码长码表 | LZ77 + 动态码表 | generateHuffmanTable |
| 3 | 保留 | 无 | 无 | 抛 Unknown block type |
块头读完先处理 BFINAL:置位就把 eof 设为 true。这里有个反直觉的顺序——eof 在解码当前块之前就被置位。之所以不冲突,是因为 readBlock 是同步执行的:置位之后它仍然会把这一块完整解完、写进缓冲,返回后 DecodeStream.getBytes 才会看到 eof 并停止再调用。也就是说 eof 的语义是「不要再调 readBlock 了」,而不是「立刻停手」。
未压缩块
BTYPE 为 0 时,剩下的位要丢弃、对齐到字节边界,然后读 4 字节长度。
if (hdr === 0) {
// uncompressed block
let b;
if ((b = str.getByte()) === -1) {
this.#endsStreamOnError("Bad block header in flate stream");
return;
}
let blockLen = b;
if ((b = str.getByte()) === -1) {
this.#endsStreamOnError("Bad block header in flate stream");
return;
}
blockLen |= b << 8;
if ((b = str.getByte()) === -1) {
this.#endsStreamOnError("Bad block header in flate stream");
return;
}
let check = b;
if ((b = str.getByte()) === -1) {
this.#endsStreamOnError("Bad block header in flate stream");
return;
}
check |= b << 8;
if (check !== (~blockLen & 0xffff) && (blockLen !== 0 || check !== 0)) {
// Ignoring error for bad "empty" block (see issue 1277)
throw new FormatError("Bad uncompressed block length in flate stream");
}
this.codeBuf = 0;
this.codeSize = 0;四段几乎一样的读取,每一段都在判 -1。读到的两个 16 位小端整数分别是 LEN 与其反码 NLEN。规范要求 NLEN 是 LEN 的按位取反;这里的判断多了一个例外:当 blockLen 和 check 都是 0 时也放行。注释指向 issue 1277——有些生成器会在末尾写一个空的未压缩块,NLEN 没有正确取反。为一个空块报错没有意义,放行即可。
对齐到字节边界的动作就是 this.codeBuf = 0; this.codeSize = 0;。前面读 3 位块头时消耗了一个字节,剩下 5 位还在缓冲里;直接清零等于把这 5 位丢掉,位置恰好落在下一个字节的开头。不需要额外调用 skip。
const bufferLength = this.bufferLength,
end = bufferLength + blockLen;
buffer = this.ensureBuffer(end);
this.bufferLength = end;
if (blockLen === 0) {
if (str.peekByte() === -1) {
this.eof = true;
}
} else {
const block = str.getBytes(blockLen);
buffer.set(block, bufferLength);
if (block.length < blockLen) {
this.eof = true;
}
}
return;
}输出缓冲一次性扩到 end,然后整块搬过去。str.getBytes(blockLen) 可能拿不到足量字节——底层流被截断时返回短数组。这时 set 只写入实际拿到的部分,随后把 eof 置位,让上层带着不完整的尾部收场,而不是抛异常。
空块的处理更细:peekByte() 返回 -1 才置 eof。如果后面还有数据,说明这只是一个中间空块,继续解下一块。
设计要点:未压缩块没有走「逐位解码」,而是整段
set拷贝。能一次解决的问题不要拆成循环,这是这份实现里反复出现的选择。同样地,ensureBuffer(end)保证后续解码主循环不会为了每个字面量反复扩容。
解码主循环: 字面量、长度与距离
固定块与动态块只在码表来源上不同,落到主循环后完全一致。进入循环前先拿到底层缓冲的引用与容量:
buffer = this.buffer;
let limit = buffer ? buffer.length : 0;
let pos = this.bufferLength;
while (true) {
let code1 = this.getCode(litCodeTable);
if (code1 < 256) {
if (pos + 1 >= limit) {
buffer = this.ensureBuffer(pos + 1);
limit = buffer.length;
}
buffer[pos++] = code1;
continue;
}
if (code1 === 256) {
this.bufferLength = pos;
return;
}code1 落在三个区间里,对应三种语义:0 到 255 是字面量,256 是块结束,257 到 285 是长度符号。ensureBuffer 只在容量不够时调用,limit 随之更新;正常路径下每个字面量只剩一次数组赋值与自增。
字面量分支的条件是 pos + 1 >= limit,而不是 pos + 1 > limit。因为要写的是下标 pos,需要保证 pos 小于 limit;用大于等于会在恰好写满时也调用一次 ensureBuffer,此时它直接返回原缓冲,属于保守但安全的写法。真正的扩容发生在请求值大于容量时,按 2 的幂增长。
遇到 256 就把 bufferLength 对齐到 pos 并返回。注意不是每写一个字节都更新 bufferLength——主循环用局部变量 pos 推进,只在块结束时同步一次。这样 bufferLength 在块内不会反复穿过对象属性。
长度与距离走两张常量表:
code1 -= 257;
code1 = lengthDecode[code1];
let code2 = code1 >> 16;
if (code2 > 0) {
code2 = this.getBits(code2);
}
len = (code1 & 0xffff) + code2;
code1 = this.getCode(distCodeTable);
code1 = distDecode[code1];
code2 = code1 >> 16;
if (code2 > 0) {
code2 = this.getBits(code2);
}
const dist = (code1 & 0xffff) + code2;两张表沿用了和码表相同的打包约定:高 16 位是额外位数,低 16 位是基值。表项本身是常量:
const lengthDecode = new Int32Array([
0x00003, 0x00004, 0x00005, 0x00006, 0x00007, 0x00008, 0x00009, 0x0000a, 0x1000b, 0x1000d, 0x1000f,
0x10011, 0x20013, 0x20017, 0x2001b, 0x2001f, 0x30023, 0x3002b, 0x30033, 0x3003b, 0x40043, 0x40053,
0x40063, 0x40073, 0x50083, 0x500a3, 0x500c3, 0x500e3, 0x00102, 0x00102, 0x00102,
]);
const distDecode = new Int32Array([
0x00001, 0x00002, 0x00003, 0x00004, 0x10005, 0x10007, 0x20009, 0x2000d, 0x30011, 0x30019, 0x40021,
0x40031, 0x50041, 0x50061, 0x60081, 0x600c1, 0x70101, 0x70181, 0x80201, 0x80301, 0x90401, 0x90601,
0xa0801, 0xa0c01, 0xb1001, 0xb1801, 0xc2001, 0xc3001, 0xd4001, 0xd6001,
]);lengthDecode 有 31 项,下标是「符号减 257」。前几项是长度 3 到 10 的短码,额外位 0;中间逐渐增加额外位,基值按规范递增;末尾三项都是 0x00102,也就是基值 258、额外位 0。规范里只有符号 285 是 258,286 与 287 保留不用。这张表把它们都映射到 258,属于「宁可给一个合理值也不报错」的宽容处理——正规编码器不会产生它们,真出现了也不会让整页崩掉。
distDecode 有 30 项,基值从 1 开始,最大到 24577,额外位最多 13 位。0x10005 表示「额外 1 位、基值 5」,即距离 5 或 6;0xd6001 表示「额外 13 位、基值 24577」。
拆包的两段代码结构相同:先取高 16 位当额外位数,需要时再 getBits,最后与低 16 位基值相加。if (code2 > 0) 的判断很重要——额外位为 0 时不能调 getBits(0),那会白读一次并返回 0,虽然结果一样,但破坏了位缓冲的语义(getBits(0) 在 codeSize 为 0 时会去读一个字节)。
LZ77 回溯复制
拿到 len 与 dist 之后,最后一段是复制:
if (pos + len >= limit) {
buffer = this.ensureBuffer(pos + len);
limit = buffer.length;
}
for (let k = 0; k < len; ++k, ++pos) {
buffer[pos] = buffer[pos - dist];
}
}只有三行,但 dist 可能小于 len:DEFLATE 允许「距离 1、长度 100」这种极端情况,用来编码一整段重复字节。此时复制过程中读到的源位置可能正是刚刚写出的字节。逐字节、正向、从 pos - dist 往 pos 搬,恰好能正确处理重叠——写到第 k 个字节时,pos - dist 可能已经落在本次复制已经写过的区间里,从而把模式不断向后铺开。如果用 set 或 memcpy 一次性搬 len 字节,重叠部分会读到旧的、未覆盖的数据,输出就错了。
这也是为什么这里不能套用未压缩块的 buffer.set 优化:两者的数据依赖模型不同。
ensureBuffer(pos + len) 在每次复制前做一次容量检查,保证整段写入不越界。条件用的是大于等于:pos + len 等于当前容量时也会调用一次 ensureBuffer,此时它直接返回原缓冲;这样写只是与字面量分支保持一致,不需要再推敲边界。
设计要点:
pos是块内游标,bufferLength是块间游标。一个块内部所有写入都只动pos,避免了属性读写;块结束时一次性提交。这个模式配合limit缓存,让主循环的内层只剩数组访问与位运算。
容错与异步快路径
FlateStream 面对的是来自任意 PDF 的字节,损坏、截断、非标准编码都很常见。它的策略不是「一律抛错」,而是分层处理。
#endsStreamOnError 方法
#endsStreamOnError(err) {
info(err);
this.eof = true;
}这个方法只做两件事:打一条日志、把 eof 置位。它被用在两类位置——读块头失败,以及未压缩块的长度字段读不全。这两种情况都意味着「后面的数据不可信,但前面已经解出的部分仍然可用」,所以以 EOF 收尾,让上层拿着部分结果继续。
与之相对,动态块内部的 getBits、getCode 失败并不会被接住,FormatError 会一路抛到调用方。原因是这类失败发生在「码表或数据本身已经错乱」的位置,继续解只会产出垃圾字节;而块头读不到、长度字段读不全,往往只是流被截断,前缀数据还是可信的。
这种「哪里能容错、哪里不能」的划分不是随手写的,它对应 DecodeStream.getBytes 的消费方式:上层通常按 Length 或按图像尺寸请求固定字节数,拿到的短数组会被当作「流提前结束」处理。
asyncGetBytes 方法
现代浏览器的 DecompressionStream 可以用原生代码解 zlib,比 JS 逐位解码快一个数量级。asyncGetBytes 先试原生路径,失败再回退:
async asyncGetBytes() {
const { decompressed, compressed } =
await this.asyncGetBytesFromDecompressionStream("deflate");
if (decompressed) {
return decompressed;
}
// DecompressionStream failed (for example because there are some extra
// bytes after the end of the compressed data), so we fallback to our
// decoder.
// We already get the bytes from the underlying stream, so we just reuse
// them to avoid get them again.
this.#isAsync = false;
this.stream = new Stream(
compressed,
2 /* = header size (see ctor) */,
compressed.length,
this.stream.dict
);
this.reset();
return null;
}asyncGetBytesFromDecompressionStream 在 DecodeStream 里实现,它先把底层流完整读出,再喂给 new DecompressionStream("deflate")。这里的 "deflate" 是 zlib 包装格式,不是裸 DEFLATE(裸格式在 Web API 里叫 "deflate-raw"),与 PDF 的 Flate 数据正好对应。
失败时返回的 compressed 是已经读出的原始字节,于是回退路径不必再从网络或文件里取一次。新建的 Stream 从偏移 2 开始,跳过已经被构造函数校验过的 zlib 头,长度沿用压缩数据长度;然后 reset() 把输出侧的位置归零,#isAsync 置 false,返回 null 表示「没解出来,请走同步路径」。
为什么要回退,而不是把异常抛给上层?注释给了一个具体原因:压缩数据结束后还有多余字节时,原生实现会报错。PDF 里 /Length 写错、多个过滤器拼接、尾部残留都很常见,浏览器实现对这些宽松度的容忍度各不相同。把「标准解码」交给原生、把「非标准但可解」交回自研,是这里的分工。
get isAsync 只是把私有字段暴露出去:
get isAsync() {
return this.#isAsync;
}BaseStream.isAsync 默认返回 false,这里覆盖为真实的可用状态。上层的 Evaluator、PDFDocument 会据此决定要不要先 await stream.asyncGetBytes(),一次性拿到整块解码结果再包成 Stream。对一张几 MB 的图片,这条路径省掉的是主线程上逐块触发的解码开销。
getImageData 方法
图像路径还有一个专属入口:
async getImageData(length, _decoderOptions) {
const data = await this.asyncGetBytes();
if (!data) {
return this.getBytes(length);
}
if (data.length <= length) {
return data;
}
return data.subarray(0, length);
}它先尝试整块异步解码。失败时 asyncGetBytes 返回 null,于是退回 getBytes(length) 的同步路径。成功时也不原样返回——length 是图像声明需要的字节数,超出的部分截掉,不足则原样返回(短数据在图像解码器里会被当作不完整行处理)。
subarray 是视图,不复制;返回的 Uint8Array 与解码缓冲共享内存。调用方只读不改,这层共享是安全的。
设计要点:异步路径与同步路径共用同一个解码状态机。回退时不是重新构造一个
FlateStream,而是就地替换底层流并复位游标,既复用了已经读出的字节,也保住了dict与对象身份。能力探测成功就用原生,失败就用自己的代码,两条路对上层完全透明。
总结
FlateStream 是 PDF.js 的熵解码层:把 zlib 包装的 DEFLATE 位流还原成字节序列,向上供 PredictorStream、图像解码器、字体解析器、xref 解析器使用。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | Stream / ChunkedStream | 文件切片、网络分块 | 压缩字节 |
| 滤镜分派 | Parser.makeFilter | /Filter、/DecodeParms | 过滤器链 |
| 熵解码 | FlateStream | zlib 头 + DEFLATE 块 | 未预测的字节序列 |
| 行预测 | PredictorStream | Predictor、Columns、BPC | 行对齐的样本字节 |
| 上层消费 | 图像解码 / 字体 / xref 解析 | 行字节 | 像素、字形、偏移表 |
用一句话串起来:Flate 负责把「变长的位」还原成「定长的字节」,Predictor 负责把「预测后的行」还原成「原始的行」。
五条设计原则
- 查表优先于遍历。Huffman 解码、长度与距离、固定码表全部展开成数组,热路径上没有任何循环或分支树。代价是内存,收益是每个符号 O(1)。
- 位缓冲只看请求,不看字节。
getBits与getCode都以「要多少位」为唯一输入,调用方从不关心底层字节边界;跨字节的残余保存在实例里,读字节的次数自然最少。 - 容错有边界。读不到块头、读不全未压缩块长度时以
eof收尾并保留前缀;码表错乱、保留块类型则立刻抛错。哪一层的问题归哪一层,不把损坏数据继续往下游扩散。 - 平台能力优先,语义兜底。能用
DecompressionStream就用原生实现;遇到尾部多余字节这类原生实现不接受的情况,就地换回自研解码器,并复用已经读出的压缩字节。 - 对齐 LZ77 的数据依赖。重叠复制必须逐字节正向进行,这是格式语义决定的;而未压缩块没有依赖,直接整块拷贝。两者用不同的复制方式,不是风格不统一,而是数据结构不同。
至此,「压缩字节怎么变回原始字节」这一环补齐了。下一篇看紧挨着它的 PredictorStream:Flate 解出来的字节为什么还要按行做差分,五种 PNG 过滤器又如何把一行行预测值还原成像素。
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 源码解读: predictor_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日