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

前言
src/shared/murmurhash3.js 实现了 PDF.js 多个子系统使用的轻量非加密指纹。它唯一的导出 MurmurHash3_64 保存两个 32 位状态字,并返回格式化为 16 个十六进制字符的 64 位结果。
这个名字不代表加密安全、数据加密能力,也不是 128 位的 MurmurHash3 x64 变体。它是 PDF.js 基于 MurmurHash3 的 64 位实现,用于低成本的内部身份标识与变化检测。输入编码、调用分块和最终化方式都会影响结果,也不能把它当作通用的流式哈希接口。
模块总览
| 部分 | 职责 |
|---|---|
SEED、MASK_HIGH、MASK_LOW | 提供默认状态,并支持 JavaScript 中的 32 位乘法 |
constructor(seed) | 用同一组种子位初始化 h1 与 h2 |
update(input) | 归一化字符串或 TypedArray,混合完整四字节块,再处理尾块 |
| 四字节块循环 | 在 h2 与 h1 之间交替处理数据块 |
尾部 switch | 通过贯穿分支吸收剩余的一到三个字节 |
hexdigest() | 最终化状态副本,返回两个补零的 32 位十六进制字 |
这个类不依赖 PDF 解析或浏览器 API。它是一个小型数值工具,由调用方决定哪些对象属性或字节应当进入指纹。
constructor(seed):一个种子,两个状态字
const SEED = 0xc3d2e1f0;
constructor(seed) {
this.h1 = seed ? seed & 0xffffffff : SEED;
this.h2 = seed ? seed & 0xffffffff : SEED;
}未提供种子时,两条通道都从 0xc3d2e1f0 开始。truthy 种子会截取低 32 位,并同时复制到 h1、h2。因为判断条件是真值而不是 seed === undefined,传入 0 也会选择默认种子,不会创建独立的零种子实例。
默认值固定而非随机,这适合可复现的内部指纹,但不能抵御刻意构造的碰撞。类中其他位运算会把状态解释为有符号或无符号 32 位值;真正重要的是位模式,而不是 JavaScript 显示出来的正负号。
update(input):先归一化,再计算哈希
字符串使用 UTF-16 码元,而非 UTF-8
对于字符串,update 先按每个 JavaScript 码元两个字节分配空间,再记录实际编码长度:
const code = input.charCodeAt(i);
if (code <= 0xff) {
data[length++] = code;
} else {
data[length++] = code >>> 8;
data[length++] = code & 0xff;
}不超过 0xff 的码元变成一个 byte,更大的码元变成高字节在前的两个 byte。因此,é(U+00E9)贡献单个 e9 字节,而不是两字节的 UTF-8 形式;U+0100 则贡献 01 00。代理对会作为两个独立的 UTF-16 码元处理,也不执行 Unicode 规范化。
TypedArray 会被复制,并按字节计量
命中 ArrayBuffer.isView(input) 后进入 TypedArray 分支:
data = input.slice();
length = data.byteLength;这里的 slice() 复制很关键。它创建从偏移零开始的新缓冲区,只包含当前视图的元素,因此同一大型 ArrayBuffer 上的两个不同视图,不会意外把无关的相邻字节算进哈希。后续的四字节视图也就建立在恰好复制出的数据上。
从实现可以直接看出两个输入边界。DataView 能通过 ArrayBuffer.isView,却没有 slice() 方法,所以通过检查并不代表可用。此外,后面的尾部代码通过 data[...] 按 TypedArray 元素索引,而不是另建 Uint8Array:任意长度输入要以字节精确处理,应走 Uint8Array 路径,不能假设更宽或有符号视图具有相同的尾部语义。仓库的 Uint32Array 测试按块对齐,没有尾块。
原始 ArrayBuffer 无法通过视图检查,会进入显式的格式错误分支。其他不支持的输入则抛出 Invalid data format, must be a string or TypedArray.。实际调用方提供的是字符串、面向字节的 TypedArray 或 subarray;这个类不是对象序列化器,要哈希对象,调用方必须先定义字符串或字节表示。
四字节块:交替通道与 32 位混合
const blockCounts = length >> 2;
const tailLength = length - blockCounts * 4;
const dataUint32 = new Uint32Array(data.buffer, 0, blockCounts);每个完整块为四个字节。Uint32Array 按平台原生字节序解释这些字节,源码明确不在这里统一端序。循环随后对每个块执行同一组 Murmur 风格操作:
- 乘以
C1 = 0xcc9e2d51。 - 左旋 15 位。
- 乘以
C2 = 0x1b873593。 - 将块 XOR 到一条状态通道。
- 将该通道左旋 13 位,乘以 5,再加上
0xe6546b64。
通道选择很容易读反:偶数块索引更新 k2,随后更新 h2;奇数索引更新 k1,随后更新 h1。连续数据块由此分布到两个 32 位状态,而不是像 128 位算法那样维护四条通道。
JavaScript number 是双精度浮点数,两个任意 32 位数的完整乘积可能超出精确整数范围。MASK_HIGH = 0xffff0000 与 MASK_LOW = 0xffff 把乘法拆成高位和低位贡献,再由位运算把结果转回 32 位。因此,掩码属于哈希运算的一部分,不是输入清洗。
尾块:面向字节的输入与 TypedArray 边界
处理完整块后,tailLength 可能为零、一、二或三。对于字符串和 Uint8Array,data[...] 按单个字节寻址,switch 刻意从 case 3 贯穿到 case 2、case 1:
- 三个尾字节时,最后一个字节进入第 16–23 位;
- 下一个字节进入第 8–15 位;
- 第一个字节 XOR 到低八位。
组装出的 k1 与完整块一样,经过 C1 乘法、15 位左旋和 C2 乘法。随后,如果 blockCounts 为奇数,就把它 XOR 到 h1,否则 XOR 到 h2,也就是交替序列中的下一条通道。尾长为零时不执行混合。
尾部不会靠虚构的四字节输入块来补齐,也不会追加长度字段。对于面向字节的输入,忽略这条路径会令只在末尾一到三个字节上不同的输入得到相同的中间状态。
但对于更宽的 TypedArray,tailLength 按 byteLength 计算,这些表达式却按元素索引。三元素 Uint16Array 有六个字节,却不存在 data[4] 或 data[5];尾部读取得到 undefined,位运算再把它转成零,第三个元素也不会被这条路径消费。这是实现边界,不是通用的字节哈希语义:任意长度输入若要求逐字节精确处理,调用方应提供 Uint8Array。
hexdigest():最终化副本,再格式化 64 位
hexdigest() 先创建局部 h1、h2。它通过 h1 ^= h2 >>> 1、额外乘法以及相互耦合的高低项交叉混合两条通道,再用另一组常量重复该阶段,最后执行一次 h1 ^= h2 >>> 1。常量包括 0xed558ccd、0xff51afd7、0x1a85ec53 与 0xc4ceb9fe,精度规避逻辑还会使用额外的 16 位拆分项和跨通道项。
对外只暴露返回文本:
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');每条通道都转换成无符号 32 位值,并分别补足八个小写十六进制字符。拼接会保留前导零,因此始终得到 16 个字符。
最终化不会把局部值写回 this.h1 或 this.h2。没有再次 update 时,连续调用两次 hexdigest() 会得到同一文本;之后调用 update(),则从保存的、最终化之前的状态继续。最终化逻辑也没有累计输入长度,这进一步说明不能换用另一个 Murmur 实现还期待摘要一致。
仓库单元测试给出了一个具体参照:
const hexDigestExpected = 'f61cfdbfdae0f65e';
const sourceText = 'test';
const hash = new MurmurHash3_64();
hash.update(sourceText);
expect(hash.hexdigest()).toEqual(hexDigestExpected);包含 [116, 101, 115, 116] 的 Uint8Array 会得到相同摘要,因为它归一化为同样的四个字节。
跨调用 update():状态会累积,尾字节不会保留
虽然 update() 会在实例上保留 h1、h2,它不会在两次调用之间保留不完整的数据块。每次调用都从字节偏移零重新开始,计算新的 blockCounts,消费自己的尾块,并根据本次块数选择尾部通道。实例也不保存累计字节长度。
因此,在任意边界拆分输入,通常不等价于一次性计算拼接结果。用示意关系表示:
update(A); update(B) ≠ update(concat(A, B)) in general这不是常见的任意分块流式契约。现有调用方会控制传入同一实例的逻辑值序列;阅读或使用它时,应把 update 的分块方式视为指纹定义的一部分。
PDF.js 中的真实用途
| 调用方 | 哈希内容 | 结果用途 |
|---|---|---|
OptionalContentConfig.getHash() | 可选内容组 ID 与可见性 | 检测初始可见性是否改变 |
AnnotationStorage | 序列化编辑器或已修改批注 ID | 为存储数据附加紧凑状态指纹 |
PartialEvaluator.preEvaluateFont() | 编码、字符范围、ToUnicode 数据、宽度及相关字体特征 | 通过 fontAliases 复用字体 ID |
PdfEditor.#resourceStreamKey() | 序列化资源字典、字节长度和采样字节块 | 筛选资源流去重候选 |
编辑器调用方明确展示了碰撞边界:哈希只作为桶键,合并前还要逐字节比较候选。这个额外保障属于该调用方,并不属于 MurmurHash3_64 本身。
这些用途都把摘要当作紧凑的概率性标识。它不适合密码存储、身份认证、数字签名,也不适合攻击者能够刻意制造碰撞时的完整性校验。
总结
MurmurHash3_64 由确定性种子初始化、PDF.js 特定的字符串归一化、交替的 32 位块通道、面向字节输入的显式尾部处理,以及不修改原状态的最终化组成,最后输出两个补零的十六进制字。
这些实现细节都是兼容性契约的一部分。默认种子的真值判断、原生端序块视图、按调用处理尾块以及缺少累计长度,在复现摘要时都不能忽略。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 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年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日