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

前言
前面几篇已经把字体层的外围走了一遍:fonts_utils.js 提供标志位、字形名与字符映射这些公共工具,standard_fonts.js 保存标准字体名、serif 表、symbol 表与若干字形映射,evaluator.js 负责读取字体字典,fonts.js 负责决定字体类型并构造 Font 对象。这一篇补上其中最小、却最常被查的一块:src/core/metrics.js。
这个文件有 3057 行,看起来体积不小,但结构极其单调:它只导出两个函数,getMetrics 与 getFontBasicMetrics。前者是一张「字形名到宽度」的表,覆盖 PDF 规范里的 14 个标准字体;后者是一张「字体名到 ascent、descent、capHeight、xHeight」的表,同样覆盖这 14 个字体。其余 2900 多行几乎全是数字赋值。
先说清它在链路里的位置,避免误会。metrics.js 不解析字体程序,也没有 CFF、TrueType、Type1 的分派分支。它被两个上游调用:
evaluator.js的getBaseFontMetrics调用getMetrics(),在需要补宽度时用字形名查表;fonts.js的Font#fallbackToSystemFont调用getFontBasicMetrics(),在字体文件缺失或损坏、且FontDescriptor没有给出数值时,补上 ascent、descent 与 capHeight。
它的下游则是排版:Font 把宽度放进 widths,把基本度量除以 1000 写进 ascent、descent;evaluator 用 glyph.width * fontMatrix[0] * fontSize 算出每个字形的用户空间宽度,交给 textContent;显示层再用 textContent 里的 ascent 与 descent 做文本层定位。
用一句话概括:metrics.js 是字体层的标准字体后备度量表,用静态数据回答「这个字有多宽、这个字体有多高」,不参与字体格式解析,也不做单位换算之外的计算。
模块总览
metrics.js 只有两块内容,加上一个依赖和一个导出:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 表工厂依赖 | getLookupTableFactory | 首次调用建表,之后返回同一个缓存对象 |
| 宽度表 | getMetrics | 14 个标准字体的字形名到宽度,单位 1/1000 em |
| 基本度量表 | getFontBasicMetrics | 14 个标准字体的 ascent、descent、capHeight、xHeight |
| 导出 | getFontBasicMetrics、getMetrics | 只对外暴露两个查表入口 |
文件顶部只有一行导入:
import { getLookupTableFactory } from './core_utils.js';这里的依赖方向很干净:metrics.js 不导入 fonts.js、evaluator.js,也不导入 standard_fonts.js。字体名归一化与标准字体名映射都在调用方完成,本文件只接受一个已经归一化好的字体名。
它在整条链路里的位置可以画成这样:
// 内容流里的 Tj / TJ
// → evaluator.extractDataStructures 读取 /BaseFont、/FontDescriptor
// → evaluator.extractWidths 读取 /Widths,缺失时查 getMetrics()
// → evaluator.getBaseFontMetrics 字体名归一化后查 getMetrics()
// → core/metrics.js 本篇:核心 14 字体的宽度与基本度量
// → fonts.js Font.constructor 除以 1000,写入 widths / ascent / descent
// → fonts.js fallbackToSystemFont 度量仍为 NaN 时查 getFontBasicMetrics()
// → evaluator.buildTextContentItem 字形宽度 × fontMatrix[0] × fontSize
// → display/text_layer、font_loader 文本层定位与字体度量读取注意这条链的调用有多条:宽度走 evaluator,垂直度量走 fonts.js。两者共用同一批字体名,却由不同的调用点在需要时才触发。
版本说明:本篇严格按当前仓库这一版
metrics.js讲解。它的导出只有getFontBasicMetrics与getMetrics两个。旧资料或其它分支里常提到的getGlyphWidth、getCharWidth、getCharPositions、getBaseFontMetrics、getStdFontMap都不在这个文件:宽度查询由evaluator.js的getBaseFontMetrics完成,标准字体名映射在standard_fonts.js。getFontBasicMetrics的每一项都带xHeight字段,但当前仓库唯一的读取点Font#fallbackToSystemFont只取ascent、descent、capHeight,表里的xHeight不会被读出;Courier 那一条的xHeight甚至写成负数-426,属于表里的历史数据。读源码时不要按记忆里的旧 API 去找。
坐标基准:字形空间里的 1/1000 em
字形空间与文本空间
文件开头的注释把整张表的单位说得很清楚:
// The Metrics object contains glyph widths (in glyph space units).
// As per PDF spec, for most fonts (Type 3 being an exception) a glyph
// space unit corresponds to 1/1000th of text space unit.
const getMetrics = getLookupTableFactory(function (t) {
t.Courier = 600;
t["Courier-Bold"] = 600;
t["Courier-BoldOblique"] = 600;
t["Courier-Oblique"] = 600;这里有两个概念必须分开:
| 概念 | 含义 | 本文件的角色 |
|---|---|---|
| 字形空间 | 字体程序内部描述字形的坐标空间,常用 1000 单位宽 | 表的数值所在空间(如 667、278) |
| 文本空间 | PDF 内容流里文字所在的空间,受 Tf 字号影响 | 表值除以 1000 后进入的空间 |
| 用户空间 | 页面坐标空间,受 CTM 与文本矩阵共同变换 | 最终绘制与文本层定位使用的空间 |
PDF 规范规定,除 Type3 字体外,字形空间单位等于文本空间单位的千分之一。也就是说,Helvetica 的 A 在表里写 667,实际表示「字号为 1 时宽 0.667」。这个约定让度量表可以完全脱离字号,变成纯整数常量。
Type3 是例外:它的字形由 PDF 内容流描述,/FontMatrix 可以任意指定,因此它的宽度不能直接套这张表。PDF.js 对 Type3 走单独路径,metrics.js 也不包含 Type3 的任何条目。
PDF_GLYPH_SPACE_UNITS 与 Font 构造
fonts.js 里把这个基准写成一个常量:
const PDF_GLYPH_SPACE_UNITS = 1000;Font 构造函数拿到 properties 后,第一件事就是把字体的垂直度量统一除回文本空间:
this.capHeight = properties.capHeight / PDF_GLYPH_SPACE_UNITS;
this.ascent = properties.ascent / PDF_GLYPH_SPACE_UNITS;
this.descent = properties.descent / PDF_GLYPH_SPACE_UNITS;
this.lineHeight = this.ascent - this.descent;这段代码解释了两件事:
- 表里的整数不是「像素」,也不是「点」,只是千分之一的 em。除以 1000 之后,
ascent才是相对于 em 的比例,可以乘以当前字号得到用户空间长度。 lineHeight直接由 ascent 减 descent 得到。Helvetica 的 718 与 -207 会变成 0.718 与 -0.207,行高 0.925 em。这是一个粗略但稳定的默认值;嵌入字体有真实hhea表时,行高会改用字体自己的lineGap。
宽度则不需要在构造时除。Font 把 properties.widths 原样保存为 this.widths,等到取字形时才做乘法:
this.widths = properties.widths;
this.defaultWidth = properties.defaultWidth;advance width 不是字形外框
表里的数字是 advance width,不是字形的墨迹宽度。两者经常被混为一谈:
| 概念 | 含义 | 在 PDF.js 里的位置 |
|---|---|---|
| advance width | 画完一个字形后,笔位向前移动的距离 | metrics.js、hmtx、charstring |
| 墨迹宽度 | 字形实际着墨的水平范围 | 字体程序的轮廓或 glyf 表 |
| bbox | 整个字体所有字形的外接矩形,四元组 | /FontDescriptor、FontBBox |
以 Helvetica 的 A 为例,表值是 667。这个 667 包含字形两侧的留白:左侧承距与右侧空白都算在里面,因为下一个字要从 667 的位置开始画。如果把 A 的墨迹宽度当成 667,用它去做紧凑排版,字符之间会挤在一起。
PDF.js 的渲染路径正好体现这个分工。canvas 画字形时使用字体程序的轮廓,轮廓坐标决定了墨迹落在哪里;文本光标的前进则使用 glyph.width:
let glyphWidth = glyph.width;
if (font.vertical) {
glyphWidth = glyph.vmetric ? glyph.vmetric[0] : -glyphWidth;
}
let scaledDim = glyphWidth * scale;渲染与排版各取所需:轮廓负责「长什么样」,advance 负责「下一个字从哪开始」。metrics.js 只提供后者。它的表里没有 bbox,也没有左右承距,因为这些信息无法从「字形名到宽度」这一张表里恢复。
这也解释了缺字时的两种回退:等宽字体如 Courier 的 defaultWidth 是 600,光标会按一个合理宽度前进,网格不会乱;Helvetica 这类比例字体的 defaultWidth 是 0,缺字占零宽,位置可能偏紧但后续文本不会叠在一起。反过来,如果只有轮廓没有 advance,字形会全部堆在同一个原点。
文本矩阵与 fontMatrix 的分工
宽度从表值变成页面坐标,要经过两层变换。第一层是 fontMatrix,它描述「字形空间到文本空间」的比例;第二层是文本矩阵与 CTM,它描述「文本空间到设备空间」的位置与旋转。
fontMatrix 在字体对象上,随字体类型变化:
| 字体类型 | 典型 fontMatrix[0] | 来源 |
|---|---|---|
| 简单字体 | 0.001 | FONT_IDENTITY_MATRIX |
| Type1 | 0.001 | 头部字典 /FontMatrix |
| CFF | 0.001 或其它 | Top DICT 的 FontMatrix |
| TrueType | 0.001 | PDF.js 统一折算到千分之一 em |
| Type3 | 任意 | 字体字典 /FontMatrix |
textState.fontMatrix 在 Tf 设置字体时被复制进来,随后与字号相乘:
const scale = textState.fontMatrix[0] * textState.fontSize;fontSize 是 Tf 的第二个操作数,fontMatrix[0] 是字体自带的基准。两者相乘得到「一个字形空间单位对应多少用户空间单位」。之后的 translateTextMatrix 再乘上文本矩阵的线性部分,最终落到页面坐标。
这条分工带来一个实际结果:内置表的 667 在任何字号、任何旋转角度下都不需要改。旋转由文本矩阵处理,缩放由字号与 fontMatrix 处理,表值只表达字体内部的比例。把表值做成「12pt 下的像素宽度」会让它在换字号时立刻失效。
宽度换算的实际算式
真正把表值变成页面长度的是 evaluator.js 的文本布局循环:
const scale = textState.fontMatrix[0] * textState.fontSize;
for (let i = 0, ii = glyphs.length; i < ii; i++) {
const glyph = glyphs[i];
// ...
let glyphWidth = glyph.width;
if (font.vertical) {
glyphWidth = glyph.vmetric ? glyph.vmetric[0] : -glyphWidth;
}
let scaledDim = glyphWidth * scale;对于普通简单字体,fontMatrix 是单位矩阵的千分之一形式:
const FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0];于是 scale = 0.001 * fontSize,scaledDim = glyph.width * 0.001 * fontSize。用几个具体值走一遍:
| 量 | 表值或输入 | 计算 | 用户空间结果 |
|---|---|---|---|
Helvetica A 宽度 | 667 | 667 × 0.001 × 12 | 8.004 |
Helvetica space 宽度 | 278 | 278 × 0.001 × 12 | 3.336 |
| Courier 默认宽度 | 600 | 600 × 0.001 × 12 | 7.2 |
| Helvetica ascent | 718 | 718 / 1000 | 0.718(相对 em) |
| Helvetica descent | -207 | -207 / 1000 | -0.207(相对 em) |
(字号取 12,单位与页面用户空间一致,通常就是 pt。)
这张表说明了一个常见误解:getMetrics 返回的 667 并不需要调用方自己乘 0.001。调用方拿到的是字形空间里的宽度,它会先进入 glyph.width,再由 fontMatrix[0] 与 fontSize 一起缩放。如果在这里提前乘,遇到非默认的 fontMatrix 或 adjustWidths 就会再缩一次,宽度被统一算小。
adjustWidths:非 1000 基准的归一化
嵌入字体不一定用 1000 单位每 em。TrueType 的 head 表可能是 2048,CFF 的 FontMatrix 也可能不是 0.001。PDF.js 的处理是把宽度统一折回 1/1000 em 基准:
function adjustWidths(properties) {
if (!properties.fontMatrix) {
return;
}
if (properties.fontMatrix[0] === FONT_IDENTITY_MATRIX[0]) {
return;
}
// adjusting width to fontMatrix scale
const scale = 0.001 / properties.fontMatrix[0];
const glyphsWidths = properties.widths;
for (const glyph in glyphsWidths) {
glyphsWidths[glyph] *= scale;
}
properties.defaultWidth *= scale;
}scale = 0.001 / fontMatrix[0]。如果 fontMatrix[0] 是 0.002(也就是 500 单位每 em),scale 是 0.5,所有宽度减半,回到 1000 基准;如果 fontMatrix[0] 是 0.0005,scale 是 2,所有宽度翻倍。这样上层只认一个基准,不必为每种字体格式单独写换算。
adjustWidths 在 Font#constructor 里对 CFF 与 TrueType 分支都会调用,位置在字体程序解析之后:
const cff =
subtype === 'Type1C' || subtype === 'CIDFontType0C'
? new CFFFont(file, properties)
: new Type1Font(name, file, properties);
adjustWidths(properties);它改的是 properties.widths,不是 metrics.js 里的表。这一点很重要:内置表永远保持 1/1000 em 的原始整数,任何缩放都发生在每次字体构造时新建的对象上。
懒加载表工厂:getLookupTableFactory
工厂的实现只有十行
两张表都不是普通对象字面量,而是由 core_utils.js 的 getLookupTableFactory 包装出来的函数:
function getLookupTableFactory(initializer, useArray = false) {
let lookup;
return function () {
if (initializer) {
lookup = useArray ? [] : Object.create(null);
initializer(lookup);
initializer = null;
}
return lookup;
};
}这段代码的行为可以用四步描述:
- 第一次调用返回的匿名函数时,
lookup被创建为空对象(或空数组),initializer被执行,把数据写进去; - 执行完立刻把
initializer置为null,释放对初始化函数的引用; - 从第二次调用开始,
if (initializer)为假,直接返回已经建好的lookup; - 同一个工厂返回的函数永远返回同一个对象实例。
metrics.js 用的是默认的 useArray = false,所以两张表的顶层都是无原型对象。
为什么要用无原型对象
Object.create(null) 创建的对象没有 Object.prototype,也就没有 toString、constructor、hasOwnProperty、__proto__ 这些继承属性。查表时写 lookup[name],如果 name 恰好是这些字符串,普通对象会返回继承来的函数或对象,造成「假命中」。
这不是理论问题。宽度表的键来自字体的 /Differences 与 /Encoding,而 buildCharCodeToWidth 的实现就是:
if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
widths[charCode] = widthsByGlyphName[differences[charCode]];
continue;
}differences[charCode] 是 PDF 文件给的字符串,完全可能是任意内容。用无原型对象,查不到就是 undefined,不会意外拿到原型成员。同样的理由也出现在 primitives.js 的 NameCache、CmdCache 上:只要键来自不可信输入,就不应该让原型链参与查找。
两级懒加载
getMetrics 的结构比普通查找表多一层。顶层只放了 14 个键,其中 10 个的值又是一个工厂函数:
// eslint-disable-next-line no-shadow
t.Helvetica = getLookupTableFactory(function (t) {
t.space = 278;
t.exclam = 278;外层的 initializer 第一次执行时,只会做 14 次赋值。其中 Courier 的四个样式直接赋数字,其余十个样式赋的是内部工厂函数。真正的 315 条字形宽度,要等调用方写 Metrics["Helvetica"]() 时才展开。
这意味着:
| 访问方式 | 展开的内容 | 未展开的内容 |
|---|---|---|
第一次调用 getMetrics() | 顶层 14 个键、4 个 Courier 标量 | 10 个字族各自的字形表 |
调用 Metrics["Helvetica"]() | Helvetica 的 315 条 | 其余 9 个字族的字形表 |
调用 Metrics["Symbol"]() | Symbol 的 190 条 | 其余字族 |
对于一份只用了 Helvetica 的 PDF,Times、Symbol、ZapfDingbats 的表都不会构造。对于一个完全不用标准字体的 PDF,连 getMetrics() 都不需要调用。这种嵌套工厂把「按字体名分片」的懒加载做到了表内部,而不只是模块级。
设计要点:查找表不是越早建越好。把 14 个字族拆成 14 个闭包,第一次访问哪个才展开哪个,既降低了单次字体解析的延迟,也避免了页面上只有一种字体却构造近三千条记录。代价是每个字族的值可能是数字,也可能是函数,调用方必须先判断类型;
getBaseFontMetrics里那句typeof glyphWidths === "number"就是为这个设计服务的。
缓存键在哪里
getMetrics 与 getFontBasicMetrics 都没有「按字体名缓存」的逻辑。缓存的粒度是整张表:工厂闭包里的 lookup 就是缓存,键是「有没有初始化过」,而不是字体名。
按字体名查找发生在调用方:
evaluator.getBaseFontMetrics先做fontName = stdFontMap[fontName] || fontName,再写Metrics[fontName];fonts.js fallbackToSystemFont先做stdFontMap[fontName] || nonStdFontMap[fontName] || fontName,再写fontBasicMetricsMap[fontName]。
所以真正决定「查到哪一行」的是归一化后的字体名,而把厂商名映射到标准名的职责在 standard_fonts.js 与 fonts_utils.normalizeFontName,不在本篇。metrics.js 只认 Helvetica、Times-Bold 这样规范的名字。
getMetrics:字形名到宽度的两级表
顶层是字族,底层是字形
把 getMetrics 的顶层键列出来,可以看到它的组织方式:
| 顶层键 | 值类型 | 说明 |
|---|---|---|
Courier | 数字 600 | 等宽字体,单一默认宽度 |
Courier-Bold | 数字 600 | 同上 |
Helvetica | 工厂函数 | 315 条字形宽度 |
Helvetica-Bold | 工厂函数 | 315 条,键集与 Helvetica 相同 |
Times-Roman | 工厂函数 | 315 条 |
Symbol | 工厂函数 | 190 条 |
ZapfDingbats | 工厂函数 | 202 条 |
(节选 7 行,完整表共 14 个顶层键。)完整的 14 个键正好是 PDF 规范的核心 14 字体:Courier 四款、Helvetica 四款、Times 四款、Symbol 一款、ZapfDingbats 一款。
Helvetica:315 条宽度
Helvetica 的表从 space 开始,按 Adobe 字形名的习惯排列:
t.Helvetica = getLookupTableFactory(function (t) {
t.space = 278;
t.exclam = 278;
t.quotedbl = 355;
t.numbersign = 556;
t.quoteright = 222;
t.ampersand = 667;
t.plus = 584;
t.zero = 556;(节选 8 行,实际共 315 条,到 Euro 结束。中间省略的是其余标点、字母与符号。)
这些键不是 Unicode 码位,也不是字符编码,而是 PostScript / Adobe Glyph List 的字形名。space、exclam、quotedbl 是 ASCII 可打印字符的规范名;quoteright 是右单引号;Euro 是欧元符号。名字大小写也有含义:A 是大写字母,a 是小写字母,二者是不同的键。
Helvetica 的表在 Euro 这一行结束:
t.onesuperior = 333;
t.imacron = 278;
t.Euro = 556;
});(节选末尾三行。)整张表从 space 到 Euro,共 315 条。
同一套键,不同的值
四款 Helvetica 共用同一组字形名,但宽度值并不相同。先看 Helvetica 的开头:
t.Helvetica = getLookupTableFactory(function (t) {
t.space = 278;
t.exclam = 278;
t.quotedbl = 355;再看 Helvetica-Bold 的开头:
t["Helvetica-Bold"] = getLookupTableFactory(function (t) {
t.space = 278;
t.exclam = 333;
t.quotedbl = 474;space 都是 278,exclam 从 278 变成 333。逐条比较 Helvetica 与 Helvetica-Bold 的 315 条记录,有 115 条的值不同,其余 200 条相同。这说明两套表是独立测量的结果,而不是一套表加一个统一缩放系数。粗体的横竖笔画更粗,标点与窄字符的 advance width 会变大,但不少字母的宽度保持不变。
斜体的情况又不同:Helvetica-Oblique 的宽度与 Helvetica 完全一致,逐条比较 315 个键,差值为 0 条。倾斜不改变 advance width,所以宽度表可以原样复用,倾斜只影响字形轮廓的绘制变换。
315 条只用了 39 个不同数值
把 Helvetica 的 315 条宽度按值统计,会出现一个很有意思的分布:
| 宽度值 | 出现次数 | 典型字形 |
|---|---|---|
| 556 | 84 | 多数小写字母与数字,例如 a、e、zero |
| 667 | 35 | 多数大写字母,例如 A、B、E |
| 333 | 29 | 窄标点与部分小写字母,例如 r、hyphen |
| 278 | 28 | space、comma、period、I 一类 |
| 722 | 28 | C、D、N、w 等较宽字形 |
| 500 | 23 | J、c、k、s 与连字 fi、fl |
| 778 | 13 | G、O、Q 等圆润大写字母 |
| 611 | 11 | F、T、Z 等中等宽度字形 |
(节选前 8 个高频值,统计自当前源码的 Helvetica 表。)
315 条记录只有 39 个不同的宽度值,最常见的是 556,覆盖 84 个字形。这个分布不是巧合:拉丁字母的 advance width 在设计时就被归入少数几个宽度档,保证正文排版有稳定的节奏。真正独特的宽度集中在符号、标点与少数宽字母上。
这也解释了为什么表可以写得这么长却仍然单调:大部分行只是把同一个整数重复赋给不同的字形名。压缩算法与打包工具能很好地处理这种重复,源码选择逐行展开,是为了可读性与可 diff 性,而不是为了运行时结构。
同一套表,不同的差异数量
把四款 Helvetica 与四款 Times 逐条比较,差异数量并不相同:
| 对比 | 不同条目数 | 说明 |
|---|---|---|
| Helvetica 与 Helvetica-Oblique | 0 | 倾斜不改 advance |
| Helvetica 与 Helvetica-Bold | 115 | 粗体加宽了部分字形 |
| Helvetica 与 Helvetica-BoldOblique | 115 | 粗斜体等于粗体的宽度 |
| Times-Roman 与 Times-Bold | 144 | 衬线粗体的宽度变化更大 |
| Times-Roman 与 Times-Italic | 114 | 斜体的宽度也独立测量 |
(对 315 个键逐条比较的结果。)Times-Italic 与 Helvetica-Oblique 的差别说明,衬线字体的斜体不是简单倾斜,它的字形设计本身不同,advance 也随之改变;而 Helvetica 这类无衬线字体的斜体更接近几何倾斜。宽度表如实记录了这个差异,没有用统一规则去推导。
如何自行核对表值
这张表是纯数据,核对成本很低。在 worker 上下文里可以直接调用导出的函数:
import { getMetrics, getFontBasicMetrics } from './metrics.js';
const metrics = getMetrics();
const helvetica = metrics.Helvetica();
helvetica.A; // 667
helvetica.space; // 278
metrics.Courier; // 600
const basic = getFontBasicMetrics();
basic.Helvetica.ascent; // 718
basic['Times-Roman'].capHeight; // 662第一次调用 getMetrics() 只构造顶层,metrics.Helvetica() 才展开 315 条;第二次调用 getMetrics() 返回同一个对象。getFontBasicMetrics() 没有嵌套工厂,第一次调用就构造全部 14 条。
核对时要注意两点:宽度表的键是字形名,不是字符;基本度量表的键是完整字体名,Times-Roman 这种带连字符的名字必须用方括号访问。把 metrics.Times-Roman 写成点号会在语法层面失败,这类错误在阅读源码时也常见。
Courier:一个数字就够了
Courier 是等宽字体,每个字形的 advance width 都是 600。四款 Courier 因此每款只写一个数字:
t.Courier = 600;
t['Courier-Bold'] = 600;
t['Courier-BoldOblique'] = 600;
t['Courier-Oblique'] = 600;这个形状直接影响了调用方。getBaseFontMetrics 里:
if (typeof glyphWidths === 'number') {
defaultWidth = glyphWidths;
monospace = true;
} else {
widths = glyphWidths(); // expand lazy widths array
}数字被当作 defaultWidth,并置 monospace = true;没有字形表,widths 保持空对象。后续取任意字符宽度时,widths[widthCode] 是 undefined,于是回落到 defaultWidth 的 600。等宽字体不需要 315 条重复的 600,这个压缩把一张表变成了一个常量。
Symbol:190 条希腊与数学字形
Symbol 不是拉丁文本字体,它的键大量是希腊字母与数学符号:
t.Symbol = getLookupTableFactory(function (t) {
t.space = 250;
t.exclam = 333;
t.universal = 713;
t.existential = 549;
t.suchthat = 439;
t.asteriskmath = 500;
t.period = 250;
t.zero = 500;(节选 8 行,实际共 190 条。)注意 universal(任意)、existential(存在)、suchthat(使得)、asteriskmath 这些名字,它们对应 Symbol 编码里的数学符号,而不是 ASCII 字符。文件末尾还出现:
t.bracerighttp = 494;
t.bracerightmid = 494;
t.bracerightbt = 494;
t.apple = 790;(节选。)apple 是 Symbol 字体里的苹果标志字形,宽度 790,这是传统 AFM 数据的一部分。Symbol 只有 190 条,因为它只覆盖 Symbol 编码所需的字形,不覆盖完整拉丁字母表。
ZapfDingbats:201 个 aN 名字
ZapfDingbats 的键形式最特殊,绝大多数是 a 加数字:
t.ZapfDingbats = getLookupTableFactory(function (t) {
t.space = 278;
t.a1 = 974;
t.a2 = 961;
t.a202 = 974;
t.a3 = 980;
t.a119 = 790;
t.a117 = 690;
t.a11 = 960;(节选 8 行,实际共 202 条。)a1 起的 aN 是 ZapfDingbats 的规范字形名,编号覆盖 a1 到 a206,中间有缺号,顺序也不是按数字递增。整张表 202 条:201 个 aN 加一个 space。文件里最后一行赋值是 a191(只是源码顺序,不是编号最大):
t.a189 = 927;
t.a190 = 970;
t.a191 = 918;
});
});(节选。)这段连续的 aN 名字也解释了一个常见疑问:为什么 ZapfDingbats 的宽度查表不能按字符编码做。它的字符编码是 0x20 到 0xFF 的 Dingbats 编码,而表键用的是字形名;中间的转换由 encoding 与 glyphlist.js 完成。
表的边界:查不到就回退
三张字形表都没有「默认宽度」字段。查不到某个字形名时,widths[glyphName] 就是 undefined。buildCharCodeToWidth 不会把 undefined 写进结果:
for (let charCode = 0; charCode < 256; charCode++) {
if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
widths[charCode] = widthsByGlyphName[differences[charCode]];
continue;
}
if (charCode in encoding && widthsByGlyphName[encoding[charCode]]) {
widths[charCode] = widthsByGlyphName[encoding[charCode]];
continue;
}
}只有当对应字形名在表里、且宽度为真值时,才写入 widths[charCode]。剩下的码位没有条目,取字形时 _charToGlyph 会执行:
if (typeof width !== 'number') {
width = this.defaultWidth;
}也就是说,getMetrics 只负责「知道的字形」,不知道的部分由 defaultWidth 兜底。对 Courier 来说 defaultWidth 是 600;对 Helvetica、Times 这类比例字体,defaultWidth 通常是 0,意味着缺字宽度按 0 处理。这个选择偏向「不猜」:宁可让文本层的宽度偏窄,也不给一个没有依据的宽度。
覆盖范围:核心 14 字体与它们的 315 个字形名
一张覆盖表
把两张表的规模放在一起看:
| 字体族 | 样式数 | getMetrics 形状 | 条目数(节选统计) | 键形态 |
|---|---|---|---|---|
| Courier | 4 | 单个数字 | 4 个标量 | 无字形键 |
| Helvetica | 4 | 字形表 | 315 × 4 | 拉丁字形名 |
| Times | 4 | 字形表 | 315 × 4 | 与 Helvetica 同一键集 |
| Symbol | 1 | 字形表 | 190 | 希腊字母与数学符号名 |
| ZapfDingbats | 1 | 字形表 | 202 | 201 个 aN 加 space |
(条目数为对当前源码逐行统计的结果。)
总计 14 个样式。八款文本字体(Helvetica 四款、Times 四款)共享同一套 315 个键;Symbol 与 ZapfDingbats 各自独立。整个 getMetrics 展开后约 2916 条宽度记录,加上包装行,正好解释了文件为何有三千行。
315 条意味着什么
315 不是一个规范数字,而是这份 AFM 数据实际包含的字形数量。它的覆盖范围大致是:
- ASCII 可打印字符;
- 西欧语言的带重音字母,例如
Eacute、Odieresis、Scaron、zcaron; - 常用标点、货币与数学符号,例如
degree、plusminus、logicalnot、Euro; - 少量连字与上标,例如
onesuperior、onequarter。
它不覆盖:
- CJK 字符。中文、日文、韩文的字形宽度不在任何一张表里;
- 完整 Unicode。315 个名字远少于现代字体的字形数;
- 字体私有字形。只有真正进入核心 14 字体 AFM 的字形才有条目。
这正好符合这张表的定位:它是 PDF 规范里「标准 14 字体」的后备度量,而不是通用字体度量数据库。
覆盖的字符范围
把实际覆盖情况按字符范围列出来,可以更直观地看到边界:
| 字符范围 | 是否覆盖 | 说明 |
|---|---|---|
| ASCII 可打印字符 | 覆盖 | 名字如 space、A、asciitilde |
| 西欧带重音字母 | 覆盖 | 名字如 Eacute、Odieresis、Scaron |
| 常用标点与货币 | 覆盖 | 名字如 endash、bullet、Euro |
| 希腊字母(文本字体) | 不覆盖 | Helvetica 与 Times 没有 Alpha、Beta 条目 |
| 希腊字母(Symbol) | 覆盖 | 名字如 alpha、beta、universal |
| CJK 字符 | 不覆盖 | 任何一张表都没有中日韩字形名 |
| Emoji 与彩色字形 | 不覆盖 | 核心 14 字体时代不存在这类字形 |
(表为对键名的归纳,不是逐个字符的穷举。)
这张表能解释一个常见现象:一份用 Helvetica 排版的中文 PDF,中文部分的宽度往往不对。原因不是 metrics.js 算错了,而是这些字形根本不在它的表里,widths 查不到,只能回落到 defaultWidth。真正的中文 PDF 通常使用复合字体并嵌入 CJK 字体,宽度走 /W 或字体程序,不会落到这套表上。
为什么不做成通用度量库
如果把这套表扩展到 CJK,规模会立刻失控。一个中文字体的字形数在一万到六万之间,每个字形名都要一条记录;14 个字体全展开会达到数十万条,源码体积与解析内存都不可接受。而且 CJK 字体几乎总是嵌入或子集化,PDF 自己会带 /W 或字形程序,内置表派不上用场。
这条取舍也体现在 PDF.js 的其它设计里:标准字体数据(pfb、ttf)通过 standardFontDataUrl 按需加载,CMap 通过 cMapUrl 按需加载,都不打进主 bundle。metrics.js 之所以敢把数据直接写进源码,正是因为它只覆盖 14 个拉丁字体、不到三千条记录。
315 条之外的字形名
如果某个字形名不在表里,buildCharCodeToWidth 会跳过它,宽度回落到 defaultWidth。但字形名本身仍然可能有 Unicode 映射,glyphlist.js 独立维护「字形名到码位」的对应;两者互不依赖:
| 情况 | 宽度表命中 | Unicode 映射命中 | 结果 |
|---|---|---|---|
| 标准拉丁字形名 | 是 | 是 | 有宽度,可选中、可复制 |
| 表外但 glyphlist 有记录 | 否 | 是 | 宽度用 defaultWidth,文本仍可提取 |
| 私有字形名 | 否 | 否 | 宽度用 defaultWidth,文本可能丢失 |
| 复合字体的 CID | 不走此表 | 由 ToUnicode 提供 | 宽度来自 /W |
这张对照说明,宽度表与文本层是两条独立的链路。一张 PDF 的字形宽度可能完全正确,文本却选不中,因为缺的是 ToUnicode;反过来,文本能选中但排版偏窄,可能只是宽度表没命中。排查问题时把这两条链路分开,能少走很多弯路。
为什么同一套键可以服务四种字形
Helvetica、Helvetica-Bold、Helvetica-Oblique、Helvetica-BoldOblique 的键集完全相同,值是独立测量的。这样做有三个好处:
- 调用方只需要一次字形名解析,换样式时不用重新映射字符;
buildCharCodeToWidth对四种样式走完全相同的编码路径,只换表对象;- 宽度差异集中为数据,不进入代码分支。
Times 四款同理。Times-Roman、Times-Bold、Times-Italic、Times-BoldItalic 也都是 315 条,键集与 Helvetica 一致。于是「文本字体」这一组在结构上可以统一处理,只有数值不同。
与其它标准字体数据的分工
standard_fonts.js 也有一批名字与映射表,容易和本篇混淆。按职责分开看:
| 文件 | 提供的内容 | 是否含宽度 |
|---|---|---|
metrics.js | 字形名到宽度、字体名到垂直度量 | 是 |
standard_fonts.js | stdFontMap、nonStdFontMap、serif 表、symbol 表 | 否 |
glyphlist.js | 字形名到 Unicode 码位 | 否 |
encodings.js | 编码名到「码位到字形名」的映射 | 否 |
fonts_utils.js | 字体名归一化、标志位、字形名恢复 | 否 |
宽度查询需要三张表合作:编码表把字节变成字形名,metrics.js 把字形名变成宽度,glyphlist.js 再把字形名变成 Unicode 供文本层使用。三者各管一段,没有一张表能单独完成字符宽度的计算。
上游查询:getBaseFontMetrics 如何选表
正常路径上只在 /Widths 缺失时才走到这里
getMetrics 的调用点都在 evaluator.js 里,且只有一个中间层:getBaseFontMetrics。它自身有两个入口,一个在 extractWidths,一个在 translateFont 的无描述符兼容分支。正常路径是第一个,简单字体的宽度有两条分支:
} else {
const widths = dict.get("Widths");
if (Array.isArray(widths)) {
let j = properties.firstChar;
for (const w of widths) {
const width = xref.fetchIfRef(w);
if (typeof width === "number") {
glyphsWidths[j] = width;
}
j++;
}
const missingWidth = descriptor.get("MissingWidth");
defaultWidth = typeof missingWidth === "number" ? missingWidth : 0;
} else {
// Trying get the BaseFont metrics (see comment above).
const baseFontName = dict.get("BaseFont");
if (baseFontName instanceof Name) {
const metrics = this.getBaseFontMetrics(baseFontName.name);
glyphsWidths = this.buildCharCodeToWidth(metrics.widths, properties);
defaultWidth = metrics.defaultWidth;
}
}
}这段代码的优先级非常明确:
| 优先级 | 数据来源 | 使用条件 | 结果 |
|---|---|---|---|
| 1 | /Widths 数组 | 存在且是数组 | 按 FirstChar 填宽度,defaultWidth 取 MissingWidth 或 0 |
| 2 | getMetrics 的内置表 | /Widths 不是数组 | 字形名查宽度,defaultWidth 取表值 |
内置表是兜底,不是首选。只要 PDF 自己写了 /Widths,哪怕只覆盖一部分字符,也先用文件里的值;getBaseFontMetrics 只在数组完全缺失或类型不对时补位。这与「标准字体必须自带宽度」的规范假设一致,也是 PDF.js 对不规范文件的一种兼容:很多生成器会省略核心 14 字体的 /Widths。
无描述符的兼容分支是例外:translateFont 在那里无条件调用一次 getBaseFontMetrics,因为此时连 FontDescriptor 都没有,需要先用它合成 Flags。但宽度本身仍优先用字典里的 /Widths:分支内先判断 Array.isArray(widths),只有数组不存在时才用 buildCharCodeToWidth(metrics.widths, ...) 把表值搬成字符码表。也就是说,兜底调用发生了,兜底数据不一定被采用。
getBaseFontMetrics 的完整实现
这个方法是整个宽度查询链路的枢纽:
getBaseFontMetrics(name) {
let defaultWidth = 0;
let widths = Object.create(null);
let monospace = false;
let fontName = normalizeFontName(name);
const stdFontMap = getStdFontMap();
fontName = stdFontMap[fontName] || fontName;
const Metrics = getMetrics();
const glyphWidths =
Metrics[fontName] ??
// Use default fonts for looking up font metrics if the passed
// font is not a base font
Metrics[this.isSerifFont(name) ? "Times-Roman" : "Helvetica"];
if (typeof glyphWidths === "number") {
defaultWidth = glyphWidths;
monospace = true;
} else {
widths = glyphWidths(); // expand lazy widths array
}
return {
defaultWidth,
monospace,
widths,
};
}它做了五件事,顺序不能乱:
- 用
normalizeFontName把名字里的逗号、下划线换成连字符,去掉空白; - 用
stdFontMap把厂商名映射成标准名,例如ArialMT变成Helvetica; - 调用
getMetrics()取得顶层表; - 用
??选表;名字查不到时按 serif 与否退回Times-Roman或Helvetica; - 判断选中的是数字还是函数:数字走等宽分支,函数则立即调用展开字形表。
注意第 5 步和前面说的两级懒加载:一旦某个字族被真正用上,它的 315 条会在这一次调用中全部构造。getMetrics 省的是「用不到的字体」,不是「用到的字体里的用不到的字形」。
返回值只有三个字段,没有缓存、没有副本保护:
return {
defaultWidth,
monospace,
widths,
};widths 是共享的表对象本身,不是拷贝。这个设计依赖调用方立即把它转换成字符码表,而不直接修改它。事实也确实如此:buildCharCodeToWidth 新建了一个 widths 对象,把字形名对应的值搬到字符码上。
名字归一化与标准字体映射
normalizeFontName 极短,却决定了大量字体能否命中表:
function normalizeFontName(name) {
return name.replaceAll(/[,_]/g, '-').replaceAll(/\s/g, '');
}它把 Arial,Bold、Arial_Bold 统一成 Arial-Bold,再把空格全部删掉。PDF 的 /BaseFont 允许带子集前缀,例如 ABCDEF+Helvetica;normalizeFontName 不会去掉这个前缀,Font 构造函数只在判断 serif 时按 + 拆分。因此查宽度表用的仍是完整名字,如果前缀没有被映射表覆盖,就会落到「未知字体」的回退分支。metrics.js 自身不做任何前缀处理,它只接受已经规范化的名字。
映射交给 standard_fonts.js 的 getStdFontMap。它的开头就把 14 个标准名映射到自己:
// The standard 14 fonts:
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.Arial = 'Helvetica';
t['Arial-Bold'] = 'Helvetica-Bold';
t['Arial-BoldItalic'] = 'Helvetica-BoldOblique';
t['Arial-Italic'] = 'Helvetica-Oblique';
t.ArialMT = 'Helvetica';(节选。)这一步把「看起来像标准字体、其实叫另一个名字」的情况收进同一张表。没有它,Arial 会被当成未知字体,白白退回 Helvetica 的默认分支;有了它,Arial 的宽度直接使用 Helvetica 的数据,结果是一样的,但语义更明确,也不需要依赖 serif 判断。
未知字体:serif 判断决定回退目标
?? 右边那一行是本篇最容易被忽略的逻辑:
Metrics[this.isSerifFont(name) ? 'Times-Roman' : 'Helvetica'];如果字体名不在表里,PDF.js 不返回空宽度,而是猜一个最接近的标准字体。猜的依据是 isSerifFont:
isSerifFont(baseFontName) {
// Simulating descriptor flags attribute
const fontNameWoStyle = baseFontName.split("-", 1)[0];
return fontNameWoStyle in getSerifFonts() || /serif/i.test(fontNameWoStyle);
}它先取字体名的第一段,去掉 -Bold、-Italic 之类的后缀,然后查 getSerifFonts 表,或者用正则看名字里有没有 serif。getSerifFonts 是一张很长的衬线字体名表,从 Adobe Jenson、Baskerville、Bembo 到 Times 一类的传统字体都在里面。
这个回退策略的效果:
| 字体名 | 归一化后 | stdFontMap 命中 | 选表结果 |
|---|---|---|---|
ArialMT | ArialMT | 是,映射 Helvetica | Helvetica |
TimesNewRomanPSMT | 归一化后带连字符 | 视映射表而定 | 命中则用 Times |
SomeSerifFont | 同名 | 否 | Times-Roman |
SomeSansFont | 同名 | 否 | Helvetica |
Helvetica-Bold | 同名 | 是,映射自身 | Helvetica-Bold |
最后一行值得说明:stdFontMap 对标准名也做了自映射,目的是让映射结果可预测。无论输入是标准名还是厂商别名,fontName 都指向一个顶层键,不需要再写「查不到就用原名」的分支。它不影响 isSerifFont,后者的入参是原始的 name,并且只取 - 前的一段来判断衬线与否。
字形名到字符码的翻译
getBaseFontMetrics 返回的 widths 是字形名索引的表,而取字形时需要的是字符码索引的宽度。转换由 buildCharCodeToWidth 完成:
buildCharCodeToWidth(widthsByGlyphName, properties) {
const widths = Object.create(null);
const differences = properties.differences;
const encoding = properties.defaultEncoding;
for (let charCode = 0; charCode < 256; charCode++) {
if (charCode in differences && widthsByGlyphName[differences[charCode]]) {
widths[charCode] = widthsByGlyphName[differences[charCode]];
continue;
}
if (charCode in encoding && widthsByGlyphName[encoding[charCode]]) {
widths[charCode] = widthsByGlyphName[encoding[charCode]];
continue;
}
}
return widths;
}它按 0 到 255 遍历每个字节码,先看 /Differences 是否给这个码位指定了字形名,再看默认编码。两处都要求 widthsByGlyphName 里存在对应条目,否则跳过。
这里有两个边界:
- 只处理 0 到 255。这是简单字体的单字节上限;复合字体(Type0)不走这条路,它的宽度来自
/W或/DW,不是这张表。 /Differences优先。同一字符码在两套映射里可能有不同字形名,文件显式声明的差异优先,这个顺序符合 PDF 的编码语义。
转换之后,widths 的键变成数字字符串,_charToGlyph 里直接写 this.widths[widthCode] 就能取到。这也是为什么 getMetrics 的表可以用字形名做键:它离字符码还有一层编码,编码层决定了「第 65 个字节是 A 还是 Alpha」。
复合字体与 Type3:两条不经过这张表的路
到目前为止讨论的都是简单字体:一个字节一个字符码,字符码范围 0 到 255。PDF 里还有两类字体不走这条路,理解它们的差异能说明这张表的边界。
第一类是复合字体(Type0)。它的宽度不按字形名查,而是按 CID 查 /W 与 /DW:
if (properties.composite) {
const dw = dict.get("DW");
defaultWidth = typeof dw === "number" ? Math.ceil(dw) : 1000;
const widths = dict.get("W");
if (Array.isArray(widths)) {
for (let i = 0, ii = widths.length; i < ii; i++) {
let start = xref.fetchIfRef(widths[i++]);
if (!Number.isInteger(start)) {
break; // Invalid /W data.
}
const code = xref.fetchIfRef(widths[i]);
if (Array.isArray(code)) {
for (const c of code) {
const width = xref.fetchIfRef(c);
if (typeof width === "number") {
glyphsWidths[start] = width;
}
start++;
}
} else if (Number.isInteger(code)) {
const width = xref.fetchIfRef(widths[++i]);
if (typeof width !== "number") {
continue;
}
for (let j = start; j <= code; j++) {
glyphsWidths[j] = width;
}
} else {
break; // Invalid /W data.
}
}
}/W 有两种写法:数组形式逐个给出宽度,或「起始 CID、结束 CID、统一宽度」的区间形式。/DW 缺省是 1000,正好是一个 em。这里的键是 CID,不是字形名,也不是字节码;字节到 CID 的映射在 CMap 里,metrics.js 完全无法参与。
第二类是 Type3。它的字形由 PDF 自己的内容流描述,/FontMatrix 可以是任意值,不满足「1/1000 em」的前提。translateFont 读取矩阵的方式如下:
const fontMatrix = lookupMatrix(dict.getArray('FontMatrix'), FONT_IDENTITY_MATRIX);
const bbox = lookupNormalRect(
descriptor.getArray('FontBBox') || dict.getArray('FontBBox'),
isType3Font ? [0, 0, 0, 0] : undefined,
);矩阵缺失时退回 FONT_IDENTITY_MATRIX,也就是 [0.001, 0, 0, 0.001, 0, 0];但对 Type3,规范本来就要求提供 /FontMatrix,而且允许它不是千分之一。规范还要求 Type3 自带 /Widths,宽度直接在字形空间里给出,再由这个矩阵缩放。extractWidths 对 Type3 走的仍是简单字体分支,读 /Widths,不会去查 getMetrics 的表。
metrics.js 的 14 个标准字体里没有 Type3,也不该有:Type3 的字形是文档私有图形,不存在可共享的标准度量。
这两类字体的存在解释了 getBaseFontMetrics 为什么只在 properties.composite === false 的简单字体分支里被调用。宽度表的键是字形名,而复合字体的宽度键是 CID;两套索引体系不同,硬套只会得到错误的宽度。
一个容易忽略的细节:默认宽度不是一个字节的宽度
buildCharCodeToWidth 只写入「两张映射都能对上」的码位。没有写进去的码位,取字形时会回落到 defaultWidth。对 Helvetica,defaultWidth 是 0;对 Courier,是 600。这个差异会在缺字时暴露出来:
| 场景 | Helvetica 的 defaultWidth | Courier 的 defaultWidth |
|---|---|---|
| 字符码在表里有对应字形 | 使用表值 | 使用 600 |
| 字符码在表里查不到 | 0 | 600 |
字体字典给了 MissingWidth | 用 MissingWidth | 用 MissingWidth |
defaultWidth 不是「一个字符的平均宽度」,而是「不知道时的宽度」。比例字体取 0 会让缺字不占位,等宽字体取 600 会让缺字保持网格对齐。两种选择都服务于同一目标:不要让一个未知字形把整行文本推歪。理解这一点,就不会把 0 当成 bug。
等宽判断与 FixedPitch 标志
回到 getBaseFontMetrics 的 monospace 字段。它只在内置表返回数字时置真,也就是 Courier 四款。但等宽判断并不完全依赖它:extractWidths 还会扫描实际宽度:
// Heuristic: detection of monospace font by checking all non-zero widths
let isMonospace = true;
let firstWidth = defaultWidth;
for (const glyph in glyphsWidths) {
const glyphWidth = glyphsWidths[glyph];
if (!glyphWidth) {
continue;
}
if (!firstWidth) {
firstWidth = glyphWidth;
continue;
}
if (firstWidth !== glyphWidth) {
isMonospace = false;
break;
}
}
if (isMonospace) {
properties.flags |= FontFlags.FixedPitch;
} else {
// Clear the flag.
properties.flags &= ~FontFlags.FixedPitch;
}这段启发式对所有非零宽度做「是否全部相等」的判断,命中就设置 FixedPitch,否则清除。它比 monospace 字段更通用:一个嵌入的等宽字体即使不叫 Courier,也能被识别;反过来,如果 Courier 的 /Widths 里有异常值,标志位也会被清掉。
FixedPitch 影响的不只是排版提示。Font 构造函数用它选择 CSS 回退名:
} else if (this.isMonospace) {
this.fallbackName = "monospace";
} else if (this.isSerifFont) {
this.fallbackName = "serif";
} else {
this.fallbackName = "sans-serif";
}最终 fallbackName 会进入 textContent.styles 的 fontFamily,决定缺字体时浏览器用哪一类系统字体显示。
没有 FontDescriptor 时的 properties
核心 14 字体在 PDF 1.5 之前不要求带 /FontDescriptor。这条兼容路径会手工拼一份 properties:
// Before PDF 1.5 if the font was one of the base 14 fonts, having a
// FontDescriptor was not required.
// This case is here for compatibility.
let baseFontName = dict.get('BaseFont');
if (!(baseFontName instanceof Name)) {
throw new FormatError('Base font is not specified');
}
// Using base font name as a font name.
baseFontName = normalizeFontName(baseFontName.name);
const metrics = this.getBaseFontMetrics(baseFontName);它把 metrics.widths 与 metrics.defaultWidth 直接放进 properties,并根据 isSerifFont、metrics.monospace、getSymbolsFonts 合成 Flags:
const flags =
(this.isSerifFont(fontNameWoStyle) ? FontFlags.Serif : 0) |
(metrics.monospace ? FontFlags.FixedPitch : 0) |
(getSymbolsFonts()[fontNameWoStyle] ? FontFlags.Symbolic : FontFlags.Nonsymbolic);紧接着:
const properties = {
type,
name: baseFontName,
loadedName: baseDict.loadedName,
systemFontInfo: null,
widths: metrics.widths,
defaultWidth: metrics.defaultWidth,
isSimulatedFlags: true,
flags,
firstChar,
lastChar,
toUnicode,
xHeight: 0,
capHeight: 0,
italicAngle: 0,
isType3Font,
};注意 xHeight 与 capHeight 在这里被直接写成 0,ascent 与 descent 则没有出现。结果就是 Font 构造函数执行 properties.ascent / 1000 时得到 NaN,properties.descent / 1000 也是 NaN。补这两个值,正是 getFontBasicMetrics 的工作。
getFontBasicMetrics:ascent、descent 与 capHeight
四个字段,十四个字体
第二张表的形状与宽度表完全不同。它的顶层不再是「字体族到字形表」,而是「字体名到一个小对象」:
const getFontBasicMetrics = getLookupTableFactory(function (t) {
t.Courier = {
ascent: 629,
descent: -157,
capHeight: 562,
xHeight: -426,
};
t["Courier-Bold"] = {
ascent: 629,
descent: -157,
capHeight: 562,
xHeight: 439,
};Helvetica 与 Times 也一样:
t.Helvetica = {
ascent: 718,
descent: -207,
capHeight: 718,
xHeight: 523,
};
// ...
t['Times-Roman'] = {
ascent: 683,
descent: -217,
capHeight: 662,
xHeight: 450,
};(节选。)把 14 条记录的代表值列出来:
| 字体 | ascent | descent | capHeight | xHeight |
|---|---|---|---|---|
Courier | 629 | -157 | 562 | -426 |
Courier-Bold | 629 | -157 | 562 | 439 |
Helvetica | 718 | -207 | 718 | 523 |
Helvetica-Bold | 718 | -207 | 718 | 532 |
Times-Roman | 683 | -217 | 662 | 450 |
Times-Bold | 683 | -217 | 676 | 461 |
Times-Italic | 683 | -217 | 653 | 441 |
Symbol | NaN | NaN | NaN | NaN |
ZapfDingbats | NaN | NaN | NaN | NaN |
(节选,共 14 条。)四个字段的含义:
ascent:从基线到字体最高处的距离,正数;descent:从基线到字体最低处的距离,负数;capHeight:从基线到大写字母顶端的距离,通常小于或等于 ascent;xHeight:从基线到小写字母 x 顶端的距离,本表里未被使用。
数值同样是 1/1000 em。Font 构造函数统一除以 1000,得到 0.629、0.718、0.683 这样的比例。
数值之间的关系
这张表里能看到几种典型关系:
| 关系 | 例子 | 说明 |
|---|---|---|
| ascent 等于 capHeight | Helvetica 718 / 718 | 大写字母顶端就是字体的最高点 |
| ascent 大于 capHeight | Times-Roman 683 / 662 | 某些小写字母或符号比大写更高 |
| descent 在同族内固定 | Helvetica 四款 -207,Times 四款 -217 | 下伸部深度通常不随字重改变 |
| xHeight 随字重变大 | Helvetica 523,Helvetica-Bold 532 | 粗体小写字母更高 |
| 同族 capHeight 随字重变 | Times-Roman 662,Times-Bold 676 | 粗体大写字母略高 |
Courier 的 xHeight 是 -426,这是明显的异常值:xHeight 表示基线以上的高度,按语义不应为负。它没有被任何代码读取,所以这个错误从未影响渲染;但这提醒读者,表里的数据是历史 AFM 的转录结果,不能假定每个字段都语义正确。
Symbol 与 ZapfDingbats 为什么是 NaN
这两个字体也出现在表里,但四个字段全是 Math.NaN:
t.Symbol = {
ascent: Math.NaN,
descent: Math.NaN,
capHeight: Math.NaN,
xHeight: Math.NaN,
};
t.ZapfDingbats = {
ascent: Math.NaN,
descent: Math.NaN,
capHeight: Math.NaN,
xHeight: Math.NaN,
};Math.NaN 在这里是「未知」的显式标记,而不是占位符。它让消费端的 isNaN 判断为真,从而执行赋值;但赋的仍然是 NaN / 1000,结果还是 NaN。所以这两族不会因为出现在表里就得到可用的垂直度量。
这个设计是有意为之的:Symbol 与 ZapfDingbats 是符号字体,它们的「大写高度」「小写高度」没有排版意义。与其编一个数字,不如保持未知。下游的文本层对 NaN 有单独处理:没有可用 ascent 时退回其它比例,不会把 NaN 直接带进样式计算。
唯一的消费点:fallbackToSystemFont
这张表的调用点只有一处,fonts.js 的 Font#fallbackToSystemFont:
fontName = stdFontMap[fontName] || nonStdFontMap[fontName] || fontName;
const fontBasicMetricsMap = getFontBasicMetrics();
const metrics = fontBasicMetricsMap[fontName];
if (metrics) {
if (isNaN(this.ascent)) {
this.ascent = metrics.ascent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.descent)) {
this.descent = metrics.descent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.capHeight)) {
this.capHeight = metrics.capHeight / PDF_GLYPH_SPACE_UNITS;
}
}这段代码有三个要点:
- 先映射字体名。
stdFontMap与nonStdFontMap都会参与,比getBaseFontMetrics多了一层nonStdFontMap。后者把一批非标准但常见的字体名映射到标准名,例如把Calibri、CenturyGothic映射到Helvetica。 - 只补 NaN。
isNaN(this.ascent)为假时,已有的值原样保留。这保证了嵌入字体程序或FontDescriptor给出的真实度量优先。 - 只补三个字段。
xHeight没有对应赋值,即使表里有值也不会写进Font。这印证了前面版本说明里的判断。
fallbackToSystemFont 的调用条件也值得明确。Font 构造函数在字体文件缺失、为空、或者解析抛错时才走它:
if (!file || file.isEmpty) {
if (file) {
// Some bad PDF generators will include empty font files,
// attempting to recover by assuming that no file exists.
warn('Font file is empty in "' + name + '" (' + this.loadedName + ')');
}
this.fallbackToSystemFont(properties);
return;
}以及 checkAndRepair 抛错后的 catch 分支:
} catch (e) {
warn(e);
this.fallbackToSystemFont(properties);
return;
}所以这张表的实际语义是:当字体程序不可用,而 FontDescriptor 又没有给出数值时,用标准字体的 AFM 数据补齐垂直度量。它不是所有字体都会经过的路径,也不是默认度量。
FontDescriptor 优先
translateFont 读取描述符的顺序解释了「为什么只补 NaN」:
let ascent = descriptor.get('Ascent');
if (typeof ascent !== 'number') {
ascent = undefined;
}
let descent = descriptor.get('Descent');
if (typeof descent !== 'number') {
descent = undefined;
}
let xHeight = descriptor.get('XHeight');
if (typeof xHeight !== 'number') {
xHeight = 0;
}
let capHeight = descriptor.get('CapHeight');
if (typeof capHeight !== 'number') {
capHeight = 0;
}注意这里的默认值不一致:ascent 与 descent 缺失时是 undefined,xHeight 与 capHeight 缺失时是 0。Font 构造函数做 undefined / 1000 得到 NaN,做 0 / 1000 得到 0。于是:
| 字段 | 描述符缺失时 | 进入 Font 后 | 是否会被基本度量表补 |
|---|---|---|---|
ascent | undefined | NaN | 会 |
descent | undefined | NaN | 会 |
capHeight | 0 | 0 | 不会 |
xHeight | 0 | 0 | 不会 |
这带来一个不那么直观的结果:一份没有 /FontDescriptor 的核心 14 字体,capHeight 会保持 0,而不是表里的 718 或 662。原因在 translateFont 的兼容分支里已经写死:
toUnicode,
xHeight: 0,
capHeight: 0,
italicAngle: 0,
isType3Font,xHeight 与 capHeight 被显式置为 0,ascent 与 descent 没有出现。所以只有后两者会变成 NaN 并被表补上。capHeight 的 0 在多数排版路径里没有影响,因为文本层主要使用 ascent 与 descent;但如果某个渲染分支需要 capHeight,它会拿到 0 而不是字体真实值。
设计要点:这里的原则是「缺失才补,绝不覆盖」。
fallbackToSystemFont用isNaN做门禁,而不是无条件赋值;capHeight因为上游已经给了 0,就被当成「已知值」放过。这个策略保证了 /FontDescriptor 与嵌入字体程序的优先级,代价是上游用 0 表示「不知道」时,下游无法区分 0 与「真的为 0」。理解这一点,才能解释为什么同一份 PDF 在有的阅读器里行高正常、有的偏紧:差别往往不在宽度表,而在垂直度量被谁覆盖了。
lineHeight 与文本层
Font 构造函数在开头用 properties 里的 ascent 与 descent 算行高:
this.ascent = properties.ascent / PDF_GLYPH_SPACE_UNITS;
this.descent = properties.descent / PDF_GLYPH_SPACE_UNITS;
this.lineHeight = this.ascent - this.descent;Helvetica 的 0.718 与 -0.207 得到 0.925;Times-Roman 的 0.683 与 -0.217 得到 0.900。这个行高不含 lineGap,因为内置表没有这个字段。TrueType 字体有 hhea 表时,行高会在 checkAndRepair 里改成 ascent - descent + lineGap,精度更高。
要注意计算顺序:lineHeight 在构造函数开头就算完,fallbackToSystemFont 只补 ascent、descent 与 capHeight,不会重算 lineHeight。所以一份缺少描述符、依赖内置表的字体,lineHeight 会停在 NaN,而 ascent 与 descent 是有效的。主渲染路径不读 lineHeight,只有 XFA 排版会用它:
const lineHeight = pdfFont.lineHeight ? Math.max(real ? 0 : 1.2, pdfFont.lineHeight) : 1.2;(节选,core/xfa/fonts.js。)那里的 pdfFont.lineHeight 为 NaN 时走 else 分支,取 1.2。换句话说,NaN 行高不会传进 canvas 文本布局,只会在 XFA 里被替换成默认值。
这些值最终进入 textContent 的样式表:
textContent.styles[loadedName] = {
fontFamily: font.fallbackName,
ascent: font.ascent,
descent: font.descent,
vertical: font.vertical,
};显示层的文本层用这两个比例把文字对齐到 canvas 上的字形:
if (style.ascent) {
ratio = style.ascent;
} else if (style.descent) {
ratio = 1 + style.descent;
}font_loader 也提供 ascent 与 descent 的 getter,需要字体度量的调用方可以直接读取。所以 metrics.js 的 629、718、683 这些数字,最终会出现在浏览器排版文本层时的基线计算里;它不只是 worker 内部的一个中间值。
ascent、descent 与 bbox
metrics.js 不包含 FontBBox。bbox 由 translateFont 从描述符或字体字典读取:
const bbox = lookupNormalRect(
descriptor.getArray('FontBBox') || dict.getArray('FontBBox'),
isType3Font ? [0, 0, 0, 0] : undefined,
);bbox 与 ascent、descent 的关系不是简单的相等:
| 概念 | 含义 | 来源 |
|---|---|---|
| bbox | 字形几何外框,[xMin, yMin, xMax, yMax] | /FontDescriptor 或字体程序 |
| ascent | 排版用的基线上高度 | 描述符、字体表或内置表 |
| descent | 排版用的基线下深度 | 描述符、字体表或内置表 |
当描述符缺少 ascent 与 descent 时,PDF.js 会用 bbox 的 yMax 与 yMin 作为兜底。createOS2Table 里写得很直白:
const typoAscent = override.ascent || Math.round(scale * (properties.ascent || bbox[3]));
let typoDescent = override.descent || Math.round(scale * (properties.descent || bbox[1]));Type1 与 CFF 的解析器则直接把 bbox 转成 ascent 与 descent。Type1 头部字典:
case "FontBBox":
const fontBBox = this.readNumberArray();
properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
properties.descent = Math.min(fontBBox[1], fontBBox[3]);
properties.ascentScaled = true;
break;CFF 的 Top DICT 也一样:
if (fontBBox?.some(coord => coord !== 0)) {
// adjusting ascent/descent
properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
properties.descent = Math.min(fontBBox[1], fontBBox[3]);
properties.ascentScaled = true;
}这说明 ascent 与 descent 在缺少权威度量时,常常就是 bbox 的上下边界。metrics.js 提供的则是另一种权威:核心 14 字体在 Adobe AFM 里测得的排版度量。两者都可能进入 properties.ascent,谁先谁后由解析路径决定。
垂直度量如何写进 OS/2 表
fallbackToSystemFont 补出的值不会写进字体文件:它在字体文件缺失或损坏时调用,随后直接返回,不会触发 createOS2Table。真正写 OS/2 的是另一条路径:字体文件存在、但 OS/2 表缺失或校验失败,需要重建字体。两条路径共用同一组 properties.ascent 与 properties.descent 字段,单位换算的逻辑值得单独看清。
createOS2Table 先决定缩放系数,再取垂直度量:
const unitsPerEm =
override.unitsPerEm ||
(properties.fontMatrix ? 1 / Math.max(...properties.fontMatrix.slice(0, 4).map(Math.abs)) : 1000);
// if the font units differ to the PDF glyph space units
// then scale up the values
const scale = properties.ascentScaled ? 1.0 : unitsPerEm / PDF_GLYPH_SPACE_UNITS;
const typoAscent = override.ascent || Math.round(scale * (properties.ascent || bbox[3]));
let typoDescent = override.descent || Math.round(scale * (properties.descent || bbox[1]));
if (typoDescent > 0 && properties.descent > 0 && bbox[1] < 0) {
typoDescent = -typoDescent; // fixing incorrect descent
}
const winAscent = override.yMax || typoAscent;
const winDescent = -override.yMin || -typoDescent;这段代码里有三层选择,顺序不能颠倒:
| 顺序 | typoAscent 的取值 | 适用场景 |
|---|---|---|
| 1 | override.ascent | TrueType 字体已经有 hhea,直接用 |
| 2 | scale * properties.ascent | 描述符或解析器给了值,按单位换算 |
| 3 | scale * bbox[3] | 前两者都没有,用几何上边界 |
properties.ascent 为 0 或 NaN 时,|| 会退到 bbox;scale 则取决于 ascentScaled。Type1 与 CFF 解析器从 FontBBox 取到值时会把它置为真,表示数值已经处在 1/1000 em 的字形空间,scale 取 1.0,不再按目标字体的 unitsPerEm 放大;来自 FontDescriptor 或 bbox 兜底的值通常没有这个标记,scale 会把它们乘到 unitsPerEm / 1000。
写表时还有两个细节:
os2.setInt16(typoAscent); // sTypoAscender
os2.setInt16(typoDescent); // sTypoDescender
os2.setArray([0x00, 0x64]); // sTypoLineGap (7%-10% of the unitsPerEM value)
os2.setInt16(winAscent); // usWinAscent
os2.setInt16(winDescent); // usWinDescentwinAscent 与 winDescent 是给 Windows 用的剪裁边界,取值优先来自 yMax 与 yMin,没有才用 typo 值。sTypoLineGap 被固定写成 0x64,也就是 100 单位,注释说明它大约是 unitsPerEm 的 7% 到 10%。这些值会影响浏览器渲染自定义字体时的行盒高度。
所以两条路径的产物不同:重建路径的 OS/2 值进入字体文件,随字体一起交给浏览器;metrics.js 兜底的值只留在 Font 实例与 textContent 里,用于文本层定位。它们都经过 Font 的 ascent 与 descent 字段,但不会互相覆盖,因为 fallback 路径在写表之前就已经返回了。
度量来源对比:内置表、CFF、TrueType、Type1
metrics.js 不做分派
这一点必须反复强调:getMetrics 里没有任何 if (type === "Type1") 或 if (cff) 分支。它的职责只有按字体名返回表项。字体格式的分派发生在两个地方:
evaluator.extractWidths:决定用/Widths、复合字体/W,还是基础字体表;Font#constructor:按type与subtype进入 Type1、CFF、TrueType 或 Type3 分支。
因此「CFF 的度量从哪来」这类问题的答案不在本篇,而在各自的解析器。下面的对比表把四条来源放在一起,方便定位。
四条来源的对比
| 来源 | 宽度来源 | ascent 与 descent 来源 | 单位基准 | 代码位置 |
|---|---|---|---|---|
| 内置核心 14 表 | getMetrics 的字形表,或 Courier 的 600 | getFontBasicMetrics 的 AFM 数据 | 1/1000 em | metrics.js |
| TrueType / OpenType | hmtx 的 advance width,按 scaleFactors 缩放 | hhea 的 ascender、descender、lineGap | unitsPerEm | fonts.js 的 checkAndRepair |
| CFF | charstring 的宽度加 defaultWidthX / nominalWidthX | Top DICT 的 FontBBox | FontMatrix | cff_parser.js、cff_font.js |
| Type1 | charstring hsbw 的 wx | 头部字典 FontBBox,或 /FontDescriptor | FontMatrix(常为 0.001) | type1_parser.js、type1_font.js |
这张表的每一行都可以在源码里找到对应实现,下面逐个看。
TrueType:hhea 与 hmtx
TrueType 的垂直度量来自 hhea 表的四个 16 位有符号整数:
// Extract some more font properties from the OpenType head and
// hhea tables; yMin and descent value are always negative.
const metricsOverride = {
unitsPerEm: int16(tables.head.data[18], tables.head.data[19]),
yMax: signedInt16(tables.head.data[42], tables.head.data[43]),
yMin: signedInt16(tables.head.data[38], tables.head.data[39]),
ascent: signedInt16(tables.hhea.data[4], tables.hhea.data[5]),
descent: signedInt16(tables.hhea.data[6], tables.hhea.data[7]),
lineGap: signedInt16(tables.hhea.data[8], tables.hhea.data[9]),
};
// PDF FontDescriptor metrics lie -- using data from actual font.
this.ascent = metricsOverride.ascent / metricsOverride.unitsPerEm;
this.descent = metricsOverride.descent / metricsOverride.unitsPerEm;
this.lineGap = metricsOverride.lineGap / metricsOverride.unitsPerEm;注释说得很直接:PDF FontDescriptor metrics lie,所以优先用字体里的真实数据。单位是 unitsPerEm,不是 1000;除以 unitsPerEm 之后才得到相对于 em 的比例。
宽度来自 hmtx。每个字形占 4 字节,前两字节是 advance width,后两字节是 left side bearing:
const metrics = tables.hmtx.data;
for (let i = 0; i < numGlyphs; i++) {
const j = 4 * i;
const advanceWidth = Math.round(
scaleFactors[i] * int16(metrics[j], metrics[j + 1])
);
metrics[j] = (advanceWidth >> 8) & 0xff;
metrics[j + 1] = advanceWidth & 0xff;这里的 scaleFactors 负责把字体的原始单位折算到目标基准,效果与 adjustWidths 类似:先把宽度归一到统一尺度,再交给上层。TrueType 的宽度不是查表得来的,而是从字体程序的二进制里读出来的。
CFF:charstring 宽度与 FontBBox
CFF 的宽度内嵌在每个 charstring 的起始处。解析器执行 charstring 时会记录 state.width,然后按私有字典的两个参数还原:
if (state.width !== null) {
const nominalWidth = privateDictToUse.getByName('nominalWidthX');
widths[i] = nominalWidth + state.width;
} else {
const defaultWidth = privateDictToUse.getByName('defaultWidthX');
widths[i] = defaultWidth;
}这是 Type2 charstring 的宽度编码规则:如果第一个操作数是宽度,它表示「相对于 nominalWidthX 的差值」;如果没有宽度操作数,就用 defaultWidthX。得到的 widths[i] 是字形空间里的宽度。
垂直度量来自 Top DICT 的 FontBBox:
const fontMatrix = topDict.getByName('FontMatrix');
if (fontMatrix) {
properties.fontMatrix = fontMatrix;
}
let fontBBox = topDict.getByName('FontBBox');当 bbox 有效时,解析器直接用它设置 ascent 与 descent:
if (fontBBox?.some(coord => coord !== 0)) {
// adjusting ascent/descent
properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
properties.descent = Math.min(fontBBox[1], fontBBox[3]);
properties.ascentScaled = true;
}ascentScaled = true 是一个信号:这些值已经是按 FontMatrix 缩放过的,不要再按 1000 单位重复换算。createOS2Table 里据此选择 scale = 1.0 而不是 unitsPerEm / 1000。
Type1:hsbw 与头部 FontBBox
Type1 的 charstring 用 hsbw 声明宽度与左侧承距。解析器把它转换成 Type2 的 hmoveto,同时保留宽度:
case 13: // hsbw
if (this.stack.length < 2) {
error = true;
break;
}
// To convert to type2 we have to move the width value to the
// first part of the charstring and then use hmoveto with lsb.
wx = this.stack.pop();
sbx = this.stack.pop();
this.lsb = sbx;
this.width = wx;
this.stack.push(wx, sbx);
error = this.executeCommand(2, COMMAND_MAP.hmoveto);
break;wx 就是 advance width,单位由字体的 FontMatrix 决定。Type1Font 把这些 charstring 与宽度一起保留,后面 Font 在构造 hmtx 时读取 charstrings[i].width。
垂直度量来自头部字典的 FontBBox,处理方式与 CFF 完全一致:
case "FontMatrix":
properties.fontMatrix = this.readNumberArray();
break;
case "FontBBox":
const fontBBox = this.readNumberArray();
properties.ascent = Math.max(fontBBox[3], fontBBox[1]);
properties.descent = Math.min(fontBBox[1], fontBBox[3]);
properties.ascentScaled = true;
break;如果头部没有 FontBBox,或者描述符给了更明确的 Ascent 与 Descent,translateFont 读到的描述符值会先进入 properties,再由 Font 构造覆盖。优先级与 TrueType 类似:字体程序与描述符优先,内置表最后。
优先级总结
四条来源在一条 PDF 里并不是并列的,而是按可用性逐层下降。宽度与垂直度量还要分开看,因为它们的优先级并不相同:
| 层 | 宽度 | 垂直度量 |
|---|---|---|
| 字体程序 | TrueType 的 hmtx 用于修复并重建字体文件;CFF 与 Type1 的 charstring 宽度服务于字形程序 | TrueType 的 hhea 直接覆盖 Font 实例的 ascent 与 descent;CFF 与 Type1 用 FontBBox 写入 properties |
| 字体字典 | 简单字体的 /Widths、复合字体的 /W 与 /DW 决定布局宽度 | /FontDescriptor 的 Ascent、Descent 先写入 properties |
| 内置表 | 仅在 /Widths 缺失时提供布局宽度 | 仅在实例值仍为 NaN 时补 ascent、descent、capHeight |
这个分层解释了一个容易混淆的点:TrueType 的 hmtx 虽然保存真实 advance width,但简单字体的布局宽度仍然优先取 /Widths;hmtx 更多用于字体文件的修复与重建。CFF 与 Type1 则在宽度缺失时会把 charstring 的宽度回填到 /Widths,type1_parser.js 里那段「Attempt to replace missing widths」就是例子。内置表始终是最后一层,只在字典和程序都给不出可用值时出现。
metrics.js 位于第三层。它不会覆盖前两层,也不会被前两层修改。这种「只兜底」的定位让它可以完全静态化,不需要关心文档、页面或字体对象的生命周期。
一次完整的数值追踪
前面的结论比较抽象,这一节用一条具体的文本把数字走完。假设页面里有一个 12pt 的 Helvetica A,字体字典没有 /Widths,也没有 /FontDescriptor。
第一步:字体名归一化与选表
/BaseFont 是 Helvetica。translateFont 进入无描述符的兼容分支,getBaseFontMetrics("Helvetica") 依次执行:
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'];normalizeFontName("Helvetica") 原样返回;stdFontMap["Helvetica"] 是 "Helvetica";Metrics["Helvetica"] 是一个函数,不是数字。于是 glyphWidths() 被调用,Helvetica 的 315 条表在此刻一次性构造完成。返回的 defaultWidth 是初始值 0,monospace 是 false。
第二步:字形名到字符码
在无描述符的兼容分支里,转换发生在 translateFont 内部:
newProperties.widths = this.buildCharCodeToWidth(metrics.widths, newProperties);常规路径则由 extractWidths 做同一件事:
glyphsWidths = this.buildCharCodeToWidth(metrics.widths, properties);
defaultWidth = metrics.defaultWidth;对 0 到 255 的每个码位,先查 /Differences,再查默认编码。默认编码是 StandardEncoding 时,码位 65 对应字形名 A,widthsByGlyphName["A"] 是 667。于是字符码表的 65 这一项写成 667。
接着 properties 被组装。ascent 与 descent 没有被赋值,xHeight 与 capHeight 是 0,widths 是刚建好的字符码表,defaultWidth 是 0。
| 键 | 值 | 来源 |
|---|---|---|
widths[65] | 667 | Helvetica 表的 A |
widths[32] | 278 | Helvetica 表的 space |
defaultWidth | 0 | 比例字体的缺省宽度 |
ascent | undefined | 没有描述符 |
descent | undefined | 没有描述符 |
xHeight | 0 | 兼容分支写死 |
capHeight | 0 | 兼容分支写死 |
顺带一个对照:同样是字形名 A,Helvetica 表里是 667,Helvetica-Bold 表里是 722。字符码与字形名的映射不变,换的只是被查的那张表。这也说明为什么四款 Helvetica 的键集必须完全一致。
(这个例子说明一件事:字形名与字符码的映射必须查源码,不能凭记忆。编码表在 encodings.js,字形名在 metrics.js,两张表的名字必须对得上才有宽度。)
第三步:Font 构造与单位换算
new Font("Helvetica", file, properties, options) 执行到构造函数前段:
this.capHeight = properties.capHeight / PDF_GLYPH_SPACE_UNITS;
this.ascent = properties.ascent / PDF_GLYPH_SPACE_UNITS;
this.descent = properties.descent / PDF_GLYPH_SPACE_UNITS;
this.lineHeight = this.ascent - this.descent;代入上面的值,结果如下:
| 表达式 | 结果 |
|---|---|
properties.capHeight / 1000 | 0 |
properties.ascent / 1000 | NaN |
properties.descent / 1000 | NaN |
ascent - descent | NaN |
如果此时有可用的字体文件(例如从 standardFontDataUrl 取到 Helvetica 对应的 pfb),构造函数会继续解析:TrueType 分支会用 hhea 覆盖 this.ascent 与 this.descent,Type1 与 CFF 则把 FontBBox 写回 properties,供字体重建使用。没有文件或文件损坏时,fallbackToSystemFont 被调用,用 getFontBasicMetrics() 补上:
if (isNaN(this.ascent)) {
this.ascent = metrics.ascent / PDF_GLYPH_SPACE_UNITS;
}
if (isNaN(this.descent)) {
this.descent = metrics.descent / PDF_GLYPH_SPACE_UNITS;
}Helvetica 的 718 与 -207 变成 0.718 与 -0.207。capHeight 仍然是 0,因为 isNaN(0) 为假。lineHeight 不会跟着更新:它在构造函数开头就算完,此时 ascent 还是 NaN,所以 lineHeight 停在 NaN;这条路径上的排版只用 ascent 与 descent,不受影响。
第四步:字形宽度进入 textContent
buildTextContentItem 遍历字形:
const glyphs = font.charsToGlyphs(chars);
const scale = textState.fontMatrix[0] * textState.fontSize;
for (let i = 0, ii = glyphs.length; i < ii; i++) {
const glyph = glyphs[i];
// ...
let glyphWidth = glyph.width;
let scaledDim = glyphWidth * scale;fontMatrix[0] 是 0.001,fontSize 是 12,scale 是 0.012;glyph.width 是 667;scaledDim 是 8.004。随后:
if (
!category.isZeroWidthDiacritic &&
!compareWithLastPosition(scaledDim)
) {
textState.translateTextMatrix(scaledDim * textState.textHScale, 0);
continue;
}textHScale 通常是 1,于是文本矩阵右移 8.004 个用户空间单位。若字距不为 0,charSpacing 会在字形前进之后再做一次额外的文本矩阵位移;若是空格且设置了 wordSpacing,还会再加一次。宽度表的值只决定字形自身的 advance,不包含这些额外间距。
第五步:文本层的垂直对齐
textContent 的样式表带着刚补好的比例:
textContent.styles[loadedName] = {
fontFamily: font.fallbackName,
ascent: font.ascent,
descent: font.descent,
vertical: font.vertical,
};显示层的 text_layer 读到 ascent = 0.718,把文本 div 的基线从顶部下移 71.8% 的字体高度;如果只有 descent,则用 1 + descent。于是 canvas 上绘制的字形与 HTML 文本层的位置对齐,选中文字时高亮框不会偏移。
这条完整链路里,metrics.js 只贡献了两个数字:667(宽度)与 718 / -207(垂直度量)。其余所有换算、覆盖与回退,都在调用方完成。这种分工让宽度表可以离线校验,也让基本度量表的错误不会扩散到布局计算之外。
缓存、共享与生命周期
缓存的层级
把两层缓存放在一起看,可以避免重复建表的误解:
| 层级 | 缓存载体 | 键 | 生命周期 |
|---|---|---|---|
| 顶层表 | getMetrics 闭包里的 lookup | 无,只有「是否初始化」 | worker 模块实例 |
| 字族表 | 每个内部工厂的 lookup | 无,只有「是否初始化」 | worker 模块实例 |
| 基本度量表 | getFontBasicMetrics 闭包 | 无 | worker 模块实例 |
| 字体对象 | Font 实例上的 widths | 字符码 | 单次 Font 构造 |
没有「按文件」或「按页面」的缓存。同一个 worker 里解析两份 PDF,只要字体名相同,用的就是同一个表对象;不同 worker 各自持有一份,因为模块实例不共享。
展开成本
最坏情况下,一份文档把 14 个字体全部用到,两张表会被完全展开:
| 表 | 展开内容 | 记录数量级 |
|---|---|---|
getMetrics | 4 个 Courier 标量 + 8 × 315 + 190 + 202 | 约 2916 |
getFontBasicMetrics | 14 个对象 × 4 个字段 | 56 |
这些记录都是整数与短字符串,内存开销很小;构造过程也只是几百次对象属性赋值,没有解析、没有 I/O。真正的成本在源码体积:三千行数据会被打包进 worker bundle,压缩后是可观但可接受的一部分。
懒加载的意义因此主要在启动路径:只用到 Helvetica 的文档不会构造 Times 与 Symbol 的表,首屏字体解析不必为未用到的字体付出赋值时间。对于批量文档,这个差异会累积。
共享对象的风险
getBaseFontMetrics 返回的 widths 是表对象本身:
widths = glyphWidths(); // expand lazy widths array它没有拷贝。如果某个调用方直接写 metrics.widths["A"] = 0,污染会持续到 worker 销毁。当前代码没有这样做:buildCharCodeToWidth 新建对象,adjustWidths 只改 properties.widths(那是 buildCharCodeToWidth 的产物,或是 /Widths 的副本),fallbackToSystemFont 只读。这个约定靠代码审查维持,没有运行时保护。
设计要点:只读数据可以共享,可变数据必须复制。
metrics.js的表被设计成只读,所以可以安全共享;Font#widths是每次构造新建的,所以可以安全修改。把这两类数据分清楚,比给共享表加Object.freeze更省成本,也更符合极简原则。代价是任何新增调用方都必须遵守「不写表」的约定。
只在 worker 里存在
metrics.js 位于 src/core/,只会被打进 pdf.worker.js。主线程的 src/display/ 不导入它,api.js 也不把它暴露给调用方。整个仓库里只有两个真正的调用点:
| 调用点 | 名称 | 用途 |
|---|---|---|
evaluator.js 的 getBaseFontMetrics | getMetrics() | 宽度表 |
fonts.js 的 fallbackToSystemFont | getFontBasicMetrics() | 垂直度量 |
(src/core/xfa/fonts.js 另有一个同名函数,服务于 XFA 表单的字体度量,与本篇无关,容易在全局搜索时误认。)
这个位置带来两个结果:
- 表只在 worker 的模块实例里初始化一次。同一个 worker 处理多份 PDF、多个页面,都复用同一份表;worker 销毁后,表也随之释放,不需要显式清理。
- 主线程拿到的是
Font换算后的结果,不是表本身。textContent 里传的是ascent、descent与每个字形的宽度累积,显示层无法、也不需要再查这张表。
如果某个应用把 Font 的构造搬到主线程,或者把 worker 换成自己实现的版本,metrics.js 的模块级缓存就不再共享,每次构造都可能重新展开。这是模块单例的固有边界,不是 bug,但在做自定义构建时需要意识到。
与标准字体数据的关系
metrics.js 的表和 standard_fonts.js 的映射表都是模块级单例,但它们的失效方式不同。映射表是名字到名字的转换,不随文档变化;度量表是名字到数字,理论上可以随 PDF 版本的 AFM 数据更新。当前实现没有版本字段,也没有外部注入入口,所有值都硬编码在源码里。
这意味着升级度量数据只能改源码、重新构建。对 PDF.js 这样的通用库,这是可接受的:核心 14 字体的 AFM 数据几十年稳定,改动极少。对需要自定义字体的场景,嵌入字体程序才是正确途径,metrics.js 不应被当作可配置的度量数据库。
边界:本文件没有提供的能力
读完两张表,容易产生「字体度量都在这里」的错觉。实际上它缺了不少常见能力,而这些缺口都有明确的替代路径:
| 能力 | 是否提供 | 替代来源 |
|---|---|---|
| 字形宽度 | 提供 | 文本字体走本表,复合字体走 /W |
| ascent 与 descent | 提供 | 嵌入字体走 hhea 或 FontBBox |
| capHeight | 提供 | 只在对 NaN 补值时使用,缺省 0 时不覆盖 |
| 字形外框 bbox | 不提供 | /FontDescriptor、FontBBox、字体程序 |
| 左右承距 | 不提供 | hmtx 的 lsb、charstring 的 hsbw |
| 字距调整 kerning | 不提供 | 内容流 TJ 的间距数字 |
| 竖直度量 vmetric | 不提供 | 复合字体的 /DW2、/W2 |
| Unicode 映射 | 不提供 | glyphlist.js、ToUnicode CMap |
| 默认宽度 | 不提供 | 调用方按数字或 MissingWidth 决定 |
这张表最能说明模块的边界:它只回答「标准 14 字体的这个字形有多宽、这个字体有多高」,其余问题都要去别处找。
字距调整尤其值得一提。metrics.js 没有任何字形对(pair)的数据,所以它无法表达「A 后面跟 V 时收紧一点」这类规则。PDF 的简单字体本来也不通过字体表做 kerning,排版间距由内容流里的 TJ 数组直接给出;buildTextContentItem 读取 TJ 的数字并换算成位移,与宽度表无关。复合字体可以在字体程序里带 kerning,但那属于布局引擎的范畴,不在 worker 的这条路径上。
TJ 的间距如何换算
TJ 里的数字被单独抽出来,换算成文本空间的位移,然后作为 extraSpacing 传给内容流构建:
const spaceFactor = ((textState.font.vertical ? 1 : -1) * textState.fontSize) / 1000;
for (const item of args[0]) {
if (typeof item === 'string') {
showSpacedTextBuffer.push(item);
} else if (typeof item === 'number' && item !== 0) {
// PDF Specification 5.3.2 states:
// The number is expressed in thousandths of a unit of text
// space.
// This amount is subtracted from the current horizontal or
// vertical coordinate, depending on the writing mode.
// In the default coordinate system, a positive adjustment
// has the effect of moving the next glyph painted either to
// the left or down by the given amount.
const str = showSpacedTextBuffer.join('');
showSpacedTextBuffer.length = 0;
buildTextContentItem({
chars: str,
extraSpacing: item * spaceFactor,
});
}
}
if (showSpacedTextBuffer.length > 0) {
const str = showSpacedTextBuffer.join('');
showSpacedTextBuffer.length = 0;
buildTextContentItem({
chars: str,
extraSpacing: 0,
});
}这段代码有两个值得注意的点:
- 数字的单位也是千分之一 em,
spaceFactor把它乘上字号再除以 1000,得到用户空间位移。这与宽度表的单位完全一致,但换算发生在内容流处理阶段,不经过metrics.js。 - 符号约定与宽度相反。正数让下一个字形向左(水平书写)或向下(竖直书写)移动,所以
extraSpacing是一个「减量」。宽度表里的正数始终表示前进距离,两者不能混在一起加。
metrics.js 只负责「字形本身占多宽」,TJ 负责「两个字之间额外挪多少」。排版正确的 PDF,这两部分相加才是真正的字距。
理解这些缺口,才能正确判断一个排版问题的归属:宽度偏了查 getMetrics 与 /Widths,字符挤在一起查 TJ 与字距,行高不对查 FontDescriptor 与 hhea,文字选不中查 ToUnicode。把问题放到错误的模块里排查,往往会得出「表值写错了」这种不成立的结论。
总结
metrics.js 是 PDF.js 字体层里最「不像代码」的一个模块:三千行里只有两个函数、一个导入、几十行逻辑,其余全是数字。但正是这些数字,决定了没有 /Widths 的 PDF 能不能正确排版,也决定了字体文件缺失时文本层会不会错位。
它在整条链路中的位置
把它的位置再放一次:
| 阶段 | 本篇贡献 | 直接消费方 |
|---|---|---|
| 字体名归一化之后 | 14 个字体名的宽度表 | evaluator.getBaseFontMetrics |
| 字体对象构造 | 无 | Font#constructor |
| 字体程序缺失或损坏 | 14 个字体名的垂直度量 | Font#fallbackToSystemFont |
| 文本布局 | 字形宽度,经 fontMatrix 缩放 | evaluator.buildTextContentItem |
| 文本层与字体度量读取 | 无直接调用,值经由 Font 传递 | display/text_layer、font_loader |
用一条链路收束:
// core/metrics.js
// getMetrics() → { Helvetica: fn, Courier: 600, ... }
// getFontBasicMetrics() → { Helvetica: { ascent: 718, ... }, ... }
//
// evaluator.js
// getBaseFontMetrics(name) → { widths, defaultWidth, monospace }
// buildCharCodeToWidth(...) → { 65: 667, 32: 278, ... }
//
// fonts.js
// Font#constructor → ascent / descent / capHeight / widths
// Font#fallbackToSystemFont → 只补 NaN 的垂直度量
//
// evaluator.js
// buildTextContentItem → glyph.width * fontMatrix[0] * fontSize六条设计原则
从这个最小实现里可以抽出几条原则:
- 兜底数据只兜底,不抢优先级。
/Widths存在就用文件里的值,FontDescriptor给了数值就不覆盖;内置表只在「不知道」时出场。这条原则让不规范 PDF 有合理输出,也让规范 PDF 完全不受表值影响。 - 单位只有一个基准,缩放集中在入口。所有表值都是 1/1000 em;
adjustWidths在字体构造时把非 1000 基准折回来,fontMatrix在布局时做最终缩放。表里不写小数,也不做任何与字号相关的计算。 - 按需展开,按字体族分片。两层
getLookupTableFactory让「用不到的字体」和「用不到的字形族」都不付出构造成本;缓存的粒度是整张表,没有按文档或按页面的条目需要清理。 - 只读数据共享,可变数据复制。表对象可以被任意多个
Font共享,因为没有人写它;每个Font的widths是新建对象,所以可以安全地被adjustWidths修改。这个约定是隐式的,新增调用方必须自己遵守。 - 模块不承担格式分派。
metrics.js不认识 CFF、TrueType 或 Type1;格式差异由Font子类与各自的解析器处理,度量表只在「标准 14 字体」这一层提供统一答案。职责单一,才能让三千行数据保持纯静态。 - 数据表忠实于原始 AFM,不修饰。Courier 的
xHeight是负数、Symbol 与 ZapfDingbats 的四项全是NaN、部分字体 ascent 等于 capHeight,这些都原样保留。下游用isNaN与真值判断来消化异常,而不是在数据层「修正」成看起来合理的值。
这六条里,前三条决定了它的性能特征,后三条决定了它的可维护性。一个只有数据与查表的模块能做到这两点,靠的不是技巧,而是把边界划得很窄:不解析、不缓存文档、不做格式判断、不修改共享对象。
metrics.js 也提醒我们一件事:字体排版里最贵的往往不是查表,而是「表从哪来」的判断。宽度可能来自 /Widths、复合字体的 /W、CFF 的 charstring、Type1 的 hsbw 或内置 AFM;垂直度量可能来自 FontDescriptor、hhea、FontBBox 或内置表。把这些来源的优先级排清楚,比记住任何一个具体数字都重要。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日