StreamPDFStreamPDF
返回博客

PDF.js 源码解读: font_loader

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: font_loader

前言

上一篇 core/fonts.js 走到 Worker 的终点:PDF 字体字典、嵌入字体程序、编码与度量已经被整理成统一的 Font,再由 exportData() 导出。obj_bin_transform_display.js 则负责把跨线程字节重新解释成 FontInfo、CssFontInfo、SystemFontInfo 与 FontPathInfo。

但字体字节到达主线程,并不等于 Canvas 已经能使用它。浏览器还需要注册字体、等待加载、处理缺失字体,并在某些绘制模式下拿到独立的字形轮廓。src/display/font_loader.js(599 行)正是这段「数据已经翻译完成,但浏览器尚未准备好」的适配层。

一句话概括:**FontLoader 管理字体与 DOM 的关系,FontFaceObject 管理 Worker 字体数据与 Canvas 渲染的关系。**前者面对 FontFace、document.fonts、样式表和旧浏览器探测;后者面对度量、状态、Path2D 与资源释放。

版本说明:本篇严格按当前仓库源码展开。文件只导出 FontLoader 与 FontFaceObject;现代路径使用 Font Loading API,旧路径仍有 @font-face、测试字体与轮询代码。不要把 attached 理解为「加载成功」,也不要把探测字体的 30 次轮询理解成业务字体加载失败后的自动重试。

模块总览

文件可以分成两条相互协作的主线:

区块关键成员职责
装载器状态#systemFonts、nativeFontFaces、#styleSheet记录系统字体、原生 FontFace 与规则容器
DOM 注册addNativeFontFace、removeNativeFontFace、insertRule操作 document.fonts 与 CSS 样式表
资源清理clear移除字体、清空去重状态、卸载样式表
系统字体loadSystemFont装载 Worker 选择的系统字体替代项
统一入口bind按 attached、缺失文件、能力支持选择路径
能力判断isFontLoadingAPISupported、isSyncFontLoadingSupported区分现代、同步与旧式异步加载
旧式异步探测_queueLoadingCallback、_loadTestFont、_prepareFontLoadEvent排队完成回调,用测试字体猜测加载结束
字体对象FontFaceObject 构造函数与两个 create* 方法包装 FontInfo,创建 FontFace 或 @font-face
内置字形渲染getPathGenerator把 FontPathInfo 编译并缓存为 Path2D
渲染数据接口代理 getter、disableFontFace setter、clearData向 Canvas 暴露状态与度量,并释放大对象

把它接上前两篇文章,完整链路是:

// Worker: Font.exportData()
//   → TranslatedFont.send(): commonobj [loadedName, "Font", fontData]
// Main thread: new FontInfo(exportedData)
//   → new FontFaceObject(fontData, ...)
//   → FontLoader.bind(font)
//       → FontFace + document.fonts       (modern path)
//       → @font-face + test-font polling  (legacy path)
//   → commonObjs.resolve(loadedName, font)
// CanvasGraphics: commonObjs.get(fontRefName)
//   → ctx.font / fillText / strokeText
//   → font.getPathGenerator(...) → Path2D (built-in outlines)

这里有两组名字不要混淆。loadedName 是 PDF.js 为文档内字体生成的装载名,同时充当 commonObjs 的对象 id 和通常情况下的 CSS 字体名;name 则来自 PDF 字体本身。cssFontInfo.fontFamily 又可能来自 PDF 文档,因此进入 CSS 前必须序列化,不能与内部生成的 loadedName 一视同仁。

从 Worker 导出到主线程对象

TranslatedFont.send:一次性发送字体主体

Worker 侧 TranslatedFont 用 #sent 保证同一字体主体只发送一次:

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);
}

fontData.buffer 被放进 transfer list,避免复制整块字体数据。消息里的 loadedName 是主线程对象 id,"Font" 决定 commonobj 路由分支,第三项包含二进制 buffer、Type3 的 charProcOperatorList 和可选 extra。

这与 core/fonts.js 的 exportData() 正好衔接:Worker 已完成字体修复、包装、编码与度量计算;主线程不再解释 TrueType 或 CFF 表,而是消费一份稳定的导出结果。

FontInfo:按需读取,而不是展开成大对象

FontInfo 构造时保存 ArrayBuffer 与 DataView,并把可选 extra 展开到实例;核心导出字段仍按需读取。布尔值按两位一个字段读取,数字、数组、字符串各有固定布局;cssFontInfo 与 systemFontInfo 在 getter 被访问时才从相应区段重建。对字体装载最关键的是 data 与 clearData():

