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

前言
这是「字节与流」层的一篇文章,对象是 Worker 端 src/core/ascii_85_stream.js。
上一篇我们拆的是 /FlateDecode,也就是真正做统计压缩的那一层;本篇讲同一层里的 /ASCII85Decode,LZW 的拆解则放在同批的另一篇。三者都是 DecodeStream 的子类,都从 Parser.makeFilter 里被创建,但职责不同:Flate 与 LZW 负责「熵解码」,ASCII85 只负责「进制转换」——把五个可打印字符还原成四个字节。
它不压缩数据,反而会让体积膨胀到原来的 125%。那为什么 PDF 还要支持它?因为在早期网络与邮件网关里,二进制字节经常被转义、截断或破坏,而 ASCII85 只使用可打印字符,能安全穿过纯文本通道。今天它主要出现在内联图像、旧版生成器产出的文件,以及「先 ASCII85、再 Flate」这种组合里。
ASCII85 也叫 Base85,但与 Base64 不是同一套东西。Base64 用 64 个字符表示 6 位,四个字节需要约 5.33 个字符;ASCII85 用 85 个字符表示约 6.4 位,四个字节只要五个字符,因此更紧凑。PDF 选它,是因为这 85 个字符都在可打印范围内,而且编解码只涉及整数乘加,不需要查表。
一句话概括它的职责:把每五个属于 0x21 到 0x75 的字符,按 85 进制拼成一个 32 位整数,再按大端序拆成四个字节。
模块总览
ascii_85_stream.js 全文 95 行,只有一个类 Ascii85Stream,代码可以切成三块:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 流身份 | constructor | 记录底层流与 dict,按 0.8 预估输出长度 |
| 组缓冲 | #input | 复用的 5 字节组缓冲,避免每次分配 |
| 解码 | readBlock | 处理一个组,输出 0 到 4 字节 |
它在整条链路里的位置:
PDF 字节
→ Parser.makeFilter (读到 /Filter /ASCII85Decode)
→ Ascii85Stream(str, maybeLength)
→ DecodeStream.getBytes (按需拉取,循环调用 readBlock)
→ FlateStream (可选:数组里的下一个过滤器)
→ 上层:图像数据 / 内容流继承关系是 BaseStream → DecodeStream → Ascii85Stream。DecodeStream 提供 buffer、bufferLength、eof、ensureBuffer、getByte、getBytes;Ascii85Stream 只需要实现 readBlock。
与 Flate、LZW 不同的是,它的 readBlock 一次只处理一个组,最多往缓冲里追加 4 个字节。Flate 一次处理一个 DEFLATE 块,LZW 一次处理 512 个码,ASCII85 则是「读五个字符,写四个字节,返回」。上层要一段较长的数据时,DecodeStream.getBytes 会反复调用它。
readBlock 内部不检查 this.eof。这不是遗漏:DecodeStream.getByte 与 getBytes 都在调用前判断 eof,正常路径不会在结束之后再次进来。直接调用 readBlock 会继续消费底层流,但它不是公开契约的一部分。
设计要点:把
readBlock做成「一个组」而不是「一大块」,换来的是实现上的极简:不需要在块内维护组边界,不需要处理「半个组跨块」的拼接,#input也只需要 5 个字节。代价是调用次数多,但 ASCII85 在 PDF 里出现得少、数据量通常也不大,这个取舍是合理的。
ASCII85 的编码规则
ASCII85 的思想很直接:四个字节一共 32 位,可以表示 0 到 4294967295 的整数;用 85 进制写出这个整数,最多需要 5 位,因为 85 的 5 次方是 4437053125,已经大于 2 的 32 次方。每一位再加上 33,就落进可打印字符区间 0x21 到 0x75,也就是 ! 到 u。
解码时反过来做:把 5 个字符各减去 33,按 85 进制累加成一个整数,再把整数按大端序拆回 4 个字节。因为 85 的 5 次方略大于 2 的 32 次方,并不是所有 5 字符组合都合法;合法组合里最大的是 s8W-!,对应 0xFFFFFFFF。
以 s8W-! 为例,五个字符减去 33 后分别是 82、23、54、12、0:
| 项目 | s | 8 | W | - | ! |
|---|---|---|---|---|---|
| 减去 33 的数字 | 82 | 23 | 54 | 12 | 0 |
| 位权 | 85 的 4 次方 | 85 的 3 次方 | 85 的 2 次方 | 85 | 1 |
把五项加起来正好得到 0xFFFFFFFF,拆成四个字节就是四个 0xFF。这也说明为什么 85 的 5 次方必须大于 2 的 32 次方:否则会有一部分 32 位值无法用五位表示。
| 输入形式 | 含义 | 输出字节数 |
|---|---|---|
| 5 个普通字符 | 一个完整组 | 4 |
z | 四个零字节 | 4 |
2 到 4 个字符后遇到 ~ | 部分组 | 字符数减 1 |
~ 或流结束 | 结束 | 0 |
z 是规范给的捷径:它代表四个 0x00,省掉 !!!!! 这五个字符。规范要求 z 只能出现在组的开头,不能跟在部分组后面;本文件的实现只在一个组的第一个非空白字符位置识别它,恰好符合这个约束。
空白字符在解码时被完全忽略,可以出现在任何位置,包括五个字符之间。~ 是结束标记,规范写作 ~>;解码器只认 ~,它后面的 > 不会被本类读取,而是留给上游的内联流长度扫描。
与其他过滤器的串联
/Filter 可以是一个名字,也可以是一个数组。数组的含义是「按顺序解码」:[/ASCII85Decode /FlateDecode] 表示先去掉 ASCII85 的外壳,再把得到的字节交给 Flate。
Parser.makeFilter 的循环从数组第一个元素开始,把当前流包成新的过滤器:
stream = this.makeFilter(stream, filter.name, maybeLength, params, cipherTransform);
// After the first stream the `length` variable is invalid.
maybeLength = null;只有第一层能拿到原始 Length。ASCII85Stream 排在数组第一个位置时,可以得到 maybeLength 做 0.8 的预估;排在后面的 Flate 拿到的是 null,因为经过一层解码后,原始长度已经不能代表剩余字节数。这个细节解释了为什么长度预估只对第一层有效,后面的过滤器都必须靠自己的缓冲增长策略。
内联图像里也能见到它:BI 与 ID 之间的数据如果声明了 /ASCII85Decode,解码就从这里起步;~> 由上游扫描长度时识别,本类只负责解到 ~ 为止。
ASCII85 单独出现时,输出直接就是图像或内容流数据;与 Flate 串联时,它只是「把可打印字符变回二进制」的第一步,真正的解压由后一层完成。
构造与长度预估
constructor 方法
constructor(str, maybeLength) {
// Most streams increase in size when decoded, but Ascii85 streams
// typically shrink by ~20%.
if (maybeLength) {
maybeLength *= 0.8;
}
super(maybeLength);
this.stream = str;
this.dict = str.dict;
}构造函数只做两件事:按 0.8 缩放长度提示、保存底层流。
缩放的原因是输入输出比固定为 5 比 4:每 5 个字符解出 4 个字节,所以压缩数据的 Length 乘 0.8 就是输出长度的合理估计。这个值只是提示,DecodeStream 用它计算初始缓冲容量,不会拿它限制实际解码长度。
DecodeStream 的构造函数会把提示值向上取到 2 的幂,作为 minBufferLength:
maybeLength | 乘 0.8 后 | minBufferLength |
|---|---|---|
| 1000 | 800 | 1024 |
| 5000 | 4000 | 4096 |
| 100 | 80 | 512(下限) |
乘 0.8 之后可能是小数,例如 127 变成 101.6,这没有问题:minBufferLength 从 512 起步按 2 倍增长,比较的是大小关系,不要求整数。原始缩放值还会存进 _rawMinBufferLength,供 StreamsSequenceStream 估算串联流的初始容量。
#input 是类字段,在实例创建时就分配好 5 个字节,readBlock 每次复用它。它是一个纯粹的临时工作区:每处理一个组,前 5 个位置都会被覆盖,不需要清零。
用私有类字段声明,而不是在构造函数里赋值,运行时效果一样,区别在可读性:字段固定在类体顶部,读代码时能一眼看到「这个类只有一个可变的组缓冲」。this.dict 直接从底层流复制,保持过滤器链里字典的同一性;Ascii85Stream 自己不读取任何字典项。
解码
readBlock 方法
readBlock() {
const TILDA_CHAR = 0x7e; // '~'
const Z_LOWER_CHAR = 0x7a; // 'z'
const EOF = -1;
const str = this.stream;
let c = str.getByte();
while (isWhiteSpace(c)) {
c = str.getByte();
}
if (c === EOF || c === TILDA_CHAR) {
this.eof = true;
return;
}
const bufferLength = this.bufferLength;
let buffer, i;
// special code for z
if (c === Z_LOWER_CHAR) {
buffer = this.ensureBuffer(bufferLength + 4);
buffer.fill(0, bufferLength, bufferLength + 4);
this.bufferLength += 4;
} else {
const input = this.#input;
input[0] = c;
for (i = 1; i < 5; ++i) {
c = str.getByte();
while (isWhiteSpace(c)) {
c = str.getByte();
}
input[i] = c;
if (c === EOF || c === TILDA_CHAR) {
break;
}
}
buffer = this.ensureBuffer(bufferLength + i - 1);
this.bufferLength += i - 1;
// partial ending;
if (i < 5) {
input.fill(0x21 + 84, i, 5);
this.eof = true;
}
let t = 0;
for (i = 0; i < 5; ++i) {
t = t * 85 + (input[i] - 0x21);
}
for (i = 3; i >= 0; --i) {
buffer[bufferLength + i] = t & 0xff;
t >>= 8;
}
}
}方法不长,但把「跳过空白」「识别结束」「z 捷径」「部分组补齐」「32 位拼接」这几件事都塞进了同一个函数。下面拆开看。
执行顺序
按代码从上到下走一遍,一次 readBlock 只有六步:
- 读一个字节,跳过空白;
- 判断是不是
-1或~,是就置eof并返回; - 判断是不是
z,是就走零字节捷径; - 否则把第一个字符放进
input[0],再读四个字符,途中跳过空白、遇到结束符就停; - 按读到的字符数
i扩容,部分组用u补齐并置eof; - 按 85 进制累加成 32 位整数,再按大端序写四个字节。
这六步没有循环嵌套,也没有跨调用状态,唯一的成员变量是 #input,唯一被修改的缓冲状态是 bufferLength。正因如此,这个方法可以随时被打断——readBlock 返回后,下一次调用从新的组重新开始,不需要记住「上一组读了几位」。
这六步也是排查问题的顺序。输出为空,先看第 2 步是不是提前遇到了 ~ 或流尾;字节数不对,先看第 5 步的 i 是否正确;内容错乱,再看第 4 步是不是漏跳了空白,或者第 6 步的字节序写反了。
空白与结束符
进入方法先读一个字节,跳过空白;如果读到 -1 或 ~,直接置 eof 并返回。这里有两个边界值得注意:
-1表示底层流已经读完。没有结束标记的截断流也会走到这里,效果与遇到~相同。~只是终止信号,不会被写进任何组。它后面的>也没有被消费——本类在~处就返回了,>留在底层流里。对独立流没有影响;对内联图像,上游的findASCII85DecodeInlineStreamEnd在找长度时已经把~>一起扫描过去。
空白判断用的是 isWhiteSpace,它只认四种字符:空格 0x20、制表 0x09、换行 0x0A、回车 0x0D。PDF 规范把 NUL、制表、换行、换页、回车、空格都算空白,但 core_utils 的这份 helper 只覆盖其中四种;对 ASCII85 数据来说,换页与 NUL 极少出现。
换页符 0x0C 是一个有趣的反例:如果它真的出现在数据里,会被当作数字 0x0C - 0x21,也就是负数,参与累加并产生错误结果。实践中生成器不会这么写,这个差异更多是读源码时的一个注脚。
跳过空白的动作出现了两次:一次在组开始前,一次在组内每读一个字符时。第二次不能省,因为规范允许空白插在五个字符之间,例如把一组拆成两行写。两次都用同一个 while (isWhiteSpace(c)) 循环,逻辑完全一样,只是位置不同。
z 快捷分支
如果组的第一个非空白字符是 z,走捷径:直接把四个 0x00 写进缓冲,bufferLength 前进 4,方法结束。
buffer.fill(0, bufferLength, bufferLength + 4) 这一行不能省。ensureBuffer 只保证容量,不保证内容是零;缓冲可能是从上一块沿用下来的,里面残留着旧字节。四个零必须显式写入。
z 分支不读取后续字符,也不检查下一个字符是不是空白。这是正确的:z 本身就是一个完整的组,后面的内容属于下一组,由下一次 readBlock 处理。
实现只在「组的开头」识别 z。如果 z 出现在五个字符的中间,它会被当作普通字符,减去 0x21 得到 89,超出 0 到 84 的合法数字范围。本文件不做字符校验,这种输入会安静地产生错误字节,而不是抛异常。
顺便说一句,!!!!! 也能表示四个零字节,只是浪费了四个字符;z 是纯粹的体积优化。规范把它限制在组边界,是因为解码器若允许它出现在组内,就必须在五位累加的中途插入一个零组,状态会复杂很多。
五位一组与部分组
普通组的分支先把第一个字符放进 input[0],再从下标 1 读到 4。每读一个字符都跳过空白;一旦读到 EOF 或 ~ 就跳出循环。注意 input[i] = c 写在判断之前,所以 input 里可能短暂出现 ~ 或 -1 转换出的 255;但只要发生提前跳出,后面的补齐会把这些位置覆盖掉。
组读完(或提前结束)后,输出长度按 i - 1 计算:
组内字符数 i | 输出字节数 i - 1 | 说明 |
|---|---|---|
| 5 | 4 | 完整组 |
| 4 | 3 | 部分组 |
| 3 | 2 | 部分组 |
| 2 | 1 | 部分组 |
| 1 | 0 | 规范不允许,实现输出为空 |
i < 5 说明这一组没有读满,通常是因为遇到了 ~ 或流结束。此时把 input 从下标 i 到 4 全部填成 0x21 + 84,也就是 0x75,字符 u,数字值 84。这是规范定义的补齐方式:用最大的数字填充,解码后只取前 i - 1 个字节,后面的字节本来就会被丢掉。
补齐的写法 input.fill(0x21 + 84, i, 5) 把「字符偏移 33」和「最大数字 84」写在一起。它比直接写 0x75 更能说明意图:这不是随便一个字节,而是「基础字符加最大数字」。
部分组不是错误状态,而是正常编码的一部分。ASCII85 按 4 字节一组编码,最后不足 4 字节时会先补零凑满,再编码成 5 个字符,然后删掉与补零数量相同的尾部字符;解码器看到不足五位,就知道末尾被删了几位,用 u 补齐正好还原编码器的补零。i - 1 就是这组原始字节的真实数量。
如果 i 等于 5,说明五个字符都读到了,不需要补齐,eof 也不置位;下一次 readBlock 会继续读下一组。
32 位拼接与字节序
拼接只有一行:
let t = 0;
for (i = 0; i < 5; ++i) {
t = t * 85 + (input[i] - 0x21);
}t 从 0 开始,每读一位乘 85 再加上当前数字。五次之后,得到的就是这个组表示的 32 位整数。因为 t 是普通 JS 数字(双精度浮点),累加过程中不会像 32 位整数那样提前溢出;即使输入非法、结果超过 0xFFFFFFFF,也能先算出来,后续再截断。
输出按大端序:高位字节写在下标 0,低位字节写在下标 3。
for (i = 3; i >= 0; --i) {
buffer[bufferLength + i] = t & 0xff;
t >>= 8;
}t & 0xff 取出最低字节,t >>= 8 右移八位。这里用的是有符号右移,t 会先被转成 32 位有符号整数;对最高位为 1 的值,转换结果是负数,右移会做符号扩展。但低字节不受影响:& 0xff 只看最低八位,连续右移也不会改变已经写过的高位字节。所以 0xFFFFFFFF 会被正确写成四个 0xFF,0x80000000 会被正确写成 0x80、0x00、0x00、0x00。
对大端序的直觉可以这样记:ASCII85 的五个字符按「从高位到低位」排列,第一个字符贡献的是 85 的 4 次方那一项。所以解码出来的整数,最高字节对应第一个字符所在的组,写回缓冲时也必须先写最高字节。
非法组合则没有这么优雅。例如五个字符全是 u,累加结果是 4437053124,超过 0xFFFFFFFF;& 0xff 与 >> 会先把它截成 32 位,再输出低四个字节。规范没有定义这种输入,实现也没有校验,属于「输入非法、输出未定义」的范畴。
ensureBuffer(bufferLength + i - 1) 保证的是「输出字节数」的容量。但对部分组,循环仍然会写 bufferLength + 0 到 bufferLength + 3 四个位置,其中超出 i - 1 的部分可能越界。这些写入会被 TypedArray 静默丢弃,而它们本来就不计入 bufferLength;又因为部分组一定伴随 eof,不会有下一块来读这些位置。真正需要保留的前 i - 1 个字节始终在容量范围内。
输出缓冲的增长
ensureBuffer 按 2 的幂扩容,readBlock 每次只请求 bufferLength + 4(完整组)或 bufferLength + i - 1(部分组)。由于初始容量至少 512,前 128 个完整组都不会触发扩容;此后每次扩容翻倍,均摊到每个组仍然很低。
这也意味着,一个 ASCII85 流的输出缓冲通常会比最终数据大一些——扩容总是取到下一个 2 的幂。多出来的容量不占额外分配,只是 Uint8Array 的尾部空位,bufferLength 之前的字节才是有效数据。
四个输入片段的结果
把前面的规则落实到具体字节上,可以对照下面四组输入:
| 输入片段 | 组内字符数 | 补齐 | 输出字节 |
|---|---|---|---|
z | 1(捷径) | 不需要 | 0x00 0x00 0x00 0x00 |
s8W-! | 5 | 不需要 | 0xFF 0xFF 0xFF 0xFF |
!!!! 后接 ~ | 4 | 补一个 u | 0x00 0x00 0x00 |
!!!!! | 5 | 不需要 | 0x00 0x00 0x00 0x00 |
第三行值得单独看:四个 ! 表示的数字全是 0,补上 u 之后整数仍是 0,四个输出字节都是 0,但只取前三个。如果编码器当初只补了三个零字节,删掉的正是最后一位对应的一字节,解码端用 i - 1 就能精确还原。第四行则说明,同样的数据写成完整五位,输出就是四个零字节,不会少一个。
对比: ASCII85、ASCIIHex 与 Flate
三个过滤器都在同一层,实现的粒度却完全不同:
| 维度 | Ascii85Stream | AsciiHexStream | FlateStream |
|---|---|---|---|
| 类型 | 进制转换 | 进制转换 | 熵压缩 |
| 字符集 | 0x21 到 0x75 | 0 到 9、A 到 F、a 到 f | 任意字节 |
| 体积变化 | 膨胀到 125% | 膨胀到 200% | 通常缩小 |
| 结束标记 | ~ | > | BFINAL 块头 |
| 特殊值 | z | 无 | 无 |
| 每次读入 | 一个组,最多 5 字符 | 最多 8000 字节 | 一个 DEFLATE 块 |
| 每次输出 | 最多 4 字节 | 最多 4000 字节 | 不限 |
maybeLength 系数 | 0.8 | 0.5 | 1 |
AsciiHexStream 的注释写着「shrink by 50%」,因为它每两个字符解出一个字节;Ascii85Stream 是「shrink by ~20%」,因为五个字符解出四个字节。两者的长度预估都只是提示,真正的解码逻辑与 maybeLength 无关。
三个系数正好对应各自的体积比:Flate 取 1,ASCII85 取 0.8,ASCIIHex 取 0.5。它们只影响初始缓冲,却也能反过来当作「这个过滤器会让数据变大还是变小」的提示。
粒度差别更值得注意。AsciiHexStream 一次从底层流取 8000 字节,解出最多 4000 字节;Ascii85Stream 一次只解一个组。同样一兆字节的数据,后者要调用 readBlock 约 25 万次,前者只需约 250 次。这不是疏忽,而是两种编码的复杂度不同:ASCII85 需要组内五位一起运算,还要处理 z 与部分组,作者选择了最直白的单组实现。ASCII85 通常只作为 Flate 的前置编码,数据量有限,这份实现没有为吞吐做进一步优化。
另一个细节是结束方式。AsciiHexStream 在遇到 > 时置 eof 并跳出,但它可能已经把同一批 8000 字节里的前半段解完;Ascii85Stream 遇到 ~ 时只处理当前组,后面的字符原样留在流里。两者都不依赖「读到流尾」来判断结束,结束标记本身就是协议的一部分。
边界与容错
没有字符校验。数字 input[i] - 0x21 可以是负数,也可以大于 84,实现都不检查。正常编码器不会产生这些字符;损坏或恶意的输入会得到无意义的字节,而不是异常。这与 ASCII85 在 PDF 里的定位一致:它只是编码层,安全边界不在这里。
把这一点与 LZW 对照会更清楚。LZWStream 对非法码有兜底(大于 nextCode 时追加首字节),因为码空间本身有结构,越界值可以被赋予一个合理含义;ASCII85 的字符空间没有这种结构,任何一个字符都可能是打字错误或数据损坏,强行解释只会掩盖问题。两端都选择「不抛异常」,但理由不同:一个是能给出近似结果,一个是无法判断。
缺少结束标记。没有 ~ 的流在读到 -1 时,组内循环会跳出,i 小于 5,走补齐与 eof 分支。已经读到的字符仍然会被解码并输出,效果是「尽量给出结果」。如果 -1 出现在组开始处,则直接置 eof 返回,不产生任何字节。
重复的结束标记。~ 之后无论还有什么,都不会被本类读取;第一次遇到就置 eof。连续两个 ~ 也只会消费第一个。
空长度提示。Parser.makeFilter 在 maybeLength 为 0 时,会在构造之前就返回 NullStream,并打一条警告。因此 Ascii85Stream 的构造函数基本不会收到 0;即使收到,if (maybeLength) 也不会执行缩放,super(0) 使用默认的 512 下限。
工作区残留。#input 在两次 readBlock 之间不清零。上一组的第 5 个字符可能还留在数组里,但下一组一定会从 input[0] 开始覆盖,读满五个或补齐五个,残留不会被读到。
不校验 z 的位置。如前所述,z 只在组首被识别;组内出现会被当成数字 89。规范禁止这种写法,实现选择不报错。
空白穿插在 ~> 之间。上游 findASCII85DecodeInlineStreamEnd 在扫描内联流长度时,会跳过 ~ 与 > 之间的空白,并容忍缺失的 >;本类只认 ~,所以 ~ 后面的空白与 > 都不影响它。两层各自处理自己能处理的异常。
eof 之后的状态。置位后 buffer 里的字节仍然可以被当前请求取走;readBlock 不会清空缓冲,也不会回退 pos。这与 Flate、LZW 的约定一致:eof 只表示「不再解码」,已解出的数据仍然有效。
长度提示与实际不符。maybeLength 可能完全错误,例如生成器把 Length 写成压缩前的长度。它只影响初始容量,解码按实际字符推进;错得离谱时无非是多几次 ensureBuffer 扩容。
总结
Ascii85Stream 是 PDF.js 的可打印字符还原层:把 5 个字符一组还原成 4 个字节,向上供下一个过滤器或最终消费者使用。
它通常不是唯一的过滤器:单独出现时输出直接可用,与 Flate 组合时只是第一层。下面的链路表按「先 ASCII85、后 Flate」的常见组合列出。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | Stream / ChunkedStream | 文件切片、网络分块 | 可打印字符 |
| 滤镜分派 | Parser.makeFilter | /Filter、/DecodeParms | 过滤器链 |
| 进制转换 | Ascii85Stream | 5 字符一组 | 4 字节 |
| 熵解码 | FlateStream(可选) | 还原后的字节 | 解压后的字节 |
| 上层消费 | 图像解码 / 内容流 | 字节 | 像素、绘制指令 |
用一句话串起来:Ascii85 负责把「可打印字符」还原成「字节」,后面的 Flate 再负责把「压缩字节」还原成「原始字节」。
链路上它是可选的:没有 ASCII85 编码的流不会经过这个类;有它时,它通常出现在 /Filter 数组的第一个位置,因为 ASCII85 是最外层的包装。这个位置也让 0.8 的长度预估只对它有意义——再往后的过滤器拿到的 maybeLength 都是 null。
五条设计原则
- 固定分组,组内无跨块状态。一个组恰好 5 个字符、最多 4 个输出字节,
readBlock处理完就返回,不需要像 LZW 那样维护跨块的字典或位缓冲。 z作为零组的短路。四个零字节不需要走 85 进制运算,直接fill写入;识别位置限定在组首,与规范约束一致。- 空白与结束符在字符层处理。跳过空白与遇到
~的判断都发生在读字符的地方,组内组外共用同一套逻辑,不需要预扫描。 - 部分组补齐最大数字,只输出有效字节。补
u是规范要求,输出i - 1个字节是分组语义;两者配合,让截断或正常结束都走同一条路径。 - 长度预估只影响缓冲。0.8 的系数让初始容量贴近真实输出,但解码逻辑从不读取
maybeLength,所以估算错了也只是多一次扩容,不会影响正确性。
从实现角度看,这个类几乎没有「状态」:除了 bufferLength 与 eof,唯一可变的是 #input 的五个字节。这让它比 Flate、LZW 更容易读,也更容易被移植到别的语言。代价是吞吐不高,但对一份以兼容为目的的实现来说,这个代价值得。
至此,ASCII85 这一环也补齐了。它与 Flate、LZW 一样,只做「字节进、字节出」;真正决定这些字节是像素还是文字,要等到更上层的图像解码器与内容流解释器。
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 源码解读: lzw_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日