StreamPDFStreamPDF
返回博客

PDF.js 源码解读: jbig2_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: jbig2_stream

前言

这是「图像解码」层的第二篇文章,对象是 Worker 端 src/core/jbig2_stream.js(104 行)中的 Jbig2Stream。

上一篇 jpeg_stream 处理的是连续色调的 DCT 码流;本篇处理的是二值图像。JBIG2 由 ITU-T T.88 定义,可以看作传真编码(CCITT G3/G4)的后继:它面向黑白扫描页,用符号字典、算术编码与区域组合把 1 位像素压到很小的体积。扫描版 PDF 里的文字层、传真归档、部分电子书,背后往往就是一段 JBIG2 码流。

它在 PDF 里的过滤器名是 /JBIG2Decode,入口同样来自 Parser.makeFilter:

        case "JBIG2Decode":
          return new Jbig2Stream(stream, maybeLength, params);

PDF.js 没有用 JavaScript 从头实现 JBIG2,而是把 PDFium 的解码器编译成 WASM,并提供纯 JS 回退。这个选择很实际:JBIG2 的算术编码与符号字典组合远比 JPEG 的 Huffman 加 IDCT 复杂,纯 JS 实现既慢又难维护。代价是解码变成异步,整个流类因此要围绕 Promise 重新设计。

与 JPEG 相比,这里多了三件麻烦事:

  1. JBIG2 有一个独立的文件头,而 PDF 里嵌入的是不带文件头的段流;有些生成器仍然把文件头写进去,解码前必须剥掉。
  2. 一份图像的符号字典可以放在另一条流里,通过 /DecodeParms 的 /JBIG2Globals 引用。主码流与全局段要分别取回、分别剥头,再一起交给解码器。
  3. 真正的解码器是一个异步的 WASM 单例,首次调用还要加载 jbig2.wasm(或纯 JS 回退模块)。同步 getBytes 那条路在这里走不通。

一句话概括它的职责:取回主码流与全局段,剥掉可能存在的 JBIG2 文件头,把字节、字典尺寸与全局段交给 WASM 解码器,异步得到一幅 1 位像素缓冲。

模块总览

jbig2_stream.js 只有 104 行,主体是一类、五个方法与两个 getter:

区块关键成员职责
构造constructor保存上游流、图像字典、长度与 DecodeParms
字节入口bytes、ensureBuffer惰性读取压缩字节,声明不做增量缓冲
类型标记isAsyncDecoder、isImageStream声明异步解码与图像身份
预处理stripFileHeader去掉 JBIG2 文件头,主码流与全局段共用
解码decodeImage取全局段,调用 WASM 解码器,异步写入缓冲
异步能力canAsyncDecodeImageFromBuffer把「能否先异步取字节」委托给上游流

它和 JpegStream 共享同一套「把整图解码器嵌入流接口」的模板:惰性 bytes、空实现的 ensureBuffer、委托上游的 canAsyncDecodeImageFromBuffer、恒为 true 的 isImageStream。差别在于解码函数返回 Promise,因此这个类不能走同步 getBytes。

三个布尔标记回答的是三个不同的问题,读源码时要分开看:

标记回答的问题本类实现
canAsyncDecodeImageFromBuffer压缩字节能否先异步取齐委托 this.stream.isAsync
isAsyncDecoder解码函数本身是否返回 Promisetrue
isImageStream这是图像流还是页面内容流true

三者组合出 DecodeStream.getImageData 的两条分支:上游可异步时先 asyncGetBytes 再解码;上游同步时因为 isAsyncDecoder 为真,直接调用 decodeImage 并等待 Promise。同步 getBytes 永远不会被图像链路选中。

stripFileHeader 是静态方法,因为它不依赖实例状态:主码流与全局段只是两个字节数组,切头规则完全相同。静态化让它可以被独立测试,也避免为全局段临时构造一个流对象。

继承与构造

类的声明与 JpegStream 几乎逐行对应:

class Jbig2Stream extends DecodeStream {
  constructor(stream, maybeLength, params) {
    super(maybeLength);
 
    this.stream = stream;
    this.dict = stream.dict;
    this.maybeLength = maybeLength;
    this.params = params;
  }

constructor 方法

四个字段的语义与上一篇相同,但用途各有侧重:

  • stream 是上游流,可能是内存 Stream,也可能是网络分块流。bytes 与 asyncGetBytes 都从它取数据。
  • dict 是图像字典。JBIG2 的 /Width 与 /Height 不来自码流,而是从这里读,后文会展开。
  • maybeLength 来自 /Length,为 null 时表示一直读到上游 EOF。JBIG2 段流没有 JPEG 那样的 EOI,读到末尾就是完整数据。
  • params 是 /DecodeParms。/JBIG2Globals 就挂在这里,它是本类区别于 JPEG 的关键输入。

super(maybeLength) 同样会计算 minBufferLength,但 ensureBuffer 是空实现,这套扩容逻辑不会执行。

bytes 惰性 getter

压缩字节仍然通过 getter 惰性读取并缓存:

  get bytes() {
    // If `this.maybeLength` is null, we'll get the entire stream.
    return shadow(this, "bytes", this.stream.getBytes(this.maybeLength));
  }

shadow 第一次访问时执行 getBytes,之后把 getter 替换成同名的自有数据属性,后续读取不再触达上游。与 JPEG 一样,这里有一个同步副作用:网络分块流如果还没下载完,getBytes 会抛 MissingDataException。DecodeStream.getImageData 在 canAsyncDecodeImageFromBuffer 为真时会先 asyncGetBytes,正是为了绕开这次同步读取。

ensureBuffer、isAsyncDecoder 与 isImageStream

接下来三个成员都很短,却决定了这个类能不能被消费:

  ensureBuffer(requested) {
    // No-op, since `this.readBlock` will always parse the entire image and
    // directly insert all of its data into `this.buffer`.
  }
 
  get isAsyncDecoder() {
    return true;
  }
 
  get isImageStream() {
    return true;
  }

ensureBuffer 空实现沿用了图像流的写法:不为「增量写入」预留空间,反正整幅图一次解码后直接替换 buffer。

但这里有一个细节值得指出:注释说 readBlock 会解析整幅图,而 Jbig2Stream 并没有覆盖 readBlock。基类的 readBlock 实现是 unreachable("Abstract method ...")。也就是说,这个类从设计上就不支持同步 getBytes;如果哪条调用链真的走到 readBlock,会立刻抛错。真正让图像链路绕开同步读取的,是 isAsyncDecoder。

isAsyncDecoder 在 BaseStream 里默认是 false。它告诉 DecodeStream.getImageData:即使上游字节已经就绪,也要走 decodeImage 并 await 它,而不能走 getBytes。原因很直接:WASM 模块加载与 decode 都是 Promise,没有任何办法把它们伪装成同步字节流。

isImageStream 则把这条流与页面内容流区分开。StreamsSequenceStream 拼接内容时会过滤掉它,PDFPage.getContentStream 也不会把像素当操作符解析。JBIG2 流尤其需要这个标记,因为它连同步读取都不支持,一旦被当成普通流处理就会直接抛错。

设计要点:JPEG 用 readBlock 把异步世界伪装成同步世界;JBIG2 不伪装,直接声明 isAsyncDecoder,让上层走异步入口。同一个模板在「解码器是否可同步驱动」这一点上分叉。承认限制比制造一个假的同步接口更安全:前者会在类型层面暴露,后者只会在运行时炸开。

stripFileHeader:剥掉独立文件头

stripFileHeader 方法

JBIG2 的独立文件格式与 PDF 内嵌的段流不是一回事。规范 T.88 的 Annex D.4 定义了文件头,PDF 里通常存的是「embedded」组织形式,不带这层头:

  // The JBIG2 file header is defined in ITU-T T.88, Annex D.4:
  // https://www.itu.int/rec/T-REC-T.88
  static stripFileHeader(bytes) {
    if (
      bytes.length >= 9 &&
      bytes[0] === 0x97 &&
      bytes[1] === 0x4a &&
      bytes[2] === 0x42 &&
      bytes[3] === 0x32 &&
      bytes[4] === 0x0d &&
      bytes[5] === 0x0a &&
      bytes[6] === 0x1a &&
      bytes[7] === 0x0a
    ) {
      const headerLength = (bytes[8] & 2) === 0 ? 13 : 9;
      return bytes.subarray(headerLength);
    }
    return bytes;
  }

文件头的布局可以写成一张表:

偏移长度内容
0 到 78魔数 0x97 0x4A 0x42 0x32 0x0D 0x0A 0x1A 0x0A
81flags,其中掩码 2 的位表示页数是否未知
9 到 124页数,仅当掩码 2 的位为 0 时存在

三个判断:

  1. 至少 9 字节。魔数加 flags 就是 9 字节;更短的输入不可能带合法文件头,直接原样返回。
  2. 逐字节比对魔数。八个常量分别对应 JBIG2 的签名,任何一位不符就认为这是纯段流,不做切割。这里没有用 TextDecoder 或字符串比较,避免在字节层引入编码开销。
  3. 由 flags 决定 9 还是 13。bytes[8] 中掩码为 2 的那一位如果为 0,说明文件头里记录了页数,后面还有 4 字节,总长 13;如果为 1,页数未知,头只有 9 字节。源码写成 (bytes[8] & 2) === 0 ? 13 : 9,位运算和三元表达式的顺序很容易读反。

返回值用 subarray,是视图而不是副本。这一点与 JPEG 的 #skipUselessBytes 一致:剥头只改变读取起点,不搬运数据。主码流与全局段都调用这个静态方法,避免两份实现分叉。

之所以要剥头,是因为 WASM 侧的 _jbig2_decode 接收的是段流:它自己解析段头、区域信息与符号字典,不期待前面再套一层文件头。保留文件头会让第一个段头解析错位,后续整段数据都读歪。

段流的组织形式

文件头之下是连续的 segment(段)。每个段由段头与段数据组成,段头记录段号、段类型、页关联、被引用段列表与数据长度。类型决定段体是页信息、符号字典、中间区域还是文字区域;页关联决定它属于哪一页;被引用段列表让符号字典可以跨段复用。

字段作用与 PDF 的关系
段号唯一标识一个段全局段里的字典靠段号被引用
flags段类型、页关联长度、引用计数决定段头后续字段如何解析
页关联该段属于哪一页多页码流据此拆分
数据长度段体的字节数长度错误会让整段解析失败

stripFileHeader 不碰这些字段,只把文件头切掉;段头的解析全部交给 WASM。流层不重复实现规范,这是边界清晰的地方:PDF 的兼容问题在 JavaScript 里解决,JBIG2 的语法问题在解码器里解决。

decodeImage:把三个输入交给 WASM

decodeImage 方法

真正的解码入口是异步的:

  async decodeImage(bytes, length, _decoderOptions) {
    if (this.eof) {
      return this.buffer;
    }
    bytes = Jbig2Stream.stripFileHeader(bytes || this.bytes);
 
    let globals = null;
    if (this.params instanceof Dict) {
      const globalsStream = this.params.get("JBIG2Globals");
      if (globalsStream instanceof BaseStream) {
        globals = Jbig2Stream.stripFileHeader(globalsStream.getBytes());
      }
    }
    this.buffer = await JBig2CCITTFaxImage.instance.decode(
      bytes,
      this.dict.get("Width"),
      this.dict.get("Height"),
      globals
    );
    this.bufferLength = this.buffer.length;
    this.eof = true;
 
    return this.buffer;
  }

逐段拆解:

  1. eof 守卫。与 JPEG 相同,解完直接返回已有缓冲。区别是这里的缓冲来自 WASM,类型与普通 Uint8Array 不完全一样,但上层只按字节长度与位深解释。
  2. 主码流剥头。bytes || this.bytes 允许异步路径传入已经取好的字节;不传时走惰性 getter。无论来源,先过 stripFileHeader。
  3. 全局段可选。params 必须是 Dict 才有 /JBIG2Globals;取到的值必须是 BaseStream,否则当作没有全局段。全局段自己也可能带文件头,所以同样剥一次。
  4. 字典尺寸。Width 与 Height 从 this.dict 读取,不是从 params,也不是从 JBIG2 段头猜。PDF 的图像字典是最终尺寸的权威来源,码流里的区域信息只描述如何拼出这幅图。
  5. WASM 解码。JBig2CCITTFaxImage.instance 是一个 shadow 缓存的单例,decode 接收主码流、宽、高与全局段,返回 Promise。
  6. 提交结果。buffer 直接替换,bufferLength 取长度,eof 置真。length 与 _decoderOptions 都被忽略:JBIG2 没有按请求长度解一半的能力,也没有可调的解码选项。

注意 eof = true 是在 await 之后才执行的。它不是并发锁:如果两个调用方在解码完成前同时进入,两边都会看到 eof 为假。实际链路里同一张图通常由一次 getImageData 驱动,但这个细节说明「幂等」只保证重复读取不重复解码,不保证并发只解一次。

JBIG2Globals 与全局段

JBIG2 的码流由一个个段组成:页信息段、符号字典段、中间区域段、文字区域段等。全局段存放可以跨页复用的符号字典与通用区域。PDF 把它们拆成两条流:

  • 图像流本身只放当前页的段;
  • /DecodeParms 里的 /JBIG2Globals 指向另一条流,存放共享段。

解码器需要同时看到两者,才能把符号编号映射回真实位图。源码的取法很直接:

if (this.params instanceof Dict) {
  const globalsStream = this.params.get('JBIG2Globals');
  if (globalsStream instanceof BaseStream) {
    globals = Jbig2Stream.stripFileHeader(globalsStream.getBytes());
  }
}

三个约束藏在短短四行里:

  • 只认 BaseStream。/JBIG2Globals 可能缺失、可能是 null、也可能被损坏文件写成别的类型;这些情况都退化成「没有全局段」,而不是抛错。
  • 同步取字节。globalsStream.getBytes() 不带长度,读到该流 EOF。全局段通常不大,且与图像字典一起被取回,这里没有再走一次 asyncGetBytes。
  • 全局段也剥头。有些生成器把全局段单独存成完整 JBIG2 文件,文件头同样不属于段流。主码流与全局段共用 stripFileHeader,保证两条输入的组织形式一致。

如果漏掉全局段,带符号字典的 JBIG2 图像会解出一堆空白或直接失败:解码器在文字区域里读到符号编号,却找不到对应的位图。这个字段在 PDF 规范里是 /DecodeParms 的可选项,但在扫描件里出现频率很高。

Width 与 Height 的权威来源

decode 的第二个、第三个参数是 this.dict.get("Width") 与 this.dict.get("Height")。这一点与 JPEG 不同:JPEG 的 SOF 段自带尺寸,JpegImage 可以从码流里读;JBIG2 的段流描述的是区域与组合方式,最终位图的尺寸由 PDF 图像字典规定。

因此会出现三种情况:

字典尺寸码流内容结果
与页信息段一致正常单页按字典尺寸输出,最常见
大于码流实际区域多区域拼接或空白填充解码器按给定尺寸布置区域,空白处留白
缺失或非正整数任意WASM 收到非法尺寸,解码失败,由图像链路兜底

源码没有在这里做类型校验,直接把 dict.get 的结果传下去。校验责任在 Parser 与 PDFImage:图像字典的 Width、Height 是必填项,损坏到缺失时,失败会发生在解码器内部,而不是流层。

WASM 单例与输出格式

JBig2CCITTFaxImage 继承自 WasmImage,负责加载 jbig2.wasm;环境不支持 WASM 时回退到 jbig2_nowasm_fallback.js。instance 是 shadow 缓存的单例,整个 Worker 共用一份模块实例:

  • 首次调用时加载并初始化模块;
  • 每次解码用 _malloc 把主码流与全局段复制进 WASM 堆;
  • 调用 _jbig2_decode,由 C 侧完成算术解码、符号组合与位图合成;
  • 结果写到 module.imageData,再复制成 JavaScript 的 Uint8ClampedArray;
  • finally 里释放两块堆内存,避免跨文档累积。

输出是1 位打包的像素行,而不是已经展开的 RGBA。这与 PDF 的 /BitsPerComponent 1、/ColorSpace /DeviceGray 对齐:Worker 里保持紧凑格式,等 PDFImage 决定绘制方式时再展开。

WasmImage.cleanup 在文档清理时可以丢弃模块 Promise,但单例对象本身仍然存在;下一次解码会重新初始化。这个生命周期由 Catalog.cleanup 等入口调用的 clearGlobalCaches 驱动,不在本文件的职责范围内。

1 位像素如何被消费

PDFImage.createImageData 拿到这条流时,因为 image instanceof DecodeStream,会直接复用 buffer,不在 Worker 里展开成 RGBA;需要反相时只在 1 位缓冲上做一次异或。位图种类被标记为 ImageKind.GRAYSCALE_1BPP,一个字节承载八个像素。

若 Worker 支持 OffscreenCanvas,createBitmap 用 convertToRGBA 展开成 RGBA 后 transferToImageBitmap;否则把 1 位缓冲与 kind 一起传回主线程,由显示端展开。两条路都把「解压」与「上色」分开:WASM 只产出位平面,颜色空间与反相留到绘制前。对整页扫描图来说,这一步省下的是一整幅 RGBA 的中间内存。

设计要点:decodeImage 把「格式预处理」「可选输入」「尺寸权威」三件事全部放在流层完成,WASM 侧只接收干净的段流、宽高与全局段。边界越窄,解码器越不需要理解 PDF 的损坏方式;反过来,PDF 的兼容逻辑也不会渗进 C 代码。

异步取字节

canAsyncDecodeImageFromBuffer 方法

最后一个方法与 JPEG 完全一致:

  get canAsyncDecodeImageFromBuffer() {
    return this.stream.isAsync;
  }

它只回答「上游字节能否异步取齐」,不回答「解码是否异步」。两者组合起来,DecodeStream.getImageData 有两条路径:

上游 isAsyncisAsyncDecodergetImageData 的行为
truetrue先 await asyncGetBytes,再 await decodeImage
falsetrue直接 await decodeImage(null, ...)
falsefalse同步 getBytes,循环 readBlock

对本类来说,第三行永远不会出现,因为 isAsyncDecoder 恒为真;如果强行调用 getBytes,会落到基类未实现的 readBlock。第二行是本地文件场景:字节已经就绪,但解码仍然是 Promise,必须等待。第一行是网络场景:先把压缩字节取齐,避免解码中途缺数据。

这里没有 MissingDataException 重试逻辑,因为 decodeImage 本身不是可恢复的解析器。WASM 一旦开始读堆内存,就没有「暂停再续」的接口;把字节取齐是唯一正确的做法。

这也是为什么本类不提供 readBlock:同步接口要求一个能立即返回的块解码函数,而 WASM 调用必须等待模块加载与 Promise 结算。与其写一个会抛错的伪实现,不如让 isAsyncDecoder 把调用方引到正确入口。

isImageStream 与消费者

isImageStream 的三行实现与 JPEG 相同,但对 JBIG2 更重要:

  get isImageStream() {
    return true;
  }

全仓库只有两处消费者:

  • StreamsSequenceStream 构造时过滤图像流,避免把像素拼进内容流;
  • PDFPage.getContentStream 排除图像流,避免把 JBIG2 字节当成页面操作符。

JBIG2 流没有可用的 readBlock,一旦被误当普通流读取,会立刻抛「抽象方法被调用」。isImageStream 把这种误用挡在类型判断阶段,而不是等运行时爆炸。

四个常见误读

