StreamPDFStreamPDF
返回博客

PDF.js 源码解读: obj_bin_transform_core

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: obj_bin_transform_core

前言

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.jsWorker五个 compile 函数,负责写 buffer
src/display/obj_bin_transform_display.js主线程五个包装类,负责按同一份 schema 读 buffer

本篇只讲写入端。读取端的字段顺序与这里逐字节对应,但不在本篇范围内。

模块总览

文件导出五个函数,覆盖四类数据:

函数输入输出结构调用点
compileCssFontInfofont.cssFontInfo三段长度前缀字符串compileFontInfo 内部
compileSystemFontInfofont.systemFontInfo1 字节标志 + 字符串块 + 可选样式块compileFontInfo 内部
compileFontInfo字体导出对象固定头 + 字符串块 + 两段嵌套 buffer + 字体数据Font.exportData
compilePatternInfo图案 IR 数组20 字节头 + 坐标 + 颜色 + 色标 + 可选 bboxPartialEvaluator.parseShading
compileFontPathInfoFloat16Array 或 Float32Array同类型的原始字节CompiledFont.getPath

五个函数都没有类、没有状态、没有缓存,只有「读字段、算长度、写字节」。这种纯函数形态让它们很容易被单测覆盖,也避免在 Worker 里留下额外生命周期。

调用链可以压成一张表:

阶段模块输入输出
字体解析fonts.js 的 Font字体程序、属性字典Font 实例
导出Font.exportDataEXPORT_DATA_PROPERTIES普通对象
编译obj_bin_transform_core.js普通对象ArrayBuffer
发送FontFaceObject.sendbuffer 与 transferablescommonobj 消息
解码obj_bin_transform_display.jsArrayBufferFontInfo 等包装类
使用FontFaceObject / FontLoader包装类FontFace 与字形路径

为什么不能直接 postMessage 类实例

MessageHandler.send 走的是结构化克隆。结构化克隆只复制对象自身的可枚举数据属性,不会复制原型链,也不会复制私有字段与访问器。一个 Dict 被克隆之后不再是 Dict,一个 Font 被克隆之后也不再拥有 exportData、fallback 这些方法。主线程拿到的是一个「长得像对象的普通对象」,却没有任何行为。

方案原型与方法大块数据主线程可用性
直接发送 Dict / Font丢失复制需要主线程重新造一套类与方法
发送手写的普通对象不涉及复制字段名成为隐式协议,容易漂移
编译成 buffer 并转移所有权不涉及零拷贝主线程用固定包装类解码

还有两个容易被忽略的成本:

  1. 重复字符串。每个字体都会带 name、loadedName、fallbackName、mimetype,直接发对象时它们是四个独立的 JS 字符串,通道两侧各留一份。
  2. 大块二进制。嵌入字体动辄几百 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_NUMBERS10 个布尔,每个 2 bit,向上取整到字节3
OFFSET_BBOX3 + 3 * 827
OFFSET_FONT_MATRIX27 + 1 + 2 * 436
OFFSET_DEFAULT_VMETRICS36 + 1 + 8 * 685
OFFSET_STRINGS85 + 1 + 2 * 392

这五个值同时被写入端与读取端使用。写入端在每段结束时用 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 或 1guessFallback
字符串总长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,编码是刻意设计的三态:

值编码
false0x01
true0x02
未设置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
}
字段元素类型固定个数缺失时
bboxInt16 小端4长度标记写 0,跳过 8 字节
fontMatrixFloat64 小端6长度标记写 0,跳过 48 字节
defaultVMetricsInt16 小端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字段必须存在
systemFontInfoUint32 长度 + 嵌套 buffer长度写 0
cssFontInfoUint32 长度 + 嵌套 buffer长度写 0
dataUint32 长度 + 原始字体字节长度写 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] 为 axialir[2] 包围盒、ir[3] 色标、ir[4] 起点、ir[5] 终点kind 1,四个坐标
RadialAxial 且 ir[1] 不是 axial同上,另有 ir[6] 起点半径、ir[7] 终点半径kind 2,六个数字
Meshir[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 位计数:

偏移字段内容
0KIND1 轴向、2 径向、3 网格
1HAS_BBOX0 或 1
2HAS_BACKGROUND0 或 1
3SHADING_TYPE仅网格图案使用,其它写 null 变 0
4N_COORD坐标对数,两个浮点一组,小端
8N_COLOR颜色条目数,小端
12N_STOP色标个数,小端
16N_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() 不能省,有两个原因:

  1. 缓存还在用原数组。CompiledFont 会把每个字形的编译结果存进 #compiledGlyphs,后续查询、检查与调试都可能再次读到它。如果把原数组的 buffer 转移走,缓存里的数组会变成「已分离」状态。
  2. 视图可能大于数据。类型化数组可能只是某个更大 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.jsshading 字典图案 IR
字形编译font_renderer.js字形指令类型化坐标数组
二进制化obj_bin_transform_core.js上述对象ArrayBuffer
传输MessageHandler.sendbuffer 与 transferablescommonobj 消息
解码obj_bin_transform_display.jsArrayBufferFontInfo 等包装类
使用FontFaceObject / CanvasGraphics包装类FontFace 与绘制指令

用一句话串起来:Worker 把对象拍平成字节,主线程把字节还原成自己的对象,中间没有任何类实例穿越线程边界。

五条设计原则

  1. 协议与实现分离。字段顺序、偏移与类型全部放在 shared/obj_bin_transform_utils.js,写入端与读取端只引用常量,不各自维护一份「约定」。
  2. 只传必要字段。布尔的三种状态、三个度量、四个名称、可选的 bbox 与 fontMatrix,都是渲染路径真正会读的东西;调试用的宽表、ToUnicode 映射留给 extra。
  3. 固定头加长度前缀尾。头部按常量寻址,尾部按长度顺序跳过,可选字段用 0 长度或 0 标记表达,不需要任何「字段位图」。
  4. 转移所有权而不是复制。编译函数返回独占 buffer,发送时放进 transferables;需要复用的数据先 slice(),避免把缓存里的数组分离。
  5. 断言锁住布局。每段结束校验偏移,每个嵌套 buffer 校验边界,每个数组校验长度;布局漂移在编译期或测试期暴露,不会拖到主线程渲染时才表现成「字体错位」。

下一篇会回到 Worker 的对象加载路径,看 ObjectLoader 如何在网络分块流上把一棵对象图提前读完,让后续的同步解析不再缺字节。

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

相关文章