get data() {
  const { offset, length } = this.#getDataOffsets();
  return length === 0
    ? undefined
    : new Uint8Array(this.#buffer, offset + 4, length);
}
 
clearData() {
  const { offset, length } = this.#getDataOffsets();
  if (length === 0) {
    return;
  }
  this.#view.setUint32(offset, 0);
 
  this.#buffer = new Uint8Array(this.#buffer, 0, offset + 4).slice().buffer;
  this.#view = new DataView(this.#buffer);
}

#getDataOffsets() 依次越过字符串区、系统字体信息区和 CSS 字体信息区,最后定位字体程序长度。data 只是原 buffer 上的一个 Uint8Array 视图;长度为零时返回 undefined,所以「没有可嵌入字体」和「数据已经清掉」在这个 getter 上表现相同。

clearData() 先把数据长度写零,再复制并保留数据块之前的前缀。这样身份、度量、CSS 与系统字体信息仍然可读,可能达到数 MB 的字体程序却不再驻留。重复调用是安全的,因为第二次看到的长度已经是零。

systemFontInfo 提供 css、loadedName、baseFontName、src、style;cssFontInfo 提供 fontFamily、fontWeight、italicAngle。前者描述系统字体替代与 CSS 使用方式,后者描述创建原生字体规则时需要的家族和字形样式。

commonobj 的 Font 分支:装载流程收尾后才发布对象

api.js 在 WorkerTransport 上创建唯一的 commonObjs = new PDFObjects() 与 fontLoader = new FontLoader(...)。收到字体消息后,关键代码如下:

case "Font":
  if ("error" in exportedData) {
    const exportedError = exportedData.error;
    warn(`Error during font loading: ${exportedError}`);
    this.commonObjs.resolve(id, exportedError);
    break;
  }
 
  const fontData = new FontInfo(exportedData);
  const inspectFont =
    this._params.pdfBug && globalThis.FontInspector?.enabled
      ? (font, url) => globalThis.FontInspector.fontAdded(font, url)
      : null;
  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;

如果 Worker 导出的是 ErrorFont,这里没有 FontFaceObject,而是直接把错误对象 resolve 给等待者。正常字体则先包装,再调用 bind(font)。

顺序非常重要:

  1. bind() 尝试附加并等待相应加载流程。
  2. 若 bind() reject,.catch() 向 Worker 发送 FontFallback,Worker 将自身字体也设为 disableFontFace = true,并尝试重发已经解析过的全部 FontPath。
  3. 无论成功或失败,.finally() 都会在未开启 fontExtraProperties 时执行 font.clearData()。
  4. 最后才 commonObjs.resolve(id, font),让依赖该字体的 Canvas 操作继续。

因此 resolve 的语义不是「浏览器字体一定可用」,而是「装载流程已经收尾,渲染可以按字体对象上的最终状态继续」。若原生字体失败,主线程对象已在 bind 的 catch 中切换到 disableFontFace;若系统字体加载失败,则是另一条不会 reject 的路径,后文单独说明。

PDFObjects.get(id) 在对象尚未 resolve 时会抛错,带 callback 的 get(id, callback) 则等待 promise。commonObjs.resolve 既是数据发布点,也是渲染依赖的同步点,所以 clearData 与 resolve 的先后关系不是无关紧要的内存优化细节。

FontLoader:管理 DOM 中的字体生命周期

构造函数:文档、注册表与旧式队列

class FontLoader {
  #systemFonts = new Set();
  #styleSheet = null;
 
  constructor({ ownerDocument = globalThis.document, styleElement = null }) {
    this._document = ownerDocument;
    this.nativeFontFaces = new Set();
    this.styleElement =
      typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING') ? styleElement : null;
 
    if (typeof PDFJSDev === 'undefined' || !PDFJSDev.test('MOZCENTRAL')) {
      this.loadingRequests = [];
      this.loadTestFontId = 0;
    }
  }
}

ownerDocument 默认是当前文档,也可以指向打印 iframe 的文档;后面所有字体与样式操作都应以它为准。styleElement 是测试注入口,非测试构建会强制置空。nativeFontFaces 跟踪通过本加载器注册的原生对象,#systemFonts 只跟踪已经成功加载的系统字体名。

loadingRequests 与 loadTestFontId 只服务于非 MOZCENTRAL 的旧式异步路径:前者保存完成请求队列,后者参与生成唯一测试字体 id。现代 Font Loading API 路径不使用它们。

addNativeFontFace 与 removeNativeFontFace

addNativeFontFace(nativeFontFace) {
  this.nativeFontFaces.add(nativeFontFace);
  this._document.fonts.add(nativeFontFace);
}
 
removeNativeFontFace(nativeFontFace) {
  this.nativeFontFaces.delete(nativeFontFace);
  this._document.fonts.delete(nativeFontFace);
}

两个集合必须同步:PDF.js 自己的 Set 用于后续统一清理,浏览器的 FontFaceSet 决定 Canvas 能否解析这个 family。只调用 new FontFace(...) 并不会完成注册;必须进入对应 document 的 document.fonts。

系统字体加载失败时调用 removeNativeFontFace,把刚加入的失败对象从两边删除。普通嵌入字体的原生加载失败路径没有在这里立即 remove,而是由 disableFontFace 切换内置轮廓渲染,最终仍可通过 clear() 统一移除注册对象。

insertRule 与 #getStyleSheet:优先 constructable stylesheet

旧式路径需要插入 @font-face。规则容器不是简单写死一个 <style>,而是按能力与测试配置选择:

insertRule(rule) {
  const styleSheet = this.#getStyleSheet();
  styleSheet.insertRule(rule, styleSheet.cssRules.length);
}
 
#getStyleSheet() {
  if (this.#styleSheet) {
    return this.#styleSheet;
  }
 
  const StyleSheet =
    this._document.defaultView?.CSSStyleSheet || globalThis.CSSStyleSheet;
  if (!this.styleElement && StyleSheet) {
    const { adoptedStyleSheets } = this._document;
    if (adoptedStyleSheets) {
      const styleSheet = new StyleSheet();
      adoptedStyleSheets.push(styleSheet);
      return (this.#styleSheet = styleSheet);
    }
  }
 
  if (!this.styleElement) {
    this.styleElement = this._document.createElement("style");
    this._document.documentElement
      .getElementsByTagName("head")[0]
      .append(this.styleElement);
  }
  return (this.#styleSheet = this.styleElement.sheet);
}

首选 constructable stylesheet,因为它不受 CSP inline-style 检查阻塞。CSSStyleSheet 构造函数优先从 this._document.defaultView 取,这是跨 window 场景的关键:打印 iframe 的 document 只能 adopt 在同一个 window 中创建的 stylesheet,不能随意复用顶层 window 的构造器。

没有测试 styleElement、构造器存在且 adoptedStyleSheets 可用时,新建 sheet 并 push 到文档数组,然后缓存到 #styleSheet。否则创建 <style> 并附加到目标文档的 <head>,使用它的 .sheet。两种方式最终都向 insertRule 暴露相同的 insertRule(rule, cssRules.length) 接口。

#styleSheet 缓存避免每条规则都重建容器;规则本身追加到末尾,没有在这里做去重或合并。字体去重由更上层的 attached 和 #systemFonts 负责。

clear:同时清理字体与规则容器

clear() {
  for (const nativeFontFace of this.nativeFontFaces) {
    this._document.fonts.delete(nativeFontFace);
  }
  this.nativeFontFaces.clear();
  this.#systemFonts.clear();
 
  if (this.#styleSheet) {
    const { adoptedStyleSheets } = this._document;
    if (adoptedStyleSheets?.includes(this.#styleSheet)) {
      this._document.adoptedStyleSheets = adoptedStyleSheets.filter(
        styleSheet => styleSheet !== this.#styleSheet
      );
    }
    this.#styleSheet = null;
  }
 
  if (this.styleElement) {
    this.styleElement.remove();
    this.styleElement = null;
  }
}

清理分三层。第一层从 document.fonts 删除每个原生 FontFace,再清空 PDF.js 的注册 Set;第二层清空系统字体成功记录,使后续新生命周期可以重新加载;第三层处理样式:若缓存 sheet 在 adoptedStyleSheets 中,就用过滤后的新数组解除 adopt,随后断开缓存;若使用过 <style>,再将其从 DOM 删除。

构造样式表与 <style> 两条回退路径都在这里闭合,测试注入的 style 也会被移除并置空。clear() 没有重置 loadTestFontId,也没有在这里显式排空 loadingRequests;它清理的是 DOM 注册、成功去重状态和样式资源,不应被扩写成不存在的异步队列取消机制。

系统字体:loadSystemFont 的去重与失败处理

当 Worker 没有可用嵌入字体、但选择了系统字体替代项时,font.systemFontInfo 非空。此时不是把 PDF 的 data 注册为字体,而是根据替代信息加载系统资源:

async loadSystemFont({ systemFontInfo: info, disableFontFace, _inspectFont }) {
  if (!info || this.#systemFonts.has(info.loadedName)) {
    return;
  }
  assert(
    !disableFontFace,
    "loadSystemFont shouldn't be called when `disableFontFace` is set."
  );
 
  if (this.isFontLoadingAPISupported) {
    const { loadedName, src, style } = info;
    const fontFace = new FontFace(loadedName, src, style);
    this.addNativeFontFace(fontFace);
    try {
      await fontFace.load();
      this.#systemFonts.add(loadedName);
      _inspectFont?.(info);
    } catch {
      warn(
        `Cannot load system font: ${info.baseFontName}, installing it could help to improve PDF rendering.`
      );
      this.removeNativeFontFace(fontFace);
    }
    return;
  }
 
  unreachable("Not implemented: loadSystemFont without the Font Loading API.");
}

去重键是 info.loadedName,而且只有 await fontFace.load() 成功后才加入 #systemFonts。所以它表达的是「这个系统字体已经成功加载」,不是「曾经尝试过」,也不是一张 in-flight promise 表。相同 FontFaceObject 的重复调用还会先被 bind 的 attached 拦住。

这里显式调用 fontFace.load(),与后面嵌入字体等待 nativeFontFace.loaded 不同。成功时调用 _inspectFont?.(info),检查器收到的是系统字体信息;失败时只警告「安装该字体可能改善渲染」并移除刚注册的 FontFace。

**系统字体失败的 catch 不向外抛错。**因此 bind() 会正常完成,api.js 不会因这次失败发送 FontFallback,commonObjs 仍会 resolve 原字体对象。Canvas 后续仍可根据 systemFontInfo.css 组 CSS 字体栈;这里完成的是加载替代资源的尝试与失败回退,而不是伪造一次成功加载。

如果环境根本没有 Font Loading API,系统字体路径直接 unreachable:旧式 @font-face 探测只服务普通字体规则,并没有另一套系统字体加载实现。

bind:所有字体进入 DOM 前的统一分支

提前返回、attached 与系统字体优先级

async bind(font) {
  if (font.attached || (font.missingFile && !font.systemFontInfo)) {
    return;
  }
  font.attached = true;
 
  if (font.systemFontInfo) {
    await this.loadSystemFont(font);
    return;
  }
 
  if (this.isFontLoadingAPISupported) {
    const nativeFontFace = font.createNativeFontFace();
    if (nativeFontFace) {
      this.addNativeFontFace(nativeFontFace);
      try {
        await nativeFontFace.loaded;
      } catch (ex) {
        warn(`Failed to load font '${nativeFontFace.family}': '${ex}'.`);
        font.disableFontFace = true;
        throw ex;
      }
    }
    return;
  }
 
  const rule = font.createFontFaceRule();
  if (rule) {
    this.insertRule(rule);
 
    if (this.isSyncFontLoadingSupported) {
      return;
    }
    if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
      throw new Error("Not implemented: async font loading");
    }
    await new Promise(resolve => {
      const request = this._queueLoadingCallback(resolve);
      this._prepareFontLoadEvent(font, request);
    });
  }
}

第一个判断包含两种完全不同的跳过:已经 attached 的对象不重复附加;missingFile && !systemFontInfo 表示既没有文件,也没有系统替代信息,无需向 DOM 注册。第二种情况直接返回,发生在设置 attached 之前。

其余路径立刻执行 font.attached = true,然后才 await。它只是一个「已经尝试过附加」的防重入标记,不是 loaded、ready 或 usable 状态。即使后面的 nativeFontFace.loaded reject,这个标记也不会被源码复位;本文件没有据此实现错误重试。

systemFontInfo 分支先于普通 data 分支,因为缺失文件仍可能有系统替代信息。若不存在系统替代,才按浏览器能力进入现代或旧式嵌入字体路径。

现代路径:等待 loaded,失败时禁用 FontFace

createNativeFontFace() 可能返回 null,例如没有 data 或已经 disableFontFace。此时 bind 直接返回,不注册空规则。创建成功则先 addNativeFontFace,再等待 nativeFontFace.loaded,而不是额外调用一次 load()。

等待失败时做三件事:打印 family 与异常、执行 font.disableFontFace = true、重新抛出原异常。重新抛出让 api.js 的 .catch() 能通知 Worker 进入 FontFallback;设置标志则让当前主线程对象之后的 paintChar 选择内置 Path2D,不再依赖失败的浏览器字体。

这里依然不能从 attached === true 推出加载成功。真正决定后续绘制方式的是 disableFontFace、missingFile、系统字体信息以及已发布的字形路径共同形成的状态。

旧式路径:规则插入不等于异步加载结束

没有 Font Loading API 时,createFontFaceRule() 生成规则并插入样式表。若 isSyncFontLoadingSupported 为真,可以立即返回;否则 MOZCENTRAL 构建明确抛出「未实现」,其余环境创建 promise,把 resolve 放进加载请求队列,再启动测试字体探测。

rule 为 null 时同样不等待探测。也就是说,_prepareFontLoadEvent 不是每个字体必经步骤,它只在「有规则、无 Font Loading API、又不能同步加载」这一窄路径运行。

两个能力 getter:能力判断只计算一次

get isFontLoadingAPISupported() {
  const hasFonts = !!this._document?.fonts;
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    return shadow(this, "isFontLoadingAPISupported", hasFonts && !this.styleElement);
  }
  return shadow(this, "isFontLoadingAPISupported", hasFonts);
}
 
get isSyncFontLoadingSupported() {
  if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
    return shadow(this, "isSyncFontLoadingSupported", true);
  }
  return shadow(
    this,
    "isSyncFontLoadingSupported",
    (typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) &&
      (isNodeJS || FeatureTest.platform.isFirefox)
  );
}