  • 把 /JBIG2Globals 当成主码流的一部分。它是 /DecodeParms 引用的独立流,必须单独取回、单独剥头,再作为第四个参数传给解码器。
  • 认为 stripFileHeader 只处理主码流。全局段同样可能带完整的 JBIG2 文件头,源码对两条输入一视同仁。
  • 看到 ensureBuffer 空实现就以为同步 getBytes 可用。readBlock 并没有被覆盖,同步读取会落到基类的 unreachable;正确入口是 getImageData,由 isAsyncDecoder 分流。
  • 把 Width、Height 当成 /DecodeParms 的字段。它们在图像字典里,源码从 this.dict 读取,params 只负责 /JBIG2Globals。

与 jpeg_stream 的对照

维度JpegStreamJbig2Stream
过滤器/DCTDecode、/DCT/JBIG2Decode
解码器纯 JS JpegImageWASM 单例 JBig2CCITTFaxImage
解码同步性同步异步
同步读取可用,readBlock 触发整图解码不可用,未覆盖 readBlock
文件头只需跳过 SOI 之前的垃圾字节需按 flags 剥 9 或 13 字节文件头
额外输入/ColorTransform/JBIG2Globals 全局段
尺寸来源drawWidth、drawHeight 可缩放字典 Width、Height,不可缩放
输出1、3 或 4 通道像素1 位打包像素
原生加速可选 ImageDecoder 路径无,统一走 WASM

两篇文章放在一起看,会发现「惰性字节 + 空 ensureBuffer + 异步能力委托 + isImageStream」是图像流的标准模板;真正不同的只有解码器接口与格式预处理。把模板与差异分开,读新过滤器时只需要回答三个问题:字节怎么来、解码器是同步还是异步、码流前面有没有格式头。

总结

Jbig2Stream 是二值图像解码层的适配器:它把 PDF 的两条流(主码流与全局段)整理成一份干净的段流输入,剥掉不属于段流的文件头,再把宽高与全局段一起交给异步 WASM 解码器。

它在整条链路中的位置

阶段模块输入输出
过滤器Parser.makeFilter/JBIG2Decode、流字典Jbig2Stream
取字节bytes、canAsyncDecodeImageFromBuffer上游 Stream 或分块流完整主码流字节
预处理stripFileHeader主码流、全局段去头的段流
解码decodeImage → JBig2CCITTFaxImage.instance段流、Width、Height、全局段1 位像素缓冲
图像封装PDFImage.createImageData1 位缓冲ImageBitmap
绘制CanvasGraphicsImageBitmapcanvas 上的图像

用一句话串起来:Parser 把 /JBIG2Decode 包装成 Jbig2Stream,流层剥头并汇总全局段,WASM 把段流解成 1 位位图,PDFImage 再决定如何展开与绘制。

五条设计原则

  1. 复用图像流模板,只替换解码器。惰性 bytes、空 ensureBuffer、委托上游的 canAsyncDecodeImageFromBuffer、恒真的 isImageStream,四项与 JPEG 完全一致;新格式只需要补自己的预处理与解码调用。
  2. 格式头在进入解码器前解决。stripFileHeader 同时服务主码流与全局段,按 flags 精确切 9 或 13 字节,并用 subarray 避免复制。解码器因此只需要理解段流这一种输入。
  3. 可选输入显式降级。/JBIG2Globals 缺失、类型不对时把 globals 保持为 null,而不是抛错。带符号字典的图会因此失败,但不带字典的图仍能正常解码,容错范围被控制在最小。
  4. 异步能力必须显式声明。isAsyncDecoder 让上层走 getImageData 而不是 getBytes,避免调用未实现的 readBlock。不伪造同步接口,是把限制暴露在类型层而不是运行时。
  5. 中间结果保持紧凑。WASM 输出 1 位打包像素,不在 Worker 里提前展开成 RGBA;真正的展开推迟到 PDFImage 决定绘制路径时。像素越大,这条原则省下的内存越多。

至此,「JBIG2 怎么变成二值位图」这一环补齐了。图像解码层还剩 JPX(JPEG 2000)与 CCITT 传真:前者同样是异步 WASM,后者是纯 JS 的位流解码,但都沿用本篇文章里的模板。读完这三类,再看 PDFImage 如何根据 ColorSpace、掩码与 BitsPerComponent 解释像素,链路就闭合了。

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

相关文章