StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 12: 字体翻译, 从 FontFile3 到 FontFace

2026年10月28日 · StreamPDF Team

PDF.js 从 demo 深入浅出 12: 字体翻译, 从 FontFile3 到 FontFace

前言

第 11 篇 里, demo 首页的 4 次 Tf 最终变成了 4 条 setFont 加 2 条 dependency 算子, 同时 worker 发出了两条 commonobj 消息:

handler.send("commonobj", ["g_d0_f1", "Font", fontData], transfers);

当时留下了两个问题:

  • fontData 是什么? 为什么它能放进 transfer list?
  • g_d0_f1 对应的字体, 在 worker 端经历了什么才变成浏览器能用的东西?

浏览器只认识 OpenType/TrueType/WOFF 等容器, 而 demo 的 PDF 内嵌的是 Type1C (CFF) 字体程序. 中间这道翻译工序, 就是本篇的主角. 我们跟随 demo 的两个真实字体, 从 /T1_0 这个名字一直走到主线程的 new FontFace.

demo 的两个字体档案

先从 PDF 对象出发, 把本篇要用的数据列出来. demo 文档是 PDF 32000-1:2008 规范本身, 首页资源字典:

<< /ExtGState << /GS0 13 0 R >>
   /Font << /T1_0 14 0 R /T1_1 15 0 R >>
   /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >>

14 0 R (/T1_0, 正文用) 与 15 0 R (/T1_1, 粗体用) 的字典几乎同构:

字段T1_0 (14 0 R)T1_1 (15 0 R)
SubtypeType1Type1
BaseFontDDPEFM+HelveticaDDPEIM+Helvetica-Bold
EncodingWinAnsiEncodingWinAnsiEncoding
FirstChar / LastChar32 / 16932 / 151
Widths138 个数120 个数
ToUnicode无无
FontDescriptor452 0 R453 0 R

DDPEFM+ 这种六字母加号前缀是字体子集 (subset) 的标记: 字体程序里只嵌入了文档用到的字形. 对应的 FontDescriptor:

字段452 (Helvetica)453 (Helvetica-Bold)
Flags32262176
FontBBox[-166 -225 1000 931][-170 -228 1003 962]
Ascent / Descent718 / -207718 / -207
CapHeight / XHeight718 / 523718 / 532
StemV88140
CharSet55 个字形名40 个字形名
FontFile31306 0 R (Type1C, 压缩 3723 → 解压 4323 字节)1307 0 R (Type1C, 压缩 2642 → 解压 3121 字节)

Flags 是位掩码: 32 是 Nonsymbolic; 262176 = 32 + 262144, 即 Nonsymbolic | ForceBold.

注意两个关键点:

  1. 字体程序走的是 FontFile3 + Subtype Type1C, 也就是 CFF (Compact Font Format) —— 浏览器无法直接加载;
  2. 两个字典都没有 ToUnicode CMap, 文本的 unicode 只能从编码字形名推导.

第 1 步, loadFont: 先查缓存, 再决定要不要翻译

入口在 handleSetFont 里, 它把资源名 T1_0 交给 loadFont. loadFont 的第一段全是缓存与兜底逻辑:

let fontRef;
if (font) {
  if (font instanceof Ref) {
    fontRef = font;
  }
} else {
  // 按名字加载: 从资源字典取原始引用
  const fontRes = resources.get("Font");
  if (fontRes) {
    fontRef = fontRes.getRaw(fontName); // T1_0 → Ref "14 0 R"
  }
}
if (fontRef) {
  if (this.type3FontRefs?.has(fontRef)) {
    return errorFont();
  }
  if (this.fontCache.has(fontRef)) {
    return this.fontCache.get(fontRef); // 同一引用直接复用 promise
  }
  try {
    font = this.xref.fetchIfRef(fontRef);
  } catch (ex) {
    warn(`loadFont - lookup failed: "${ex}".`);
  }
}
 
if (!(font instanceof Dict)) {
  if (!this.options.ignoreErrors && !this.parsingType3Font) {
    return errorFont(); // 生成 loadedName = "g_font_error" 的错误字体
  }
  // 忽略错误模式下, 退回内置的 fallback 字体字典
  font = fallbackFontDict || PartialEvaluator.fallbackFontDict;
}