document.fonts 是现代路径的基础。测试模式额外要求 !this.styleElement,使注入 style 的测试可以强制走旧式回退。shadow 把首次计算结果固化到实例上,后续不再重复能力探测。

同步路径在 MOZCENTRAL 中直接视为支持;其他非 CHROME 构建中,Node.js 可以假装同步支持,Firefox 也走这一分支。源码注释对应 Firefox 14 起的行为。这里的「同步」只描述此抽象采用的加载模型,不应推广为所有浏览器或所有字体来源的通用保证。

旧式异步加载:队列、测试字体与像素轮询

_queueLoadingCallback:完成可以乱序,回调必须按入队顺序

_queueLoadingCallback(callback) {
  function completeRequest() {
    assert(!request.done, "completeRequest() cannot be called twice.");
    request.done = true;
 
    while (loadingRequests.length > 0 && loadingRequests[0].done) {
      const otherRequest = loadingRequests.shift();
      setTimeout(otherRequest.callback, 0);
    }
  }
 
  const { loadingRequests } = this;
  const request = { done: false, complete: completeRequest, callback };
  loadingRequests.push(request);
  return request;
}

每个 request 保存 done、complete 与原回调。探测结束时只能调用一次 complete;它先标记自己完成,再从队首开始,连续取出所有已经完成的请求,并用 setTimeout(callback, 0) 调度。

