StreamPDFStreamPDF
返回博客

PDF.js 源码解读: ascii_hex_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: ascii_hex_stream

前言

这是「字节与流」层里最短的两个过滤器之一,对象是 Worker 端 src/core/ascii_hex_stream.js(79 行)中的 AsciiHexStream。

前面几篇把解码链的骨架搭完了:base_stream 定义读取契约,decode_stream 提供输出缓冲与 readBlock 驱动,flate_stream 处理 DEFLATE 位流,predictor_stream 负责行差分的还原。它们都有真正的算法可讲:Huffman 树、LZ77 窗口、五种预测公式。本篇换一个极端:一个几乎不需要算法的过滤器。

PDF 规范里有一类过滤器不压缩数据,只负责「换一种表示」。ASCIIHexDecode 是最朴素的一种:每个字节写成两个十六进制字符,用空白分隔也不影响,最后以大于号收尾。它存在的理由是历史性的——早期传输通道只保证 7 位安全,二进制流必须先包装成可打印文本。今天它更多出现在内联图像、测试文件与某些扫描仪输出的 PDF 里。

规范对这一层的要求只有四条:

  1. 输入只应包含十六进制字符与空白。
  2. 每两个字符解释成一个字节,高位在前。
  3. 若最后剩下一个孤立的半字节,按后补 0 处理。
  4. 大于号是结束标记,之后的内容不再属于这个流。

AsciiHexStream 要做的就是把文本按这四条还原成字节。它在过滤器链里的入口很短:

        case "AHx":
        case "ASCIIHexDecode":
          return new AsciiHexStream(stream, maybeLength);

这段来自 Parser.makeFilter。上游可能是普通的 Stream,也可能是已经解密或分块的流;maybeLength 是流字典里的 /Length,只当提示用。还有一个容易忽略的分支:内联图像不走这个类,Parser.makeInlineImage 会先调用 findASCIIHexDecodeInlineStreamEnd 只扫描结束标记来定长度,再构造过滤器。扫描与解码因此分成两处,本篇讲的是解码这一处。

一句话概括它的职责:把「两个字符一个字节」的文本流还原成二进制字节,并在块边界上保留半个字节,遇到结束标记立即收尾。

模块总览

ascii_hex_stream.js 只有 79 行,去掉版权头与 import,实际逻辑集中在两个方法:

区块关键成员职责
构造constructor尺寸提示减半、接管上游流与字典、初始化半字节状态
解码readBlock取块、逐字符分类、拼字节、处理结束与补零
继承设施DecodeStream输出缓冲、bufferLength、eof、ensureBuffer
对外读取getByte、getBytes按需触发 readBlock,返回连续字节

整条数据路径可以写成:

十六进制文本块
  → AsciiHexStream.readBlock    (本文件,一次最多 8000 个输入字节)
  → DecodeStream.buffer         (输出缓冲,最多 4000 个字节)
  → getBytes / getByte          (按调用方需求继续驱动 readBlock)
  → Lexer / 图像解码器 / XRef 记录解析

这里要强调一个继承关系带来的分工:readBlock 一次只负责「把上游给的一块文本尽量解码」,它不保证产出调用方要的数量。真正的「凑够字节数」由 DecodeStream.getBytes 循环调用 readBlock 完成。这个分工让本类不必关心调用方要多少,只需要关心自己这一块的边界。

readBlock 没有参数。过滤器链里其它流可能接收 decoderOptions,本类完全忽略它,因为十六进制解码没有任何可调参数。

构造:把尺寸提示减半

constructor 方法