fontCache 以 Ref 为键, 值是 promise. demo 首页内容流里 T1_0 出现两次、 T1_1 出现两次, 但每个字体只翻译一次, 靠的就是这层缓存. 缓存的另一个作用在 第 8 篇 的 cleanup 里: 清理时遍历缓存卸载字体, 防止内存泄漏.

随后 preEvaluateFont 做轻量预解析, 不求值字体文件本身.

第 2 步, preEvaluateFont: 给字体算一个指纹

preEvaluateFont 只读字典, 不解字体程序, 返回:

{
  descriptor,   // FontDescriptor 字典 (452 0 R)
  dict,         // 字体字典本体 (Type0 时换成 DescendantFonts)
  baseDict,
  composite,    // Type0 复合字体? demo 为 false
  type: "Type1",
  firstChar: 32,
  lastChar: 169,
  toUnicode,    // demo 为 undefined
  hash,         // MurmurHash3_64 指纹
}

hash 的用途是跨页面去重: 不同对象号但内容完全相同的字体, 只翻译一次. 指纹的输入是与渲染结果相关、又不需要解开字体程序的字段:

hash.update(encoding);            // "WinAnsiEncoding"
hash.update(`${firstChar}-${lastChar}`); // "32-169" — 宽度表的范围
hash.update(toUnicode 字节);      // demo 无, 跳过
hash.update(widthsBuf.join());    // "278,0,0,..." 宽度数组
// composite 字体还会混入 W 数组与 CIDToGIDMap
// Type3 字体额外混入 CharProcs 键值

demo 两个字体的 encoding、范围、宽度表都不同, 指纹自然不同, 各自进入 fontAliases:

const fontAliases = (descriptor.fontAliases ||= Object.create(null));
if (fontAliases[hash]) {
  // 已翻译过同内容字体: 建立别名, 直接返回缓存
  ...
} else {
  fontAliases[hash] = { fontID: this.idFactory.createFontId() };
}
fontID = fontAliases[hash].fontID; // "f1" / "f2"

注意指纹放在 descriptor 字典的 fontAliases 字段上, 而这个字典对象在文档级缓存里存活, 所以跨页同内容字体也能命中.

第 3 步, fontID、loadedName 与字体缓存登记

拿到 fontID 后, loadFont 拼出跨线程唯一名:

font.loadedName = `${this.idFactory.getDocId()}_${fontID}`;
// g_d0_f1 / g_d0_f2

然后立刻把一个 promise 放进 fontCache, 再启动翻译:

if (fontRefIsRef) {
  this.fontCache.put(fontRef, promise);
} else {
  font.cacheKey = `cacheKey_${fontID}`;
  this.fontCache.put(font.cacheKey, promise);
}
 
this.translateFont(preEvaluatedFont)
  .then(async translatedFont => {
    const translated = new TranslatedFont({
      loadedName: font.loadedName,
      font: translatedFont,
      dict: font,
    });
    if (translatedFont.isType3Font) {
      await translated.loadType3Data(this, resources, task, seenRefs);
    }
    resolve(translated);
  })
  .catch(reason => {
    // 翻译失败不抛错, resolve 一个 ErrorFont, 渲染继续
    resolve(new TranslatedFont({ loadedName, font: new ErrorFont(...), dict }));
  });
return promise;

两个设计值得注意:

  • 先存 promise 再翻译: 内容流里第二次遇到同一字体时, 拿到的是同一个 promise, 天然去掉了并发重复翻译;
  • 翻译失败不中断页面: catch 里 resolve 一个 ErrorFont, 它的字形全是方框, 但页面其余部分照常渲染.

demo 不是 Type3 字体, loadType3Data 不会触发.

第 4 步, translateFont: 取出字体程序

demo 的字体带 FontDescriptor, 走的是 translateFont 主分支 (无 descriptor 的标准 14 字体分支这里不涉及). 第一件事是找字体文件流:

for (const n of ["FontFile", "FontFile2", "FontFile3"]) {
  fontFile = descriptor.get(n);
  if (fontFile) {
    fontFileN = n;
    break;
  }
}

demo 命中 FontFile3, 即 1306 0 R / 1307 0 R, 流字典 /Subtype /Type1C, /Filter /FlateDecode. 如果流是异步的 (比如大文档的流式传输), 这里会先 await fontFile.asyncGetBytes() 换成同步流.