假设 A、B、C 按序入队,而 B 先完成:B 只会变成 done,不会越过 A。随后 A 完成时,循环依次取出 A、B 并调度回调;若 C 也已完成,也会继续取出 C。这样保证的是完成回调按入队顺序调度,不是证明字体字节按这个顺序下载,更不是额外的加载成功校验。

bind 传入的 callback 就是 promise 的 resolve。因此队列顺序最终决定了这些旧式 bind() promise 的完成发布顺序。

_loadTestFont:一个只含句点字形的已知 CFF 字体

get _loadTestFont() {
  // MOZCENTRAL throws "Not implemented" here.
  const testFont = atob(/* Full CFF test-font Base64 omitted in this excerpt. */);
  return shadow(this, "_loadTestFont", testFont);
}

源码内嵌的完整 Base64 是一个 CFF 字体,只有一个 . 字形,而且该字形填满整个宽度与高度。atob 得到二进制字符串,shadow 缓存解码结果,避免每次探测都重复解码同一份常量。

这个字体不是 PDF 里的业务字体。它是一个形状完全已知、像素结果稳定的「探针」:如果能用 Canvas 测到它画出的 alpha,就推测排在它前面的目标字体也已加载。

_prepareFontLoadEvent:先构造探针,再观察一个像素

方法开头定义了 int32、string32、spliceString 三个二进制字符串工具,随后创建 1×1 Canvas 与探测函数:

const canvas = this._document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d');
 
let called = 0;
function isFontReady(name, callback) {
  if (++called > 30) {
    warn('Load test font never loaded.');
    callback();
    return;
  }
  ctx.font = '30px ' + name;
  ctx.fillText('.', 0, 20);
  const imageData = ctx.getImageData(0, 0, 1, 1);
  if (imageData.data[3] > 0) {
    callback();
    return;
  }
  setTimeout(isFontReady.bind(null, name, callback));
}

每次用 30px 测试字体画一个句点,再读取唯一像素的 alpha。alpha 大于零说明已知字形已经画出来,于是调用完成回调;否则通过 setTimeout 再查。源码注释指出,计时器 clamping 下整个窗口约为 100ms。

++called > 30 的精确含义是最多进行 30 次实际像素探测,第 31 次进入时警告并直接完成。这里没有 reject,也没有重新发起业务字体请求;所谓「30 次轮询」只是加载完成探测的兜底窗口,绝不能描述成 PDF.js 会重试失败字体 30 次。

唯一 id 与 CFF checksum:避免 Chromium 按字体数据缓存误判

const loadTestFontId = `lt${Date.now()}${this.loadTestFontId++}`;
let data = this._loadTestFont;
const COMMENT_OFFSET = 976;
data = spliceString(data, COMMENT_OFFSET, loadTestFontId.length, loadTestFontId);
 
const CFF_CHECKSUM_OFFSET = 16;
const XXXX_VALUE = 0x58585858;
let checksum = int32(data, CFF_CHECKSUM_OFFSET);
for (i = 0, ii = loadTestFontId.length - 3; i < ii; i += 4) {
  checksum = (checksum - XXXX_VALUE + int32(loadTestFontId, i)) | 0;
}
if (i < loadTestFontId.length) {
  checksum = (checksum - XXXX_VALUE + int32(loadTestFontId + 'XXX', i)) | 0;
}
data = spliceString(data, CFF_CHECKSUM_OFFSET, 4, string32(checksum));

id 由 lt、当前毫秒时间戳与递增计数器组成。Chromium 可能按实际字体数据的 hash 缓存,所以只改 CSS family 不够:每次测试还必须改变字体二进制内容,否则新探针会被误判为早已加载。

实现把 id 写进偏移 976 的 comment 区域,该区域按 4 字节对齐。原内容相当于一串 X,所以校验和修正会按每 4 字节减去 0x58585858,再加上 id 对应整数;末尾不足 4 字节时用 XXX 补齐参与计算。最后把新 checksum 写回偏移 16。

这一步同时满足两个约束:数据对 Chromium 来说是新的,CFF checksum 对 IE 来说仍然正确。它修改的是内嵌测试字体的副本,不会改动 PDF 导出的业务字体数据。

插入探针规则、触发字体使用、结束后移除临时 DOM

const url = `url(data:font/opentype;base64,${btoa(data)});`;
const rule = `@font-face {font-family:"${loadTestFontId}";src:${url}}`;
this.insertRule(rule);
 
const div = this._document.createElement('div');
div.style.visibility = 'hidden';
div.style.width = div.style.height = '10px';
div.style.position = 'absolute';
div.style.top = div.style.left = '0px';
 
for (const name of [font.loadedName, loadTestFontId]) {
  const span = this._document.createElement('span');
  span.textContent = 'Hi';
  span.style.fontFamily = name;
  div.append(span);
}
this._document.body.append(div);
 
isFontReady(loadTestFontId, () => {
  div.remove();
  request.complete();
});

测试字体被编码成 data URL 并插入 @font-face。隐藏、绝对定位的 div 中有两个 span:一个使用目标 font.loadedName,另一个使用测试 id。它们让两个 family 都实际参与页面布局与字体请求,而探针 Canvas 只观察测试字体。

探测结束或超过轮询上限后,先移除 div,再 request.complete()。临时 span 与 div 因而不会残留;测试规则则留在加载器的样式容器中,由后续 clear() 随整个 sheet 一起清理。

这套 hack 的基本假设是字体按顺序加载:把已知测试字体排在目标字体之后,测试字体可用时便推测目标字体已准备好。它是旧浏览器缺少字体完成事件时的兼容方案,不是现代路径仍需执行的通用流程。

FontFaceObject:把字体数据变成渲染接口

构造函数:保存二进制视图,保留可选扩展

class FontFaceObject {
  compiledGlyphs = Object.create(null);
  #fontData;
 
  constructor(translatedData, inspectFont = null, charProcOperatorList, extra) {
    this.#fontData = translatedData;
    this._inspectFont = inspectFont;
 
    if (charProcOperatorList) {
      this.charProcOperatorList = charProcOperatorList;
    }
    if (extra) {
      Object.assign(this, extra);
    }
  }
}

每个实例都有无原型字典 compiledGlyphs,用于按字符缓存 Path2D;#fontData 私有保存 FontInfo,所有基础 getter 都从这里读。_inspectFont 是可选调试钩子。

charProcOperatorList 仅在存在时挂到实例上,供 Type3 字符过程使用;extra 通过 Object.assign 展开额外属性。测试构建还会断言 disableFontFace 与 fontExtraProperties 必须是 boolean,确保跨线程格式没有漏字段。

