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

前言
src/core/obj_bin_transform_core.js 只有 364 行,却是 Worker 与主线程之间最「硬」的一层协议:它把五类对象编译成 ArrayBuffer,让 postMessage 只搬运字节,不搬运对象。
它解决的问题很具体。字体在 Worker 端解析完成后,主线程需要知道名称、度量、嵌入数据与 CSS 回退信息;图案在 Worker 端求值完成后,主线程需要拿到坐标、颜色与色标;Type3 字体的字形轮廓也要从 Worker 送到主线程。如果这些东西以类实例的形式发送,会同时踩到三个坑:结构化克隆丢原型、大块数据被复制、重复字符串被逐个编码。
所以 PDF.js 把这五个函数的产物统一成「扁平 buffer」:Worker 写,主线程读,双方共享同一份字段顺序与偏移定义。
| 文件 | 运行位置 | 职责 |
|---|---|---|
src/shared/obj_bin_transform_utils.js | 两侧共享 | 字段顺序、偏移常量、TextEncoder 与 TextDecoder |
src/core/obj_bin_transform_core.js | Worker | 五个 compile 函数,负责写 buffer |
src/display/obj_bin_transform_display.js | 主线程 | 五个包装类,负责按同一份 schema 读 buffer |
本篇只讲写入端。读取端的字段顺序与这里逐字节对应,但不在本篇范围内。
模块总览
文件导出五个函数,覆盖四类数据:
| 函数 | 输入 | 输出结构 | 调用点 |
|---|---|---|---|
compileCssFontInfo | font.cssFontInfo | 三段长度前缀字符串 | compileFontInfo 内部 |
compileSystemFontInfo | font.systemFontInfo | 1 字节标志 + 字符串块 + 可选样式块 | compileFontInfo 内部 |
compileFontInfo | 字体导出对象 | 固定头 + 字符串块 + 两段嵌套 buffer + 字体数据 | Font.exportData |
compilePatternInfo | 图案 IR 数组 | 20 字节头 + 坐标 + 颜色 + 色标 + 可选 bbox | PartialEvaluator.parseShading |
compileFontPathInfo | Float16Array 或 Float32Array | 同类型的原始字节 | CompiledFont.getPath |
五个函数都没有类、没有状态、没有缓存,只有「读字段、算长度、写字节」。这种纯函数形态让它们很容易被单测覆盖,也避免在 Worker 里留下额外生命周期。
调用链可以压成一张表:
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字体解析 | fonts.js 的 Font | 字体程序、属性字典 | Font 实例 |
| 导出 | Font.exportData | EXPORT_DATA_PROPERTIES | 普通对象 |
| 编译 | obj_bin_transform_core.js | 普通对象 | ArrayBuffer |
| 发送 | FontFaceObject.send | buffer 与 transferables | commonobj 消息 |
| 解码 | obj_bin_transform_display.js | ArrayBuffer | FontInfo 等包装类 |
| 使用 | FontFaceObject / FontLoader | 包装类 | FontFace 与字形路径 |
为什么不能直接 postMessage 类实例
MessageHandler.send 走的是结构化克隆。结构化克隆只复制对象自身的可枚举数据属性,不会复制原型链,也不会复制私有字段与访问器。一个 Dict 被克隆之后不再是 Dict,一个 Font 被克隆之后也不再拥有 exportData、fallback 这些方法。主线程拿到的是一个「长得像对象的普通对象」,却没有任何行为。
| 方案 | 原型与方法 | 大块数据 | 主线程可用性 |
|---|---|---|---|
直接发送 Dict / Font | 丢失 | 复制 | 需要主线程重新造一套类与方法 |
| 发送手写的普通对象 | 不涉及 | 复制 | 字段名成为隐式协议,容易漂移 |
| 编译成 buffer 并转移所有权 | 不涉及 | 零拷贝 | 主线程用固定包装类解码 |
还有两个容易被忽略的成本:
- 重复字符串。每个字体都会带
name、loadedName、fallbackName、mimetype,直接发对象时它们是四个独立的 JS 字符串,通道两侧各留一份。 - 大块二进制。嵌入字体动辄几百 KB 到几 MB,结构化克隆会在 Worker 侧完整复制一遍再交给主线程,峰值内存直接翻倍。
编译方案把这两件事一起解决:字符串连续编码进一个 buffer,字体数据追加在尾部,整个 buffer 作为 transferable 发送,Worker 侧原 buffer 立刻失效,不再占用内存。
要注意,这并不是把整个消息协议改成二进制。同一批消息里,charProcOperatorList 与 extra 仍然是普通对象,非 Type3 图案的 IR 仍然走普通 obj 消息。二进制只用在「每个字体、每个字形、每个 Type3 图案都要走一遍」的高频路径上。
设计要点:跨线程传输的第一原则是只传数据,不传行为。类实例的价值在方法、缓存与私有状态,这些东西本来就不该跨线程;真正需要过去的只有字段。把字段拍平之后,协议就变成一份可以用常量描述的字节布局,而常量可以放在
shared目录里被两侧同时引用。
共享 schema:字段与偏移只写一次
shared/obj_bin_transform_utils.js 里没有任何编解码逻辑,只有四组常量与一个 TextEncoder / TextDecoder 的懒加载容器。它们定义了「第几个字段写在第几字节」。
FONT_INFO 的偏移是算出来的,不是手写的:
| 常量 | 计算 | 值 |
|---|---|---|
OFFSET_NUMBERS | 10 个布尔,每个 2 bit,向上取整到字节 | 3 |
OFFSET_BBOX | 3 + 3 * 8 | 27 |
OFFSET_FONT_MATRIX | 27 + 1 + 2 * 4 | 36 |
OFFSET_DEFAULT_VMETRICS | 36 + 1 + 8 * 6 | 85 |
OFFSET_STRINGS | 85 + 1 + 2 * 3 | 92 |
这五个值同时被写入端与读取端使用。写入端在每段结束时用 assert 校验当前偏移是否等于下一个常量,读取端则直接用常量寻址。一旦有人往 FONT_INFO.bools 里加一个字段而忘了同步布局,断言会立刻炸掉,而不是让主线程默默读出错位的数据。
另一个细节是字节序。字体数值段用 DataView 的默认大端,fontMatrix 却显式传了 true 走小端;图案里的计数、坐标、色标也都显式小端。同一个文件里混用两种字节序看起来奇怪,但只要读写两侧逐字段对齐就没有问题。真正危险的是「想当然地按统一字节序读」,那会在某些字段上得到完全错误的数字。
设计要点:二进制协议最难维护的不是编码,而是两侧对同一份布局的信任。PDF.js 用共享常量加运行时断言把这份信任变成可检查的约束;没有这层约束,任何一次字段增删都会变成线上才发现的错位。
字体信息的编译
字体的导出对象来自 Font.exportData,字段集合由 EXPORT_DATA_PROPERTIES 决定。这个集合只包含「渲染必须」的属性:度量、bbox、fontMatrix、名称、嵌入数据、CSS 与系统字体回退信息。真正的可选调试信息放在 EXPORT_DATA_EXTRA_PROPERTIES,只有 fontExtraProperties 打开时才以普通对象随消息发送。
compileCssFontInfo 方法
CSS 字体信息只服务一件事:主线程创建 FontFace 时需要 fontFamily、fontWeight 与倾斜角。
function compileCssFontInfo(info) {
const { encoder } = InfoUtils;
const encodedStrings = {};
let stringsLength = 0;
for (const prop of CSS_FONT_INFO.strings) {
const encoded = encoder.encode(info[prop]);
encodedStrings[prop] = encoded;
stringsLength += 4 + encoded.length;
}
const buffer = new ArrayBuffer(stringsLength);
const data = new Uint8Array(buffer);
const view = new DataView(buffer);
let offset = 0;
for (const prop of CSS_FONT_INFO.strings) {
const encoded = encodedStrings[prop];
const length = encoded.length;
view.setUint32(offset, length);
data.set(encoded, offset + 4);
offset += 4 + length;
}
assert(offset === buffer.byteLength, 'compileCssFontInfo: Buffer overflow');
return buffer;
}字段顺序来自 CSS_FONT_INFO.strings,依次是 fontFamily、fontWeight、italicAngle。
布局是「长度前缀 + UTF-8 字节」重复三段,没有总长度、没有字段个数、没有类型标记。读取端按索引循环:每跳过一个字符串就把 4 + 长度 累加到偏移,最后一段读到的就是目标。因为只有三个字段,这种线性扫描的代价可以忽略。
stringsLength 先累加再分配,因此 buffer 大小精确,最后的 assert 是严格相等而不是小于等于。这里没有为「字段缺失」留分支:encoder.encode(undefined) 会得到字符串 "undefined" 的字节,所以调用方必须先保证三个字段都是字符串。字体回退路径里这三个值由字体替换表生成,不会缺。
compileSystemFontInfo 方法
系统字体信息用在「PDF 没有嵌入字体,但操作系统里可能有一份同名或近似字体」的场景。它比 CSS 信息多一个布尔标志与一个可选的样式块。
function compileSystemFontInfo(info) {
const { encoder } = InfoUtils;
const encodedStrings = {};
let stringsLength = 0;
for (const prop of SYSTEM_FONT_INFO.strings) {
const encoded = encoder.encode(info[prop]);
encodedStrings[prop] = encoded;
stringsLength += 4 + encoded.length;
}
stringsLength += 4;
let encodedStyleStyle,
encodedStyleWeight,
lengthEstimate = 1 + stringsLength;
if (info.style) {
encodedStyleStyle = encoder.encode(info.style.style);
encodedStyleWeight = encoder.encode(info.style.weight);
lengthEstimate += 4 + encodedStyleStyle.length + 4 + encodedStyleWeight.length;
}
const buffer = new ArrayBuffer(lengthEstimate);
const data = new Uint8Array(buffer);
const view = new DataView(buffer);
let offset = 0;
view.setUint8(offset++, info.guessFallback ? 1 : 0);
view.setUint32(offset, 0);
offset += 4;
stringsLength = 0;
for (const prop of SYSTEM_FONT_INFO.strings) {
const encoded = encodedStrings[prop];
const length = encoded.length;
stringsLength += 4 + length;
view.setUint32(offset, length);
data.set(encoded, offset + 4);
offset += 4 + length;
}
view.setUint32(offset - stringsLength - 4, stringsLength);
if (info.style) {
view.setUint32(offset, encodedStyleStyle.length);
data.set(encodedStyleStyle, offset + 4);
offset += 4 + encodedStyleStyle.length;
view.setUint32(offset, encodedStyleWeight.length);
data.set(encodedStyleWeight, offset + 4);
offset += 4 + encodedStyleWeight.length;
}
assert(offset <= buffer.byteLength, 'compileSystemFontInfo: Buffer overflow');
return buffer.transferToFixedLength(offset);
}布局可以拆成四段:
| 区段 | 编码 | 说明 |
|---|---|---|
| 标志 | 1 字节,0 或 1 | guessFallback |
| 字符串总长 | Uint32,大端 | 先写 0,字符串写完后回填 |
| 字符串块 | 四条「Uint32 长度 + UTF-8」 | css、loadedName、baseFontName、src |
| 样式块 | 可选,两条「Uint32 长度 + UTF-8」 | style.style 与 style.weight |
字段顺序来自 SYSTEM_FONT_INFO.strings,依次是 css、loadedName、baseFontName、src。
字符串总长是一个「跳转索引」:读取端不需要逐条扫描四条字符串,只要读总长就能直接定位到样式块。回填表达式 offset - stringsLength - 4 看起来绕,实际就是把偏移减回第 1 字节:此时 offset 等于 5 加字符串总长,再减去总长与四个字节的自身长度,正好落在占位处。
样式块的位置由 info.style 是否存在决定,没有额外的存在标志。读取端拿到 systemFontInfo 时默认样式块存在;这个前提由字体替换逻辑保证:生成系统字体信息的对象总会带上 style,它来自内置的 NORMAL、BOLD、ITALIC、BOLDITALIC 四组描述。
guessFallback 在编译前可能已经被修改。fonts.js 里有一段逻辑:如果字体是猜出来的回退,会先把 guessFallback 置为 false,再把这个字体的 fallbackName 追加到 css 后面。编译发生在 exportData 时,因此 buffer 里保存的是修正后的最终值,主线程不需要再次判断。
transferToFixedLength 返回一个指定长度的新 buffer。前面的 lengthEstimate 实际上已经算准,这里仍然调用是为了输出一个独立、精确长度的 buffer,避免把估算过程留下的任何余量带过去。
compileFontInfo 方法
这是五个函数里唯一带「完整布局」的一个:固定头部 + 字符串块 + 两段嵌套 buffer + 字体数据。它先递归编译两个子结构:
function compileFontInfo(font) {
const systemFontInfoBuffer = font.systemFontInfo
? compileSystemFontInfo(font.systemFontInfo)
: null;
const cssFontInfoBuffer = font.cssFontInfo
? compileCssFontInfo(font.cssFontInfo)
: null;
const { encoder } = InfoUtils;
const encodedStrings = {};
let stringsLength = 0;
for (const prop of FONT_INFO.strings) {
encodedStrings[prop] = encoder.encode(font[prop]);
stringsLength += 4 + encodedStrings[prop].length;
}
const lengthEstimate =
FONT_INFO.OFFSET_STRINGS +
4 +
stringsLength +
4 +
(systemFontInfoBuffer?.byteLength ?? 0) +
4 +
(cssFontInfoBuffer?.byteLength ?? 0) +
4 +
(font.data?.length ?? 0);子 buffer 不为 transferable,它们是「复制进父 buffer」的普通字节段。因此发送时只需要转移父 buffer 一次。
固定头部:布尔、数值与三个可选数组
头部的前 92 字节全部按常量布局,每一段结束都做一次断言:
const buffer = new ArrayBuffer(lengthEstimate);
const data = new Uint8Array(buffer);
const view = new DataView(buffer);
let offset = 0;
const numBools = FONT_INFO.bools.length;
let boolByte = 0,
boolBit = 0;
for (let i = 0; i < numBools; i++) {
const value = font[FONT_INFO.bools[i]];
// eslint-disable-next-line no-nested-ternary
const bits = value === undefined ? 0x00 : value ? 0x02 : 0x01;
boolByte |= bits << boolBit;
boolBit += 2;
if (boolBit === 8 || i === numBools - 1) {
view.setUint8(offset++, boolByte);
boolByte = 0;
boolBit = 0;
}
}
assert(offset === FONT_INFO.OFFSET_NUMBERS, 'compileFontInfo: Boolean properties offset mismatch');
for (const prop of FONT_INFO.numbers) {
view.setFloat64(offset, font[prop]);
offset += 8;
}
assert(offset === FONT_INFO.OFFSET_BBOX, 'compileFontInfo: Number properties offset mismatch');布尔字段有十个,顺序是 black、bold、disableFontFace、fontExtraProperties、isInvalidPDFjsFont、isType3Font、italic、missingFile、remeasure、vertical。每个布尔占 2 bit,编码是刻意设计的三态:
| 值 | 编码 |
|---|---|
false | 0x01 |
true | 0x02 |
| 未设置 | 0x00 |
四组 2 bit 拼成一个字节,十个布尔正好三字节。读取端按同样的位偏移取值,0x00 还原成 undefined。
保留 undefined 很重要。字体的许多逻辑判断的是「这个属性有没有被显式设置」,而不是「它是否为真」。比如 black、remeasure、isInvalidPDFjsFont 都可能在字体修复流程里保持未定义,让后续代码走默认分支。如果编译时把 undefined 压成 false,主线程的行为就会和 Worker 端不一致。
数值段只有三个字段:ascent、defaultWidth、descent,每个 8 字节的 Float64。字体度量直接参与行高与文本定位,单精度在这里不够用。三个数值都没有「缺失」标记,因为字体对象在导出前已经为它们填好默认值。
三个可选数组的编码都是「1 字节长度 + 固定个数的元素」:
if (font.bbox) {
view.setUint8(offset++, 4);
for (const coord of font.bbox) {
view.setInt16(offset, coord, true);
offset += 2;
}
} else {
view.setUint8(offset++, 0);
offset += 2 * 4; // TODO: optimize this padding away
}| 字段 | 元素类型 | 固定个数 | 缺失时 |
|---|---|---|---|
bbox | Int16 小端 | 4 | 长度标记写 0,跳过 8 字节 |
fontMatrix | Float64 小端 | 6 | 长度标记写 0,跳过 48 字节 |
defaultVMetrics | Int16 小端 | 3 | 长度标记写 0,跳过 6 字节 |
缺失时仍然推进偏移,这是为了让后面所有段的偏移保持固定。ArrayBuffer 初始就是全零,所以跳过的区域天然是零填充,不需要显式写。源码里的 TODO: optimize this padding away 说明作者知道这部分是空间浪费,但保留固定偏移换来的解码简洁性更值钱。
读取端把长度标记当作存在标志:读到 0 就直接返回 undefined,读到非 0 再断言它等于期望的元素个数。这个断言是布局的第二道保险:如果写入端和读取端的字段长度不一致,这里会立刻失败。
可变尾部:字符串、嵌套 buffer 与字体数据
尾部全部使用「Uint32 长度 + 数据」的格式:
view.setUint32(FONT_INFO.OFFSET_STRINGS, 0);
offset += 4;
for (const prop of FONT_INFO.strings) {
const encoded = encodedStrings[prop];
const length = encoded.length;
view.setUint32(offset, length);
data.set(encoded, offset + 4);
offset += 4 + length;
}
view.setUint32(FONT_INFO.OFFSET_STRINGS, offset - FONT_INFO.OFFSET_STRINGS - 4);
if (!systemFontInfoBuffer) {
view.setUint32(offset, 0);
offset += 4;
} else {
const length = systemFontInfoBuffer.byteLength;
view.setUint32(offset, length);
assert(
offset + 4 + length <= buffer.byteLength,
'compileFontInfo: Buffer overflow at systemFontInfo',
);
data.set(new Uint8Array(systemFontInfoBuffer), offset + 4);
offset += 4 + length;
}尾部顺序是固定的:
| 区段 | 格式 | 缺失时 |
|---|---|---|
| 字符串总长 | Uint32 大端 | 必然存在 |
| 四条字符串 | 每条 Uint32 长度 + UTF-8 | 字段必须存在 |
systemFontInfo | Uint32 长度 + 嵌套 buffer | 长度写 0 |
cssFontInfo | Uint32 长度 + 嵌套 buffer | 长度写 0 |
data | Uint32 长度 + 原始字体字节 | 长度写 0 |
四条字符串的顺序是 fallbackName、loadedName、mimetype、name。
字符串总长的回填表达式是 offset - OFFSET_STRINGS - 4,结果是四条字符串各自长度前缀与内容的总和,不含这四个字节自身。读取端先用它整体跳过字符串块,再依次读两段嵌套 buffer 与字体数据。
字体数据是最后一段,这个位置不是巧合:
if (font.data === undefined) {
view.setUint32(offset, 0);
offset += 4;
} else {
view.setUint32(offset, font.data.length);
data.set(font.data, offset + 4);
offset += 4 + font.data.length;
}
assert(offset <= buffer.byteLength, "compileFontInfo: Buffer overflow");
return buffer.transferToFixedLength(offset);
}主线程把字体绑定到 DOM 之后,如果 fontExtraProperties 为假,会立刻调用 clearData() 释放字体文件。clearData() 做的事很简单:把 data 的长度字段改成 0,再把 buffer 截到「字体数据长度字段之后」的前缀并复制一份。字体数据在最后一段,所以截断不会破坏任何其它字段。
读取端为 data 返回的是 buffer 上的 Uint8Array 视图,不是副本。视图的生命周期与 buffer 相同;clearData() 之后,长度字段为 0,再取 data 就得到 undefined。
嵌套 buffer 的存在让 compileFontInfo 有一个隐含约束:子函数必须在父函数之前完成。这不仅是为了拿到长度,也是为了拿到字节。先编译 systemFontInfo 与 cssFontInfo,再把它们复制进父 buffer,是唯一可行的顺序。
设计要点:
compileFontInfo用「固定头 + 长度前缀尾」的组合,在同一个 buffer 里同时满足了三种需求:头部字段可以按常量直接寻址,尾部字段可以按长度顺序跳过,最后一段可以原地截断释放。把大块字体数据放在最后,是一个纯粹为了内存回收做出的布局决策。
图案与路径的编译
字体之外,还有两类对象需要跨线程:Type3 字体里的图案,以及 Type3 字体的字形轮廓。它们的共同点是数据量大、结构简单、只用于绘制。
compilePatternInfo 方法
输入是图案 IR 数组,也就是 Pattern.getIR() 的产物。三种图案对应两种形态:
ir[0] 与 ir[1] | 相关字段 | 编译结果 |
|---|---|---|
RadialAxial 且 ir[1] 为 axial | ir[2] 包围盒、ir[3] 色标、ir[4] 起点、ir[5] 终点 | kind 1,四个坐标 |
RadialAxial 且 ir[1] 不是 axial | 同上,另有 ir[6] 起点半径、ir[7] 终点半径 | kind 2,六个数字 |
Mesh | ir[1] 类型、ir[2] 坐标、ir[3] 颜色、ir[6] 包围盒、ir[7] 背景 | kind 3,忽略 ir[4] 与 ir[5] |
function compilePatternInfo(ir) {
let kind,
bbox = null,
coords = [],
colors = [],
colorStops = [],
shadingType = null, // only needed for mesh patterns
background = null; // background for mesh patterns
switch (ir[0]) {
case "RadialAxial":
kind = ir[1] === "axial" ? 1 : 2;
bbox = ir[2];
colorStops = ir[3];
if (kind === 1) {
coords.push(...ir[4], ...ir[5]);
} else {
coords.push(ir[4][0], ir[4][1], ir[6], ir[5][0], ir[5][1], ir[7]);
}
break;
case "Mesh":
kind = 3;
shadingType = ir[1];
coords = ir[2];
colors = ir[3];
bbox = ir[6];
background = ir[7];
break;
default:
throw new Error(`Unsupported pattern type: ${ir[0]}`);
}径向图案把两个圆心与两个半径压成六个数字,顺序是「起点 x、起点 y、起点半径、终点 x、终点 y、终点半径」。轴向图案只取两个端点,四个数字。读取端按 kind 拆回原来的数组结构。
网格图案丢弃了 ir[4] 与 ir[5]:前者是顶点数,坐标数组的长度除以 2 就能得到;后者是 Worker 端已经算好的 bounds,主线程拿到坐标后会重新算一遍。声明包围盒 ir[6] 与背景 ir[7] 仍然保留。
const nCoord = Math.floor(coords.length / 2);
const nColor = Math.floor(colors.length / 4);
const nStop = colorStops.length;
const byteLen = 20 + nCoord * 8 + nColor * 4 + nStop * 8 + (bbox ? 16 : 0) + (background ? 3 : 0);
const buffer = new ArrayBuffer(byteLen);
const dataView = new DataView(buffer);
const u8data = new Uint8Array(buffer);
dataView.setUint8(PATTERN_INFO.KIND, kind);
dataView.setUint8(PATTERN_INFO.HAS_BBOX, bbox ? 1 : 0);
dataView.setUint8(PATTERN_INFO.HAS_BACKGROUND, background ? 1 : 0);
dataView.setUint8(PATTERN_INFO.SHADING_TYPE, shadingType); // Only for mesh pattern, null otherwise
dataView.setUint32(PATTERN_INFO.N_COORD, nCoord, true);
dataView.setUint32(PATTERN_INFO.N_COLOR, nColor, true);
dataView.setUint32(PATTERN_INFO.N_STOP, nStop, true);
dataView.setUint32(PATTERN_INFO.N_FIGURES, 0, true);20 字节头分成四个单字节与四个 32 位计数:
| 偏移 | 字段 | 内容 |
|---|---|---|
| 0 | KIND | 1 轴向、2 径向、3 网格 |
| 1 | HAS_BBOX | 0 或 1 |
| 2 | HAS_BACKGROUND | 0 或 1 |
| 3 | SHADING_TYPE | 仅网格图案使用,其它写 null 变 0 |
| 4 | N_COORD | 坐标对数,两个浮点一组,小端 |
| 8 | N_COLOR | 颜色条目数,小端 |
| 12 | N_STOP | 色标个数,小端 |
| 16 | N_FIGURES | 本版恒为 0 |
头部长度选 20 还有一个技术原因:Float32Array 的字节偏移必须按 4 对齐,20 正好满足,后面的坐标段可以直接建视图,不需要再复制。
let offset = 20;
const coordsView = new Float32Array(buffer, offset, nCoord * 2);
coordsView.set(coords);
offset += nCoord * 8;
u8data.set(colors, offset);
offset += nColor * 4;
for (const [pos, hex] of colorStops) {
dataView.setFloat32(offset, pos, true);
offset += 4;
dataView.setUint32(offset, parseInt(hex.slice(1), 16), true);
offset += 4;
}
if (bbox) {
for (const v of bbox) {
dataView.setFloat32(offset, v, true);
offset += 4;
}
}
if (background) {
u8data.set(background, offset);
}
return buffer;
}主体按「坐标、颜色、色标、声明包围盒、背景」的顺序排列:
| 区段 | 编码 | 说明 |
|---|---|---|
| 坐标 | Float32 小端 | 每个顶点 x、y 共 8 字节 |
| 颜色 | 原始字节 | 每项 4 字节,已是 RGBA 步长 |
| 色标 | Float32 位置 + Uint32 颜色 | 位置单精度,颜色由 #rrggbb 解析 |
| 声明包围盒 | 4 个 Float32 小端 | 仅在 HAS_BBOX 为 1 时存在 |
| 背景 | 3 字节 | 仅在 HAS_BACKGROUND 为 1 时存在 |
色标颜色的解析是 parseInt(hex.slice(1), 16),前提是输入为六位十六进制,例如 #a1b2c3。读取端用 toString(16).padStart(6, "0") 还原成同样的字符串形式。这个往返会丢掉大小写,但颜色值本身不变。
N_COORD、N_COLOR、N_STOP 都由数组长度推导,不存在「声明与实际不一致」的可能。N_FIGURES 是一个预留字段,本版读写两端都没有使用它。
| 数据 | 处理方式 |
|---|---|
| 坐标对数 | 写入端由 coords.length / 2 计算,读取端直接使用 |
| 颜色个数 | 写入端由 colors.length / 4 计算 |
| 色标个数 | 写入端由 colorStops.length 计算 |
| 网格 bounds | 不传输,读取端用 Util.pointBoundingBox 重算 |
| 网格 bbox | 传输,作为可选段跟在色标之后 |
N_FIGURES | 固定写 0 |
compilePatternInfo 的返回值没有截断,byteLen 本身就是精确值。它被 PartialEvaluator.parseShading 作为 transferable 发送,而且只在 Type3 字体解析期间使用;非 Type3 图案仍然走普通的 obj 消息,直接发送 IR 数组。主线程的 PatternInfo 立刻调用 getIR() 还原成 IR 数组。对网格图案来说,coords 与 colors 是 buffer 上的视图,没有复制;对轴向与径向图案来说,坐标会被复制进小数组。这种差别来自「大块数据尽量零拷贝,小数据以易用为先」的取舍。
compileFontPathInfo 方法
字形轮廓是一个扁平的类型化数组:每个字形由若干条指令组成,每条指令的第一个数字是绘制操作码,后面跟坐标。Worker 端已经用字体矩阵变换过坐标,主线程只需要按同样的操作码重放。
function compileFontPathInfo(path) {
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
assert(
FeatureTest.isFloat16ArraySupported
? path instanceof Float16Array
: path instanceof Float32Array,
'compileFontPathInfo: Unexpected path format.',
);
}
return path.slice().buffer;
}这个函数只做一件事:复制一份,然后把底层 buffer 交出去。slice() 不能省,有两个原因:
- 缓存还在用原数组。
CompiledFont会把每个字形的编译结果存进#compiledGlyphs,后续查询、检查与调试都可能再次读到它。如果把原数组的 buffer 转移走,缓存里的数组会变成「已分离」状态。 - 视图可能大于数据。类型化数组可能只是某个更大 buffer 上的视图,
slice()保证输出从 0 开始、长度精确。
buffer 里没有类型标记,读取端用与写入端相同的构建条件决定构造函数:MOZCENTRAL 构建或支持 Float16Array 的浏览器按半精度读,否则按单精度读。两侧运行在同一个构建产物里,判断结果一致,所以不需要额外标记。代价是:如果这个假设被打破,读取端会按错误的元素宽度解释整段字节,得到一堆无意义的坐标。
| 环节 | 类型 | 是否复制 | 说明 |
|---|---|---|---|
| 生成 | Float16Array 或 Float32Array | 否 | 由 CompiledFont.getPath 产生 |
| 编译 | 同类型 | 是 | slice() 后取 buffer |
| 发送 | ArrayBuffer | 否 | 作为 transferable |
| 解码 | 同类型视图 | 否 | FontPathInfo.path 直接建视图 |
| 回收 | 主线程移除引用 | 否 | 随包装类一起由垃圾回收释放 |
设计要点:
compileFontPathInfo是整个文件里最像「协议」的函数:它不定义任何字段,只约定「这段字节就是原数组的二进制表示」。约定越短,越依赖上下文;类型标记被省略,换来的是每个字形少一个字节、解码端少一次分支。前提是两侧共享同一个运行时特性,这个前提在 Worker 架构里成立。
总结
obj_bin_transform_core.js 是 PDF.js 的跨线程序列化层:向上接收 Worker 端的字体、图案与字形对象,向下输出可转移的 ArrayBuffer,中间靠 shared 目录里的字段常量维持两侧一致。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字体解析 | fonts.js | 字体程序、属性 | Font 实例 |
| 图案求值 | evaluator.js | shading 字典 | 图案 IR |
| 字形编译 | font_renderer.js | 字形指令 | 类型化坐标数组 |
| 二进制化 | obj_bin_transform_core.js | 上述对象 | ArrayBuffer |
| 传输 | MessageHandler.send | buffer 与 transferables | commonobj 消息 |
| 解码 | obj_bin_transform_display.js | ArrayBuffer | FontInfo 等包装类 |
| 使用 | FontFaceObject / CanvasGraphics | 包装类 | FontFace 与绘制指令 |
用一句话串起来:Worker 把对象拍平成字节,主线程把字节还原成自己的对象,中间没有任何类实例穿越线程边界。
五条设计原则
- 协议与实现分离。字段顺序、偏移与类型全部放在
shared/obj_bin_transform_utils.js,写入端与读取端只引用常量,不各自维护一份「约定」。 - 只传必要字段。布尔的三种状态、三个度量、四个名称、可选的 bbox 与 fontMatrix,都是渲染路径真正会读的东西;调试用的宽表、ToUnicode 映射留给
extra。 - 固定头加长度前缀尾。头部按常量寻址,尾部按长度顺序跳过,可选字段用 0 长度或 0 标记表达,不需要任何「字段位图」。
- 转移所有权而不是复制。编译函数返回独占 buffer,发送时放进 transferables;需要复用的数据先
slice(),避免把缓存里的数组分离。 - 断言锁住布局。每段结束校验偏移,每个嵌套 buffer 校验边界,每个数组校验长度;布局漂移在编译期或测试期暴露,不会拖到主线程渲染时才表现成「字体错位」。
下一篇会回到 Worker 的对象加载路径,看 ObjectLoader 如何在网络分块流上把一棵对象图提前读完,让后续的同步解析不再缺字节。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年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 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日