随后从字典与 descriptor 收集一批度量属性:

const properties = {
  type: "Type1",
  name: "DDPEFM+Helvetica", // FontName 与 BaseFont 一致
  subtype: "Type1C",        // 来自字体流字典
  file: fontFile,           // 解压后 4323 字节的 CFF 程序
  fontFileN: "FontFile3",
  fontMatrix,               // 字典无 FontMatrix, 用默认 FONT_IDENTITY_MATRIX
  firstChar: 32, lastChar: 169,
  toUnicode: undefined,
  bbox: [-166, -225, 1000, 931],
  ascent: 718, descent: -207,
  xHeight: 523, capHeight: 718,
  flags: 32, italicAngle: 0,
  ...
};

接着 extractDataStructures 解析编码 (WinAnsiEncoding 是内置命名编码, 直接映射字形名表), extractWidths 把 138 个宽度按 FirstChar + i 填入 properties.widths. 因为 fontMatrix 是单位阵, adjustWidths 直接跳过.

第 5 步, Font 构造: 嗅探真实类型, 把 CFF 翻译成 OpenType

const font = new Font(fontName.name, fontFile, newProperties, this.options);

Font 构造函数不信字典里的声明, 先嗅探文件头:

[type, subtype] = getFontFileType(file, properties);
// isTrueTypeFile / isOpenTypeFile / isType1File / isCFFFile 依次看魔数
// demo: CFF 魔数 (0x01 0x00) 命中 → type "Type1", subtype "Type1C"

demo 的声明恰好与嗅探一致, 只打一条 info 日志的分支都不会进. 然后按类型分派:

case "Type1":
  this.mimetype = "font/opentype";
 
  const cff =
    subtype === "Type1C" || subtype === "CIDFontType0C"
      ? new CFFFont(file, properties)   // demo 走这里
      : new Type1Font(name, file, properties);
 
  adjustWidths(properties);
 
  // 把 CFF 数据包进一个 OTF 字体文件
  data = this.convert(name, cff, properties);
  break;

CFFFont 内部用 CFFParser 解析 CFF 的 INDEX、charset、charstrings, 再用 CFFCompiler 重新编译出一份"干净"的 CFF 数据. convert() 则以它为核心, 手工拼出一个标准 OpenType 容器:

const builder = new OpenTypeFileBuilder(...);
builder.addTable("cmap", ...);  // 平台 3/1 的字符到字形映射
builder.addTable("CFF ", cff.data); // 重新编译后的 CFF
builder.addTable("OS/2", createOS2Table(..., properties)); // 含 fsSelection 粗斜体位
builder.addTable("head", ...);
builder.addTable("hhea", ...);  // caretSlopeRun = tan(italicAngle) * xHeight
builder.addTable("hmtx", ...);  // 逐字形宽度, 来自 charstrings 的 width
builder.addTable("maxp", ...);  // numGlyphs
builder.addTable("name", createNameTable(fontName));
builder.addTable("post", createPostTable(properties));
return builder.toArray(); // Uint8Array, 一个完整可加载的 OTF

这就是"字体翻译"的核心: 把 PDF 内嵌的 CFF 程序重新打包成浏览器 FontFace API 能加载的 OpenType. 期间还完成了字形映射: getGlyphMapping 生成 charCode → glyphId (WinAnsi 编码 + charset), adjustMapping 在没有 ToUnicode 时把字形搬到私有区 (PUA), 保证 fillText 与文本复制都正确 —— 比如 \251 (169) 先经 WinAnsi 得到字形名 copyright, 再映射到 CFF 里的实际字形, 其宽度正是 Widths 表末尾的 737, 与 第 11 篇 里 Glyph { unicode: "©", width: 737 } 对得上.

两个字体的差异也在这一步落定:

属性g_d0_f1g_d0_f2
flags32 (Nonsymbolic)262176 (Nonsymbolic | ForceBold)
OS/2 fsSelection常规位ForceBold 影响粗体位
fallbackNamesans-serifsans-serif
bold / italic / black未设置未设置

