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

前言
本篇完整阅读 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-endian | 0xff000000 | black 与 RGB 的 alphaMask |
| Big-endian | 0x000000ff | black;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 |
|---|---|---|
false | black | nonBlackColor |
true | nonBlackColor | black |
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.js | 1-bit 转换或 convertToRGBA | 处理 inverseDecode、创建 ImageData,可能 transfer ImageBitmap |
core/image_resizer.js | 对连续条带调用 convertToRGBA | 携带 srcPos、抽样转换后的像素,可能创建 OffscreenCanvas |
core/jpg.js | grayToRGBA | 解码 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 的写入器。
四点可以概括它的设计:
- 分派不是检测。
convertToRGBA()信任params.kind,支持 1-bit 灰度与 24-bit RGB;其他 kind 返回null,包括已经展开的 RGBA。 - 每个转换器都有不同的 buffer 契约。 1-bit 路径自行创建目标
Uint32Array视图,RGB 接受单位不同的源/目标游标,灰度则按src.length迭代。 - 端序是实现细节,不是输出差异。 原生 32-bit 写入减少逐分量工作,同时在两种宿主机端序上保持相同的
R, G, B, A字节序列。 - canvas 与解码留在模块之外。
ImageData、分块、OffscreenCanvas、bitmap transfer、JPEG 解码和putImageData()都由调用方负责。
应把这个文件读作小型 buffer 布局层,而不是通用图像工具库。它紧凑的 API 来自真实 PDF.js 像素路径,也来自大图像逐分量展开的成本约束。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- 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 源码解读: 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 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日