class AsciiHexStream extends DecodeStream {
  constructor(str, maybeLength) {
    // Most streams increase in size when decoded, but AsciiHex streams shrink
    // by 50%.
    if (maybeLength) {
      maybeLength *= 0.5;
    }
    super(maybeLength);
 
    this.stream = str;
    this.dict = str.dict;
 
    this.firstDigit = -1;
  }

构造函数只做四件事,但每一件都影响后面的行为。

第一件是尺寸提示。maybeLength 是流字典里的 /Length,含义是编码后的字节数,也就是十六进制字符加空白的总量。解码后的有效字节数等于十六进制字符数除以二,而十六进制字符数不会超过总量,所以「总量的一半」是输出的一个上界。乘以 0.5 不是精确计算,而是一个偏保守的起点。

这个值会进入 DecodeStream 的构造函数,被抬到「不小于它的最小 2 的幂」,成为 minBufferLength。之后 ensureBuffer 从这里开始翻倍。也就是说,/Length 只决定第一次分配多大,不决定最终能解码多少。Parser 里有一句注释说得很直接:流字典的 /Length 可能完全错误,例如非空流写成 0,所以它只能当提示。

第二件是保存上游流:this.stream = str。所有输入字节都从 str.getBytes 来,本类不缓存文本。

第三件是转发字典:this.dict = str.dict。过滤器链是多层嵌套的,字典必须一路传下去。下游的图像解码器要靠字典里的 /Width、/Height、/ColorSpace 解释像素,一旦在这一层断掉,图像就无法还原。这个赋值只有一行,却是过滤器链能成立的前提。

第四件是初始化 firstDigit = -1。这是一个跨块状态,用来保存「等待配对的半个字节」。有效取值是 0 到 15,-1 表示当前没有任何待配对的半字节。

字段初值作用
this.stream上游 BaseStream十六进制文本来源
this.dict上游字典供后续过滤器与消费者读取
this.firstDigit-1跨块保留的半个字节,-1 表示没有
this.eoffalse(基类)遇到结束标记或上游耗尽时置真
this.buffer空数组(基类)解码输出
this.bufferLength0(基类)已产出的有效字节数

设计要点:尺寸提示只影响「第一次分配多大」,不影响正确性。0.5 的缩放让常见流一次分配到位;即使 /Length 完全错误,ensureBuffer 的几何扩容仍能兜底。把提示与容量保证分开,是这一层所有解码流的共同做法。

readBlock:一次取八千字节

readBlock 方法

