StreamPDFStreamPDF
返回博客

PDF.js 源码解读: jpeg_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: jpeg_stream

前言

这是「图像解码」层的第一篇文章,对象是 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 流程是这样的:

  1. 如果调用方给了长度,先 ensureBuffer(pos + length);
  2. 只要还没 eof、且 bufferLength 不够,就反复 readBlock;
  3. 最后返回 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);
  }

三个细节:

  1. 只认整数。ColorTransform 在规范里只有 0 与 1 两个取值,非整数的值(例如损坏文件里的名字或数组)会被忽略,回落到默认行为。
  2. 默认是 undefined,不是 0。这样 JpegImage 才能根据 JFIF、Adobe APP14 等标记自行判断;如果这里强行默认 0,所有 YCbCr JPEG 都会被当成 RGB 解错。
  3. 缓存复用。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;
  }

每一步都对应一个明确的约束:

  1. eof 守卫。decodeImage 可能被 readBlock 多次调用,也可能被异步路径直接调用;一旦解完就返回已有像素。reset() 只重置读取位置,不重置 buffer 与 eof,所以同一页重绘时不会重复解码。
  2. 字节来源二选一。bytes || this.bytes 允许调用方传入已经异步取好的字节;不传时才走惰性 getter。两条路径共用同一段解码逻辑。
  3. 去垃圾字节。无论字节来自哪里,都先过一遍 #skipUselessBytes。
  4. 掩码留待上层。源码的 TODO 说得很直白:带掩码的图像需要把像素与 mask 合成,这件事目前由 PDFImage 在更上层完成,JpegStream 只负责还原主图像。原生路径的 VideoFrame 加 copyTo 是未来可能的优化方向。
  5. 解析与取值分离。parse 只遍历标记、建立量化表与 Huffman 表;getData 才做反量化、IDCT、上采样与色彩变换。本类每次新建实例,因此同一份码流不会被两种输出尺寸共享。
  6. 整体替换缓冲。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 加 JpegImagegetTransferableImage 加 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压缩字节、ColorTransformVideoFrame
图像封装PDFImage.getImageBytes / createImageData像素或 VideoFrameImageBitmap
绘制CanvasGraphicsImageBitmapcanvas 上的图像

用一句话串起来:Parser 把 /DCTDecode 包装成 JpegStream,JpegStream 把整段 JPEG 变成像素或 VideoFrame,PDFImage 再把它变成可绘制的位图。

五条设计原则

  1. 只覆盖必要钩子,保持基类外形。readBlock 与 ensureBuffer 两个点足以把非增量解码器嵌入增量接口,其余 getByte、getBytes、reset、makeSubStream 全部复用,调用方无需分支。
  2. 整图一次解码,用 eof 做幂等。不假装支持分块,也不在 readBlock 里保存中间状态;eof 一旦置位,重复读取直接命中缓冲。状态少,出错路径就少。
  3. 编码字节与像素严格分离。bytes 缓存的是压缩数据,buffer 持有的是像素;reset 只动读取位置,不销毁任何一方。两者混用会让重绘或掩码合成读到错误的内容。
  4. 原生能力静态检测,失败一律降级。能力查询做成缓存的 Promise,码流适配检查放在轻量标记扫描里,任何不满足都返回 null。原生路径只允许加速,不允许改变结果。
  5. 不修改调用方的字节。SOI 裁剪用 subarray 视图,EXIF 清零先 slice 复制。PDF 的流缓冲可能被多处共享,任何原地写入都会在另一条解码路径上变成难查的脏数据。

至此,「JPEG 怎么变成像素」这一环补齐了。下一篇处理 jbig2_stream:同样是整图解码,同样有惰性 bytes 与空实现的 ensureBuffer,但解码器换成了一个异步的 WASM 单例,还要额外处理 JBIG2 文件头、全局段与字典尺寸。

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

相关文章