createNativeFontFace:现代浏览器注册对象的工厂

createNativeFontFace() {
  if (!this.data || this.disableFontFace) {
    return null;
  }
  let nativeFontFace;
  if (!this.cssFontInfo) {
    nativeFontFace = new FontFace(this.loadedName, this.data, {});
  } else {
    const css = { weight: this.cssFontInfo.fontWeight };
    if (this.cssFontInfo.italicAngle) {
      css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
    }
    nativeFontFace = new FontFace(
      serializeFontFamily(this.cssFontInfo.fontFamily),
      this.data,
      css
    );
  }
 
  this._inspectFont?.(this);
  return nativeFontFace;
}

没有字体数据或已经禁用 FontFace 时返回 null,让 bind 无需创建空对象。普通路径以内部 loadedName 为 family,直接传入 data 与空 descriptors。

存在 cssFontInfo 时,family 改用其 fontFamily,weight 来自 fontWeight;italicAngle 非空时转成 oblique <angle>deg。serializeFontFamily 的返回值可直接作为 FontFace family 字符串使用。

创建后触发 _inspectFont?.(this)。这里还没有把对象加入 document.fonts,注册仍是 FontLoader.addNativeFontFace 的职责;工厂方法与生命周期操作保持分离。

createFontFaceRule:旧式 CSS 规则的工厂

createFontFaceRule() {
  if (!this.data || this.disableFontFace) {
    return null;
  }
  const url = `url(data:${this.mimetype};base64,${this.data.toBase64()});`;
  let rule;
  if (!this.cssFontInfo) {
    rule = `@font-face {font-family:"${this.loadedName}";src:${url}}`;
  } else {
    let css = `font-weight: ${this.cssFontInfo.fontWeight};`;
    if (this.cssFontInfo.italicAngle) {
      css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
    }
    const fontFamily = serializeFontFamily(this.cssFontInfo.fontFamily);
    rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;
  }
 
  this._inspectFont?.(this, url);
  return rule;
}

与现代工厂相同,缺数据或禁用时返回 null。旧路径需要把二进制转成 Base64 data URL,并使用 Worker 导出的 mimetype;有 CSS 信息时同样附加 weight 与 oblique style。

调试钩子在这里额外收到 url,因为规则路径已经物化出可供检查的字体 URL。方法只返回字符串,何时插入哪个 document 的 stylesheet 仍由 FontLoader 决定。

cssFontInfo.fontFamily:为什么必须序列化

