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

前言
前面十几篇讲的都是「过滤器」: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 的取值决定读取范围:
maybeLength | getBytes 的行为 | 结果 |
|---|---|---|
| 具体数字 | 读指定长度 | 通常等于 /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,否则为 0 | 0 表示让解码器按码流自行决定输出分量数 |
isIndexedColormap | colorSpace.name === "Indexed" | 索引颜色需要解码器按调色板展开 |
smaskInData | 图像字典 /SMaskInData 大于等于 1 | 码流内自带 alpha,需要解码成 RGBA |
reducePower | ImageResizer.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.js | jpx_stream.js + OpenJPEG WASM | ccitt_stream.js / jbig2_stream.js |
| 纯 JS 解码 | 有,JpegImage 自带 | 无,必须走 WASM | 无,必须走 WASM |
| 浏览器原生快路 | 有,ImageDecoder 可产出 ImageBitmap | 无 | 无 |
| 解码同步性 | decodeImage 同步,可被 readBlock 调 | decodeImage 异步 | decodeImage 异步 |
是否实现 readBlock | 实现,内部调用 decodeImage | 不实现 | 不实现 |
| 参数来源 | /ColorTransform | PDFImage 生成 decoderOptions | /DecodeParms 七个键 |
| 主要优化 | 原生解码器、降采样、跳过无用字节 | 小波降采样 reducePower、码流内 alpha | 位级传真解码、单色位图直出 |
三个结论:
- 同步解码器可以保留
readBlock,异步解码器不行。JpegStream.decodeImage是同步函数,所以readBlock可以简单转调它,getBytes这条老路径仍然可用;JPX 的解码要等待 WASM,无法塞进同步的readBlock,只能收窄到getImageData。 - 原生快路只对 JPEG 开放。
JpegStream.getTransferableImage会尝试ImageDecoder,直接拿到浏览器解码好的ImageBitmap,省掉 Worker 里的像素解码;JpxStream没有对应方法,因为浏览器普遍不支持把 JPEG 2000 交给ImageDecoder。 - 参数位置由信息位置决定。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 中的 JPXDecode | JpxStream |
| 属性探测 | JpxImage.parseImageProperties | 原始码流 | 宽、高、分量数、位深 |
| 参数生成 | PDFImage | 图像字典、目标尺寸 | jpxDecoderOptions |
| 取字节 | JpxStream.bytes / asyncGetBytes | 上游流、maybeLength | 完整码流 |
| 解码 | JpxStream.decodeImage | 码流、decoderOptions | 像素缓冲 |
| 消费 | PDFImage、ImageResizer | 像素缓冲 | ImageData 或位图 |
用一句话串起来:Parser 创建流,PDFImage 准备参数,DecodeStream.getImageData 决定取字节的方式,decodeImage 等待 WASM 完成整图解码,渲染层只拿到像素。
五条设计原则
- 图像流不实现
readBlock。整图编码无法按块独立解码,同步读取契约在这里失效;能力收窄到getImageData,用decodeImage承担全部工作。 - 字节惰性、只取一次。
bytes用shadow缓存,maybeLength为null时退化成全读;网络流则通过canAsyncDecodeImageFromBuffer提前异步取齐。 - 参数分层。过滤器层只负责创建流;解码选项由掌握图像字典与渲染尺寸的
PDFImage生成,再以decoderOptions透传,避免底层猜测上层意图。 - 单例复用、单次隔离。
JpxImage.instance共享 WASM 模块,每次解码独立申请与释放码流内存,输出及时摘走,避免跨次污染。 - 输出以码流为准。忽略
_length,用实际像素长度回填bufferLength,让降采样与码流内 alpha 都能正确反映到下游。
下一篇看同层的 ccitt_stream.js。它同样是一次整图解码,但把「异步取字节」放进了 decodeImage 内部,并且把传真编码的七个参数从字典压成一个平面对象——两条路正好构成图像解码层里「谁来决定字节来源」的两种答案。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_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 源码解读: 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日