  readBlock() {
    const UPSTREAM_BLOCK_SIZE = 8000;
    const bytes = this.stream.getBytes(UPSTREAM_BLOCK_SIZE);
    if (!bytes.length) {
      this.eof = true;
      return;
    }
 
    const maxDecodeLength = (bytes.length + 1) >> 1;
    const buffer = this.ensureBuffer(this.bufferLength + maxDecodeLength);
    let bufferLength = this.bufferLength;

开头这十几行决定了整个方法的粒度与内存行为。

UPSTREAM_BLOCK_SIZE = 8000 是每次向上游索取的输入字节数。选这个数有三个考虑:

  • 上游 getBytes 返回的是视图而不是副本,8000 字节的零拷贝成本很低。
  • 一块输入最多产出 4000 字节输出,单次解码的内存增量有明确上界,不会因为某个巨大的流一次性吃满内存。
  • DecodeStream.getBytes 会反复调用 readBlock,块越大,调用开销越少;块再大,收益递减,反而增加单块的内存峰值。

bytes.length 为 0 表示上游已经空了,直接把 eof 置真并返回。这一步是终止条件,不需要再往下走。

maxDecodeLength = (bytes.length + 1) >> 1 是这一块最多能产出的字节数。加 1 再右移,是向上取整:如果上一块留下了一个半字节,本块的第一个字符就能和它拼成一个完整字节,所以理论上最多是「长度加一」的一半。这里用移位而不是 Math.ceil,既避免浮点,也统一了整数的语义。

ensureBuffer(this.bufferLength + maxDecodeLength) 一次性把容量扩到「已有输出加上本块最大产出」。这一步很关键:因为循环体内的产出量已经被上界约束,后面每写一个字节都不需要再检查容量。扩容只在块与块之间发生,不会落在逐字符的热循环里。

最后一行 let bufferLength = this.bufferLength 把长度复制到局部变量。循环里每次写入都只改局部值,方法结束前才写回 this.bufferLength。这样做的收益是减少属性访问,更重要的是让「本块解码了多少」与「对象当前状态」分开,避免中途扩容时出现半更新的状态。

设计要点:输出缓冲的容量预估发生在循环之外,循环内只做「判断字符、写字节」两件事。这是所有 DecodeStream 子类的统一形态:ensureBuffer 负责内存,readBlock 负责内容,二者在块边界上对齐。

与 getBytes 的协作

readBlock 不保证一次产出调用方要的数量。真正把多个块缝起来的是基类的读取循环:

while (!this.eof && this.bufferLength < end) {
  this.readBlock(decoderOptions);
}

这段来自 DecodeStream.getBytes。它先按请求量扩容,再反复调用 readBlock,直到缓冲覆盖目标位置或者 eof 为真。对本类来说,每次调用最多贡献 4000 字节,所以请求 10 万字节会触发二十多次 readBlock,每次再向更上游索取 8000 字节文本。

这也解释了 eof 为什么必须在「上游返回 0 字节」和「遇到结束标记」两个地方都置真:基类的循环只看这一个布尔值。如果漏设,getBytes 会一直调用下去,而 readBlock 每次都返回空,形成死循环。契约很简单,代价是每个子类都必须守住它。

对调用方而言,结果长度可能小于请求长度:源文本提前结束时,getBytes 会把终点夹到 bufferLength,返回一段较短的视图。上层解析器习惯按实际返回的 length 推进,不会假设一定拿满。

字符判定:让 ASCII 布局当查表

十六进制字符分类

    let firstDigit = this.firstDigit;
    for (const ch of bytes) {
      let digit;
      if (ch >= /* '0' = */ 0x30 && ch <= /* '9' = */ 0x39) {
        digit = ch & 0x0f;
      } else if (
        (ch >= /* 'A' = */ 0x41 && ch <= /* 'Z' = */ 0x46) ||
        (ch >= /* 'a' = */ 0x61 && ch <= /* 'z' = */ 0x66)
      ) {
        digit = (ch & 0x0f) + 9;
      } else if (ch === /* '>' = */ 0x3e) {
        this.eof = true;
        break;
      } else {
        // Probably whitespace, ignoring.
        continue;
      }

这段分支是全文唯一需要「算」的地方,但它算得极省:两次范围比较、一次掩码、一次加法,没有任何查表数组,也没有把字节转成字符串。

原理是 ASCII 的编码布局:

字符范围示例digit 计算说明
0x30 到 0x390 到 9ch & 0x0f低四位恰好就是数值 0 到 9
0x41 到 0x46A 到 F(ch & 0x0f) + 9低四位是 1 到 6,加 9 得 10 到 15
0x61 到 0x66a 到 f(ch & 0x0f) + 9小写字母同理
0x3e大于号无置 eof 并跳出循环
其它空格、换行、NUL无直接跳过

为什么字母要加 9 而不是加 10?因为 A 的低四位是 1 而不是 0:0x41 & 0x0f 等于 1,要让 A 对应 10,加 9 正好。F 的低四位是 6,6 加 9 是 15。这个偏置量之所以是常数,正是因为字母在 ASCII 表里连续排列。如果换成 EBCDIC 这类布局,这种写法就完全不成立。

源码注释在字母上界处写的是 'Z',实际值 0x46 是 F 的编码。判定必须停在 F:如果真按 Z 的上界放行,G 到 Z 会被算成 16 以上的非法值,拼出的字节就会错。注释是笔误,代码是对的。

else 分支的注释写「Probably whitespace」,但它的实际行为比空白更宽:任何既不是十六进制字符、也不是结束标记的字节都会被跳过,包括规范里没有列为空白的一些控制字符。这是一种容错取态:宁可放过垃圾,也不要因为一个多余字节让整份文档报错。代价是真正的损坏不会被发现,解码结果可能悄悄变短。

还有一个分支顺序的细节:结束标记的判断放在十六进制判定之后。两者在数值上不重叠,顺序不影响正确性;但把最常见、最快命中的分支放在前面,可以减少平均比较次数。对 8000 字节的循环来说,这点差异是可以被测量到的。

设计要点:ASCII 的连续性本身就是一张免费的表。数字与字母的低四位连续,因此「分类」与「取值」可以合并成同一次掩码运算。用查表实现同样的逻辑会多一次内存访问,用字符串比较则更慢。位运算在这里不是炫技,而是让热循环只剩算术。

拼字节:只保存半个字节

if (firstDigit < 0) {
  firstDigit = digit;
} else {
  buffer[bufferLength++] = (firstDigit << 4) | digit;
  firstDigit = -1;
}

这是整个解码器的状态机,只有两个状态:没有待配对的半字节、有一个待配对的半字节。源码用一个整数 -1 编码前者,用 0 到 15 编码后者,不再引入额外的布尔字段。

高位在前是规范要求:第一个字符是高四位,第二个是低四位。(firstDigit << 4) | digit 把待配对的值左移四位,再用按位或填入低位。这里用或而不是加也可以,因为低四位在移位后全为 0,两种运算结果一致;用或更能表达「填位」的意图。

写回时先取 bufferLength 再自增,是典型的「先用后加」。buffer 是 ensureBuffer 返回的引用,可能是扩容后的新数组;由于扩容发生在循环之前,循环内不会再有换数组的风险。

这个状态机还解释了一个容易被忽略的事实:解码进度不是按块对齐的。块边界可能正好落在两个字符之间,也可能落在两个字节之间。状态机只关心「当前有没有半个字节」,不关心块在哪里结束。

设计要点:跨块续接不需要缓存文本,只需要一个半字节。这是「按语义保存状态」而不是「按输入保存状态」的典型例子:无论上游把文本切成多大的块,解码器保存的信息量都是常数。

逐字符循环的代价

for (const ch of bytes) 遍历的是 Uint8Array 的迭代器,每次产出一个 0 到 255 的数字。写成索引循环会更贴近机器层,但这里选择可读性:类型固定、长度已知,引擎对这类迭代通常能做得很好;即使没有优化,单次迭代的额外开销也被后面的分支与写字节掩盖。

按输入字节分类,单字节的处理成本如下:

输入类别比较次数算术产出
数字 0 到 921 次掩码每两个字符 1 字节
大写或小写字母4 到 51 次掩码、1 次加法每两个字符 1 字节
空白或其它杂字节4 到 5无0
结束标记4 到 5无0,并跳出

最坏情况是 5 次比较,且没有任何内存分配。8000 字节的一块最多做四万次整数比较,对现代引擎来说可以忽略;真正的成本在输出写入与上游取块,而不是判断。

那为什么不用 256 项的查找表?查表把「判断」压成一次内存读,但需要初始化一张表,且随机访问的内存延迟不一定比几个可预测的分支低。对以十六进制字符为主的输入,分支预测命中率很高,范围判断反而是更稳的选择。换成以空白为主的异常输入,两者都无所谓,因为那时瓶颈在跳过的字节数上。

break 只会跳出当前块,不会回卷上游。被跳过的后续字节已经消费掉了,但流已经结束,它们本来就不属于这个过滤器。内联图像场景里,结束标记之后往往紧跟着 /EI,那部分由 Parser 的另一段逻辑负责,不归解码器管。

内联图像里的另一条路径

同样是 ASCIIHexDecode,内联图像不走本类来定长度。Parser.findASCIIHexDecodeInlineStreamEnd 只扫描结束标记:

  findASCIIHexDecodeInlineStreamEnd(stream) {
    const GT = 0x3e;
    const startPos = stream.pos;
    let ch;
    while ((ch = stream.getByte()) !== -1) {
      if (ch === GT) {
        break;
      }
    }
    const length = stream.pos - startPos;
    if (ch === -1) {
      warn(
        "Inline ASCIIHexDecode image stream: " +
          "EOD marker not found, searching for /EI/ instead."
      );
      stream.skip(-length); // Reset the stream position.
      return this.findDefaultInlineStreamEnd(stream);
    }
    this.inlineStreamSkipEI(stream);
    return length;
  }

内联图像没有流字典,也就没有 /Length。Parser 必须先知道图像数据在文件里占多少字节,才能把它切出来构造子流。此时最便宜的做法不是解码,而是「找标记」:逐字节读到大于号,位置差就是长度。这个扫描不做半字节配对,不分配输出缓冲,复杂度只有一次线性读。

找到标记后调用 inlineStreamSkipEI 跳过紧随其后的 /EI。这是一个小型状态机,按顺序匹配字母 E、I,匹配失败就回到初始状态,避免把数据里偶然出现的 E、I 误判成结束符。

找不到标记时,代码先 stream.skip(-length) 回卷到起点,再退回通用的 findDefaultInlineStreamEnd。回卷能成立,是因为 BaseStream.skip 接受负数,这也是游标必须可写的另一处体现。通用的扫描会去找 /EI,虽然不如显式标记可靠,但总比把整份文件读穿强。

两条路径的分工很清楚:扫描负责「切多长」,解码负责「变成什么字节」。扫描是近似判断,解码是精确还原;它们只在同一个结束标记上达成一致。还要注意返回的长度包含结束标记本身,所以下游 AsciiHexStream 仍会读到大于号,并按结束处理——扫描没有替解码器消费它,只是把它算进了切分范围。

设计要点:同样的数据格式,按用途可以有两套实现。流对象走「懒解码」,长度由字典或结束标记约束;内联图像走「先扫描后解码」,因为必须先切出边界。把这两件事分开,谁也不拖慢谁。

结束标记与补零

收尾逻辑

if (firstDigit >= 0 && this.eof) {
  // incomplete byte
  buffer[bufferLength++] = firstDigit << 4;
  firstDigit = -1;
}
this.firstDigit = firstDigit;
this.bufferLength = bufferLength;

收尾分三步。

第一步处理规范里的「奇数位补零」。如果循环结束时还有一个半字节,且 eof 已经为真,就把这个半字节左移四位写出去,低四位补 0。比如最后剩下一个 2,输出就是 0x20,也就是空格字符。补零只在 eof 时发生;如果只是块结束,半字节要留给下一块。

第二步把状态写回对象。即使 firstDigit 是 -1,这行赋值也要执行,因为它可能覆盖了上一块留下的值。第三步写回长度。

三个可能触发 eof 的位置,行为并不完全相同:

结束条件触发位置半个字节的处理结果
遇到结束标记块内任意位置补 0 后输出符合规范
上游返回 0 字节下一次 readBlock不补,直接丢弃截断文件
上游耗尽但没有结束标记,且跨块块边界不补,直接丢弃与截断文件相同

第二、三行值得单独说明。if (!bytes.length) 那条提前返回路径只是把 eof 置真就退出了,不会执行补零逻辑。也就是说,如果一个流既没有结束标记、最后一个半字节又恰好落在块边界之前,这个半字节会被静默丢掉。规范要求流必须以结束标记收尾,正常文件不会走到这里;只有在文件被截断或生成器出错时才会出现。这是一种有意的取舍:为几乎不可能发生的非法输入增加一次判断不划算,而且丢掉半个字节本来也无法还原出正确数据。

再看一个具体的解码过程。输入是 48 65 6C 6C 6F 21(字符间用空格只是为了阅读,实际空格会被跳过):

输入片段digit 序列输出说明
484,80x48高四位在前
656,50x65小写字母也可以
6C 6C 6F6,12,6,12,6,150x6C 0x6C 0x6F一段连续的字节
212,10x21标点
大于号无无结束,后面的字节不再读取

如果把最后一个字节换成单独一个 2,输出就是 0x20。如果输入在中途被切成两块,例如第一块是 48 6,第二块是 5 3E,第一块结束时 firstDigit 保留 6,第二块读到 5 时拼成 0x65,随后遇到结束标记,eof 置真。两次 readBlock 之间只传递了一个整数。

设计要点:结束标记是唯一权威,/Length 只用于估算缓冲区。标记之后即使同一块里还有字节,也不再处理。这一条让解码器对错误的长度字段天然免疫——只要文本里有结束标记,解码结果就是对的。

与其它过滤器的差异

把三个「非压缩」或「简单编码」的过滤器放在一起,差异一目了然:

过滤器输入字符集结束标记输出与输入之比跨块状态
ASCIIHexDecode十六进制字符,其余字节全部跳过大于号最多 0.5半个字节
ASCII85Decode33 到 117 的可打印字符波浪号约 0.8无
RunLengthDecode任意二进制0x80复制型接近 1,重复型最高 64 倍无

ASCII85Decode 的构造函数把提示乘以 0.8,而 AsciiHexStream 乘以 0.5,原因相同:让缓冲区的首次分配贴近真实输出。Ascii85Stream 每次读满一组五个字符,组内使用 isWhiteSpace 判断空白,因此不需要跨块保存状态;遇到波浪号或上游结束就收尾,末组不足时用 0x21 + 84 补齐。

RunLengthDecode 走的是完全不同的路:它处理的是二进制字节,不需要跳过任何字符,也不保存半个字节。ASCIIHex 与 RunLength 虽然都很短,但状态管理方式相反:一个靠一个常驻整数,一个靠「每次读满一个 run」。

这三个过滤器有一个共同点:它们都不改变数据的语义,只改变表示方式。也正因如此,它们都不需要像 Flate 那样维护字典、窗口或码表,readBlock 可以短到几十行。

总结

AsciiHexStream 是 PDF.js 的文本转二进制过滤器:向上接受任意字节源,向下输出连续的二进制字节,中间只靠一个半字节的状态完成跨块拼接。

它在整条链路中的位置

阶段模块输入输出
字典解析Parser.makeStream/Filter、/Length过滤器名、提示长度
过滤器构造Parser.makeFilter上游 BaseStreamAsciiHexStream
解码AsciiHexStream.readBlock十六进制文本块DecodeStream.buffer
读取DecodeStream.getBytes调用方需要的字节数二进制字节视图
消费Lexer、图像解码器、XRef字节token、像素、xref 记录

用一句话串起来:上游提供文本块,readBlock 把每两个字符拼成一个字节,getBytes 把多个块缝成一条连续字节流,解析器与图像解码器只看结果。

五条设计原则

  1. 提示归提示,容量靠兜底。maybeLength 乘以 0.5 只用于第一次分配;ensureBuffer 的翻倍策略才是真正的容量保证。分离两者,使解码器能容忍错误的长度字段。
  2. 让编码布局承担分类工作。数字与字母的低四位连续,于是「判断是不是十六进制字符」与「算出数值」可以合并成一次掩码加一次加法。热循环里没有查表,也没有字符串转换。
  3. 状态按语义保存,不按输入保存。跨块续接只需要一个 0 到 15 的整数,外加 -1 表示空。无论块多大、文本怎么切,状态大小恒定。
  4. 扩容在循环外,写字节在循环内。(bytes.length + 1) >> 1 给出本块产出的严格上界,因此循环体内永远不需要再检查容量。
  5. 显式结束标记优先于长度字段。遇到结束标记立即停止,不依赖 /Length 的准确性;这也是它能处理内联图像这类长度不定的流的原因。

下一篇换一种展开方式:run_length_stream 处理的是「一个头字节加一段数据」的行程编码。它没有字符要跳过,也不需要半个字节的状态,复杂度落在 run 语义与缓冲区累积上。

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

相关文章