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

前言
这是「图像解码」层的第一篇文章,对象是 Worker 端 src/core/jpeg_stream.js(192 行)中的 JpegStream。
前面几篇讲的 flate_stream、lzw_stream、ascii_85_stream、ascii_hex_stream、run_length_stream,本质上都是字节到字节的变换:输入一段压缩字节,输出仍然是可继续解析的字节,因此它们可以首尾相接,组成任意长度的过滤器链。
DCTDecode 与它们不同。它的输出不是「更短的字节」,而是像素。PDF 并没有自己定义一套 DCT 编码,而是直接嵌入一段标准 JPEG 码流;解码这一步必须交给一个完整的图像解码器。JpegStream 因此站在两套语义的交界处:对外仍然是 DecodeStream,对内却是一次性调用的图像解码器。
规范把 JPEG 对应的过滤器写作 /DCTDecode(缩写 /DCT),DCT 指 Discrete Cosine Transform。码流可以是基线或渐进式 JPEG,可以带 JFIF 头,也可以带 Adobe APP14 标记。图像字典的 /Width、/Height、/ColorSpace 描述解码结果,JPEG 自己的 SOF 段也写了一组尺寸与分量数;两者通常一致,损坏文件里可能不同,这个差异会直接影响后文的原生解码路径。
入口只有一行,来自 Parser.makeFilter:
case "DCT":
case "DCTDecode":
return new JpegStream(stream, maybeLength, params);一句话概括它的职责:把上游的 JPEG 码流整体取回,去掉 SOI 之前的垃圾字节,交给 JpegImage 解成像素;在浏览器支持时,再尝试用原生 ImageDecoder 直接产出 VideoFrame。
模块总览
jpeg_stream.js 只有 192 行,去掉版权头与 import,主体是一个类。可以按职责切成八块:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 构造 | constructor | 保存上游流、图像字典、长度与 DecodeParms |
| 字节入口 | bytes、ensureBuffer、readBlock | 惰性取压缩字节,并让基类读取流程落到整图解码 |
| 参数 | jpegOptions | 读取 ColorTransform,缓存解码选项 |
| 预处理 | #skipUselessBytes | 去掉 SOI 之前的垃圾字节 |
| 解码 | decodeImage | 调用 JpegImage 解析并输出像素 |
| 异步能力 | canAsyncDecodeImageFromBuffer | 把「能否先异步取字节」委托给上游流 |
| 原生路径 | getTransferableImage、canUseImageDecoder | 用浏览器 ImageDecoder 直接产出 VideoFrame |
| 类型标记 | isImageStream | 告诉上层这不是内容流,而是图像 |
它在 DecodeStream 体系里的位置很特殊。基类假设「数据可以一小块一小块地解」,而 JPEG 解码器没有这样的增量接口:JpegImage.parse 需要看到完整的码流,getData 一次返回整幅像素。JpegStream 没有改造基类,只覆盖 readBlock 与 ensureBuffer 两个钩子,把「增量拉取」映射成「一次解完」,因此调用方拿到的仍然是普通的 DecodeStream。
继承与三处覆盖
class JpegStream extends DecodeStream {
static #isImageDecoderSupported = FeatureTest.isImageDecoderSupported;
constructor(stream, maybeLength, params) {
super(maybeLength);
this.stream = stream;
this.dict = stream.dict;
this.maybeLength = maybeLength;
this.params = params;
}constructor 方法
构造函数只有四个赋值,但每个字段的来源都值得说明:
stream是上游流。它可能是文件里的Stream,也可能是网络分块的ChunkedStream,还可能是另一个DecodeStream。保留它是为了同步getBytes与异步asyncGetBytes两条取字节路径。dict直接指向stream.dict,也就是图像流自己的字典。Width、Height、ColorSpace都从这里读,Jbig2Stream也一样。maybeLength来自流字典的/Length。它只是「大概长度」,可能为 0,也可能因为在过滤器链里排在第二位而被Parser置成null;后者会让上游getBytes一直读到 EOF。params是/DecodeParms,可能为null,也可能是一个Dict。JPEG 的ColorTransform就藏在这里。
super(maybeLength) 会按 2 的幂计算 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 的作用是把原型上的 getter 替换成同名的自有数据属性。第一次访问时执行 getBytes,之后每次访问都直接返回同一个 Uint8Array,不会再触达上游。shadow 还会在测试构建里断言「原型上确实存在这个属性」,防止拼写错误变成静默的 undefined。
这里有一个容易被忽略的副作用:bytes 只在第一次访问时计算。上游是网络分块流且字节未下载完时,这次同步 getBytes 会抛 MissingDataException;异步路径因此必须绕开 getter,直接调用 this.stream.asyncGetBytes()。maybeLength 为 null 时 getBytes 会读到上游 EOF,这对 JPEG 是安全的:码流以 EOI 结束,解码器读到标记就会停。
ensureBuffer 与 readBlock 方法
ensureBuffer(requested) {
// No-op, since `this.readBlock` will always parse the entire image and
// directly insert all of its data into `this.buffer`.
}
readBlock() {
this.decodeImage();
}基类的 getBytes 流程是这样的:
- 如果调用方给了长度,先
ensureBuffer(pos + length); - 只要还没
eof、且bufferLength不够,就反复readBlock; - 最后返回
buffer.subarray(pos, end)。
在本类里,第 1 步什么都不做;第 2 步的第一次 readBlock 就调用 decodeImage,后者把整幅像素赋给 this.buffer、把 bufferLength 设成像素数组长度、并把 eof 置为 true。循环因此只会执行一次。
readBlock 不接收 length 与 decoderOptions。这不是遗漏:JPEG 解码没有「解一半」的能力,任何长度参数都只能被忽略。decodeImage 自己用 this.eof 做了幂等保护,重复调用会直接返回已有 buffer。
设计要点:
ensureBuffer与readBlock是同一枚硬币的两面。前者告诉基类「不要为增量写入准备缓冲」,后者告诉基类「一次就已经读完」。两个钩子合起来,让一个非增量的图像解码器无损地嵌入增量流的接口,而调用方完全不需要知道自己拿到的是普通FlateStream还是JpegStream。
jpegOptions 与 ColorTransform
jpegOptions 方法
get jpegOptions() {
const jpegOptions = { colorTransform: undefined };
// Fetching the 'ColorTransform' entry, if it exists.
if (this.params instanceof Dict) {
const colorTransform = this.params.get("ColorTransform");
if (Number.isInteger(colorTransform)) {
jpegOptions.colorTransform = colorTransform;
}
}
return shadow(this, "jpegOptions", jpegOptions);
}三个细节:
- 只认整数。
ColorTransform在规范里只有 0 与 1 两个取值,非整数的值(例如损坏文件里的名字或数组)会被忽略,回落到默认行为。 - 默认是
undefined,不是 0。这样JpegImage才能根据 JFIF、Adobe APP14 等标记自行判断;如果这里强行默认 0,所有 YCbCr JPEG 都会被当成 RGB 解错。 - 缓存复用。
shadow让选项对象只构造一次。选项本身不可变,多个解码调用共享没有问题。
ColorTransform 的语义很简单:0 表示三个分量已经是 RGB,不做 YCbCr 变换;1 表示按 YCbCr 做变换。四分量 JPEG(CMYK 或 YCCK)的处理更复杂,JpegImage.getData 里会结合 Adobe 标记决定是否反相、是否做 YCCK 到 CMYK 的转换。
ColorTransform 是过滤器参数,不是图像属性。图像字典描述「解出来是什么」,DecodeParms 描述「怎么解」。二者混在一起会让同一份 JPEG 在不同 ColorSpace 下被错误解读,源码把边界划得很清楚。
SOI 之前的垃圾字节
#skipUselessBytes 方法
JPEG 码流必须以 SOI(Start Of Image)开始,标记值是 0xff 0xd8。但现实里的 PDF 并不总是这么干净:
#skipUselessBytes(data) {
// Some images may contain 'junk' before the SOI (start-of-image) marker.
// Note: this seems to mainly affect inline images.
for (let i = 0, ii = data.length - 1; i < ii; i++) {
if (data[i] === 0xff && data[i + 1] === 0xd8) {
if (i > 0) {
data = data.subarray(i);
}
break;
}
}
return data;
}- 循环上界是
data.length - 1,保证data[i + 1]不越界。长度小于 2 的数组直接原样返回。 - 找到 SOI 后,如果它不在开头,就用
subarray(i)切掉前面的字节。subarray是视图,不复制数据,因此这一步几乎没有成本。 - 如果 SOI 本来就在第 0 位,不做任何切割,避免制造一个多余的对象。
- 找不到 SOI 时原样返回,交给
JpegImage.parse抛SOI not found。错误在哪一层产生,就在哪一层处理,这个函数不越权。
源码注释说这主要影响内联图像(BI ... ID ... EI)。内联图像的字节夹在内容流里,Parser 扫描 EI 时可能把前面的空白或残留字节一并圈进来。独立图像流理论上不该有这种前缀,但多做一次十几次比较的扫描,比让整页解码失败便宜得多。
注意它只处理 SOI 之前的字节。EOI 之后的多余数据不清理,因为 JPEG 解码器的标记循环读到 EOI 就会退出,尾部数据不会参与解码。
decodeImage:JpegImage 路径
decodeImage 方法
同步路径的全部逻辑在 decodeImage 里:
decodeImage(bytes) {
if (this.eof) {
return this.buffer;
}
bytes = this.#skipUselessBytes(bytes || this.bytes);
// TODO: if an image has a mask we need to combine the data.
// So ideally get a VideoFrame from getTransferableImage and then use
// copyTo.
const jpegImage = new JpegImage(this.jpegOptions);
jpegImage.parse(bytes);
const data = jpegImage.getData({
width: this.drawWidth,
height: this.drawHeight,
forceRGBA: this.forceRGBA,
forceRGB: this.forceRGB,
});
this.buffer = data;
this.bufferLength = data.length;
this.eof = true;
return this.buffer;
}每一步都对应一个明确的约束:
eof守卫。decodeImage可能被readBlock多次调用,也可能被异步路径直接调用;一旦解完就返回已有像素。reset()只重置读取位置,不重置buffer与eof,所以同一页重绘时不会重复解码。- 字节来源二选一。
bytes || this.bytes允许调用方传入已经异步取好的字节;不传时才走惰性 getter。两条路径共用同一段解码逻辑。 - 去垃圾字节。无论字节来自哪里,都先过一遍
#skipUselessBytes。 - 掩码留待上层。源码的 TODO 说得很直白:带掩码的图像需要把像素与 mask 合成,这件事目前由
PDFImage在更上层完成,JpegStream只负责还原主图像。原生路径的VideoFrame加copyTo是未来可能的优化方向。 - 解析与取值分离。
parse只遍历标记、建立量化表与 Huffman 表;getData才做反量化、IDCT、上采样与色彩变换。本类每次新建实例,因此同一份码流不会被两种输出尺寸共享。 - 整体替换缓冲。
this.buffer = data不是追加;bufferLength直接取像素数组长度;eof = true表示不会再有下一块。
getData 的尺寸与通道
getData 的四个参数都不是 JPEG 文件里的值,而是调用方希望得到的输出:
drawWidth/drawHeight由PDFImage.getImageBytes在调用前写到图像流上,默认等于字典的Width与Height,但渲染尺寸可能更小。forceRGBA在需要 alpha、软掩码或OffscreenCanvas路径时为true,要求输出四通道。forceRGB在只需要不透明三通道时为true,避免多分配一个 alpha 字节。
这一点很关键:JpegImage.getData 不是「先按 SOF 尺寸解出全图再缩放」,而是把目标尺寸传进块解码,直接按需要的采样率还原。对于一张 6000 像素宽的扫描图,如果页面只显示 800 像素宽,这一步省下的是整幅全尺寸像素的内存与时间。
输出统一是 Uint8ClampedArray,通道数取决于 JPEG 分量数与调用方的强制选项:灰度默认 1 通道;三分量在需要时转成 RGB 或 RGBA;四分量按 CMYK 或 YCCK 规则转换;forceRGB 与 forceRGBA 分别要求三通道与四通道。bufferLength 因此可能是 width * height 的一倍、三倍或四倍,上层 PDFImage 会根据 ColorSpace 实际解释这些字节。
设计要点:解码尺寸取「绘制尺寸」而不是「文件尺寸」,是图像链路里收益最大的一次前置。它把内存峰值从「原图像素」压到「显示像素」,而且不需要额外的缩放步骤。代价是同一张图如果以两种尺寸出现,需要解码两次;这个代价由上层缓存策略承担,而不是让流层保存多份结果。
异步取字节
canAsyncDecodeImageFromBuffer 方法
这个 getter 名字有点长,但它表达的不是「异步解码」,而是「能不能先把压缩字节异步取齐」:
get canAsyncDecodeImageFromBuffer() {
return this.stream.isAsync;
}DecodeStream.getImageData 会根据它分流:
- 为
true:先await this.stream.asyncGetBytes(),拿到完整压缩字节后调用decodeImage(data, length, decoderOptions); - 为
false:走getBytes(length, decoderOptions),也就是同步readBlock。
为什么要专门分一条路?因为 JpegImage.parse 是不可恢复的。它按标记一路向后读,读到一半发现缺字节只能抛 MissingDataException;上层重试时只能从头再解一遍。与其让解码器在中途失败,不如先把字节取齐,再一次性交给它。网络分块流上的大图因此少掉大量重复工作。
注意它与原生 ImageDecoder 无关:这一层只解决「字节到齐没有」,下一层才解决「谁来解像素」。
浏览器原生解码路径
现代浏览器提供 WebCodecs 的 ImageDecoder。它可以把一段 JPEG 直接解成 VideoFrame,跳过 JavaScript 里的 Huffman 解码与 IDCT。JpegStream.getTransferableImage 就是这条可选路径,PDFImage 在 #getImage 里消费它的返回值:
const bitmap = await this.image.getTransferableImage(width, height);
if (!bitmap) {
return null;
}
// ImageDecoder may ignore the requested dimensions.
return {
data: null,
width: bitmap.displayWidth ?? width,
height: bitmap.displayHeight ?? height,
bitmap,
interpolate: this.interpolate,
};返回 null 就表示「原生路径不可用」,调用方回退到 canvas 加像素的路径。
getTransferableImage 方法
完整实现如下:
async getTransferableImage(width, height) {
if (!(await JpegStream.canUseImageDecoder)) {
return null;
}
const jpegOptions = this.jpegOptions;
let decoder;
try {
// TODO: If the stream is Flate & DCT we could try to just pipe the
// the DecompressionStream into the ImageDecoder: it'll avoid the
// intermediate ArrayBuffer.
const bytes =
(this.canAsyncDecodeImageFromBuffer &&
(await this.stream.asyncGetBytes())) ||
this.bytes;
if (!bytes) {
return null;
}
let data = this.#skipUselessBytes(bytes);
const useImageDecoder = JpegImage.canUseImageDecoder(
data,
jpegOptions.colorTransform
);
if (!useImageDecoder) {
return null;
}
const { width: frameWidth, height: frameHeight } = useImageDecoder;
const reducePower = ImageResizer.getReducePower(frameWidth, frameHeight);
if (
(frameWidth !== width || frameHeight !== height) &&
(reducePower || !frameHeight)
) {
// Only downscale when the SOF and image dictionary dimensions match.
return null;
}
if (useImageDecoder.exifStart) {
// Replace the entire EXIF-block with dummy data, to ensure that a
// non-default EXIF orientation won't cause the image to be rotated
// when using `ImageDecoder` (fixes bug1942064.pdf).
//
// Copy the data first, to avoid modifying the original PDF document.
data = data.slice();
data.fill(0x00, useImageDecoder.exifStart, useImageDecoder.exifEnd);
}
const init = {
data,
type: "image/jpeg",
preferAnimation: false,
};
// Request reduced dimensions; ImageDecoder treats them as best-effort.
if (reducePower) {
const factor = 2 ** reducePower;
init.desiredWidth = Math.ceil(frameWidth / factor);
init.desiredHeight = Math.ceil(frameHeight / factor);
}
decoder = new ImageDecoder(init);
return (await decoder.decode()).image;
} catch (reason) {
warn(`getTransferableImage - failed: "${reason}".`);
return null;
} finally {
decoder?.close();
}
}整个方法是一条「层层放弃」的流水线:能力检测不通过、取不到字节、码流不适合原生解码、尺寸关系不满足前提,任何一步失败都返回 null;构造 ImageDecoder 或 decode 抛错时也只在 catch 里记录一条警告。代价始终只是回退到纯 JS 解码,不会让页面渲染失败。这是典型的「优化路径必须可失败」设计。
静态检测与 canUseImageDecoder
static get canUseImageDecoder() {
return shadow(
this,
"canUseImageDecoder",
this.#isImageDecoderSupported
? ImageDecoder.isTypeSupported("image/jpeg")
: Promise.resolve(false)
);
}
static setOptions({ isImageDecoderSupported = false }) {
this.#isImageDecoderSupported = isImageDecoderSupported;
}FeatureTest.isImageDecoderSupported 只做一件事:判断 ImageDecoder 这个全局对象是否存在。ImageDecoder.isTypeSupported("image/jpeg") 返回的是 Promise,shadow 把这个 Promise 缓存下来,后续所有 JPEG 共用一次查询,不再重复问浏览器。
setOptions 由 pdf_manager 在文档初始化时调用。evaluator.js 里的默认值是 false,pdf_manager 再与 FeatureTest 的结果做与运算。也就是说,原生解码是显式开启的能力,不是「浏览器有就一定要用」;调用方可以通过配置关掉它,便于测试或规避平台 bug。
JpegImage.canUseImageDecoder 则是另一层检测,它看的是码流本身:
- 轻量扫描 JPEG 标记,读到 SOF0、SOF1 或 SOF2,取出宽高与分量数;
- 遇到 APP1 且负载以
Exif开头时,记录 EXIF 块的起止位置,供后面清零; - 四分量(CMYK/YCCK)直接返回
null,因为原生解码器对这类 JPEG 的色彩处理与 PDF 预期不一致; - 三分量且
ColorTransform为 0 时也返回null,因为这意味着分量本来就是 RGB,而ImageDecoder会按 YCbCr 处理; - SOF 高度可能为 0,表示真实高度由后面的 DNL 标记定义;这种情况会把 0 原样带回,交给尺寸守卫处理。
这一步只扫标记,不做熵解码,成本远低于真正解一遍。因此「先问能不能用原生解码」的开销可以忽略。
尺寸守卫与降采样
ImageResizer.getReducePower(frameWidth, frameHeight) 返回的是「需要降几个 2 的幂」:
- 图像在
MAX_DIM与MAX_AREA限制内时返回 0; - 超出限制时,按宽、高、面积三者算出一个最小缩放因子,再取
ceil(log2(factor)); - 参数不是正整数时返回 0,避免非法尺寸把计算带偏。
这个值有两个用途:一是判断是否该请求降采样,二是决定 desiredWidth / desiredHeight。ImageDecoder 把这两个字段当作建议而非命令,最终尺寸仍以返回的 VideoFrame 为准;PDFImage 用 displayWidth / displayHeight 兜底,正是为了这个不确定性。
尺寸守卫的条件值得逐字读:
(frameWidth !== width || frameHeight !== height) && (reducePower || !frameHeight);它只在下面三种情况拒绝原生路径:
| 情况 | 结果 | 原因 |
|---|---|---|
| SOF 尺寸与字典尺寸一致 | 通过 | 可以按 reducePower 请求等比降采样 |
| 尺寸不一致,且需要降采样 | 拒绝 | 降采样基于 SOF 尺寸,无法保证与字典尺寸对齐 |
| 尺寸不一致,且 SOF 高度为 0 | 拒绝 | 高度要等 DNL 才能确定,此时没有可靠尺寸 |
| 尺寸不一致,但不需要降采样、高度有效 | 通过 | 原生解码按自身尺寸输出,上层采纳实际显示尺寸 |
第三行是 DNL(Define Number of Lines)的兼容分支。少数 JPEG 把高度写成 0,真正的行数在扫描数据之后才给出;JpegImage 的完整解析能处理这种情况,但轻量扫描只看到了 0,不能拿它做尺寸判断。
EXIF 清零与资源释放
JPEG 的 APP1 段可以携带 EXIF,其中 Orientation 标签告诉查看器「这张图应该旋转多少度」。PDF 不需要这个提示:页面内容流里的图像矩阵已经决定了摆放方式。如果 ImageDecoder 再按 EXIF 转一次,图像就会多转 90 度或 180 度。源码注释里提到的 bug1942064.pdf 就是这类文件。
处理方式是用 0 覆盖整个 EXIF 负载:
data = data.slice();
data.fill(0x00, useImageDecoder.exifStart, useImageDecoder.exifEnd);先 slice() 复制是必须的。bytes 可能是上游流缓冲的 subarray 视图,直接 fill 会改掉 PDF 文档自己的字节,同一张图第二次走纯 JS 路径时就会拿到被破坏的 EXIF。复制之后随便改,原文档不受影响。
清零之后,TIFF 头里的 Orientation 变成默认值,ImageDecoder 不再旋转;真正的摆放仍由 PDF 的变换矩阵负责。
decoder.close() 放在 finally 里,无论成功、抛错还是提前返回都会执行。ImageDecoder 内部持有平台解码器与帧缓冲,不关闭会泄漏原生资源;decoder?. 又照顾了「构造之前就抛错」的情况,此时变量还是 undefined。catch 只记录一条警告并返回 null,把决策权交回上层。
设计要点:原生路径的每一处判断都在回答同一个问题——「这条捷径会不会改变结果」。能力不支持、色彩模式不匹配、尺寸关系不成立、EXIF 会干扰,任何一条命中就放弃。优化必须与纯 JS 路径结果等价,否则宁可不走。这种保守让
ImageDecoder成为一个可开关的加速器,而不是一条需要单独维护的第二实现。
isImageStream
最后一个 getter 只有三行,却承担了类型标记的作用:
get isImageStream() {
return true;
}基类 BaseStream 默认返回 false。整个仓库里只有两处消费者:
StreamsSequenceStream构造时过滤掉isImageStream为true的流。内容流拼接不应该把图像像素当成页面操作符。PDFPage.getContentStream在判断content是不是可解析的内容流时排除图像流。损坏文件里如果/Contents指向一张 JPEG,没有这个标记就会把像素当 lexer 输入,产生大量无意义的警告。
两条解码路径对比
| 维度 | decodeImage 加 JpegImage | getTransferableImage 加 ImageDecoder |
|---|---|---|
| 运行位置 | Worker 内的纯 JS | 浏览器或 Worker 的原生实现 |
| 可用性 | 始终可用 | 需要 ImageDecoder 且支持 image/jpeg |
| 输入 | 压缩字节 | 压缩字节,可先异步取齐 |
| 输出 | Uint8ClampedArray 像素 | VideoFrame |
| 尺寸控制 | 直接按 drawWidth / drawHeight 解码 | SOF 与字典尺寸一致时可用 desiredWidth 降采样 |
| EXIF | 不处理方向,像素保持原始朝向 | 清零 EXIF 负载,避免二次旋转 |
| 色彩 | 完整处理 YCbCr、CMYK、YCCK | 仅 1 或 3 分量,且 3 分量不能是纯 RGB 标记 |
| 失败行为 | 抛出异常,由图像链路处理 | 捕获后告警并返回 null,静默回退 |
两条路径不是二选一:原生先试,失败返回 null,PDFImage 再走 canvas 路径;纯 JS 实现始终保留,既是兜底,也是正确性参照。
总结
JpegStream 是图像解码层的适配器:向上保持 DecodeStream 的读取接口,向下把整段 JPEG 码流交给 JpegImage,并在浏览器支持时把同一段字节转交给原生 ImageDecoder。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 过滤器 | Parser.makeFilter | /DCTDecode、流字典 | JpegStream |
| 取字节 | bytes、canAsyncDecodeImageFromBuffer | 上游 Stream 或分块流 | 完整压缩字节 |
| 纯 JS 解 | decodeImage → JpegImage.parse / getData | 压缩字节、目标尺寸 | 像素数组 |
| 原生解 | getTransferableImage → ImageDecoder | 压缩字节、ColorTransform | VideoFrame |
| 图像封装 | PDFImage.getImageBytes / createImageData | 像素或 VideoFrame | ImageBitmap |
| 绘制 | CanvasGraphics | ImageBitmap | canvas 上的图像 |
用一句话串起来:Parser 把 /DCTDecode 包装成 JpegStream,JpegStream 把整段 JPEG 变成像素或 VideoFrame,PDFImage 再把它变成可绘制的位图。
五条设计原则
- 只覆盖必要钩子,保持基类外形。
readBlock与ensureBuffer两个点足以把非增量解码器嵌入增量接口,其余getByte、getBytes、reset、makeSubStream全部复用,调用方无需分支。 - 整图一次解码,用
eof做幂等。不假装支持分块,也不在readBlock里保存中间状态;eof一旦置位,重复读取直接命中缓冲。状态少,出错路径就少。 - 编码字节与像素严格分离。
bytes缓存的是压缩数据,buffer持有的是像素;reset只动读取位置,不销毁任何一方。两者混用会让重绘或掩码合成读到错误的内容。 - 原生能力静态检测,失败一律降级。能力查询做成缓存的 Promise,码流适配检查放在轻量标记扫描里,任何不满足都返回
null。原生路径只允许加速,不允许改变结果。 - 不修改调用方的字节。SOI 裁剪用
subarray视图,EXIF 清零先slice复制。PDF 的流缓冲可能被多处共享,任何原地写入都会在另一条解码路径上变成难查的脏数据。
至此,「JPEG 怎么变成像素」这一环补齐了。下一篇处理 jbig2_stream:同样是整图解码,同样有惰性 bytes 与空实现的 ensureBuffer,但解码器换成了一个异步的 WASM 单例,还要额外处理 JBIG2 文件头、全局段与字典尺寸。
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 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_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日