StreamPDFStreamPDF
返回博客

PDF.js 源码解读: jpx_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: jpx_stream

前言

前面十几篇讲的都是「过滤器」:Flate、LZW、ASCIIHex、ASCII85、RunLength、Brotli、Predictor。它们共享同一个节奏——实现 readBlock,每次产出一小段字节,由 DecodeStream 的读取循环把片段缝成连续数据。输入可以是网络分块,解码器只关心当前窗口。

从本篇开始进入「图像解码」层。图像编解码与通用压缩有本质区别:JPEG、JPEG 2000、JBIG2、CCITT 的码流以整幅图或整行为单位组织,块与块、行与行、小波层级之间存在依赖,无法像 Deflate 那样任意切断后独立解出前若干字节。因此 PDF.js 把这些流的出口统一成一次 decodeImage,而不是让它们按块吐字节。

这一层的第一篇看 src/core/jpx_stream.js(68 行)里的 JpxStream。它对接 JPEG 2000,也就是 PDF 里的 /JPXDecode 过滤器。JPEG 2000 由 ISO/IEC 15444 定义,PDF 1.5 起支持;它既能压连续色调照片,也能压二值扫描件,还允许在码流内部携带 alpha 通道(SMaskInData),因此在医学影像、地图和高质量扫描 PDF 里很常见。同属这一层的还有 CCITT 传真解码(下一篇展开),两者都是「一次 decodeImage 出整幅像素」,差别只在字节怎么取、参数从哪来。

入口在 Parser.makeFilter:

        case "JPX":
        case "JPXDecode":
          return new JpxStream(stream, maybeLength);

注意这里只传了两个参数,没有把 DecodeParms 传进来。DCT 与 CCITT 的构造都接收 params,JPX 不接收。原因在后面「解码参数从哪来」一节会展开:JPEG 2000 真正需要的解码选项取决于图像字典与渲染尺寸,而不是过滤器字典,所以由 PDFImage 在更上层准备好,再通过 decoderOptions 传入。

一句话概括它的职责:把上游完整码流取到手,交给单例 JpxImage 异步解码成像素缓冲,并把结果直接写成 DecodeStream 的输出。

模块总览

jpx_stream.js 去掉版权头与 import 之后只有四十多行,可以分成五块:

区块关键成员职责
构造constructor保存上游流、字典与长度提示
字节bytes惰性取出完整码流,并用 shadow 缓存
缓冲ensureBuffer空实现,声明本流不参与增量扩容
解码decodeImage调用 JpxImage.instance.decode,异步整图解码
能力声明isAsyncDecoder、isImageStream、canAsyncDecodeImageFromBuffer告诉基类与调用方该怎么用这个流

它在整条链路里的位置:

XObject 字典 /Filter /JPXDecode
  → Parser.makeFilter
  → JpxStream(stream, maybeLength)
  → PDFImage 读取字典,生成 decoderOptions
  → DecodeStream.getImageData
  → JpxStream.decodeImage
  → JpxImage.instance.decode(OpenJPEG WASM)
  → 像素缓冲
  → PDFImage / ImageResizer / 主线程

与传统过滤器相比,最大的差异是没有 readBlock。DecodeStream 的同步读取契约在这里被主动放弃,换成了 decodeImage 的异步契约。

整图解码:为什么没有 readBlock

DecodeStream 的基类把 readBlock 定义为必须实现的抽象方法:

  readBlock() {
    unreachable("Abstract method `readBlock` called");
  }

Flate、LZW 这些子类都覆盖了它。JpxStream 没有覆盖,因为它无法满足「读一段、解一段」的语义。JPEG 2000 的码流由多个 tile、多个分辨率层级和算术编码通道组成,解码器必须拿到完整码流(至少是整个 tile-part)才能确定一幅图的像素。强行按块切分,只会让解码器反复初始化,或者得到不完整的图像。

于是调用路径改走 DecodeStream.getImageData:

  async getImageData(length, decoderOptions) {
    if (!this.canAsyncDecodeImageFromBuffer) {
      return this.isAsyncDecoder
        ? this.decodeImage(null, length, decoderOptions)
        : this.getBytes(length, decoderOptions);
    }
    const data = await this.stream.asyncGetBytes();
    return this.decodeImage(data, length, decoderOptions);
  }

这段基类代码把图像流分成三种情况:

条件走的路径适用对象
不能从缓冲异步解码,且是异步解码器decodeImage(null, length, options)CCITTFaxStream;同步上游时的 Jbig2Stream
不能从缓冲异步解码,且不是异步解码器getBytes(length, options)普通 DecodeStream
能从缓冲异步解码先 await asyncGetBytes,再解码JpxStream、JpegStream;异步上游时的 Jbig2Stream

JpxStream 的两个能力声明决定了它走第三行:

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

isAsyncDecoder 是「这个解码器本身是异步的」;canAsyncDecodeImageFromBuffer 是「能不能先从底层流异步把整个缓冲取出来」。两者不是一回事:前者描述解码器,后者描述数据源。JpxStream 把后者委托给 this.stream.isAsync,于是:

  • 上游是 ChunkedStream(网络 PDF)时,isAsync 为 true,基类先 await this.stream.asyncGetBytes(),把整幅图下载齐,再调用 decodeImage(data, ...)。
  • 上游是普通 Stream(内存或本地文件)时,isAsync 为 false,基类调用 decodeImage(null, ...),由 decodeImage 里的 bytes ||= this.bytes 同步取出码流。

设计要点:canAsyncDecodeImageFromBuffer 让「取字节」的策略跟着数据源走,而不是写死在解码器里。同一个 JpxStream,在网络流上走异步预取,在内存流上走同步读取,解码逻辑一行都不用变。

构造与 bytes:惰性缓存整幅码流

constructor 方法

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

构造函数只有三件事:

动作作用
super(maybeLength)让基类把长度提示换算成不小于它的 2 的幂,作为初始容量
this.stream = stream保留上游流,后续 bytes 与异步预取都要用
this.dict = stream.dict透传图像字典,PDFImage 之后要靠它读 Width、Height、SMaskInData

this.dict = stream.dict 而不是从构造参数单独接收,是因为过滤器链总是共享同一份字典:无论中间套了多少层 Flate 或 Predictor,最外层的 /Width、/Height 仍然描述最终图像。JPX 在 PDF.js 里通常是过滤器链的最后一层,但它依旧沿用这个约定。

maybeLength 被单独保存了一份。基类只把它存成 _rawMinBufferLength 用于扩容,而 bytes 需要原始值来决定向上游要多少字节;null 与具体数字的语义不同,不能从基类的幂次容量反推。

bytes 方法

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

shadow 的作用是把 getter 的第一次结果直接写成实例属性,后续访问不再执行函数体。对一个只需要读一次的完整图像来说,这既避免重复取字节,也避免重复分配。

maybeLength 的取值决定读取范围:

maybeLengthgetBytes 的行为结果
具体数字读指定长度通常等于 /Length
null一直读到 eof整条上游流

PDF 的 /Length 有时不可靠,尤其是增量更新或损坏文件;maybeLength 为 null 时退化成「全读」,是更稳妥的兜底。注意这里没有做任何长度校验,取到多少就解多少,真正的合法性检查在 JpxImage 内部。

ensureBuffer 方法

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

这是一个空实现,参数 requested 完全没用。它的存在是一个「契约声明」:JpxStream 不会通过基类的几何扩容来增长缓冲,因为 decodeImage 会一次性把解码结果赋给 this.buffer。

从 DecodeStream.getBytes 的视角看,这个空实现其实意味着「不要走同步读取」:getBytes 先调用 ensureBuffer,再循环调用 readBlock;而 JpxStream 没有 readBlock,同步路径必然触发基类的 unreachable。所以这个类只能通过 getImageData 消费,ensureBuffer 的空实现是在配合这个收窄后的契约。

设计要点:空实现不是省代码,而是显式否认基类的一项能力。DecodeStream 把「缓冲扩容 + 分块读取」做成默认模板,图像流用空 ensureBuffer 和缺失的 readBlock 告诉读者:这里不适用模板,请走图像专用入口。

decodeImage:异步整图解码

decodeImage 方法

  async decodeImage(bytes, _length, decoderOptions) {
    if (this.eof) {
      return this.buffer;
    }
    bytes ||= this.bytes;
    this.buffer = await JpxImage.instance.decode(bytes, decoderOptions);
    this.bufferLength = this.buffer.length;
    this.eof = true;
 
    return this.buffer;
  }

逐行拆开:

| 行 | 含义 | | ------------------------------------------------------- | ---------------------------------------- | ------------- | ---------------------------------- | | if (this.eof) return this.buffer | 解码幂等;重复调用直接返回上次结果 | | bytes | | = this.bytes | 基类没预取时,在这里同步取完整码流 | | await JpxImage.instance.decode(bytes, decoderOptions) | 交给单例解码器,异步等待 WASM 完成 | | this.bufferLength = this.buffer.length | 输出长度以实际像素为准,不使用 _length | | this.eof = true | 标记整图已完成,后续读取立即返回 |

第三个参数 decoderOptions 是原样透传的,_length 则被忽略。这个取舍很关键:JPEG 2000 的输出尺寸由码流头部的 SIZ 标记段决定,PDF 字典里的 /Width、/Height 只用于布局与校验;如果按 _length 预分配或截断,反而会破坏图像。

bytes ||= this.bytes 用的是逻辑或赋值,只在 bytes 为 null 或 undefined 时回退。空 Uint8Array 是 truthy,不会被替换,因此「零长度码流」会原样送进解码器并得到一个明确的解码错误,而不是被悄悄换成上游数据。

JpxImage.instance

JpxImage 的入口是单例:

class JpxImage extends WasmImage {
  _filename = "openjpeg.wasm";
 
  static get instance() {
    return shadow(this, "instance", new JpxImage(/* trackInstance = */ true));
  }

instance 通过 shadow 缓存,整个 Worker 共享同一个 JpxImage。这样 OpenJPEG 的 WASM 模块只需要初始化一次,代价是解码时要把码流写入模块堆、再把结果读出来。真正的方法体做了三件事:

const size = bytes.length;
ptr = module._malloc(size);
module.writeArrayToMemory(bytes, ptr);
const ret = module._jp2_decode(
  ptr,
  size,
  numComponents > 0 ? numComponents : 0,
  !!isIndexedColormap,
  !!smaskInData,
  reducePower,
);

_malloc 申请与码流等长的堆空间,writeArrayToMemory 把 JS 的 Uint8Array 复制进去,_jp2_decode 在 WASM 里完成熵解码、反量化、逆小波与颜色变换。finally 中一定会 _free,即使解码抛错也不会泄漏。

返回的 imageData 会从模块上摘下来(module.imageData = null),避免下一次解码读到旧结果。

设计要点:单例复用的是「模块」,不是「状态」。每次调用仍然独立申请、复制、释放码流,输出也从模块上摘走。这样既省掉反复初始化的开销,又不会让两次解码互相污染。

解码参数从哪来

JpxStream 自己不解析 /DecodeParms,decoderOptions 也不来自过滤器链。真正的构造发生在 PDFImage 里:

this.jpxDecoderOptions = {
  numComponents: 0,
  isIndexedColormap: false,
  smaskInData: dict.get('SMaskInData') >= 1,
  reducePower,
};

不过这四个字段并不是一次定稿。图像颜色空间解析完之后,PDFImage 还会回填两个字段:

if (this.jpxDecoderOptions) {
  this.jpxDecoderOptions.numComponents = hasColorSpace ? this.numComps : 0;
  // If the jpx image has a color space then it mustn't be used in order
  // to be able to use the color space that comes from the pdf.
  this.jpxDecoderOptions.isIndexedColormap = this.colorSpace.name === 'Indexed';
}

于是四个字段的最终来源是:

字段来源作用
numComponents有颜色空间时取 colorSpace.numComps,否则为 00 表示让解码器按码流自行决定输出分量数
isIndexedColormapcolorSpace.name === "Indexed"索引颜色需要解码器按调色板展开
smaskInData图像字典 /SMaskInData 大于等于 1码流内自带 alpha,需要解码成 RGBA
reducePowerImageResizer.getReducePowerForJPX降采样层级,直接减少输出像素量

有颜色空间时指定 numComponents,是为了让码流里的分量数与 PDF 声明的颜色空间对齐。JPEG 2000 允许码流自带颜色空间,但 PDF 规范要求以字典里的 /ColorSpace 为准;显式传入分量数,等于告诉解码器「按 PDF 的约定输出」,避免码流内嵌的 ICC 信息覆盖页面资源。isIndexedColormap 同理:索引颜色必须让解码器直接展开成 RGB,否则上层拿到的索引值无法直接渲染。

smaskInData 还影响颜色空间的选择。SMaskInData 大于等于 1 时,PDFImage 会把颜色空间强制成 DeviceRGBA,并在值为 2 时额外标记 jpxPremultiplied(alpha 已预乘)。这意味着同一个码流可能解出 RGB、RGBA 或预乘 RGBA 三种形态,decodeImage 不需要知道这些差异——它只负责把 decoderOptions 原样递下去。

reducePower 是 JPEG 2000 最有价值的特性之一:逆小波可以只还原到较低的分辨率层级,不必先解出全尺寸再缩放。对于一张 6000 乘 8000 的扫描图,少解一级就少四分之一的像素,少解两级只剩十六分之一。解码器在 _jp2_decode 里按这个指数降低输出分辨率,PDFImage 同时把 image.width、image.height 按 2 ** reducePower 向上取整缩小,保证后续布局与像素尺寸一致。

这也解释了为什么 Parser.makeFilter 不给 JpxStream 传 params:过滤器字典只知道「用什么算法」,不知道「最终画多大」。解码选项依赖渲染尺寸、颜色空间与 alpha 需求,这些信息只在 PDFImage 里齐全。

isImageStream:给调用方一个明确信号

JpxStream 最后一行能力声明是:

  get isImageStream() {
    return true;
  }

这个布尔值不参与解码,却会影响两条上层分支。基类默认返回 false,只有图像流覆盖成 true:

使用点行为
StreamsSequenceStream 构造过滤掉 isImageStream 为 true 的流,避免把图像当内容流拼接
document.getContentStream内容流不是图像流时,才把它包装成新的 Stream

第一条尤其重要。一页的内容可能由多个流组成,StreamsSequenceStream 会把它们首尾相接当成一条内容流解析。图像 XObject 的流如果混进去,Lexer 会把像素字节当成 PDF 操作符来读,结果必然是乱码和异常。isImageStream 让这种误用在第一层就被拦住:

streams = streams.filter(s => s instanceof BaseStream && !s.isImageStream);

第二条出现在页面内容装配处:

if (content instanceof BaseStream && !content.isImageStream) {
  if (content.isAsync) {
    const bytes = await content.asyncGetBytes();
    if (bytes) {
      return new Stream(bytes, 0, bytes.length, content.dict);
    }
  }
  return content;
}

图像流被排除在外,因为它只能通过 getImageData 消费,不能像内容流那样先取字节再重新包装成 Stream。如果这里不判断,JpxStream 会被当成普通内容流,后续的同步读取就会撞上缺失的 readBlock。

设计要点:一个布尔 getter 承担的是类型标记职责,而不是行为开关。解码路径通过 decodeImage 分流,内容流路径通过 isImageStream 分流,二者互不干扰。把图像流和内容流混在一条类型判断里,是 PDF.js 刻意避免的耦合。

与 JPEG 解码器的选择对比

PDF 里几类主流图像编码在 PDF.js 里走了不同的路:

维度DCTDecode(JPEG)JPXDecode(JPEG 2000)CCITTFaxDecode / JBIG2Decode
实现位置jpeg_stream.js + jpg.jsjpx_stream.js + OpenJPEG WASMccitt_stream.js / jbig2_stream.js
纯 JS 解码有,JpegImage 自带无,必须走 WASM无,必须走 WASM
浏览器原生快路有,ImageDecoder 可产出 ImageBitmap无无
解码同步性decodeImage 同步,可被 readBlock 调decodeImage 异步decodeImage 异步
是否实现 readBlock实现,内部调用 decodeImage不实现不实现
参数来源/ColorTransformPDFImage 生成 decoderOptions/DecodeParms 七个键
主要优化原生解码器、降采样、跳过无用字节小波降采样 reducePower、码流内 alpha位级传真解码、单色位图直出

三个结论:

  1. 同步解码器可以保留 readBlock,异步解码器不行。JpegStream.decodeImage 是同步函数,所以 readBlock 可以简单转调它,getBytes 这条老路径仍然可用;JPX 的解码要等待 WASM,无法塞进同步的 readBlock,只能收窄到 getImageData。
  2. 原生快路只对 JPEG 开放。JpegStream.getTransferableImage 会尝试 ImageDecoder,直接拿到浏览器解码好的 ImageBitmap,省掉 Worker 里的像素解码;JpxStream 没有对应方法,因为浏览器普遍不支持把 JPEG 2000 交给 ImageDecoder。
  3. 参数位置由信息位置决定。JPEG 只需要一个 ColorTransform 提示,放在 DecodeParms 就够了;JPX 需要分量数、alpha、降采样层级,这些只有拿到图像字典与渲染尺寸才算得出来,所以参数从 PDFImage 流向 decodeImage。

设计要点:同样是「整图解码」,JpegStream 与 JpxStream 的能力边界并不相同。判断标准不是编码格式,而是解码器是否同步、浏览器是否有原生快路、参数是否能在过滤器层确定。JpxStream 的三条能力声明,正是这三点的结果。

边界、错误与幂等

JpxStream 很短,边界情况也就集中在几处:

| 情况 | 行为 | | ------------------------ | ------------------------------------------------------------- | --- | -------------------------- | | 第二次调用 decodeImage | eof 为 true,直接返回 buffer,不重复解码 | | 基类已异步预取字节 | bytes 非空,不再访问 this.bytes | | 基类未预取 | bytes | | = this.bytes 同步取完整流 | | 码流损坏 | WASM 返回错误码,JpxImage 抛 JpxError,eof 保持 false | | WASM 初始化失败 | 抛 JpxError("OpenJPEG failed to initialize") | | 输出像素比 /Length 大 | 正常现象;bufferLength 记录真实像素长度 | | decoderOptions 未提供 | JpxImage.decode 的解构默认值生效,四个字段取默认 |

两个细节值得单独说。

第一,eof 只在解码成功后置位。如果 JpxImage.decode 抛错,buffer 没有被赋值,eof 仍是 false,下一次调用会重新尝试。对网络流来说,这给了重试的机会;对损坏文件来说,错误会稳定复现,不会被上一次的失败状态掩盖。

第二,_length 被忽略不是疏忽。PDFImage 计算 length 时用的是「宽度乘高度乘分量」的估算,而 JPX 的实际输出可能是 RGBA、可能是降采样后的尺寸,甚至可能因为 SMaskInData 多出一个通道。以码流为准,才能保证 bufferLength 与真实像素一一对应。

设计要点:错误处理只做「不置 eof」这一件事,把重试策略留给调用方。JpxStream 不吞异常、不返回空缓冲,因为图像解码失败与「图像是空白」在渲染层是完全不同的两件事。

总结

JpxStream 是 PDF.js 的 JPEG 2000 整图解码适配器:把上游码流完整取到手,连同上层准备好的解码选项交给 OpenJPEG WASM,再把像素缓冲写成 DecodeStream 的输出。它不实现 readBlock,解码路径也不依赖增量扩容,所有能力都围绕一次异步解码展开。

它在整条链路中的位置

阶段模块输入输出
过滤器选择Parser.makeFilter/Filter 中的 JPXDecodeJpxStream
属性探测JpxImage.parseImageProperties原始码流宽、高、分量数、位深
参数生成PDFImage图像字典、目标尺寸jpxDecoderOptions
取字节JpxStream.bytes / asyncGetBytes上游流、maybeLength完整码流
解码JpxStream.decodeImage码流、decoderOptions像素缓冲
消费PDFImage、ImageResizer像素缓冲ImageData 或位图

用一句话串起来:Parser 创建流,PDFImage 准备参数,DecodeStream.getImageData 决定取字节的方式,decodeImage 等待 WASM 完成整图解码,渲染层只拿到像素。

五条设计原则

  1. 图像流不实现 readBlock。整图编码无法按块独立解码,同步读取契约在这里失效;能力收窄到 getImageData,用 decodeImage 承担全部工作。
  2. 字节惰性、只取一次。bytes 用 shadow 缓存,maybeLength 为 null 时退化成全读;网络流则通过 canAsyncDecodeImageFromBuffer 提前异步取齐。
  3. 参数分层。过滤器层只负责创建流;解码选项由掌握图像字典与渲染尺寸的 PDFImage 生成,再以 decoderOptions 透传,避免底层猜测上层意图。
  4. 单例复用、单次隔离。JpxImage.instance 共享 WASM 模块,每次解码独立申请与释放码流内存,输出及时摘走,避免跨次污染。
  5. 输出以码流为准。忽略 _length,用实际像素长度回填 bufferLength,让降采样与码流内 alpha 都能正确反映到下游。

下一篇看同层的 ccitt_stream.js。它同样是一次整图解码,但把「异步取字节」放进了 decodeImage 内部,并且把传真编码的七个参数从字典压成一个平面对象——两条路正好构成图像解码层里「谁来决定字节来源」的两种答案。

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

相关文章