最后一行容易误解: bold、italic、black、remeasure 这几个字段只在无内嵌文件、回退系统字体的路径 (fallbackToSystemFont) 里用正则从名字推导. demo 字体有内嵌程序, 不走那条路, 导出的数据里这些键直接不存在. 粗细信息由打包进 OS/2 表的 flags 承担, 而画布端两个字体是各自独立的 font-family (loadedName), 不需要 CSS weight 来区分.

第 6 步, exportData: 把字体编译成一块二进制 buffer

翻译完成后, 字体对象要跨线程发送. 直接 postMessage 一个带几十KB字体数据的普通对象会触发结构化克隆的深拷贝, PDF.js 选择先编译成一块 ArrayBuffer:

exportData() {
  return {
    buffer: compileFontInfo(this.#getExportData(EXPORT_DATA_PROPERTIES)),
    charProcOperatorList: this.charProcOperatorList, // Type3 才有
    extra: this.fontExtraProperties ? ... : undefined,
  };
}

EXPORT_DATA_PROPERTIES 固定 22 个键:

[
  "ascent", "bbox", "black", "bold", "cssFontInfo", "data",
  "defaultVMetrics", "defaultWidth", "descent", "disableFontFace",
  "fallbackName", "fontExtraProperties", "fontMatrix", "isInvalidPDFjsFont",
  "isType3Font", "italic", "loadedName", "mimetype", "missingFile",
  "name", "remeasure", "systemFontInfo", "vertical",
]

compileFontInfo 把这些属性压成单个 ArrayBuffer, 布局紧凑且类型化: 布尔位两个比特一个、数字是 Float64、bbox/fontMatrix 定长数组、字符串区集中存放、字体二进制原样内嵌. 未设置的键 (如 demo 的 bold) 以"不存在"编码, 不占空间.

第 7 步, TranslatedFont.send: 一次且仅一次

send(handler) {
  if (this.#sent) {
    return;
  }
  this.#sent = true;
 
  const fontData = this.font.exportData(),
    transfers = fontData.buffer ? [fontData.buffer] : null;
 
  handler.send("commonobj", [this.loadedName, "Font", fontData], transfers);
}

#sent 保证同一个字体只发一次 —— demo 内容流里 4 次 Tf 只产生 2 条 commonobj 的原因就在这里 (与第 11 篇的 dependency 去重是两道独立的闸门). buffer 进入 transfer list, 所有权转移给主线程, worker 侧不再保留拷贝.

至此 worker 侧的工作完成. 时序上, 这条 commonobj 与 operatorList chunk 的先后没有保证, 这正是第 11 篇"竞态"一节的来源.

第 8 步, 主线程: FontInfo 解码 → FontFace 挂载

主线程 PDFWorker 的 commonobj handler 收到消息:

messageHandler.on("commonobj", ([id, type, exportedData]) => {
  if (this.commonObjs.has(id)) {
    return null;
  }
  switch (type) {
    case "Font":
      const fontData = new FontInfo(exportedData);
      const font = new FontFaceObject(
        fontData,
        inspectFont,
        exportedData.charProcOperatorList,
        exportedData.extra
      );
      this.fontLoader
        .bind(font)
        .catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
        .finally(() => {
          if (!font.fontExtraProperties) {
            font.clearData(); // 挂载完成后立即释放字体二进制
          }
          this.commonObjs.resolve(id, font);
        });
      break;

三个角色各司其职:

  • FontInfo: 包住 buffer, 用 DataView 按编译时的布局惰性解码 —— bold、ascent、data 等都是 getter, 访问时才从二进制读出;
  • FontFaceObject: 一层 getter 代理, 外加 compiledGlyphs 缓存 (Type3/兜底路径用) 与 createNativeFontFace;
  • FontLoader.bind: 真正挂载到文档.

bind 的核心路径:

async bind(font) {
  if (font.attached || (font.missingFile && !font.systemFontInfo)) {
    return; // 同一字体只挂一次
  }
  font.attached = true;
 
  if (font.systemFontInfo) {
    await this.loadSystemFont(font); // 系统字体替换路径, demo 不涉及
    return;
  }
 
  if (this.isFontLoadingAPISupported) {
    const nativeFontFace = font.createNativeFontFace();
    if (nativeFontFace) {
      this.addNativeFontFace(nativeFontFace); // document.fonts.add(...)
      try {
        await nativeFontFace.loaded;          // 等浏览器真正加载完
      } catch (ex) {
        font.disableFontFace = true;
        throw ex;
      }
    }
  }
  ...
}

createNativeFontFace 对 demo 来说就是一行:

nativeFontFace = new FontFace(this.loadedName, this.data, {});
// new FontFace("g_d0_f1", <OpenType buffer>, {})

this.data 是 worker 打包好的 OTF 字节, mimetype (font/opentype) 在这条 API 路径里不需要, 只在老的 @font-face + data URL 回退路径里拼 url(data:font/opentype;base64,...).

await nativeFontFace.loaded 完成后, commonObjs.resolve(id, font) 才执行 —— 也就是说 dependency 解除的时刻 = 浏览器真的加载完字体的时刻, 而不是消息到达的时刻. 之后 第 10 篇 的 setFont 从 commonObjs.get("g_d0_f1") 取出的, 就是这个 FontFaceObject.

finally 里的 clearData() 把 FontFaceObject 里的字体二进制引用清掉: 字体已交给 document.fonts, buffer 留着只会占内存 (大字体可能有几 MB). 下次 cleanup 时 (第 8 篇) 再把 FontFace 从文档移除.

第 9 步, 失败兜底: FontFallback 与逐字形路径

如果 bind 失败 (字体损坏、浏览器拒绝加载), catch 里会向 worker 回发 FontFallback. worker 端 TranslatedFont.fallback 的反应:

fallback(handler, evaluatorOptions) {
  if (!this.font.data) {
    return;
  }
  // 字体加载失败, 退回内置渲染器
  this.font.disableFontFace = true;
  // 把之前解析过的所有字形的路径数据全部补发到主线程
  PartialEvaluator.buildFontPaths(
    this.font,
    this.font.glyphCacheValues,
    handler,
    evaluatorOptions
  );
}

之后该字体的文本不再走 fillText, 而是逐字形用 Path2D 画路径 (主线程 FontFaceObject.getPathGenerator 缓存). 这是字体管线的降级形态: 慢一点, 但内容不会丢. demo 的两个字体都正常加载, 不会走到这条路.

总结

本篇跟随 demo 的两个内嵌字体, 走完了从 Tf 操作符到浏览器 FontFace 的完整字体管线:

  1. loadFont 先按 Ref 查 fontCache, 同引用直接复用 promise;
  2. preEvaluateFont 用 MurmurHash3_64 对 encoding、字符范围、ToUnicode、宽度表算指纹, 同内容字体跨页去重;
  3. fontID (f1/f2) 加文档前缀拼出跨线程唯一名 g_d0_f1 / g_d0_f2;
  4. translateFont 依次尝试 FontFile / FontFile2 / FontFile3, demo 命中 FontFile3 (Type1C), 并把 descriptor 度量收进 properties;
  5. Font 构造函数嗅探文件魔数确认真实类型, CFFParser + CFFCompiler 重新编译 CFF, convert() 手工拼出带 cmap/CFF/OS2/head/hhea/hmtx/maxp/name/post 九张表的 OpenType;
  6. exportData 把 22 个属性经 compileFontInfo 压成单个 ArrayBuffer;
  7. TranslatedFont.send 用 #sent 保证只发一次, buffer 走 transfer 零拷贝;
  8. 主线程 FontInfo 惰性解码 buffer, FontFaceObject 包装, FontLoader.bind 创建 FontFace 并 await loaded, 完成才 resolve dependency;
  9. 挂载成功后立即 clearData() 释放二进制; 失败则 FontFallback 回退到逐字形路径渲染.

所以 setFont 算子背后的那个 commonObjs 条目, 不是一个简单的字体文件传递, 而是一次完整的格式翻译、去重、传输与挂载流水线的结果. 而 demo 首页能正确画出 © 与粗体文字, 依赖的正是这条流水线两端 737 宽度与 OS/2 粗体位这些不起眼的字段.

至此, demo 渲染链路上 render 的两大输入 —— operatorList (第 11 篇) 与字体 (本篇) —— 都已齐备. 下一篇我们回到渲染的另一条腿: 文本层 getTextContent, 看 worker 如何把同一份内容流再求值一遍, 提取出可供选择的文本与位置.

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

相关文章