StreamPDFStreamPDF
返回博客

PDF.js 源码解读: metrics

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: metrics

前言

前面几篇已经把字体层的外围走了一遍: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首次调用建表,之后返回同一个缓存对象
宽度表getMetrics14 个标准字体的字形名到宽度,单位 1/1000 em
基本度量表getFontBasicMetrics14 个标准字体的 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;

这段代码解释了两件事:

  1. 表里的整数不是「像素」,也不是「点」,只是千分之一的 em。除以 1000 之后,ascent 才是相对于 em 的比例,可以乘以当前字号得到用户空间长度。
  2. 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.001FONT_IDENTITY_MATRIX
Type10.001头部字典 /FontMatrix
CFF0.001 或其它Top DICT 的 FontMatrix
TrueType0.001PDF.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 宽度667667 × 0.001 × 128.004
Helvetica space 宽度278278 × 0.001 × 123.336
Courier 默认宽度600600 × 0.001 × 127.2
Helvetica ascent718718 / 10000.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;
  };
}

这段代码的行为可以用四步描述:

  1. 第一次调用返回的匿名函数时,lookup 被创建为空对象(或空数组),initializer 被执行,把数据写进去;
  2. 执行完立刻把 initializer 置为 null,释放对初始化函数的引用;
  3. 从第二次调用开始,if (initializer) 为假,直接返回已经建好的 lookup;
  4. 同一个工厂返回的函数永远返回同一个对象实例。

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 条宽度按值统计,会出现一个很有意思的分布:

宽度值出现次数典型字形
55684多数小写字母与数字,例如 a、e、zero
66735多数大写字母,例如 A、B、E
33329窄标点与部分小写字母,例如 r、hyphen
27828space、comma、period、I 一类
72228C、D、N、w 等较宽字形
50023J、c、k、s 与连字 fi、fl
77813G、O、Q 等圆润大写字母
61111F、T、Z 等中等宽度字形

(节选前 8 个高频值,统计自当前源码的 Helvetica 表。)

315 条记录只有 39 个不同的宽度值,最常见的是 556,覆盖 84 个字形。这个分布不是巧合:拉丁字母的 advance width 在设计时就被归入少数几个宽度档,保证正文排版有稳定的节奏。真正独特的宽度集中在符号、标点与少数宽字母上。

这也解释了为什么表可以写得这么长却仍然单调:大部分行只是把同一个整数重复赋给不同的字形名。压缩算法与打包工具能很好地处理这种重复,源码选择逐行展开,是为了可读性与可 diff 性,而不是为了运行时结构。

同一套表,不同的差异数量

把四款 Helvetica 与四款 Times 逐条比较,差异数量并不相同:

对比不同条目数说明
Helvetica 与 Helvetica-Oblique0倾斜不改 advance
Helvetica 与 Helvetica-Bold115粗体加宽了部分字形
Helvetica 与 Helvetica-BoldOblique115粗斜体等于粗体的宽度
Times-Roman 与 Times-Bold144衬线粗体的宽度变化更大
Times-Roman 与 Times-Italic114斜体的宽度也独立测量

(对 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 形状条目数(节选统计)键形态
Courier4单个数字4 个标量无字形键
Helvetica4字形表315 × 4拉丁字形名
Times4字形表315 × 4与 Helvetica 同一键集
Symbol1字形表190希腊字母与数学符号名
ZapfDingbats1字形表202201 个 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 的键集完全相同,值是独立测量的。这样做有三个好处:

  1. 调用方只需要一次字形名解析,换样式时不用重新映射字符;
  2. buildCharCodeToWidth 对四种样式走完全相同的编码路径,只换表对象;
  3. 宽度差异集中为数据,不进入代码分支。

Times 四款同理。Times-Roman、Times-Bold、Times-Italic、Times-BoldItalic 也都是 315 条,键集与 Helvetica 一致。于是「文本字体」这一组在结构上可以统一处理,只有数值不同。

与其它标准字体数据的分工

standard_fonts.js 也有一批名字与映射表,容易和本篇混淆。按职责分开看:

文件提供的内容是否含宽度
metrics.js字形名到宽度、字体名到垂直度量是
standard_fonts.jsstdFontMap、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
2getMetrics 的内置表/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,
    };
  }

它做了五件事,顺序不能乱:

  1. 用 normalizeFontName 把名字里的逗号、下划线换成连字符,去掉空白;
  2. 用 stdFontMap 把厂商名映射成标准名,例如 ArialMT 变成 Helvetica;
  3. 调用 getMetrics() 取得顶层表;
  4. 用 ?? 选表;名字查不到时按 serif 与否退回 Times-Roman 或 Helvetica;
  5. 判断选中的是数字还是函数:数字走等宽分支,函数则立即调用展开字形表。

注意第 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 命中选表结果
ArialMTArialMT是,映射 HelveticaHelvetica
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 的 defaultWidthCourier 的 defaultWidth
字符码在表里有对应字形使用表值使用 600
字符码在表里查不到0600
字体字典给了 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 条记录的代表值列出来:

字体ascentdescentcapHeightxHeight
Courier629-157562-426
Courier-Bold629-157562439
Helvetica718-207718523
Helvetica-Bold718-207718532
Times-Roman683-217662450
Times-Bold683-217676461
Times-Italic683-217653441
SymbolNaNNaNNaNNaN
ZapfDingbatsNaNNaNNaNNaN

(节选,共 14 条。)四个字段的含义:

  • ascent:从基线到字体最高处的距离,正数;
  • descent:从基线到字体最低处的距离,负数;
  • capHeight:从基线到大写字母顶端的距离,通常小于或等于 ascent;
  • xHeight:从基线到小写字母 x 顶端的距离,本表里未被使用。