function serializeFontFamily(fontFamily) {
  if (isCSSString(fontFamily)) {
    return fontFamily;
  }
  const escaped = fontFamily.replaceAll(/["\\\p{Cc}]/gu, char =>
    char === `"` || char === '\\' ? `\\${char}` : `\\${char.codePointAt(0).toString(16)} `,
  );
  return `"${escaped}"`;
}

fontFamily 可能源自不可信 PDF,不能直接拼接进 @font-face,否则引号、反斜杠或控制字符可能破坏规则边界,形成任意 CSS 注入。serializeFontFamily 对已经是合法 CSS string 的输入原样返回;其他值统一输出带引号的 <string>,转义双引号与反斜杠,并把控制字符改成十六进制 CSS 转义。

统一使用 <string> 还避免把 serif、inherit 等值误当作 generic family 或 CSS-wide keyword。普通 loadedName 是 PDF.js 内部生成的装载名,所以无 cssFontInfo 的规则分支可以按现有格式直接引用;来自文档的 family 则必须经过这道边界。

getPathGenerator:按需生成并缓存 Path2D

getPathGenerator(objs, character) {
  if (this.compiledGlyphs[character] !== undefined) {
    return this.compiledGlyphs[character];
  }
 
  const objId = this.loadedName + "_path_" + character;
  let cmds;
  try {
    cmds = objs.get(objId);
  } catch (ex) {
    warn(`getPathGenerator - ignoring character: "${ex}".`);
  }
  const path = makePathFromDrawOPS(cmds?.path);
 
  if (!this.fontExtraProperties) {
    objs.delete(objId);
  }
  return (this.compiledGlyphs[character] = path);
}

对象 id 与 Worker buildFontPaths 完全对应:${loadedName}_path_${fontChar}。主线程收到 "FontPath" 时把它 resolve 为 FontPathInfo,其 path getter 按平台能力给出 Float16Array 或 Float32Array 绘制操作序列。

makePathFromDrawOPS 创建 Path2D,依次翻译 moveTo、lineTo、curveTo、quadraticCurveTo 与 closePath;输入为空时返回空路径。getPathGenerator 因而不是字形解析器,它只是 Worker 已编译轮廓与 Canvas 原生路径之间的轻量转换器。

缓存判断使用 !== undefined,所以空 Path2D 也会命中。若 objs.get 因对象未发布等原因抛错,这里警告并继续生成空路径,没有异步等待或重试;正常渲染依赖 commonObjs 的依赖机制保证路径可用。

首次编译后,若未开启 fontExtraProperties,就从对象池删除原始 FontPathInfo,只保留编译后的 Path2D。开启额外属性时保留原始数据,便于调试,代价是更多内存。这与字体主体的 clearData() 采用同一策略:常规渲染留最少数据,调试模式保留更多原始信息。

全部代理 getter、状态 setter 与 clearData

除路径方法外,FontFaceObject 有意保持为一层很薄的代理。下表覆盖当前文件定义的全部基础 getter:

分类getter消费者或含义
字形与样式状态black、bold、italic、verticalCanvas 字体样式与竖排布局
装载与回退状态disableFontFace、missingFile、isInvalidPDFjsFont决定浏览器字体或内置回退路径
类型与配置isType3Font、fontExtraProperties、remeasureType3 分支、调试保留策略、重新测量
水平度量ascent、descent、defaultWidth文本布局与默认字宽
几何信息bbox、fontMatrix、defaultVMetrics包围盒、文本矩阵、默认竖排度量
身份与字体程序fallbackName、loadedName、mimetype、name、dataCSS family、对象 id、data URL、原始字体名与字节
可选装载信息cssFontInfo、systemFontInfoCSS descriptors 与系统字体替代

这些 getter 都直接返回 this.#fontData.<property>,不在主线程重新计算度量,也不复制 Worker 已完成的字体分析。唯一带写入语义的状态是 disableFontFace:

get disableFontFace() {
  return this.#fontData.disableFontFace;
}
 
set disableFontFace(value) {
  shadow(this, "disableFontFace", !!value);
}
 
clearData() {
  this.#fontData.clearData();
}

setter 用 shadow 在 FontFaceObject 实例上写入布尔化的自有属性,覆盖原型上的 getter/setter。这样原生字体失败后,当前对象立即读到 true,无需修改 FontInfo 的二进制布局;后续读取也直接使用这个已确定状态。

clearData() 只是委托给 FontInfo.clearData()。它释放的是字体程序字节,不会清空 compiledGlyphs,也不会移除已注册的 FontFace;后者属于 FontLoader.clear(),两类资源不能混为一谈。

CanvasGraphics 如何消费 FontFaceObject

commonObjs.resolve 之后,CanvasGraphics.setFont 才能按 fontRefName 同步取得对象。它把对象保存到 current.font,读取矩阵与样式,并设置浏览器的 ctx.font:

const fontObj = this.commonObjs.get(fontRefName);
current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;
this.current.font = fontObj;
this.current.fontSize = size;
 
if (fontObj.isType3Font) {
  return;
}
 
const name = fontObj.loadedName || 'sans-serif';
const typeface = fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;
// `black` / `bold` choose the weight, `italic` chooses the style.
this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;

Type3 不需要 ctx.font,其字形由字符过程绘制。其他字体优先使用 systemFontInfo.css;否则使用 loadedName 加 fallbackName。black、bold、italic 与字体大小共同组成 CSS font shorthand,尺寸还会被钳制到浏览器可处理的范围,再通过变换补偿。

paintChar 则在需要内置轮廓时调用路径接口:

if ((font.disableFontFace || isAddToPathSet || patternFill || patternStroke) && !font.missingFile) {
  path = font.getPathGenerator(this.commonObjs, character);
}
 
if (path && (font.disableFontFace || patternFill || patternStroke)) {
  // Translate/scale to the glyph position, then ctx.fill(path) / ctx.stroke(path).
}

禁用 FontFace、把文本加入裁剪路径、或使用特定 pattern 填充/描边时,仅靠 fillText 不够,需要独立轮廓。missingFile 为真时则不走这条嵌入字体路径。原生字体加载失败后的 disableFontFace = true 正是在这里生效,Worker 重发的 FontPath 也正是在这里被消费。

因此 FontFaceObject 同时提供两种渲染表示:浏览器字体的 family、样式与度量,以及 Canvas 可直接 fill/stroke 的 Path2D。FontLoader 负责让第一种表示尽力可用,第二种表示则是内置渲染器与失败回退的基础。

具体加载时序

正常嵌入字体的现代路径

// 1. Worker exports FontInfo bytes and sends "commonobj".
// 2. Main thread constructs FontInfo + FontFaceObject.
// 3. bind: attached = true.
// 4. createNativeFontFace → document.fonts.add.
// 5. Await nativeFontFace.loaded.
// 6. On success: finish bind.
//    On failure: disableFontFace = true, throw, await FontFallback handling.
// 7. finally: clearData unless fontExtraProperties.
// 8. commonObjs.resolve(id, font).
// 9. CanvasGraphics consumes ctx.font and/or Path2D.

这个时序里有两个独立「完成」:nativeFontFace.loaded 表示浏览器原生加载结果,commonObjs.resolve 表示 PDF.js 已把字体对象发布给渲染器。失败时前者 reject,后者仍会在回退处理与数据清理之后发生。

其他分支的完成条件

分支bind 等待什么失败如何表现resolve 前状态
已 attached不等待,立即返回不重新判断原加载结果保留第一次尝试后的状态
missingFile 且无系统替代不等待,立即返回没有可注册字体由 Canvas 使用缺失字体逻辑
系统字体fontFace.load()警告、移除 FontFace,但不 rethrow正常结束,不触发 FontFallback
原生嵌入字体成功nativeFontFace.loaded不适用disableFontFace 保持原值
原生嵌入字体失败loaded reject,随后 API catch 通知 Worker设置 disableFontFace 并走 FontFallback回退收尾后仍发布字体对象
旧式同步插入规则后立即返回无异步探测结果视为同步加载路径完成
旧式异步测试字体像素出现,或探测超过上限超限只警告并完成队列按入队顺序调度 resolve

最容易误读的是第一行与最后一行:attached 只防重复附加,旧式探针超时也不等于成功。源码没有在这些位置重置 attached、循环调用 bind 或实现通用错误重试。

clearData、cleanup 与 destroy 的时间边界

字体主体字节通常在每次 bind 链收尾时就释放,不必等文档 cleanup。只有 fontExtraProperties 开启时才保留。字形原始路径则在第一次 getPathGenerator 后按同一开关决定是否删除。

PDFDocumentProxy.cleanup(keepLoadedFonts = false) 转发到 startCleanup。后者先等待 Worker 的 "Cleanup",再逐页调用 page.cleanup();任何页面仍在渲染都会抛错。随后总是 commonObjs.clear(),但只有 !keepLoadedFonts 时才 fontLoader.clear():

this.commonObjs.clear();
if (!keepLoadedFonts) {
  this.fontLoader.clear();
}

keepLoadedFonts 允许 DOM 字体继续驻留,避免后续重用成本,但源码文档明确警告会增加持久内存。纯 XFA 在 PDFDocumentProxy.cleanup 入口会强制保留字体。

WorkerTransport.destroy() 则先标记 destroyed,等待各页 _destroy() 和 Worker "Terminate",在 Promise.all(waitOn) 成功后无条件执行 commonObjs.clear() 与 fontLoader.clear(),再清理消息处理器等资源。PDFDocumentLoadingTask.destroy() 负责等待并调用 transport 的销毁流程。

所以三个清理动作层级不同:font.clearData() 释放单个字体的大字节块;fontLoader.clear() 释放 DOM 注册与样式;commonObjs.clear() 释放主线程对象池。文档销毁会把后两者一起收尾,而普通 cleanup 可以选择保留已加载字体。

设计要点

  1. 把「附加」「加载」「可渲染」分开。attached 只表示已走过附加入口;原生 promise、系统字体 catch、旧式探测和 disableFontFace 才共同决定后续渲染方式。单一 boolean 无法也不被迫表达整个状态机。

  2. 发布对象前完成回退收尾。api.js 把 clearData() 和 commonObjs.resolve() 放在 finally,让 Canvas 看到的始终是已经结束装载流程的对象,而不是一个仍在变化的中间状态。

  3. 注册与创建分离。FontFaceObject 只创建 FontFace 或规则,FontLoader 才操作目标 document。打印 iframe、测试 style、CSP 回退和统一清理由一个拥有明确文档边界的类处理。

  4. **CSP 与跨 window 是样式设计的一部分。**constructable stylesheet 不仅是为了「新 API 更好看」,而是规避 inline-style 限制;从 ownerDocument.defaultView 取构造器则保证 sheet 能被正确 adopt。

  5. 系统字体去重记录成功,不伪装尝试。#systemFonts 在 load() 成功后写入,失败会移除注册对象并正常返回。它既没有把失败说成成功,也没有被扩展成源码中不存在的 in-flight 缓存或重试框架。

  6. **旧式兼容代码把不确定性局部化。**测试字体、唯一 id、checksum、像素 alpha、30 次探测和顺序队列都封装在非现代路径中。现代 Canvas 与 FontFaceObject 不需要知道这些 hack 的存在。

  7. 不可信字符串必须在 CSS 边界序列化。loadedName 是内部 id,cssFontInfo.fontFamily 却可能来自 PDF。serializeFontFamily 用 CSS string 与转义消除规则注入,而不是依赖字体名「通常很正常」。

  8. 缓存编译结果,尽快删除原始大对象。Path2D 按字符缓存,原始 FontPathInfo 用后可删;字体字节在 bind 完成后可裁剪。fontExtraProperties 明确把调试可见性与内存占用做成一个开关。

  9. **回退是跨线程协作,不是主线程单方面换 family。**原生字体失败后,主线程设置 disableFontFace 并发送 FontFallback;Worker 同步自身状态并重发已解析字形路径,Canvas 才能可靠切换到内置轮廓。

  10. 清理按资源所有权划分。FontFaceObject 不管 DOM,FontLoader 不重新计算字体度量,commonObjs 不负责创建 CSS。各自释放自己拥有的资源,才使 cleanup 与 destroy 的组合保持可预测。

总结

font_loader.js 位于「Worker 已理解字体」与「浏览器可以绘制字体」之间。FontInfo 保留紧凑导出数据,FontFaceObject 提供创建规则、读取度量、生成路径的接口,FontLoader 则把现代 API、系统字体、旧浏览器 hack 与资源生命周期收拢在同一个文档边界内。

问题当前源码的答案
字体如何进入浏览器?现代路径注册 FontFace,旧路径插入 @font-face
系统字体如何避免重复?#systemFonts 记录成功加载的 loadedName
如何知道旧式异步加载结束?已知 CFF 测试字体、1 像素 alpha、最多 30 次实际探测
多个旧式请求如何收尾?完成可乱序,队首连续出队保证回调按入队顺序调度
原生字体失败怎么办?disableFontFace = true、抛出异常、FontFallback、使用字形路径
字体对象何时给 Canvas?bind/回退收尾、clearData 后才 commonObjs.resolve
字形路径如何避免重复工作?compiledGlyphs 缓存 Path2D,常规模式删除原始路径对象
文档结束如何释放?cleanup 可选择保留 DOM 字体,destroy 无条件清理加载器与对象池

读完这个文件,最应该记住的不是某条浏览器 hack,而是它的状态边界:**附加不等于成功,resolve 不等于原生字体可用,失败也不等于渲染流程必须崩溃。**PDF.js 用明确的发布时序和内置轮廓回退,把这些差异挡在 CanvasGraphics 的稳定接口之后。

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

相关文章