StreamPDFStreamPDF
返回博客

PDF.js 源码解读: standard_fonts

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: standard_fonts

前言

前面几篇已经把字体层的公共底座铺开:fonts_utils.js 提供 FontFlags、MacStandardGlyphOrdering 与 normalizeFontName,encodings.js 定义了几套内置编码,glyphlist.js 给出字形名到 Unicode 的对照,cmap.js 负责复合字体的字符码到 CID 或 Unicode 的翻译。这些模块解决的是「怎么编码」「怎么查字形名」「怎么解析 CMap」。但还有一类问题没有回答:当字体根本没有嵌入文件时,名字该怎么认、替代字体该怎么选、字形编号该怎么猜。

src/core/standard_fonts.js(1092 行)就是为这类问题准备的数据层。它几乎不含算法,主体是九张由工厂函数懒加载的查表,加上两个查询函数。它不解析字体文件,也不构造 Font 对象;它只把「哪些名字属于标准字体」「某个名字应该退化成谁」「某个 CID 大概率对应哪个 Unicode」这些领域知识集中起来。

用一句话概括:它是字体层的标准字体字典,把字体名归一化后的查表结果交给 evaluator.js 与 fonts.js,让没有嵌入文件的 PDF 仍能选出一个可用的替代字体并尽量保住字形。

把它称作「字典」而不是「模块」是有意的:字典的职责是收录与检索,不负责使用。真正决定用哪个字体、画哪个字形的,是调用它的解析器与字体构造器。

模块总览

standard_fonts.js 可以切成九块,每一块都是一张独立的表或一个极薄的查询函数:

区块关键常量或函数职责
标准字体表getStdFontMap标准 14 字体,以及 Arial、Times New Roman 等常见别名
文件名映射getFontNameToFileMap标准字体名到内置字体文件的对应关系
非标准字体表getNonStdFontMapCalibri 一类未嵌入字体的退化目标
衬线名集合getSerifFonts用名字判断衬线倾向,弥补不可信的 /Flags
符号名集合getSymbolsFontsSymbol、Dingbats、Wingdings 一族的名字
Mac 序字形表getGlyphMapForMacOrderedFonts按 Macintosh 标准字形序生成 CID 到 Unicode 的基表
标准字形表getGlyphMapForStandardFontsGhostscript 一类无映射 CID 字体的常用 CID 到 Unicode
补充字形表三个 getSupplementalGlyphMapFor...Trebuchet、ArialBlack、Calibri 的稀疏覆盖
查询与导出getStandardFontName、isKnownFontName归一化名字后查表,并提供「是否认识这个字体名」的判断

如果按项数看,这九张表的分工更直观:

表项数键的类型值的类型是否生成式
getStdFontMap72字体名字符串标准字体名或别名否
getFontNameToFileMap18标准字体名字体文件名否
getNonStdFontMap50非标准字体名替代字体名否
getSerifFonts134族名布尔值否
getSymbolsFonts6族名布尔值否
getGlyphMapForMacOrderedFonts最多 256glyph id 数字Unicode 码位是
getGlyphMapForStandardFonts496CID 数字Unicode 码位否
getSupplementalGlyphMapForTrebuchetMS75CID 数字Unicode 码位否
getSupplementalGlyphMapForArialBlack3CID 数字Unicode 码位否
getSupplementalGlyphMapForCalibri117CID 数字Unicode 码位否

名字表与字形表的规模差异说明了两件事。名字表的键是有限的厂商命名,几十到一百多项就能覆盖主流情况;字形表的键是字体内部的槽位,理论上可以到几千,PDF.js 只收录真实样本里验证过的对应关系。生成式的 Mac 序表是唯一例外,它从 258 个标准字形名反查 Unicode,用循环换体积。

它在整条链路里的位置可以画成这样:

// 字体字典 /BaseFont、/Subtype、/FontDescriptor
//   → evaluator.js                读取字体字典、决定是否使用系统字体
//   → standard_fonts.js           本篇:名字表、替代表、字形映射表
//   → fonts.js                    Font 家族:fallbackToSystemFont、toFontChar
//   → font_substitutions.js       真正把名字落到系统字体或内置字体
//   → canvas / 文本层             绘制字形、提取 Unicode

版本说明:本篇严格按当前仓库这一版 standard_fonts.js 讲解。本版导出 12 个成员,其中 getGlyphMapForMacOrderedFonts 与 getSupplementalGlyphMapForTrebuchetMS 是围绕 TrebuchetMS 字形序单独拆出的两表;fonts.js 的 Trebuchet 分支会先套 Mac 序基表,再叠补充表,源码注释标注了 issue 21713。旧资料若只讲 getGlyphMapForStandardFonts 一张表,就会漏掉这条分支。另外,所有表都由 getLookupTableFactory 懒构造,但源码没有调用 Object.freeze,只读是模块契约,不是运行时保证。

依赖方向

文件头的导入只有三个,全部指向底层公共模块:

import { MacStandardGlyphOrdering, normalizeFontName } from './fonts_utils.js';
import { getGlyphsUnicode } from './glyphlist.js';
import { getLookupTableFactory } from './core_utils.js';
依赖提供的能力在本文件的使用点
fonts_utilsMacStandardGlyphOrdering、归一化Mac 序表的生成循环、两个查询函数
glyphlistgetGlyphsUnicode把 Mac 字形名翻成 Unicode 码位
core_utilsgetLookupTableFactory九张表的懒加载包装

它不导入 fonts.js、evaluator.js、cmap.js,也不导入任何流或解析器。这条单向依赖让它可以被 Worker 内的多个模块安全引用,而不会把字体实现细节带进数据层。反过来,evaluator.js 与 fonts.js 都从它导入,font_substitutions.js 则通过参数接收已经算好的 standardFontName,不直接依赖本文件。

懒加载工厂与数据形状

九张表全部通过 getLookupTableFactory 创建。这个工厂不在本文件里,而在 core_utils.js,实现只有十行左右:

function getLookupTableFactory(initializer, useArray = false) {
  let lookup;
  return function () {
    if (initializer) {
      lookup = useArray ? [] : Object.create(null);
      initializer(lookup);
      initializer = null;
    }
    return lookup;
  };
}

它返回的不是表,而是一个取表函数。第一次调用时创建容器、执行初始化器,然后把 initializer 置空;之后的调用直接返回同一个 lookup。这是一种「首次访问才构造,此后永久复用」的单例模式,与 Catalog 里的 shadow 目标相同,只是作用域在模块级。

这里有几个容易忽略的细节:

细节具体表现影响
容器类型默认 Object.create(null),数组模式才用 []表没有原型链,toString 之类名字不会假命中
构造时机第一次调用 getter打开文档时不构造,真正遇到字体时才构造
构造次数初始化器执行一次后置空同一模块实例内永远只有一份表
可变性返回原对象,没有包装或冻结调用方理论上能改表,实际只读
数字键t[2] = 10 最终变成字符串键 "2"查询用数字或字符串都可以,遍历时需还原

Object.create(null) 的选择与 primitives.js 的 NameCache 一致:字体名来自 PDF 内容,可能是 constructor、__proto__ 这类字符串。普通对象字面量会继承 Object.prototype,用 in 判断或直接下标都可能拿到非表项;无原型对象把这种风险降到零。注意 getSerifFonts 的判断在 evaluator.js 里用了 in 运算符,这正是无原型容器才能安全支撑的写法。

数字键的行为值得单独说明。getGlyphMapForStandardFonts 的写法是 t[2] = 10、t[2424] = 7821,JavaScript 会把数字键转成字符串属性。之后 applyStandardFontGlyphMap 里的 for (const charCode in glyphMap) 拿到的是字符串,所以下游必须写 map[+charCode] = glyphMap[charCode],用一元加号转回数字下标。这不是随手写的转换,而是对象表与数组表之间的必要桥接。

表的生命周期

这些表的生命周期与文档无关。它们在模块第一次被求值时登记初始化器,在第一次查询时构造,之后一直留在 Worker 的模块作用域里。打开第二份 PDF 不会重新构造,关闭第一份 PDF 也不会释放。这一点与 Catalog 里的 fontCache、standardFontDataCache 形成对比:

缓存或表作用域何时构造何时清理
getStdFontMap 等九张表模块级首次调用 getter不清理,随 Worker 存活
Catalog.fontCache文档级首次遇到字体文档 cleanup
Catalog.standardFontDataCache文档级首次取回字体文件文档 cleanup
getFontSubstitution 的系统缓存文档级首次需要系统替代字体文档 cleanup

这个区分是合理的:九张表是不可变的领域知识,不随文档变化,也没有持有大块二进制;字体对象与取回的文件字节才是文档级资源,必须按文档回收。把领域知识做成模块级单例,避免了每开一份文档就重建一遍映射表。

