StreamPDFStreamPDF
返回博客

PDF.js 源码解读: image_utils.js

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: image_utils.js

前言

本篇完整阅读 src/shared/image_utils.js,共 147 行。

文件名比实际实现更宽泛。这个模块不会从字节中检测图像格式、解析 CSS 图像、创建 object URL、解码 JPEG、分配 canvas,也不会调用 putImageData()。它的四个导出都是同步、少分配的像素转换器,向调用方提供的 buffer 写入结果。

它只依赖 util.js 中的 FeatureTest 和 ImageKind:

  • ImageKind 告诉分派器:调用方已经识别出的源布局是哪一种;
  • FeatureTest.isLittleEndian 让转换器通过一次原生 Uint32Array 写入完成四个 RGBA 字节的赋值。

与 canvas 的联系确实存在,但属于调用方。display/canvas.js、core/image.js 和 core/image_resizer.js 使用这些函数准备 ImageData;core/jpg.js 则在 JPEG 解码后使用灰度辅助函数。

模块总览

导出行号输入输出与返回值
convertToRGBA(params)18-27带 kind 标记的 params 对象分派到 1-bit 或 RGB 转换器;其他情况返回 null
convertBlackAndWhiteToRGBA(...)29-75打包的 1-bit 像素写入 RGBA 像素;返回 { srcPos, destPos }
convertRGBToRGBA(...)77-128打包的 8-bit RGB 三元组写入不透明 RGBA 像素;返回 { srcPos, destPos }
grayToRGBA(src, dest)130-140每像素一个 8-bit 灰度样本写入不透明 RGBA 像素;无返回值

三种源布局最终都转为四字节 RGBA:

GRAYSCALE_1BPP:  1 bit per pixel, packed MSB first  → 8-bit RGBA
RGB_24BPP:       R, G, B bytes per pixel            → 8-bit RGBA, A = 255
8-bit gray:      one gray byte per pixel            → R = G = B = gray, A = 255

模块转换的是内存布局,不是色彩空间。这里没有 ICC 转换、gamma 校正、重采样或 alpha 合成。

共享 buffer 与端序模型

canvas 的 ImageData.data 按字节组织:每个像素依次占据 R、G、B、A 四个字节。这些转换器则尽量通过 Uint32Array 写入,一个数组元素正好覆盖一个像素。

这个优化依赖宿主机字节序。PDF.js 在 FeatureTest.isLittleEndian 中通过“先写一个字节、再以 32-bit word 读同一块存储”完成一次检测并缓存结果。

不透明黑色在内存中必须始终是:

00 00 00 ff   // R, G, B, A

但产生这些字节所需的数值因端序而异:

宿主机端序数值 word本模块中的用途
Little-endian0xff000000black 与 RGB 的 alphaMask
Big-endian0x000000ffblack;RGB 使用 0xff 作为 alpha mask

灰度与 RGB 打包也是同一个道理。端序分支不是为了改变 canvas 的字节布局,而是为了让不同 CPU word 布局产生相同的 RGBA 字节。

这些是内部 buffer 工具,不是面向任意 typed-array view 的通用转换器。部分路径直接从 .buffer 构造视图,因此调用方必须提供预期的底层 buffer 布局与对齐。输出 buffer 始终由调用方持有。

convertToRGBA():按 ImageKind 分派,而不是检测图像

function convertToRGBA(params) {
  switch (params.kind) {
    case ImageKind.GRAYSCALE_1BPP:
      return convertBlackAndWhiteToRGBA(params);
    case ImageKind.RGB_24BPP:
      return convertRGBToRGBA(params);
  }
 
  return null;
}

这是文件中唯一的 ImageKind 分支。它检查已有标记,不会根据 magic bytes、尺寸或 buffer 长度推断 kind。

ImageKind 在 util.js 中定义了三个值:

const ImageKind = {
  GRAYSCALE_1BPP: 1,
  RGB_24BPP: 2,
  RGBA_32BPP: 3,
};

这里只有前两种需要展开。RGBA_32BPP 已经是目标布局,所以刻意没有转换分支。它和未知 kind 一样,会让 convertToRGBA() 返回 null。接受 RGBA 的调用方必须在分派器之前或周围单独处理,不能把 null 当成已经完成的转换结果。

整个 params 对象会被继续转发。每个实现只解构自己认识的字段,所以 inverseDecode 只影响 1-bit 路径,对 RGB 没有意义。

convertBlackAndWhiteToRGBA():展开打包位

function convertBlackAndWhiteToRGBA({
  src,
  srcPos = 0,
  dest,
  width,
  height,
  nonBlackColor = 0xffffffff,
  inverseDecode = false,
}) {
  // ...
}

这个转换器把一个 1-bit 源像素展开为一个 32-bit 目标元素。