数值同样是 1/1000 em。Font 构造函数统一除以 1000,得到 0.629、0.718、0.683 这样的比例。

数值之间的关系

这张表里能看到几种典型关系:

关系例子说明
ascent 等于 capHeightHelvetica 718 / 718大写字母顶端就是字体的最高点
ascent 大于 capHeightTimes-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;
  }
}

这段代码有三个要点:

  1. 先映射字体名。stdFontMap 与 nonStdFontMap 都会参与,比 getBaseFontMetrics 多了一层 nonStdFontMap。后者把一批非标准但常见的字体名映射到标准名,例如把 Calibri、CenturyGothic 映射到 Helvetica。
  2. 只补 NaN。isNaN(this.ascent) 为假时,已有的值原样保留。这保证了嵌入字体程序或 FontDescriptor 给出的真实度量优先。
  3. 只补三个字段。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 后是否会被基本度量表补
ascentundefinedNaN会
descentundefinedNaN会
capHeight00不会
xHeight00不会

这带来一个不那么直观的结果:一份没有 /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 的取值适用场景
1override.ascentTrueType 字体已经有 hhea,直接用
2scale * properties.ascent描述符或解析器给了值,按单位换算
3scale * 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); // usWinDescent

winAscent 与 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 的 600getFontBasicMetrics 的 AFM 数据1/1000 emmetrics.js
TrueType / OpenTypehmtx 的 advance width,按 scaleFactors 缩放hhea 的 ascender、descender、lineGapunitsPerEmfonts.js 的 checkAndRepair
CFFcharstring 的宽度加 defaultWidthX / nominalWidthXTop DICT 的 FontBBoxFontMatrixcff_parser.js、cff_font.js
Type1charstring hsbw 的 wx头部字典 FontBBox,或 /FontDescriptorFontMatrix(常为 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]667Helvetica 表的 A
widths[32]278Helvetica 表的 space
defaultWidth0比例字体的缺省宽度
ascentundefined没有描述符
descentundefined没有描述符
xHeight0兼容分支写死
capHeight0兼容分支写死

顺带一个对照:同样是字形名 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 / 10000
properties.ascent / 1000NaN
properties.descent / 1000NaN
ascent - descentNaN

如果此时有可用的字体文件(例如从 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 个字体全部用到,两张表会被完全展开:

表展开内容记录数量级
getMetrics4 个 Courier 标量 + 8 × 315 + 190 + 202约 2916
getFontBasicMetrics14 个对象 × 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 的 getBaseFontMetricsgetMetrics()宽度表
fonts.js 的 fallbackToSystemFontgetFontBasicMetrics()垂直度量

(src/core/xfa/fonts.js 另有一个同名函数,服务于 XFA 表单的字体度量,与本篇无关,容易在全局搜索时误认。)

这个位置带来两个结果:

  1. 表只在 worker 的模块实例里初始化一次。同一个 worker 处理多份 PDF、多个页面,都复用同一份表;worker 销毁后,表也随之释放,不需要显式清理。
  2. 主线程拿到的是 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,
  });
}

这段代码有两个值得注意的点:

  1. 数字的单位也是千分之一 em,spaceFactor 把它乘上字号再除以 1000,得到用户空间位移。这与宽度表的单位完全一致,但换算发生在内容流处理阶段,不经过 metrics.js。
  2. 符号约定与宽度相反。正数让下一个字形向左(水平书写)或向下(竖直书写)移动,所以 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

六条设计原则

从这个最小实现里可以抽出几条原则:

  1. 兜底数据只兜底,不抢优先级。/Widths 存在就用文件里的值,FontDescriptor 给了数值就不覆盖;内置表只在「不知道」时出场。这条原则让不规范 PDF 有合理输出,也让规范 PDF 完全不受表值影响。
  2. 单位只有一个基准,缩放集中在入口。所有表值都是 1/1000 em;adjustWidths 在字体构造时把非 1000 基准折回来,fontMatrix 在布局时做最终缩放。表里不写小数,也不做任何与字号相关的计算。
  3. 按需展开,按字体族分片。两层 getLookupTableFactory 让「用不到的字体」和「用不到的字形族」都不付出构造成本;缓存的粒度是整张表,没有按文档或按页面的条目需要清理。
  4. 只读数据共享,可变数据复制。表对象可以被任意多个 Font 共享,因为没有人写它;每个 Font 的 widths 是新建对象,所以可以安全地被 adjustWidths 修改。这个约定是隐式的,新增调用方必须自己遵守。
  5. 模块不承担格式分派。metrics.js 不认识 CFF、TrueType 或 Type1;格式差异由 Font 子类与各自的解析器处理,度量表只在「标准 14 字体」这一层提供统一答案。职责单一,才能让三千行数据保持纯静态。
  6. 数据表忠实于原始 AFM,不修饰。Courier 的 xHeight 是负数、Symbol 与 ZapfDingbats 的四项全是 NaN、部分字体 ascent 等于 capHeight,这些都原样保留。下游用 isNaN 与真值判断来消化异常,而不是在数据层「修正」成看起来合理的值。

这六条里,前三条决定了它的性能特征,后三条决定了它的可维护性。一个只有数据与查表的模块能做到这两点,靠的不是技巧,而是把边界划得很窄:不解析、不缓存文档、不做格式判断、不修改共享对象。

metrics.js 也提醒我们一件事:字体排版里最贵的往往不是查表,而是「表从哪来」的判断。宽度可能来自 /Widths、复合字体的 /W、CFF 的 charstring、Type1 的 hsbw 或内置 AFM;垂直度量可能来自 FontDescriptor、hhea、FontBBox 或内置表。把这些来源的优先级排清楚,比记住任何一个具体数字都重要。

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

相关文章