表不是冻结对象

getLookupTableFactory 返回的是初始化后的原对象,没有 Object.freeze,也没有 Object.seal。也就是说,任何拿到表的调用方都能写入新键。源码依赖两层约束来维持只读:

  1. 初始化器执行完就置空,工厂不会再重新构造,也不会在构造后主动改写;
  2. 所有表常量都不直接导出,外部只能通过 getter 拿到引用,模块内部没有暴露写入接口。

这是契约式只读,不是运行时只读。它的好处是构造路径极短,没有冻结成本;代价是误写不会被立刻发现。对像 getGlyphMapForStandardFonts 这种 496 项的表,冻结一遍要遍历所有键,而它只在 Worker 内部被少数路径读取,PDF.js 选择了前者。

设计要点:懒加载工厂把「数据定义」和「数据实例化」分开。模块加载时只登记初始化器,不分配九张表的内存;真正需要某张表时才构造。字体数据在多数文档里只会用到其中两三张,这种按需构造避免了启动阶段无意义的分配,也让未被用到的表永远不进入堆。

标准 14 字体与别名表

getStdFontMap 是整个文件最核心的一张表。它先列出 PDF 规范里的标准 14 字体,再追加一大批商业字体的别名:

const getStdFontMap = getLookupTableFactory(function (t) {
  // The standard 14 fonts:
  t["Times-Roman"] = "Times-Roman";
  t.Helvetica = "Helvetica";
  t.Courier = "Courier";
  t.Symbol = "Symbol";
  t["Times-Bold"] = "Times-Bold";
  t["Helvetica-Bold"] = "Helvetica-Bold";
  t["Courier-Bold"] = "Courier-Bold";
  t.ZapfDingbats = "ZapfDingbats";
  t["Times-Italic"] = "Times-Italic";
  t["Helvetica-Oblique"] = "Helvetica-Oblique";
  t["Courier-Oblique"] = "Courier-Oblique";
  t["Times-BoldItalic"] = "Times-BoldItalic";
  t["Helvetica-BoldOblique"] = "Helvetica-BoldOblique";
  t["Courier-BoldOblique"] = "Courier-BoldOblique";

标准 14 字体是 PDF 规范里不需要嵌入文件也必须被查看器支持的字体集合,实际是 Times、Helvetica、Courier 三族各四种字形,再加 Symbol 与 ZapfDingbats 两个符号字体,共 14 个。表里的值大多与键相同,这不是冗余:它让「标准字体查询」有一个统一的返回形态,命中的键总能拿到一个可用的标准名。

族常规粗体斜体粗斜体
TimesTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
HelveticaHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
CourierCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
符号Symbol无无无
符号ZapfDingbats无无无

表中的命名并不完全对称:Helvetica 与 Courier 用 Oblique 表示斜体,Times 用 Italic;Courier 的粗斜体叫 BoldOblique,而 Arial 的别名里又同时出现 BoldItalic 与 BoldOblique 两种拼写。这些不对称不是笔误,而是各家字体厂商的历史命名,表必须按真实名字登记,不能强行统一。

真正的价值在后面的「Extra mappings」。表一共 72 项,除 14 个标准名之外,其余都是别名归一化:

// Extra mappings
t.ArialNarrow = 'Helvetica';
t['ArialNarrow-Bold'] = 'Helvetica-Bold';
t['ArialNarrow-BoldItalic'] = 'Helvetica-BoldOblique';
t['ArialNarrow-Italic'] = 'Helvetica-Oblique';
t.ArialBlack = 'Helvetica';
t['ArialBlack-Bold'] = 'Helvetica-Bold';
t['ArialBlack-BoldItalic'] = 'Helvetica-BoldOblique';
t['ArialBlack-Italic'] = 'Helvetica-Oblique';
t['Arial-Black'] = 'Helvetica';
t['Arial-Black-Bold'] = 'Helvetica-Bold';
t['Arial-Black-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Black-Italic'] = 'Helvetica-Oblique';
t.Arial = 'Helvetica';
t['Arial-Bold'] = 'Helvetica-Bold';
t['Arial-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Italic'] = 'Helvetica-Oblique';
t.ArialMT = 'Helvetica';
t['Arial-BoldItalicMT'] = 'Helvetica-BoldOblique';
t['Arial-BoldMT'] = 'Helvetica-Bold';
t['Arial-ItalicMT'] = 'Helvetica-Oblique';

这段别名不是简单的「Arial 等于 Helvetica」。它同时处理了四类差异:

差异类型例子处理方式
族名不同Arial、ArialMT、ArialUnicodeMS统一落到 Helvetica
字重后缀不同Bold、BoldMT、Black统一落到 Helvetica-Bold
斜体命名不同Italic、Oblique、BoldItalic统一落到 Helvetica-Oblique 系列
连字符差异Arial-Black 与 ArialBlack两个拼写都登记

这些别名覆盖了 Arial、ArialNarrow、ArialBlack、ArialUnicodeMS、CourierNew、CourierNewPS、TimesNewRoman、TimesNewRomanPS、HelveticaLTStd 等常见商业字体。它们的共同点是:PDF 里写了名字,却没有嵌入字形数据;查看器必须把它们当成某个标准字体来画。

原始族常规落点粗体落点斜体落点粗斜体落点
ArialHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialNarrowHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialBlackHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
ArialUnicodeMSHelveticaHelvetica-BoldHelvetica-ObliqueHelvetica-BoldOblique
TimesNewRomanTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
TimesNewRomanPSTimes-RomanTimes-BoldTimes-ItalicTimes-BoldItalic
CourierNewCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
CourierNewPSCourierCourier-BoldCourier-ObliqueCourier-BoldOblique
HelveticaLTStdHelveticaHelvetica-Bold无无

这张表也解释了为什么 getStdFontMap 有 72 项而不是 14 项:同一个族在 PDF 里可能出现带 MT、PS、PSMT、UnicodeMS、Narrow、Black 等多种后缀,每一种都要单独登记。漏掉一个拼写,字体就会落回原名,既拿不到内置文件,也拿不到标准度量。

Symbol 一族还有一个特殊处理:Symbol-Bold、Symbol-BoldItalic、Symbol-Italic 全部映射回 Symbol。符号字体没有真正的字重与字形变化,强行保留样式名只会让下游找不到对应文件。

别名表不是穷举

这张表按「实际遇到过的 PDF」积累,不是按字体厂商目录穷举。72 项里,14 项是标准字体,58 项是别名;别名又集中在 Arial、Times New Roman、Courier New、Helvetica 四个族。它覆盖的是桌面出版与 Office 导出里最常见的名字,不保证覆盖所有商业字体。

分组项数代表
标准 1414Times-Roman、Helvetica、Symbol
Arial 族别名27Arial、ArialMT、ArialUnicodeMS
Times 族别名15TimesNewRoman、TimesNewRomanPSMT
Courier 族别名10CourierNew、CourierNewPSMT
Helvetica 族别名3Helvetica-Italic、HelveticaLTStd-Bold
Symbol 样式别名3Symbol-Bold、Symbol-Italic

遇到表里没有的名字,getStandardFontName 返回 undefined,字体走系统替代或默认回退。这不是缺陷,而是有意的边界:把所有字体名都猜一个替代目标,会让错误更难发现;只登记验证过的名字,未知字体至少能保留原名,交给平台字体匹配。

normalizeFontName 在查表之前先做一次清洗:

function normalizeFontName(name) {
  return name.replaceAll(/[,_]/g, '-').replaceAll(/\s/g, '');
}

逗号与下划线都变成连字符,所有空白被删除。于是 Times New Roman 归一成 TimesNewRoman,正好命中表项;Times,New_Roman 归一成 Times-New-Roman,与前者不是同一个键,表里也没有这一项。注意它不转小写,所以表里的键必须与 PDF 里的实际大小写一致。这也是表里同时出现 ArialMT、Arial-BoldMT、TimesNewRomanPSMT-Bold 的原因:归一化只能抹平空白与两种分隔符,抹不平厂商的命名习惯。

设计要点:getStdFontMap 同时承担两种语义。对标准 14 字体,它是「这是规范保证可用的字体」;对 Arial 一族,它是「这虽然不在标准 14 里,但可以直接借 Helvetica 的字形与度量」。两种语义共用一张表,换来的是一个查询入口;代价是 isStandardFont 会把 Arial 也算成标准字体,下游必须用 isNarrow、isMappedToStandardFont 等额外判断来修正宽度重算。

标准字体文件名映射

getFontNameToFileMap 只有 18 项,把标准字体名映射到随 PDF.js 分发的字体文件:

// Contains mapping for standard fonts and xfa fonts.
const getFontNameToFileMap = getLookupTableFactory(function (t) {
  t.Courier = 'FoxitFixed.pfb';
  t['Courier-Bold'] = 'FoxitFixedBold.pfb';
  t['Courier-BoldOblique'] = 'FoxitFixedBoldItalic.pfb';
  t['Courier-Oblique'] = 'FoxitFixedItalic.pfb';
  t.Helvetica = 'LiberationSans-Regular.ttf';
  t['Helvetica-Bold'] = 'LiberationSans-Bold.ttf';
  t['Helvetica-BoldOblique'] = 'LiberationSans-BoldItalic.ttf';
  t['Helvetica-Oblique'] = 'LiberationSans-Italic.ttf';
  t['Times-Roman'] = 'FoxitSerif.pfb';
  t['Times-Bold'] = 'FoxitSerifBold.pfb';
  t['Times-BoldItalic'] = 'FoxitSerifBoldItalic.pfb';
  t['Times-Italic'] = 'FoxitSerifItalic.pfb';
  t.Symbol = 'FoxitSymbol.pfb';
  t.ZapfDingbats = 'FoxitDingbats.pfb';
  t['LiberationSans-Regular'] = 'LiberationSans-Regular.ttf';
  t['LiberationSans-Bold'] = 'LiberationSans-Bold.ttf';
  t['LiberationSans-Italic'] = 'LiberationSans-Italic.ttf';
  t['LiberationSans-BoldItalic'] = 'LiberationSans-BoldItalic.ttf';
});

这张表有两个明显特征。第一,Courier、Times、Symbol、ZapfDingbats 用 Foxit 的 .pfb 文件,Helvetica 族用 Liberation Sans 的 .ttf 文件。第二,末尾额外登记了四个 LiberationSans-* 名字,对应 XFA 表单里可能直接写出的字体名。注释里的「standard fonts and xfa fonts」说的就是这件事。

它的消费点在 evaluator.js 的 fetchStandardFontData:

const standardFontNameToFileName = getFontNameToFileMap(),
  filename = standardFontNameToFileName[name];
let data;
 
try {
  if (this.options.useWorkerFetch) {
    data = await fetchBinaryData(
      `${this.options.standardFontDataUrl}${filename}`
    );
  } else {

上游只有在 useSystemFonts 关闭、且名字不是 Symbol 或 ZapfDingbats 时才会走这里。也就是说,这张表服务于「用自己的字体文件渲染标准字体」这条路径;开启系统字体时,名字会交给 font_substitutions.js 去匹配本机字体,不需要内置文件。文件名不是路径,真正的目录由 standardFontDataUrl 决定,Worker 只负责拼出完整 URL 并取回字节。

表项来源例子文件类型典型用途
标准 14 的衬线族Times-Roman.pfb内置 Type1 轮廓
标准 14 的无衬线Helvetica.ttf内置 TrueType 轮廓
符号字体Symbol、ZapfDingbats.pfb特殊字符集
XFA 备用名LiberationSans-Regular.ttfXFA 字体名直接命中

取回与缓存

fetchStandardFontData 的完整入口更能说明这张表在链路里的角色:

async fetchStandardFontData(name) {
  const cachedData = this.standardFontDataCache.get(name);
  if (cachedData) {
    return new Stream(cachedData);
  }
 
  if (
    this.options.useSystemFonts &&
    name !== "Symbol" &&
    name !== "ZapfDingbats"
  ) {
    return null;
  }
 
  const standardFontNameToFileName = getFontNameToFileMap(),
    filename = standardFontNameToFileName[name];
  let data;

缓存键是标准字体名,不是文件名。同一个 Helvetica 被多页引用时只取回一次字节;取回后存进 Catalog.standardFontDataCache,文档清理时统一释放。name 不在表里时,filename 是 undefined,后续请求会失败并返回 null,字体随后落到系统字体或默认替代路径。

这里还有一个容易忽略的条件:useSystemFonts 打开时,只有 Symbol 与 ZapfDingbats 仍然使用内置文件。原因是系统字体里通常没有这两个符号字体,而它们的字形又不能靠普通文本字体替代;其余标准字体交给系统字体匹配,省掉一次网络请求。

条件是否取内置文件原因
useSystemFonts 为假是没有系统字体可用,必须用内置文件
useSystemFonts 为真,名字是 Symbol是系统字体通常没有符号字形
useSystemFonts 为真,名字是 ZapfDingbats是同上
useSystemFonts 为真,其它标准名否交给 font_substitutions.js 匹配本机字体

XFA 的四个 LiberationSans 名字

表末的四个 LiberationSans-* 项不是标准 14 字体,而是 XFA 表单的备用名:

t['LiberationSans-Regular'] = 'LiberationSans-Regular.ttf';
t['LiberationSans-Bold'] = 'LiberationSans-Bold.ttf';
t['LiberationSans-Italic'] = 'LiberationSans-Italic.ttf';
t['LiberationSans-BoldItalic'] = 'LiberationSans-BoldItalic.ttf';

XFA 表单由 XML 描述,字体名可能直接写成 LiberationSans-Regular,而不是 PDF 里的 /BaseFont 名字。xfa_fonts.js 在构造字体字典时会把这类名字带进来,fetchStandardFontData 再用这张表取回对应文件。如果没有这四项,XFA 表单里的默认无衬线字体就只能落到系统字体或默认回退,排版会与设计稿偏离。

这四个名字与 Helvetica 指向同一批 .ttf 文件,但键必须单独登记,因为 getFontNameToFileMap 是精确匹配,不做别名推导。Helvetica 的替代关系在 getStdFontMap 里,文件映射表只负责「名字到文件」这一层,不负责「名字到名字」。

非标准字体与替代链

getNonStdFontMap 收录 50 项,处理的是「不是标准 14,但经常被当成标准字体写在 PDF 里」的名字:

/**
 * Holds the map of the non-standard fonts that might be included as
 * a standard fonts without glyph data.
 */
const getNonStdFontMap = getLookupTableFactory(function (t) {
  t.Calibri = "Helvetica";
  t["Calibri-Bold"] = "Helvetica-Bold";
  t["Calibri-BoldItalic"] = "Helvetica-BoldOblique";
  t["Calibri-Italic"] = "Helvetica-Oblique";
  t.CenturyGothic = "Helvetica";
  t["CenturyGothic-Bold"] = "Helvetica-Bold";
  t["CenturyGothic-BoldItalic"] = "Helvetica-BoldOblique";
  t["CenturyGothic-Italic"] = "Helvetica-Oblique";
  t.ComicSansMS = "Comic Sans MS";
  t["ComicSansMS-Bold"] = "Comic Sans MS-Bold";
  t["ComicSansMS-BoldItalic"] = "Comic Sans MS-BoldItalic";
  t["ComicSansMS-Italic"] = "Comic Sans MS-Italic";
  t.GillSansMT = "Helvetica";
  t["GillSansMT-Bold"] = "Helvetica-Bold";
  t["GillSansMT-BoldItalic"] = "Helvetica-BoldOblique";
  t["GillSansMT-Italic"] = "Helvetica-Oblique";
  t.Impact = "Helvetica";

这张表与 getStdFontMap 的差别不在形式,而在结果是否仍然是标准字体。Calibri、CenturyGothic、GillSansMT、Impact、TrebuchetMS 都退化成 Helvetica 族;ComicSansMS 退化成带空格的 Comic Sans MS 族;LucidaConsole 退化成 Courier 族;MS Gothic、MS Mincho、MS PGothic、MS PMincho 退化成同名的日文字体族。后两类的结果不一定能命中 getStdFontMap,因此下游会把它们视为「被映射到标准字体」而非「本身是标准字体」。

原始族替代目标目标是否标准说明
CalibriHelvetica 族是Office 默认字体,常被无嵌入引用
CenturyGothicHelvetica 族是几何无衬线,形状接近 Helvetica
GillSansMTHelvetica 族是英式无衬线,用 Helvetica 兜底
ImpactHelvetica是仅常规字重,没有斜体与粗体变体
TrebuchetMSHelvetica 族是此处只做名字替换,字形表另有分支
ComicSansMSComic Sans MS 族否保留原名,交给系统字体匹配
LucidaConsoleCourier 族是等宽控制台字体,落到 Courier
MS Gothic 系列MS Gothic 等同名族否日文 Windows 字体,保留族名
MS Mincho 系列MS Mincho 等同名族否同上
NuptialScriptTimes-Italic是手写体无法替代,退化为斜体衬线

这张表的 50 项里,MS Gothic 与 MS Mincho 系列共 16 项,是最大的一组;Calibri、CenturyGothic、GillSansMT、TrebuchetMS 四个四字形族合计 16 项;ItcSymbol 6 项、LucidaConsole 4 项、ComicSansMS 4 项;剩下的是 Impact、LucidaSans-Demi、NuptialScript、SegoeUISymbol 这类单项。把四种字重全部登记,是为了让替代目标也保留对应的字重,而不是把粗体正文画成常规体。

fonts.js 的 fallbackToSystemFont 把这条链写得很清楚:

const { name, type } = this;
let fontName = normalizeFontName(name);
const stdFontMap = getStdFontMap(),
  nonStdFontMap = getNonStdFontMap();
const isStandardFont = !!stdFontMap[fontName];
const isMappedToStandardFont = !!(nonStdFontMap[fontName] && stdFontMap[nonStdFontMap[fontName]]);
 
fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;

查找顺序是标准表优先、非标准表其次、最后保留原名。isStandardFont 只看标准表;isMappedToStandardFont 要求非标准表的结果还能在标准表里再命中一次。这个双重条件是必要的:Calibri 映射到 Helvetica,而 Helvetica 在标准表里,所以它为真;ComicSansMS 映射到 Comic Sans MS,后者不在标准表里,所以它为假。两者后续的宽度重算与 toFontChar 构造路径不同。

名字标准表命中非标准表结果实际使用的名字是否视为标准
Helvetica是无Helvetica是
Arial是无Helvetica是
Calibri否HelveticaHelvetica是(间接)
ComicSansMS否Comic Sans MSComic Sans MS否
MS-Gothic否MS GothicMS Gothic否
SomeUnknownFont否无SomeUnknownFont否

设计要点:替代链把「识别」与「替换」分成两步。getStdFontMap 回答「这个名字是不是标准字体」,getNonStdFontMap 回答「这个名字应该换成谁」。两步分开之后,isMappedToStandardFont 可以只对真正落到标准字体上的替代返回真,避免把 Comic Sans MS 也当成标准字体去做度量修正。

衬线名与符号名集合

两张布尔集合表不负责替换,只负责给字体打标签。

衬线名集合

getSerifFonts 有 134 项,全部映射为 true:

const getSerifFonts = getLookupTableFactory(function (t) {
  t["Adobe Jenson"] = true;
  t["Adobe Text"] = true;
  t.Albertus = true;
  t.Aldus = true;
  t.Alexandria = true;
  t.Algerian = true;
  t["American Typewriter"] = true;
  t.Antiqua = true;
  t.Apex = true;
  t.Arno = true;
  t.Aster = true;
  t.Aurora = true;
  t.Baskerville = true;
  t.Bell = true;
  t.Bembo = true;
  t["Bembo Schoolbook"] = true;
  t.Benguiat = true;
  t["Berkeley Old Style"] = true;
  t["Bernhard Modern"] = true;
  t["Berthold City"] = true;
  t.Bodoni = true;
  t["Bauer Bodoni"] = true;
  t["Book Antiqua"] = true;
  t.Bookman = true;
  t["Bordeaux Roman"] = true;
  t["Californian FB"] = true;
  t.Calisto = true;
  t.Calvert = true;
  t.Capitals = true;
  t.Cambria = true;
  t.Cartier = true;
  t.Caslon = true;

中段与末尾同样密集:

t.Garamond = true;
t.Gentium = true;
t.Georgia = true;
t.Gloucester = true;
t['Goudy Old Style'] = true;
t['Goudy Schoolbook'] = true;
t['Goudy Pro Font'] = true;
t.Granjon = true;
t['Guardian Egyptian'] = true;
t.Heather = true;
t.Hercules = true;
t['High Tower Text'] = true;
t.Hiroshige = true;
t['Hoefler Text'] = true;
t['Humana Serif'] = true;
t.Imprint = true;
t['Ionic No. 5'] = true;
t.Janson = true;
t.Joanna = true;
t.Korinna = true;
t.Lexicon = true;
t.LiberationSerif = true;
t['Liberation Serif'] = true;
t['Linux Libertine'] = true;
t.Literaturnaya = true;
t.Lucida = true;
t['Lucida Bright'] = true;
t.Melior = true;

表里既有 Garamond、Baskerville、Bodoni、Caslon 这些传统衬线体,也有 Cambria、Constantia、Georgia、Times、LiberationSerif、FreeSerif 这些现代或自由字体,末尾还有 Bitstream Vera、Vera Serif、XITS 这类技术排版字体。它的判断单位是去掉样式后缀的族名,不是完整字体名。

类别代表项特征
旧式衬线Garamond、Caslon、Janson、Bembo笔画对比低,斜轴,传统书籍排版
过渡与现代Baskerville、Bodoni、Bauer Bodoni笔画对比高,竖轴,标题常用
埃及体Rockwell、Guardian Egyptian、Clarendon粗衬线,近似 slab serif
现代系统体Georgia、Cambria、Constantia屏幕阅读优化,Windows 常见
自由字体LiberationSerif、FreeSerif、Gentium开源许可,常作为 Times 替代
技术排版Computer Modern、STIX、XITS数学与学术排版

由于集合只存族名,Times-BoldItalic、TimesNewRomanPSMT 这类完整名不会直接命中,必须先经过归一化与替代表查一次,再 split 掉样式后缀。这也是 fonts.js 那段循环比 evaluator.js 的 isSerifFont 更长的原因:它要同时处理子集前缀、厂商别名与样式后缀。

evaluator.js 的 isSerifFont 展示了这个用法:

isSerifFont(baseFontName) {
  // Simulating descriptor flags attribute
  const fontNameWoStyle = baseFontName.split("-", 1)[0];
  return fontNameWoStyle in getSerifFonts() || /serif/i.test(fontNameWoStyle);
}

split("-", 1)[0] 只取连字符之前的第一段,所以 Times-BoldItalic 会变成 Times;后面的正则再兜底识别名字里带 serif 的情况。fonts.js 里的判断更宽松一些,会先按 + 拆子集前缀,再归一化,经过标准表与非标准表替换,最后才 split 并查集合:

let isSerifFont = !!(properties.flags & FontFlags.Serif);
// Fallback to checking the font name, in order to improve text-selection,
// since the /Flags-entry is often wrong (fixes issue13845.pdf).
if (!isSerifFont && !properties.isSimulatedFlags) {
  const stdFontMap = getStdFontMap(),
    nonStdFontMap = getNonStdFontMap(),
    serifFonts = getSerifFonts();
  for (const namePart of name.split('+')) {
    let fontName = normalizeFontName(namePart);
    fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;
    fontName = fontName.split('-', 1)[0];
    if (serifFonts[fontName]) {
      isSerifFont = true;
      break;
    }
  }
}

这段代码的注释说明了动机:/Flags 里的 Serif 位经常是错的,如果只看标志位,文本层会选错字体族,选区与复制都受影响。名字查表是一个成本极低的二次确认,只在标志位没有声明衬线时才执行。

判断路径输入依据结果用途
/Flags 的 Serif 位字体描述符生成器写入的位首选判断
getSerifFonts 名字查表去样式后的族名本文件的 134 项集合修正错误的标志位
serif 正则族名名字里是否含 serif兜底识别
getBaseFontMetrics 回退无度量数据衬线则 Times,否则 Helvetica决定默认宽度表

符号名集合

getSymbolsFonts 只有 6 项,却覆盖了 PDF 里最常见的符号字体家族:

const getSymbolsFonts = getLookupTableFactory(function (t) {
  t.Dingbats = true;
  t.Symbol = true;
  t.ZapfDingbats = true;
  t.Wingdings = true;
  t['Wingdings-Bold'] = true;
  t['Wingdings-Regular'] = true;
});

它的使用点有两处。第一处是 evaluator.js 构造模拟字体描述符时,用 getSymbolsFonts()[fontNameWoStyle] 决定 FontFlags.Symbolic 还是 FontFlags.Nonsymbolic:

const flags =
  (this.isSerifFont(fontNameWoStyle) ? FontFlags.Serif : 0) |
  (metrics.monospace ? FontFlags.FixedPitch : 0) |
  (getSymbolsFonts()[fontNameWoStyle] ? FontFlags.Symbolic : FontFlags.Nonsymbolic);

第二处是 fonts.js 的字形映射分流:名字匹配 Symbol 或 Dingbats 时,不再走普通编码,而是使用专门的符号编码表:

} else if (/Symbol/i.test(fontName)) {
  const isCidKeyed =
    this.composite && this.cidEncoding.startsWith("Identity-");
 
  this.toFontChar = buildToFontChar(
    isCidKeyed ? getSymbolGlyphIdEncoding() : SymbolSetEncoding,
    getGlyphsUnicode(),
    this.differences
  );
} else if (/Dingbats/i.test(fontName)) {
  this.toFontChar = buildToFontChar(
    ZapfDingbatsEncoding,
    getDingbatsGlyphsUnicode(),
    this.differences
  );
}

这里正则匹配的是替代后的 fontName,不是原始名字。Symbol-Bold 先被标准表归一成 Symbol,再进入这个分支;Wingdings 虽然不在正则里,但因为它在符号集合中而获得 Symbolic 标志,后续走系统字体或符号路径。集合表与正则各管一段,并不重复。

注意集合里没有 Wingdings 2、Webdings 这类名字。它的角色是「已知的符号字体提示」,不是穷举所有符号字体;未命中的符号字体仍然可以靠 /Flags 的 Symbolic 位与 /Encoding 被识别,只是少了一条按名字兜底的路径。

设计要点:布尔集合表把「名字到类别」的知识独立出来,不参与替换。替换表改变字体名,类别表只回答是或否。两者混在一起会让 Calibri 这种「替换后变成无衬线」与 Times 这种「本身就是衬线」难以区分,后续的度量与文本层判断就失去了稳定输入。

字形映射:Mac 序与标准字体

字体没有嵌入文件时,PDF 里的字符码往往只是 CID 或 glyph id,缺少可信的 CIDToGIDMap。这时 PDF.js 需要用一张经验表猜出「这个编号大概对应哪个 Unicode 字形」。本文件提供两张基表,三张补充表。

Mac 标准字形序基表

getGlyphMapForMacOrderedFonts 不是手写表,而是从 MacStandardGlyphOrdering 生成的:

// Base glyph map for fonts whose glyph ids follow the standard Macintosh
// ordering implied by a `post` table of format 1. Fonts such as TrebuchetMS can
// use part of this ordering with supplemental overrides.
const getGlyphMapForMacOrderedFonts = getLookupTableFactory(function (t) {
  const glyphsUnicode = getGlyphsUnicode();
  t[2] = 10;
  for (let gid = 3; gid < MacStandardGlyphOrdering.length; gid++) {
    const unicode = glyphsUnicode[MacStandardGlyphOrdering[gid]];
    if (unicode !== undefined) {
      t[gid] = unicode;
    }
  }
});

MacStandardGlyphOrdering 是 258 个按 glyph id 排列的标准 Mac 字形名,开头几项是:

const MacStandardGlyphOrdering = [
  ".notdef", ".null", "nonmarkingreturn", "space", "exclam", "quotedbl",
  "numbersign", "dollar", "percent", "ampersand", "quotesingle", "parenleft",
  "parenright", "asterisk", "plus", "comma", "hyphen", "period", "slash",
  "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
  "nine", "colon", "semicolon", "less", "equal", "greater", "question", "at",

第 0 到第 2 项分别是 .notdef、.null、nonmarkingreturn。循环从 3 开始,因为前三项没有可用的 Unicode;t[2] = 10 单独把 nonmarkingreturn 映射成换行符,这是一条历史约定,避免文本层把回车位当成缺失字形。space 在 gid 3,对应 Unicode 32;exclam 在 gid 4,对应 33;此后基本按 ASCII 顺序推进,直到 gid 97 的 asciitilde。

前三项的处理值得单独记住:.notdef 是缺字形的占位,.null 是空字形,nonmarkingreturn 是不留墨的回车位。它们都不该出现在可见文本里,但 nonmarkingreturn 需要保留换行语义,所以源码给了它一个码位 10,而没有像前两项那样跳过。如果把它也跳过,某些按 Mac 序生成的字体在文本提取时会丢掉段落分隔。

循环最多写入 255 项,加上单独的 t[2] 一共不超过 256 项,实际数量取决于 glyphsUnicode 是否收录对应名字。它生成的是完整基表,适合字形编号遵循 Mac post 表格式 1 的字体。fonts.js 只在名字匹配 Trebuchet 时使用它,因为多数无嵌入的标准字体并不遵循这套序,直接套用会把字符映射到错误的字形。

标准字体字形表

getGlyphMapForStandardFonts 是本文件最长的一张表,共 496 项,键是 CID,值是 Unicode:

// Glyph map for well-known standard fonts. Sometimes Ghostscript uses CID
// fonts, but does not embed the CID to GID mapping. The mapping is incomplete
// for all glyphs, but common for some set of the standard fonts.
const getGlyphMapForStandardFonts = getLookupTableFactory(function (t) {
  t[2] = 10;
  t[3] = 32;
  t[4] = 33;
  t[5] = 34;
  t[6] = 35;
  t[7] = 36;
  t[8] = 37;
  t[9] = 38;
  t[10] = 39;
  t[11] = 40;
  t[12] = 41;
  t[13] = 42;
  t[14] = 43;
  t[15] = 44;
  t[16] = 45;
  t[17] = 46;
  t[18] = 47;
  t[19] = 48;
  t[20] = 49;
  t[21] = 50;
  t[22] = 51;
  t[23] = 52;
  t[24] = 53;
  t[25] = 54;
  t[26] = 55;
  t[27] = 56;
  t[28] = 57;
  t[29] = 58;
  t[30] = 894;
  t[31] = 60;

前 30 项几乎是连续的 ASCII 区:CID 3 到 29 对应空格到冒号,CID 30 跳到 894,是「因此」符号,随后 31 到 97 继续对应小于号到波浪号。这个缺口说明它不是按字符码直排的表,而是按某套 CID 字体的实际字形槽位整理出来的。

中段覆盖 Latin-1 补充字符与常见标点,后段则伸向更广的字符集:

t[130] = 8224;
t[131] = 176;
t[132] = 162;
t[133] = 163;
t[134] = 167;
t[135] = 8226;
t[136] = 182;
t[137] = 223;
t[138] = 174;
t[139] = 169;
t[140] = 8482;
t[141] = 180;
t[142] = 168;
t[143] = 8800;
t[144] = 198;
t[145] = 216;
t[146] = 8734;

这些值是 Unicode 码位,例如 8224 是剑号、8226 是项目符号、8482 是商标符号、8800 是不等于、8734 是无穷。表里还能看到希腊字母、西里尔字母、希伯来字母、阿拉伯字母的区段:

t[493] = 1039;
t[494] = 1040;
t[570] = 1040;
t[571] = 1041;
t[672] = 1488;
t[673] = 1489;
t[759] = 1617;
t[761] = 1776;
t[2424] = 7821;
t[2425] = 7822;
t[3282] = 11799;
t[3379] = 42785;
t[3416] = 8377;

t[570] 到 t[633] 是西里尔大写字母区,t[672] 起是希伯来字母,t[759]、t[761] 一带有阿拉伯字符,t[2424] 一带则是更晚的补充区。表中的键并不连续,末尾甚至出现 3416 这样的高位 CID,说明它是从真实字体样本里积累出来的经验表,而不是按规范完整生成的映射。

CID 区段典型 Unicode 内容说明
2 到 97控制符、基本拉丁、ASCII 标点最常用的一段,覆盖空格到波浪号
98 到 171Latin-1 补充与常见标点含重音字母、货币、版权、商标
179 到 229引号、破折号、数学符号弯引号、省略号、不等号、无穷
305 到 337希腊字母与制表符号含求和、乘积、积分、方框绘制
493 到 633西里尔字母大写与小写区段
672 到 711希伯来字母连续排列的 Alef 到 Tav
759 到 805阿拉伯附加符号与展示形式含 shadda 一类附加符号、连写形式
2424 到 3416拉丁扩展、数学与货币补充真实样本里出现的高位 CID,稀疏分布

这张分布表说明:它不是「CID 减某个常数就是 Unicode」的线性映射,而是每个区段各自成段、段间留空。PDF.js 没有尝试补全空隙,因为空隙对应的字形在真实样本里没有可靠证据;补错一个键,文本层就会输出错误字符,比缺失更难排查。

四层映射的叠加顺序

fonts.js 在 fallbackToSystemFont 里最终构造的不是一张表,而是四层叠加后的结果:

层来源作用是否覆盖前层
1getGlyphMapForStandardFonts 或 Mac 序基表给出 CID 到 Unicode 的初始猜测初始层
2Trebuchet、ArialBlack、Calibri 补充表修正已知的错位键是
3/CIDToGIDMap用字体自带的映射修正下标是
4/ToUnicode用文档自带的 Unicode 映射修正是

第三层的代码体现得很直接:

if (cidToGidMap) {
  for (const charCode in map) {
    const cid = map[charCode];
    if (cidToGidMap[cid] !== undefined) {
      map[+charCode] = cidToGidMap[cid];
    }
  }
}

它遍历的是已经叠加过基表与补充表的 map,把每个 CID 对应的值再通过 cidToGidMap 翻译一次。第四层则用 toUnicode.forEach 把 charCode 到 unicodeCharCode 的映射直接写入。层数越多,结果越接近真实字体;但只有第一、二层来自本文件,后两层来自 PDF 自身。

设计要点:这张表刻意保持「不完整」。完整映射需要为每个可能的 CID 找到正确字形,而真实 PDF 只用到其中一小部分;把所有已知样本的对应关系攒成稀疏表,既能覆盖常见字体,又把体积压在不到五百项。fallbackToSystemFont 在表之后还会用 cidToGidMap 与 ToUnicode 覆盖,说明它只是兜底起点,不是最终真相。

下游如何把它变成数组

fonts.js 的 applyStandardFontGlyphMap 负责把对象表摊平成数组:

function applyStandardFontGlyphMap(map, glyphMap) {
  for (const charCode in glyphMap) {
    map[+charCode] = glyphMap[charCode];
  }
}

map 是以下标为 CID 的数组,glyphMap 是字符串键的对象。+charCode 把 "2424" 转回 2424,写入对应下标。多次调用时后写的覆盖先写的,这正是补充表能够修正基表的机制。

在 fallbackToSystemFont 里,基表与补充表的组合方式按字体名分流:

if (/Trebuchet/i.test(name)) {
  applyStandardFontGlyphMap(map, getGlyphMapForMacOrderedFonts());
  applyStandardFontGlyphMap(map, getSupplementalGlyphMapForTrebuchetMS());
} else {
  applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());
 
  if (/Arial-?Black/i.test(name)) {
    applyStandardFontGlyphMap(map, getSupplementalGlyphMapForArialBlack());
  } else if (/Calibri/i.test(name)) {
    applyStandardFontGlyphMap(map, getSupplementalGlyphMapForCalibri());
  }
}

Trebuchet 走完全不同的基表,ArialBlack 与 Calibri 则在标准基表上打补丁。这个分流顺序不能颠倒:先铺大表,再叠小表,小表才有修正效果;如果把补充表放在前面,基表会把同样的键覆盖回去。

补充字形表:Trebuchet、ArialBlack、Calibri

三张补充表的规模差异很大,但结构完全相同:只登记需要修正的键。

TrebuchetMS 补充表

TrebuchetMS 的注释说明了它的策略:

// TrebuchetMS largely follows the standard Macintosh glyph ordering through
// glyph id 235, with the exceptions below. Additional mappings for the affected
// Latin glyph repertoire follow.
const getSupplementalGlyphMapForTrebuchetMS = getLookupTableFactory(
  function (t) {
    // A few of the standard Macintosh glyph slots hold a different, but
    // similar looking, glyph in TrebuchetMS.
    t[151] = 956;
    t[159] = 937;
    t[168] = 916;
    t[189] = 8364;
    t[195] = 8729;
    t[218] = 713;
 
    t[236] = 222;
    t[237] = 254;
    t[238] = 8722;
    t[239] = 185;
    t[240] = 178;
    t[241] = 179;

前六项是「同一个槽位放着形近但不同的字形」,例如 956 是希腊字母 mu,937 是 Omega,916 是 Delta,8364 是欧元符号,8729 是圆点运算符,713 是上横线。从 236 开始则是 Mac 序之外的扩展区,覆盖 thorn、minus、上标数字、分数、钩形符号等拉丁字符。

这张表共 75 项,使用方式是「先 Mac 序基表,再本表覆盖」。注释里的「largely follows」是关键:Trebuchet 与 Mac 序大部分一致,所以可以复用基表;不一致的部分才需要逐项修正。fonts.js 的源码注释直接给出过一个真实后果:如果误用标准字体基表,š 会被映射成 ž(issue 21713)。

ArialBlack 补充表

getSupplementalGlyphMapForArialBlack 只有 3 项:

// The glyph map for ArialBlack differs slightly from the glyph map used for
// other well-known standard fonts. Hence we use this (incomplete) CID to GID
// mapping to adjust the glyph map for non-embedded ArialBlack fonts.
const getSupplementalGlyphMapForArialBlack = getLookupTableFactory(function (t) {
  t[227] = 322;
  t[264] = 261;
  t[291] = 346;
});

三项分别修正 227、264、291 三个 CID:值 322 是 U+0142 的 ł,261 是 U+0105 的 ą,346 是 U+015A 的 Ś。表小到可以一眼看完,却足以让 ArialBlack 的几个关键拉丁扩展字形落到正确位置。它说明补充表不需要成体系,只需要覆盖已知的错位点。

Calibri 补充表

getSupplementalGlyphMapForCalibri 有 117 项,是最大的补充表:

// The glyph map for Calibri (a Windows font) differs from the glyph map used
// in the standard fonts. Hence we use this (incomplete) CID to GID mapping to
// adjust the glyph map for non-embedded Calibri fonts.
const getSupplementalGlyphMapForCalibri = getLookupTableFactory(function (t) {
  t[1] = 32;
  t[4] = 65;
  t[5] = 192;
  t[6] = 193;
  t[9] = 196;
  t[17] = 66;
  t[18] = 67;
  t[21] = 268;
  t[24] = 68;
  t[28] = 69;
  t[29] = 200;
  t[30] = 201;
  t[32] = 282;
  t[38] = 70;
  t[39] = 71;
  t[44] = 72;
  t[47] = 73;
  t[48] = 204;
  t[49] = 205;
  t[58] = 74;
  t[60] = 75;
  t[62] = 76;
  t[68] = 77;
  t[69] = 78;
  t[75] = 79;
  t[76] = 210;
  t[80] = 214;
  t[87] = 80;
  t[89] = 81;
  t[90] = 82;
  t[92] = 344;

它的 CID 编号从 1 开始,而不像标准表那样从 2 或 3 开始;字母 A 落在 4,B 落在 17,C 落在 18。这说明 Calibri 有一套自己的 CID 排序,无法靠标准表加少量补丁解决,只能用一张较大的表覆盖整个基本拉丁区。后半段还覆盖小写字母与数字、标点:

t[258] = 97;
t[259] = 224;
t[260] = 225;
t[263] = 228;
t[268] = 261;
t[271] = 98;
t[272] = 99;
t[273] = 263;
t[275] = 269;
t[282] = 100;
t[853] = 44;
t[855] = 58;
t[856] = 46;
t[876] = 47;
t[878] = 45;
t[882] = 45;
t[894] = 40;
t[895] = 41;
t[896] = 91;
t[897] = 93;
t[923] = 64;
t[940] = 163;
t[1004] = 48;
t[1005] = 49;
t[1006] = 50;

小写 a 在 258,逗号在 853,数字 0 在 1004。键位跨度极大,说明 Calibri 的后段槽位还混入了标点与数字;这张表只登记有把握的 117 项,其余仍交给 cidToGidMap 与 ToUnicode 修正。

三张补充表放在一起,可以看出一条共同的设计线索:

字体基表补充项数触发条件修正范围
TrebuchetMSMac 标准字形序75名字匹配 Trebuchet形近字符与扩展拉丁区
ArialBlack标准字体字形表3名字匹配 Arial-?Black三个错位字母
Calibri标准字体字形表117名字匹配 Calibri基本拉丁、数字与标点

分流条件为什么用正则

三个分支都匹配原始 name,不是已经替换过的 fontName。这一点很关键:TrebuchetMS 在 getNonStdFontMap 里会被替换成 Helvetica,如果分支看的是替换后的名字,就永远进不了 Trebuchet 的 Mac 序基表。原始名保留了厂商信息,替换名只负责选择可用字体,两者用途不同。

判断对象来源是否保留厂商信息用途
name字体字典原始名是选择字形映射基表与补充表
fontName归一化并替换后部分保留选择内置文件或系统字体
loadedName最后 split 的族名否CSS 字体名与内部标识

分支的正则都写得比较宽松。/Trebuchet/i 只要求名字里出现 Trebuchet,能覆盖 TrebuchetMS、Trebuchet MS-Bold 以及带子集前缀的形式;/Arial-?Black/i 同时接受 ArialBlack 与 Arial-Black,对应标准表里登记过的两种拼写;/Calibri/i 覆盖四个字重。宽松匹配的代价是可能误伤同名前缀的其它字体,但字体名空间里以这些字符串开头的商业字体极少,实际风险可以接受。

三个分支的优先级也值得注意。ArialBlack 与 Calibri 是互斥的 if / else if,同一时刻只会应用一张补充表;Trebuchet 则单独走外层 if,不会与它们同时命中。这个顺序与 getStdFontMap 里的别名不冲突,因为别名只改变 fontName,不影响原始 name 的正则判断。

设计要点:补充表不是补丁集合,而是可叠加的覆盖层。基表负责大多数键,补充表只负责已知差异;叠加顺序固定为「基表在前、补充表在后」。这种结构让新增一个字体特例只需要加一张小表,不必复制或修改整张基表,也让每张表的来源与适用范围保持可追溯。

查询函数、导出与调用面

文件末尾只有两个函数,逻辑都很薄:

function getStandardFontName(name) {
  const fontName = normalizeFontName(name);
  const stdFontMap = getStdFontMap();
  return stdFontMap[fontName];
}
 
function isKnownFontName(name) {
  const fontName = normalizeFontName(name);
  return !!(
    getStdFontMap()[fontName] ||
    getNonStdFontMap()[fontName] ||
    getSerifFonts()[fontName] ||
    getSymbolsFonts()[fontName]
  );
}

getStandardFontName 只查标准表,返回标准名或 undefined。它不查非标准表,因为调用方要的是「这个名字能否直接对应一个内置标准字体」。evaluator.js 用它拿到 standardFontName,再交给 fetchStandardFontData 或 getFontSubstitution。

isKnownFontName 是「四表并集」:标准表、非标准表、衬线集合、符号集合,任意一张命中就返回真。它不判断替代结果,只判断名字是否在知识库里。evaluator.js 用它在字体名与描述符名不一致时决定是否信任描述符:

if (
  fontNameStr &&
  baseFontStr &&
  (baseFontStr.startsWith(fontNameStr) ||
    (!isKnownFontName(fontNameStr) && isKnownFontName(baseFontStr)))
) {
  fontName = null;
}
fontName ||= baseFont;

这段判断处理两类真实情况。第一类是 fontNameStr 为 Arial、baseFontStr 为 Arial,Bold:描述符名是基础名的前缀,说明两者是同一个字体,采用哪个都可以。第二类是 fontNameStr 为 wg09np、baseFontStr 为 Wingdings-Regular:描述符名是生成器随手写的内部名,基础名才是可读的族名;此时 isKnownFontName 给出「内部名不认识、基础名认识」的结论,于是改用基础名。若两个名字都不认识,就不做替换,保留原始名字,避免把未知字体误判成已知。

归一化的边界

normalizeFontName 只做三种替换,因此查表时会出现命中与不命中的明确分界:

输入归一化结果是否命中标准表原因
Times New RomanTimesNewRoman是空白被删除,正好等于表项
Times,New_RomanTimes-New-Roman否逗号与下划线变成连字符,表里没有
Arial-BoldMTArial-BoldMT是无空白,连字符保留
Arial Bold MTArialBoldMT否空白删除后与表项不一致
Symbol-BoldSymbol-Bold是表里单独登记了该样式别名
ABCDEF+Times-RomanABCDEF+Times-Roman否子集前缀不在表里,需要先剥离

最后一行说明了为什么 fonts.js 要先 name.split("+") 再查表。子集前缀是六位大写字母加一个加号,PDF.js 不把它当成字体名的一部分;直接拿完整名去查,永远命中不了。查询函数本身不负责剥离前缀,因为它不知道调用方处于哪条解析路径。

两个查询函数为什么不合并

getStandardFontName 与 isKnownFontName 看起来可以合成一个函数:先查四张表,再返回标准名或布尔值。源码没有这样做,因为两者的返回语义完全不同。

getStandardFontName 返回的是可继续传递的标准身份。调用方拿到它之后,会去取内置文件、去查度量、去交给系统替代模块;返回值必须是标准表里的名字,不能是 true 或原始名。isKnownFontName 返回的是信任判断。调用方只拿它做条件分支,不需要知道命中哪张表,也不需要标准名。

维度getStandardFontNameisKnownFontName
查询范围仅标准表标准表、非标准表、衬线集合、符号集合
返回值标准名字符串或 undefined布尔值
典型用途取内置文件、查度量、系统替代判断描述符名是否可信
未命中含义不是可直接使用的标准字体完全陌生,四类知识都没有记录

合并之后,调用方要么额外判断返回值是不是字符串,要么在需要标准名时再查一次标准表,反而多了一步。拆成两个小函数,代价只是一次重复的归一化,换来的是每个调用点都能直接使用结果。

导出清单把十二个成员分成三类:

export {
  getFontNameToFileMap,
  getGlyphMapForMacOrderedFonts,
  getGlyphMapForStandardFonts,
  getNonStdFontMap,
  getSerifFonts,
  getStandardFontName,
  getStdFontMap,
  getSupplementalGlyphMapForArialBlack,
  getSupplementalGlyphMapForCalibri,
  getSupplementalGlyphMapForTrebuchetMS,
  getSymbolsFonts,
  isKnownFontName,
};
类别成员调用方
名字表getStdFontMap、getNonStdFontMap、getSerifFonts、getSymbolsFonts、getFontNameToFileMapevaluator.js、fonts.js
字形表getGlyphMapForMacOrderedFonts、getGlyphMapForStandardFonts、三张补充表fonts.js
查询函数getStandardFontName、isKnownFontNameevaluator.js

从调用面看,这个文件没有任何状态,也不接收 PDF 对象。所有函数都是「传名字、查表、返回结果」或「取表、由调用方叠加」。这让它可以被 Worker 内的多个字体路径共享,也让它容易测试:不需要构造 Dict,不需要流,只需要字符串。

设计要点:查询函数只做归一化与查表,不把替代链、宽度度量、字形映射混在一起。getStandardFontName 不知道 Calibri 会退化成 Helvetica,isKnownFontName 不知道 Trebuchet 需要 Mac 序基表。职责边界清晰,调用方才能在不同阶段选择不同的表,而不是被一个「万能查询」绑死。

与相邻模块的契约

这个文件不持有状态,也不发起异步操作,因此它与相邻模块的契约可以用「输入什么、输出什么」完整描述。

对上游的依赖

上游模块导入项契约
fonts_utilsMacStandardGlyphOrdering提供 258 项的标准 Mac 字形名数组
fonts_utilsnormalizeFontName提供空白删除与分隔符统一的纯函数
glyphlistgetGlyphsUnicode提供字形名到 Unicode 码位的懒加载表
core_utilsgetLookupTableFactory提供「首次构造、之后复用」的包装器

四个依赖都是纯数据或纯函数,没有一个会读取 PDF、访问网络或修改全局状态。这让 standard_fonts.js 可以在任何线程、任何时机被调用,不依赖 XRef、PDFManager 或 PartialEvaluator 的生命周期。

对下游的输出

下游模块使用的成员使用方式
evaluator.js 字体名解析getStandardFontName、isKnownFontName决定标准名、判断描述符名是否可信
evaluator.js 度量getStdFontMap、getSerifFonts选择标准度量表,衬线则回退 Times
evaluator.js 文件取回getFontNameToFileMap标准名到 .pfb 或 .ttf 文件名
evaluator.js 标志位getSymbolsFonts决定 Symbolic 标志
fonts.js 回退getStdFontMap、getNonStdFontMap、getSerifFonts计算 isStandardFont、fontName、衬线倾向
fonts.js 字形映射五张字形表构造 toFontChar 与 ToUnicodeMap

getStandardFontName 在 evaluator.js 里出现多处,覆盖简单字体、复合字体与 CFF 字体分支;下面这段是简单字体分支:

const standardFontName = getStandardFontName(baseFontName);
let file = null;
if (standardFontName) {
  file = await this.fetchStandardFontData(standardFontName);
  properties.isInternalFont = !!file;
}
if (!properties.isInternalFont && this.options.useSystemFonts) {
  properties.systemFontInfo = getFontSubstitution(
    this.systemFontCache,
    this.idFactory,
    this.options.standardFontDataUrl,
    baseFontName,
    standardFontName,
    type,
  );
}

这段代码把三条路径串了起来:先查标准名,再用标准名取内置文件;取不到且允许系统字体时,把原始名与标准名同时交给替代模块。getFontSubstitution 需要两者:原始名用于在替代表里匹配具体族,标准名用于再兜底一次标准字体。这个接口设计说明 standard_fonts.js 的输出不是最终决定,而是下游决策的一项输入。

getBaseFontMetrics 则展示了度量路径:

let fontName = normalizeFontName(name);
const stdFontMap = getStdFontMap();
fontName = stdFontMap[fontName] || fontName;
const Metrics = getMetrics();
 
const glyphWidths =
  Metrics[fontName] ?? Metrics[this.isSerifFont(name) ? 'Times-Roman' : 'Helvetica'];

先归一化,再查标准表得到标准名,然后去 metrics.js 取宽度表;取不到时按衬线与否回退到 Times-Roman 或 Helvetica。这里没有用 getNonStdFontMap,因为度量表只认标准字体;非标准名会保持原名,随后落到默认回退。这个细节与 fonts.js 的 fallbackToSystemFont 不同,后者会用非标准表,因为它要的是可绘制的字体名,而不是标准度量。

不负责的事情

为了避免把职责扩大,这里也列出它明确不做的几件事:

不做的事实际负责的模块原因
读取 /BaseFont 与 /Flagsevaluator.js本文件不接触 PDF 对象
解析字体文件type1_font.js、cff_font.js、fonts.js本文件只提供映射数据
决定系统字体font_substitutions.js需要平台与字体列表信息
缓存取回的字体字节Catalog.standardFontDataCache文档级资源必须按文档释放
生成字形轮廓font_renderer.js、glyph.js本文件不涉及绘制

这条边界让本文件可以保持「纯数据加两个查询函数」的形态。任何需要 PDF 上下文、异步 IO 或平台信息的能力,都被推到相邻模块;数据层只回答领域问题。

缺失值怎么流动

本文件没有抛异常,也没有 null 检查。所有「不认识」都表现为 undefined 或 false,由调用方决定下一步:

调用未命中时的返回值调用方的处理
getStdFontMap()[name]undefined继续查非标准表或保留原名
getNonStdFontMap()[name]undefined保留原名
getSerifFonts()[name]undefined视为非衬线
getSymbolsFonts()[name]undefined视为非符号字体
getFontNameToFileMap()[name]undefined拼出的 URL 缺文件名,取回失败后返回 null
getStandardFontName(name)undefined不取内置文件,直接进入系统字体分支
isKnownFontName(name)false不信任该名字,保留原始名

这套约定与 Catalog 的容错思路一致:数据层不判断「这是不是错误」,只返回「有没有」。undefined 与空字符串、null 严格区分;表里没有的值不会用默认值填充,因为默认值会掩盖真实的名字拼写问题。

getFontNameToFileMap 的未命中尤其要注意。filename 是 undefined 时,fetchBinaryData 会拿到一个以 undefined 结尾的 URL,请求失败后 fetchStandardFontData 捕获异常并返回 null。字体不会因此报错,只会走 isInternalFont 为假的路径。这是有意的降级:一个未知字体的文件名缺失,不应该让整页渲染失败。

总结

standard_fonts.js 是 PDF.js 字体层的数据字典:向下依赖 fonts_utils 的归一化与 Mac 字形序、glyphlist 的字形名表;向上给 evaluator.js 提供名字识别与内置字体文件名,给 fonts.js 提供无嵌入字体的字形映射基表与补充表。它不解析字体文件,也不绘制字形,只把领域知识整理成懒加载的查表。

它在整条链路中的位置

阶段模块输入输出
字典读取evaluator.js/BaseFont、/Flags、/Encoding归一化后的字体名、字体类型
名字识别standard_fonts.js字体名标准名、替代名、衬线或符号标签
文件选择getFontNameToFileMap标准名.pfb 或 .ttf 文件名
系统替代font_substitutions.js标准名与非标准名本机字体信息或内置字体信息
字形映射fonts.jsCID、cidToGidMap、基表与补充表toFontChar、ToUnicodeMap
绘制与提取canvas 与文本层字形与 Unicode页面图像、可选中文本

用一句话串起来:evaluator.js 把字体字典变成名字,standard_fonts.js 把名字变成标准身份与替代目标,fonts.js 再把身份变成可绘制的字形映射。

数据规模的实际意义

把九张表加在一起,常驻内存的项数大约是一千二百项:名字表 280 项,字形表 691 项,外加生成式 Mac 序表最多 256 项。这个量级对 Worker 来说很小,却足以覆盖绝大多数无嵌入字体文档。

数据类别项数范围增长方式维护成本
字体名字280 左右遇到新厂商名就追加低,单行即可
字形映射690 左右从真实样本里补键中,需要验证 Unicode 正确性
生成式 Mac 序256 以内由字形序与字形名表自动生成极低,跟随上游表变化

数据规模也解释了为什么这些表可以完全静态。它们不需要数据库、不需要运行时下载、不需要按文档更新;打包进 Worker 脚本后,任何时候都能同步查询。对于需要快速打开文档的查看器,这比在解析阶段临时构建映射表要稳定得多。

一次典型查询的完整路径

假设 PDF 里写了一个未嵌入的 Arial,Bold,并且关闭了系统字体。链路大致如下:

// /BaseFont = "Arial,Bold",没有嵌入字体文件
const baseFontName = 'Arial,Bold';
const standardFontName = getStandardFontName(baseFontName);
// normalizeFontName("Arial,Bold") → "Arial-Bold"
// getStdFontMap()["Arial-Bold"] → "Helvetica-Bold"
 
const fileName = getFontNameToFileMap()[standardFontName];
// → "LiberationSans-Bold.ttf"
 
// evaluator 取回文件后,fonts.js 按名字构造 Font
// 若它是 CIDFontType2 + Identity 编码且没有完整 CIDToGIDMap:
applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());

这条路径里有三次查表,分别回答三个问题:这个名字是不是标准字体、对应的内置文件叫什么、没有字形映射时 CID 大概对应哪些 Unicode。三次查表都不依赖 PDF 对象,只依赖字符串与几张大表;真正的异步只发生在取回字体文件的那一步。

如果 baseFontName 是 Calibri,第一步会失败,因为 Calibri 不在标准表里;evaluator.js 随后把原始名交给 getFontSubstitution,由 font_substitutions.js 决定系统替代;如果关闭系统字体,字体最终走 fallbackToSystemFont,由非标准表把名字换成 Helvetica,再套标准字形表。路径不同,但每一步仍然只查本文件的一张表。

遇到问题时查哪张表

问题应查的表返回形态
这个名字是不是标准字体getStdFontMap标准名或 undefined
未嵌入的字体应该换成谁getNonStdFontMap替代名或 undefined
这个字体名有没有衬线倾向getSerifFonts布尔值
这个字体名是不是符号字体getSymbolsFonts布尔值
标准字体对应哪个内置文件getFontNameToFileMap文件名或 undefined
CID 大概对应哪个 UnicodegetGlyphMapForStandardFonts对象表
字形是否遵循 Mac 标准序getGlyphMapForMacOrderedFonts对象表
是否是 Trebuchet、ArialBlack、Calibri三张 getSupplementalGlyphMapFor...覆盖表

这张索引也适合当作读源码时的导航:先确认你要回答哪一类问题,再去找对应的工厂函数,不要在一张表里找另一张表的答案。

补充一句:表之间没有隐式优先级。标准表与非标准表的先后顺序由调用方决定,基表与补充表的先后顺序由 fonts.js 决定,衬线集合与符号集合则只提供布尔判断。把顺序写在调用方,而不是写进数据,意味着同一张表可以在不同路径里以不同方式组合。

六条设计原则

  1. 数据与算法分离:1092 行里绝大多数是表项,真正的逻辑只有工厂包装、归一化查表和两个查询函数。字体知识可以独立审阅、独立扩充,不需要读懂解析器。
  2. 懒加载而不是启动构造:九张表都由 getLookupTableFactory 在首次调用时构造,之后复用。打开文档不会为未使用的字体支付内存与构造成本。
  3. 归一化只抹平分隔符:normalizeFontName 把逗号、下划线、空白统一成连字符,但保留大小写。表项必须按厂商实际拼写登记,查询结果才稳定。
  4. 标准身份与替代目标分离:getStdFontMap 回答「是不是标准字体」,getNonStdFontMap 回答「应该换成谁」。两者分开,isMappedToStandardFont 才能准确判断替代后是否仍落在标准字体上。
  5. 字形映射采用基表加覆盖层:Mac 序或标准表提供大多数键,Trebuchet、ArialBlack、Calibri 的补充表只修正已知差异,叠加顺序固定为基表在前、补充表在后。
  6. 映射表刻意不完整:getGlyphMapForStandardFonts 只有 496 项,Calibri 补充表只有 117 项。它们是无嵌入字体时的兜底起点,最终仍由 cidToGidMap、ToUnicode 与真实字体文件修正。

理解 standard_fonts.js 之后,再看 fonts.js 的 fallbackToSystemFont、evaluator.js 的 getBaseFontMetrics,以及 font_substitutions.js 的字体匹配,就会清楚它们共享的是同一批名字与映射,而不是各自维护一套字体知识。下一篇会继续留在字体层,进入真正构造 Font 家族的 fonts.js,看这些表如何变成 toFontChar、toUnicode 与字形宽度。

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

相关文章