参数契约
src打包位;每个字节的 bit 7 是第一个像素
srcPos本次调用开始读取的字节下标;默认 0
dest目标存储,其 .buffer 会被视为 Uint32Array
width、height像素尺寸;每行占 ceil(width / 8) 个源字节
nonBlackColor非黑映射使用的原生端序 32-bit 颜色;默认不透明白
inverseDecode交换 0 位与 1 位对应的颜色

函数通过 new Uint32Array(dest.buffer) 新建 32-bit 视图,并从目标元素 0 开始写入。它不接受初始 destPos。因此返回的 destPos 是已写入的 32-bit 像素数,不是字节数。

颜色映射与 inverseDecode

const black = FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff;
const [zeroMapping, oneMapping] = inverseDecode ? [nonBlackColor, black] : [black, nonBlackColor];
const xorMask = zeroMapping ^ oneMapping;

映射关系非常明确:

inverseDecode源位 0源位 1
falseblacknonBlackColor
truenonBlackColorblack

nonBlackColor 是打包后的数值 word,不是 CSS 颜色字符串。默认值 0xffffffff 在两种端序下都是不透明白色。掩码调用方还会传入 0,让该映射产生四个全零的透明 RGBA 字节。

完整字节、行尾余量与短输入

const widthInSource = width >> 3;
const widthRemainder = width & 7;

每行有 floor(width / 8) 个完整字节,最多再剩七个像素。外层循环按行推进;内层循环为每个完整字节输出八个像素,随后只处理最后一个字节中的 widthRemainder 位。

每个像素都从 bit 7 向 bit 0 读取。对完整字节,八次赋值直接展开,而不是再嵌套一层循环。

如果最后的部分字节不存在,这个特定分支会用 255 代替:

const elem = srcPos < srcLength ? src[srcPos++] : 255;

这是 remainder 路径的局部短输入策略,不能把它推广成对整个源 buffer 的完整性校验。

无分支位选择

一条展开后的赋值展示了颜色选择技巧:

dest[destPos] = zeroMapping ^ (-((elem >> 7) & 1) & xorMask);

提取出的位只有 0 或 1:

  • 为 0 时,取负得到 0,mask 部分为 0,结果就是 zeroMapping;
  • 为 1 时,取负得到 -1,其补码所有位都是 1,所以表达式选择 zeroMapping ^ xorMask,也就是 oneMapping。

这样无需为每个像素写一个条件分支,就能在两种颜色间选择。八个位与行尾余量都使用同样的模式。

函数返回下一个源字节下标与下一个目标像素下标:

return { srcPos, destPos };

这个游标结果让 canvas 与缩放调用方能够连续转换多个水平条带,而不必重新分配源数据。

convertRGBToRGBA():重组 RGB 三元组

function convertRGBToRGBA({ src, srcPos = 0, dest, destPos = 0, width, height }) {
  // ...
}

RGB 转换器把源长度计算为 width * height * 3,把输出尺寸计算为 width * height 个不透明像素。与 1-bit helper 不同,它的 dest 应当已经是 Uint32Array,并接受以 32-bit 目标元素为单位的初始 destPos。

srcPos 则是字节偏移。两个返回游标刻意使用不同单位:

return { srcPos: srcPos + len, destPos };

一个与偏移调用有关的源码细节不能忽略:src32 从 srcPos 开始,但标量尾循环把 j 初始化为 i * 4,又以 srcPos + len 为边界。因此 fast 与 tail 路径并没有组成统一的任意偏移抽象;非零 srcPos 的实际行为必须按这些下标的字面逻辑理解。

三个源 word 变成四个目标像素

const len = width * height * 3;
const len32 = len >> 2;
const src32 = new Uint32Array(src.buffer, srcPos, len32);
const alphaMask = FeatureTest.isLittleEndian ? 0xff000000 : 0xff;

三次 32-bit 读取覆盖 12 个源字节,恰好是四个 RGB 像素。随后四次 32-bit 写入就能覆盖对应的 16 个 RGBA 字节。

Uint32Array 构造函数收到的 srcPos 是底层 buffer 的字节偏移,因此 word 路径带有正常的 typed-array 对齐要求。len32 统计完整 32-bit word 数;len 始终是 RGB 字节数的权威值。

优化循环要求至少还有三个 word:

for (; i < len32 - 2; i += 3, destPos += 4) {
  // Read s1, s2, s3 and write four pixels.
}

每轮推进 12 个源字节与四个目标像素。最后的逐字节循环处理三 word 路径之外的剩余像素。

Little-endian 重排

在 little-endian 宿主机上,源码注释把三个数值 word 记为 R2B1G1R1、G3R3B2G2 和 B4G4R4B3。这些写入在内存中保持 RGB 顺序,同时替换或插入 alpha 字节:

dest[destPos] = s1 | alphaMask;
dest[destPos + 1] = (s1 >>> 24) | (s2 << 8) | alphaMask;
dest[destPos + 2] = (s2 >>> 16) | (s3 << 16) | alphaMask;
dest[destPos + 3] = (s3 >>> 8) | alphaMask;

移位负责让分量跨越 word 边界。alpha mask 把每个输出像素的 A 字节强制设为 255;源数据中没有需要保留的 alpha。

Big-endian 重排与标量尾部

big-endian 分支用相反方向的移位执行镜像操作。它的源 word 被记为 R1G1B1R2、G2B2R3G3 和 B3R4G4B4,对应另一种数值布局,但最终产生相同的字节序 RGBA 结果。

两个分支最后都用逐分量循环处理余量。little-endian 的标量写入这样组装像素:

src[j] | (src[j + 1] << 8) | (src[j + 2] << 16) | alphaMask;

big-endian 则使用 24、16、8 位移。标量路径更直观;word 路径的意义在于减少图像主体的逐分量操作。

grayToRGBA():把一个字节复制到三个通道

function grayToRGBA(src, dest) {
  if (FeatureTest.isLittleEndian) {
    for (let i = 0, ii = src.length; i < ii; i++) {
      dest[i] = (src[i] * 0x10101) | 0xff000000;
    }
  } else {
    for (let i = 0, ii = src.length; i < ii; i++) {
      dest[i] = (src[i] * 0x1010100) | 0x000000ff;
    }
  }
}

这是最简单的导出函数。它以 src.length 而不是 width * height 作为像素数,并要求 dest 是至少包含同样多元素的 Uint32Array。

乘法在一次计算中复制灰度样本,不必分别做三次移位:

Little-endian: v * 0x0010101 → 0x00vvvvvv, then alpha → 0xffvvvvvv
Big-endian:    v * 0x1010100 → 0xvvvvvv00, then alpha → 0xvvvvvvff

在内存中,两种形式都产生 v, v, v, 255。黑白仍是黑白,所有中间样本都成为三个通道等值的灰色。

这里没有 params 对象、没有返回游标对,也不会新建目标视图。函数同步写入并返回 undefined。core/jpg.js 在 JPEG 解码器已经产生每像素一个字节后,用它处理灰度 JPEG 输出。

真实的 canvas 与解码器调用方

这些 helper 不依赖 canvas API,但它们的 buffer 形态正是围绕以下消费者设计的。

调用方使用的转换调用方负责的部分
display/canvas.js — putBinaryImageData按 imgData.kind 直接进行 1-bit 或 RGB 转换创建可复用的分块 ImageData,逐条带转换并调用 putImageData()
display/canvas.js — putBinaryImageMask使用 nonBlackColor: 0 的 1-bit 转换在慢路径展开掩码并绘制分块图像数据
core/image.js1-bit 转换或 convertToRGBA处理 inverseDecode、创建 ImageData,可能 transfer ImageBitmap
core/image_resizer.js对连续条带调用 convertToRGBA携带 srcPos、抽样转换后的像素,可能创建 OffscreenCanvas
core/jpg.jsgrayToRGBA解码 JPEG 样本并分配最终 RGBA 存储

canvas 分块方式解释了为什么 API 要返回游标。putBinaryImageData 保留一个 chunkImgData,而不是为每个条带分配全尺寸 RGBA 输出:

({ srcPos } = convertBlackAndWhiteToRGBA({
  src,
  srcPos,
  dest,
  width,
  height: i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight,
}));
 
ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);

模块只提供中间的转换步骤。之前的源解码与之后的 canvas 操作分别留在各自模块中,使这个 shared 文件可以同时用于 worker 侧与 display 侧代码。

同样,这里也没有隐藏的 CSS 图像路径:不读取 URL、样式规则、blob 或 image 元素。ImageKind 是 PDF.js 的 buffer 布局标记,不是浏览器图像格式检测器。

总结

image_utils.js 是一组聚焦的 打包像素到 RGBA 的写入器。

四点可以概括它的设计:

  1. 分派不是检测。 convertToRGBA() 信任 params.kind,支持 1-bit 灰度与 24-bit RGB;其他 kind 返回 null,包括已经展开的 RGBA。
  2. 每个转换器都有不同的 buffer 契约。 1-bit 路径自行创建目标 Uint32Array 视图,RGB 接受单位不同的源/目标游标,灰度则按 src.length 迭代。
  3. 端序是实现细节,不是输出差异。 原生 32-bit 写入减少逐分量工作,同时在两种宿主机端序上保持相同的 R, G, B, A 字节序列。
  4. canvas 与解码留在模块之外。 ImageData、分块、OffscreenCanvas、bitmap transfer、JPEG 解码和 putImageData() 都由调用方负责。

应把这个文件读作小型 buffer 布局层,而不是通用图像工具库。它紧凑的 API 来自真实 PDF.js 像素路径,也来自大图像逐分量展开的成本约束。

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

相关文章