(五)文字排版与渲染:字体度量模型

在 Web 开发中,为页面文字设置字体是一件很常见的事。但只要切换字体,即使文字内容和字号都没有变,也很容易发现文字看起来变大或变小了,原来的位置和疏密也会随之改变。

类似的问题还会出现在跨格式、跨软件的转换中。比如,把网页中的内容转换成 PPT 或 SVG,再分别用 PowerPoint、Illustrator 等软件打开。即使文字内容、字号和使用的字体都保持不变,文字仍可能发生偏移,占位范围和视觉大小也可能与网页中的效果对不上。

这两类问题看起来都是文字显示发生了变化,背后的原因却不相同。切换字体,等于把排版所使用的一组字体数据换成了另一组;更换软件,则可能改变同一组数据的换算、组合和绘制方式。只说"字体不一样"或"软件不一样",仍然无法知道差异具体出在哪里,也很难预判它会怎样影响最终结果。

上一篇《(四)文字排版与渲染:字体资源的生命周期》已经追踪了字体资源怎样从设计结果进入系统,并说明字体资源会把度量作为一类基础数据提供给排版环境。本文从这处接口继续,不再重讲资源怎样形成、封装和被选择,而是专门考察一份已经确定、能够被系统单独使用的字体资源提供了哪些尺寸与位置数据。

本文将从这些常见差异出发,继续追踪这些数据怎样根据字号被换算为页面中的长度,不同软件在使用它们时又可能从哪里产生差异。通过这条路径,我们可以从原理上理解这些现象背后有哪些基本机制在作用,而不只是记住几个互不相连的参数。最终得到的不是一条能够直接算出所有结果的"字体高度公式",而是一套判断问题的方法:分清当前测量的对象,识别变化发生在哪个环节,并为预测字体切换的影响和继续排查跨软件偏差提供依据。

一、问题背景

1.1 字体差异

当我们在浏览器中给一段文字设置一个字号 font-size,之后切换字体,最先看到的是字形风格改变了。但变化并不止于风格:字母或汉字在垂直方向上可能显得更高或更低,横向占位可能变宽或变窄,文字相对周围边界的位置也可能发生变化。

相同字号之所以没有消除这些差异,是因为 font-size 并不直接决定每个字实际看起来有多大。它只是为整套字体提供一个共同的缩放基准,却没有要求每种字体中的每个字符,例如 Hx必须画成同样高、占据同样宽的区域。不同字体可以围绕同一个字号安排不同的外形比例、左右占位和上下参考位置。

因此,切换字体不只是换了一种视觉风格,也会让浏览器读取另一套字体设计数据。要看清字体本身带来了哪些变化,可以先保持浏览器不变,只更换字体。接下来再反过来保持字体不变,只更换使用它的软件,这样就能把两类变化分开来看。

1.2 环境差异

另一种情况是固定同一份具体字体资源和同一段文字,只更换使用它们的环境。例如,让文字分别出现在浏览器、打开 PPTX 文档的 PowerPoint,以及 Photoshop 或 Illustrator 中。即使几个界面都显示"18 px"或"18 pt",文字也未必落在完全相同的位置,更不保证拥有相同的行占位或最终显示效果。

这时,字体提供的原始材料没有变,但使用材料的过程变了。每个环境都需要先把界面中设置的字号转换成自己页面中的长度,再决定怎样放置文字、怎样为相邻文字留出空间,以及最后怎样把文字显示在屏幕或输出文件中。不同环境可以在这些步骤中采用不同规则,所以"资源相同"只固定了数据来源,并没有固定全部处理条件。

我们由此得到两条需要分开的观察路径:固定环境换字体,用来追踪字体提供的数据怎样变化;固定字体换环境,用来追踪同一份数据怎样被不同系统使用。它们都可能改变最终画面,却不发生在同一个环节。要继续定位具体偏差,需要先弄清楚字体数据记录在哪里、为什么能随字号缩放,以及这些数据分别描述了文字的哪些大小和位置,再逐项检查字号换算、排版规则和最终绘制。

1.3 文字生成链路

要知道差异可能从哪里进入,需要把一段文字从输入到输出的完整链路展开来看。这个过程同时涉及字体资源的生产和文字输入后的处理:它们起点不同,却会在排版时汇合。

第一条是字体的生产路径:字体设计者先决定字形的外形、比例和排版关系,再把这些设计构建成可以重复使用的字体资源。这份资源在用户输入文字之前就已经存在。

第二条是文字的运行路径:排版环境接收字符序列,选择字体资源,从中取得相应的字形和相关数据,再把它们组织成页面中的文字,最后输出到屏幕或其他目标。

在这里先介绍几个相关概念:

  • 字符(character) 是文本中的抽象信息单位,例如字符"x"表达的是文本内容,还没有指定由哪一种具体形状呈现。
  • 字形(glyph) 是某个字体中用于呈现文字的具体图形单位。字符和 glyph 并不稳定地一一对应:一个字符可能因上下文选择不同 glyph,多个字符也可能合成一个 glyph。例如,同一个阿拉伯字母在词首、词中和词尾可能使用不同的 glyph;字符序列 fi 在启用连字时,则可能组合成一个 glyph。
  • 可缩放字体(scalable font) 中的"可缩放",是指同一份字体数据可以根据字号放大或缩小。例如,把字体从 12px 改为 48px 时,系统不需要换用一套专门为 48px 绘制的字形,而是用原来的字形数据按比例计算出新的大小。本文主要讨论其中的可缩放轮廓字体:它用轮廓而不是固定尺寸的像素图记录字形外形,常见的 TrueType、CFF 与 CFF2 都是轮廓格式。
  • 轮廓(outline)可缩放轮廓字体中描述可见外形的矢量路径。许多可见 glyph 使用轮廓表示外形,但空格一类 glyph 可以只有排版占位而没有可见轮廓。
  • 字体资源 包含字符到 glyph 的对应关系、字形轮廓,以及与文字大小、位置和排版占位有关的度量数据(metrics)。注意,这里的度量数据(metrics)在字体设计和制作阶段就已经形成:有些由设计者直接设定,有些可以根据字形轮廓计算出来。文字进入排版环境后,系统会读取这些既有数据,再根据字号把它们换算成当前页面中的长度。
  • shaping 是文字显示前实际发生的一步处理:排版系统结合字符序列、所用字体、script、语言和上下文,选择具体的 glyph,并确定它们的排列位置。这里的 script 不是程序脚本,而是拉丁文字、阿拉伯文字、天城文这类用于书写语言的符号体系;例如,英语和法语是不同语言,但都使用拉丁 script。HarfBuzz 对 text shaping 的说明
  • glyph run 中的 run 表示一段被当作整体处理的连续序列 ,不是"运行"这个动作。shaping 完成后,字符序列会变成一串已经选定、并带有前进距离和位置调整的 glyph;这段序列就是 glyph run。它还不是最终的一行文字,排版系统还需要把一个或多个 glyph run 组织成行,并决定它们在页面中的位置。DirectWrite 中的 glyph run 数据结构
  • 栅格化(rasterization) 是把已经获得尺寸和位置的矢量几何转换为设备像素覆盖的过程。

完整链路由几种性质不同的过程组成:

  • 设计意图被编码为字体数据;
  • 字体内部数值按当前字号转换为排版环境内的长度;
  • 字符在上下文中被转换并组织成 glyph 序列和行;
  • 排版几何再被转换为最终像素。

本文聚焦两条路径之间的交汇处,也就是字体资源提供了哪些尺度与度量数据、它们怎样进入排版空间,以及不同数值分别说明什么。图中仍会保留 shaping、行构造和栅格化,但本文不会把这些过程简化成一次单位换算。

沿着这条接口,我们能够回答这三个问题:

  • 相同 font-size 到底统一了什么;
  • 整个字体的通用参考值与单个 glyph 的几何数据有什么区别;
  • 当字体或环境发生变化时,差异可以从哪些位置开始出现。

二、坐标空间

在一张二维图中,一个点的位置只有在三个条件明确时才有意义:从哪里开始计算、横向和纵向分别朝哪边增加,以及每一格代表多长。字形轮廓也是二维图形,记录它的形状和距离时同样需要这些约定。原点、X / Y 轴方向和单位合在一起,就构成了一套坐标空间。

文字从字体资源走到屏幕,会依次使用不同的坐标空间。在字体设计空间中,轮廓点和相关距离先以字体内部单位记录,还没有固定为 16px12pt;进入排版空间后,这些内部距离才按当前字号换算成页面中的长度;到了设备空间,系统再决定轮廓最终覆盖哪些像素。因此,三者不是同一套单位的不同名称,而是记录字形、安排页面和生成像素时分别使用的坐标规则。

下图使用同一个 glyph 展示三者的关系。左侧的轮廓仍处在字体设计空间的内部网格中;中间的轮廓已经获得当前页面中的尺寸和位置,但仍是可连续缩放的排版几何;直到右侧,栅格化才决定它覆盖哪些设备像素。

2.1 字体设计空间

可缩放字体在制作时,还不知道最终会显示为 12px100px,还是用于印刷或其他输出环境。因此,设计者不能直接用屏幕像素记录字形,而是需要先在字体内部保存轮廓点的位置、glyph 的基础占位数据和整套字体共享的参考值。

本文把这套不依赖最终输出尺寸的内部坐标环境称为字体设计空间 。其中的原点、X / Y 方向和内部单位共同组成字体的设计坐标系。字形轮廓上的每个点都可以用 (x, y) 坐标记录,相关距离也可以相互比较,但这些数值此时还不是厘米、point 或像素。

这套坐标网格使用 em square 表示一 em 的方形参考尺度,它的宽和高都为一 em。这里可以先把 em 理解为整套字体中每个 glyph 共用的一把尺子:字形轮廓和各种距离用它记录相对大小;设置字号后,排版环境再把这一 em 换算成页面中的长度。这个概念来自活字印刷,下文"em"小节会进一步说明它的来源及其在数字字体中的含义。

字体再把一 em 划分成固定数量的内部单位,这个数量就是后文将要介绍的 UPM。em square 规定共同尺度,UPM 决定网格分得多细,原点和轴向则决定坐标怎样解释,它们共同让字形轮廓和度量能够以内部坐标保存。

em square 不是轮廓的裁切边界。一个 glyph 可以只使用其中一部分,也可以伸到它外面。em square 与 (0,0) 之间也没有"左下角必然是原点"的统一关系:在常见的横排字体中,y = 0 通常对应 baseline,而 x = 0 与轮廓之间没有统一关系。

2.2 排版空间

字体设计空间解决了"怎样保存可缩放的内部关系",却还不能回答这段文字在页面上究竟占多大。浏览器需要把文字与容器、图片和页面边界放在一起计算,幻灯片或图形应用也需要用自己的页面坐标安排对象。字体内部长度因此必须先被转换到当前环境使用的逻辑坐标系中。

这套供当前页面组织位置和长度的坐标系,就是排版空间 ;其中使用的单位可以统称为 layout unit 。只有进入这个空间之后,字体资源中的 700 design units 才可能在一组明确条件下变成 11.2 CSS px8.4 pt 或另一种应用内部的长度。转换后的轮廓仍然是排版中的矢量路径,并不会因为使用了 px 这个名称就提前变成一格一格的屏幕像素。

本文以 Web 作为主要环境,因此后文默认用 CSS px 表示排版长度。在 CSS 的长度系统内,1in = 96px1pt = 1/72in,所以 1pt = 4/3px。这项关系来自 CSS Values and Units,说明的是浏览器怎样求值 CSS 长度;它不能脱离条件直接推广到 PowerPoint、印刷软件或原生图形 API。进入其他环境时,仍要重新确认它采用的单位、页面坐标和导出规则。

2.3 设备空间

排版空间已经能回答一个 glyph 在页面中有多大、从哪里放置,却仍然没有决定屏幕上的哪些像素会亮起。一个 CSS px 也不固定等于某一块物理屏幕上的一个设备像素:页面缩放、设备像素比和其他坐标变换,都可能改变两者之间的映射。

当坐标与单位最终绑定到具体输出设备时,就进入了设备空间 。浏览器会把排版空间中的矢量轮廓继续变换并栅格化,使它落到离散的 device pixels 上。devicePixelRatio 可以帮助描述当前页面条件下 CSS pixel 与 device pixel 的尺度关系,但即使这个比例已经确定,hinting、栅格化和抗锯齿仍会影响轮廓边缘覆盖哪些像素以及覆盖到什么程度。

三个空间由此形成一条明确的职责链:

  • 字体设计空间保存内部几何数值和关系
  • 排版空间赋予它当前页面中的逻辑尺寸
  • 设备空间再把排版结果变成具体像素覆盖

字体度量的基础换算终点是排版空间,而不是设备像素。后文谈到字号、轮廓范围和最终画面时,都需要先判断当前数值属于哪一个空间。

三、字体尺度

三个坐标空间说明了内部几何最终会去往哪里,但中间还缺少一座桥:同一份字体数据为什么既能显示为 12px,也能显示为 18px100px?字体资源必须提供一个稳定的内部参考,排版环境再为这个参考指定当前页面中的大小。

本文把这组连接关系称为字体尺度。它不是字体资源中的某一个独立字段,而是一组彼此配合的角色:

  • em 提供共同参考尺度(见"字体空间"的图示)
  • UPM 说明一 em 在字体内部被划分为多少个设计单位
  • font-size 则为一 em 选择当前排版空间中的使用尺寸

三者共同决定字体内部长度怎样按比例进入页面。

3.1 em

em 这一术语来自活字印刷。在活字排印中,不同字形的轮廓大小并不相同,但它们需要被并排组合,并在同一行中保持稳定的字号和基线位置。为此,同一字号的金属活字会采用共同的字身高度,字身宽度则可以随字形变化。这份与字身高度相同的长度,成为排版时衡量文字大小和间距的共同尺度,也就是一 em。Thomas Phinney 对金属字身与数字 em 的说明

em 不是某组单词的缩写,而是英文字母 M 的名称拼写。这个名称由早期排印中的 m quadrat,也就是后来的 em quad 沿用而来,传统上也常与大写 M 联系在一起。不过,M 的实际宽度并不总是一 em,因此这层联系只能帮助理解名称来源,不能作为 em 的定义。

进入数字排印后,真实的金属字身消失了,这份尺度却被保留下来,成为字体内部虚拟的 em square。字体通过 UPM 把一 em 划分成内部单位,排版环境再按照当前字号决定这一 em 在页面中有多大。因此,em 是可缩放字体用来记录内部比例,并随字号一起缩放的共同尺度 。em square 的宽和高都为一 em,因此它确实是正方形;但它既不是某个 glyph 的可见宽高,也不是轮廓的包围盒。OpenType 对 em square 与内部单位的说明

em 的角色在可缩放字体之间是相同的,但每种字体怎样使用这套尺度并不相同。设计者可以让小写字母在一 em 中占据较大的比例,也可以留出更多上下空间;不同 glyph 的宽度、可见高度和相对基线的位置也可以不同。em 因而不是任意 glyph 的实际宽度或高度,更不能作为一条可见边界去套住所有轮廓。

这也解释了为什么 font-size 相同不等于字符高度相同:字号会为 em 指定外部大小,而每个 glyph 在同一个 em square 中有自己的设计比例,这些设计比例会随着 em 与外部尺寸之间的映射而相应发生变化。要把这些比例实际记录进字体资源,仅有"一 em"这条参考尺度还不够,还需要规定它包含多少内部刻度。

3.2 UPM 与字体设计单位

em 只规定了整套字体共同使用的大小基准,但字体资源还需要用具体数字记录每个 glyph 轮廓点的位置、占位宽度和各种距离。为此,字体会把一 em 等分成许多小格,每一格就是一个字体设计单位(design unit) 。一 em 总共被分成多少个单位,就是 UPM,全称 units per em。

在 OpenType 字体中,可以在 head.unitsPerEm 字段中看到 UPM 的值。如果 UPM 为 1000,表示一 em 被分成 1000 个单位;UPM 为 2048,则表示一 em 被分成 2048 个单位。横向和纵向的位置与距离都使用这套单位记录。

例如,在 UPM 为 1000 的字体中,700 design units 相当于 700 / 1000 = 0.7em。UPM 更大不代表字体显示得更大,只表示同样的一 em 被划分得更细;它最终显示多大,仍然由字号决定。

图中的两个 em square 大小相同,只是内部坐标的刻度密度不同。若同一相对位置位于一 em 的 70% 处,它在 UPM 1000 的字体中可以记作 700,在 UPM 2048 的字体中则约为 1434;两者表达的仍是同一个 0.7em 比例。

UPM 越高,直接增加的是内部坐标网格的刻度密度。但它不能证明字体更清晰、轮廓更精细或屏幕输出更好;最终效果还取决于轮廓设计、hinting、缩放和栅格化等其他条件。

3.3 font-size

em 和 UPM 都来自字体内部,却没有指定文字在当前页面上显示多大。这个外部尺寸由排版环境提供。在 Web 中,承担这一角色的是 font-size。按照 CSS Fonts 的定义,对可缩放字体,font-size 是施加到字体 EM unit 上的缩放因子。因此,font-size: 100px 可以理解为:当前字体的一 em 在排版计算中对应 100 CSS px。

如果文字没有被另外拉宽或压扁,font-size: 100px 就表示一 em 在横向和纵向都按 100 CSS px 计算。em square 的宽和高都是一 em,因此此时对应一个 100px × 100px 的参考方框。

这个方框只提供缩放基准,并不规定 glyph 必须占满它。H 的轮廓可能只使用其中一部分,带重音符号的 glyph 也可能伸到方框外面。换句话说,font-size 决定的是这把共同的尺子有多大,不是每个 glyph 最终必须画多大。

这就是相同字号仍会产生不同文字几何的第一层原因:不同字体都可以把一 em 映射为相同的 100 CSS px,却在这一 em 中提供不同的轮廓比例、基础占位和字体级参考距离。

3.4 尺度换算

当一个内部数值的测量对象已经确定,字体设计空间到排版空间的基础换算可以写成:

text 复制代码
排版长度 = 字体内部长度 × font-size / UPM

其中,字体内部长度,就是字体内部的度量数据,如果要计算某个度量值最终在指定的排版环境下,得到的具体值,就可以用这个公式。

如果需要用变量表示,可以把字体内部长度记为 L_design,排版长度记为 L_layout

text 复制代码
L_layout = L_design × font-size / UPM

这个关系包含两步:先用 L_design / UPM 得到内部长度占多少 em,再乘以当前 font-size 得到排版长度。前面的 700 design units 在 UPM 为 1000、font-size: 100px 时,会得到 700 × 100 / 1000 = 70 CSS px

这里的 L 可以是 X 方向的宽度,也可以是 Y 方向的高度。下图把一个解释性的 H 放进 UPM 为 1000 的 em square:它的轮廓宽度为 600 design units、高度为 700 design units;当 font-size: 100px 时,同一个轮廓进入排版空间后分别得到 60 CSS px70 CSS px。横向并没有另一套尺度公式,两根轴使用同一个基础比例。

这是一条展示尺度关系的基础公式 ,不是能够直接给出"文字最终大小"的完整公式。要想得到一个 glyph 在排版环境下更精准的数值,还需要结合其他度量参数一起考虑。接下来,我们将介绍两个维度的度量参数:字体级度量字形度量

四、字体级度量

在上一章节我们介绍了关于字体设计的基础概念和基础公式,理解了"内部长度怎样获得外部尺寸"。而在本章节,我们需要了解,当设计一套字体时,为了让同一字体下的所有 glyph 在视觉上保持统一,让整套字体拥有一致的比例、共同的对齐依据和可预期的空间关系,需要为 glyph 设置哪些公共参数。如果没有这些公共参数,这些 glyph 即使单独成立,排列在一起也不会形成协调的视觉系统。

这些公共参数,被称为字体级度量(Font Metrics)。它们对整套字体设置,作用范围不是某一个字符,而是当前字体整体。

字体级度量根据承担的职责,大致可以划分为三类:

  • 共同的设计参考或特征:例如基线(baseline)、x-height 与 cap height;
  • 供排版使用的范围与间距建议:例如 ascent、descent 与 line gap;
  • 从全体 glyph 汇总出的范围:例如最大 advance 与最小 side bearing。

这三类数据都是整套字体共用的参考值,但不要求每个 glyph 的轮廓都刚好到达相应位置。glyph 可以没有达到这些位置,也可以超出;这些位置不会成为裁切轮廓的边界。

下图先以常见的拉丁文字横排为例,把字体级参考和单 glyph 的轮廓范围放在同一个坐标中:横跨多个 glyph 的线属于字体级参考,围住单个 glyph 的虚线框则属于下一章要讨论的字形度量范围。

4.1 基线

当我们把 Hxp 放在同一行时,不能简单地把三个轮廓的底边对齐,因为 p 本来就需要伸到其他字母下方。字体设计需要一条不依赖任意单个轮廓底边的共同参考线,用来规定每个 glyph 相对于什么位置设计和放置。这条线就是基线(baseline)

对常见的拉丁文字横排,可以先把字母基线(alphabetic baseline) 理解为大多数字母在一行中共同对齐的横向参考线。x 的轮廓下缘通常接近这条线,p 的下伸部会越过它向下延伸;o 这类底部为圆弧的字母,也可能略微伸到线下,使它在视觉上与底部平直的字母看起来一样齐。因此,基线是字形对齐时使用的参考线,不是某个 glyph 的轮廓边,也不等于"字符底部"。

基线不一定是水平线。文字纵排时,一列 glyph 同样不能直接按照各自轮廓的左边或右边对齐,因此也需要一条纵向延伸的共同参考线。横排与纵排中的基线承担的是同一种职责,只是方向和主要控制的位置不同:

排版方式 基线怎样延伸 glyph 序列怎样排列 基线主要统一什么
横排 沿水平方向延伸 glyph 沿基线向左或向右排列 不同轮廓的上下位置
纵排 沿垂直方向延伸 glyph 沿基线向上或向下排列 不同轮廓的左右位置

基线只回答"一行或一列 glyph 依据哪条线对齐",还不能说明当前 glyph 具体位于这条线的哪个位置。要把一个二维字形准确定位到基线上,还需要下一章将要介绍的字形原点(origin)

不同书写系统和排版方向还可能使用不同类型的基线,字体资源也能够提供相关参考数据。这里先建立横排基线与纵排基线的共同作用;具体环境怎样选择基线、直立或旋转的 glyph 怎样对齐,属于后续的排版过程。

这里还要区分字体数据与 CSS 中的同名概念。两者共享"对齐参考线"这一几何思想,但不处在同一层:字体中的 baseline 是字形设计和字体坐标中的参考;CSS inline layout 中的 baseline 则是浏览器用于对齐 glyph 与 inline-level box 的布局基准。它会结合所选字体和 CSS 盒子规则确定,并不是某一个 OpenType 字段的别名。

同样,CSS 的 line-height 规定 inline box 对 line box 的首选高度贡献,并不等同于字体资源中的 lineGap。更完整的 baseline alignment 与 line box 关系会在后续文章中展开。

4.2 横排中的上升、下降与行间距

下面继续以横排为例。建立横排基线之后,字体制作者还需要记录整套字体在它上方和下方采用怎样的纵向参考,以及希望默认排版保留多少额外行间距。这三个问题可以对应到行业中常见的三个定义:

  • ascent :表示 baseline 上方的一段字体级参考距离
  • descent :表示 baseline 下方的一段字体级参考距离
  • line gap :则表示字体建议在相邻基线范围之外额外加入的间距

它们的共同点是作用于整个字体,而不会随着单个字形而改变。多个字形可以共享同一组 ascent 和 descent 参考,同时保留完全自身的轮廓高度;且所有字形的轮廓也不需要都贴合这些参考位置。

当多行文字被放进一个文本框时,排版环境需要决定三件事:第一行基线距离文本框顶部多远、相邻两行的基线相隔多远,以及最后一行基线距离文本框底部多远。ascent、descent 和 line gap 可以为这些计算提供初始参考:ascent 用于估算基线上方需要保留的空间,descent 用于估算基线下方的空间,line gap 则用于补充相邻行之间的间距。

不过,这些数值只是排版计算的输入,不是最终结果。同一个字体可能需要被不同的排版系统使用,例如操作系统文本渲染、桌面排版软件或 Web 浏览器,因此字体资源中可能保存多组不同用途的 ascent、descent 和 line gap 数据。排版环境会根据自身规则选择合适的一组数据;随后,CSS line-height 或其他页面布局规则还可能进一步调整行内空间。

因此,这些字段提供的是字体推荐的排版参考,而不是最终显示结果。它们既不能直接表示某个 glyph 的可见边界,也不能单独决定最终行高。

4.3 参考值与聚合范围

ascent 和 descent 描述相对 baseline 的字体级纵向参数,字体设计中还存在更直接的视觉特征。例如:

  • x-height 近似表示 baseline 到无上伸部小写字母主体高度的距离(例如,xm 是无上伸部的小写字母,而 fb 是有上伸部的小写字母);
  • cap height 近似表示 baseline 到大写字母高度的距离。

它们通常由字体设计者指定,可以帮助解释为什么两种相同字号的字体,字形轮廓的高度并不一致;但它们仍是整套字体的特征值,不是每个小写或大写字形必须遵守的轮廓边界。

另一些字体级数值不是由字体设计师直接指定,而是从所有 glyph 的字形度量中汇总出来的结果。单个 glyph 先分别具有自己的推进距离(advance)和轮廓边距(side bearing);把这些数据汇总后得到的最大 advance、最小 side bearing,才是描述整套字体范围的字体级数据。

这里暂时只需要知道:推进距离表示排完一个 glyph 后,下一字形原点默认前进多远;轮廓边距表示轮廓与前后字形原点所确定的推进边界之间的位置关系。它们的具体空间关系会在下一章"字形度量"中展开。这里引入它们,是为了说明字体级度量除了共同参考线和间距建议,还会记录整套字体中的单字形数据可能达到怎样的范围。

4.4 横排与纵排会使用不同的数据

横排和纵排都需要解决两个问题:一行或一列中的 glyph 怎样继续排列,以及下一行或下一列应该放在哪里。排版方向改变时,坐标轴本身不会改变;改变的是排版系统选择和使用的数据组合。

排版方式 同一行或同一列中的基础推进 相邻行或相邻列的位置参考
横排 glyph 通常沿 X 轴排列,使用 advance width;LSB / RSB 描述轮廓在两个横排字形原点之间的位置,这些距离沿 X 轴测量 各行沿 Y 轴排列,需要字体级的上下参考与行间距建议
纵排 glyph 通常沿 Y 轴排列,使用 advance height;TSB / BSB 描述轮廓在两个纵排字形原点之间的位置,这些距离沿 Y 轴测量 各列沿 X 轴排列,需要字体级的左右参考与列间距建议

因此,advance width 始终沿 X 轴测量,advance height 始终沿 Y 轴测量。横排数据和纵排数据的区别,不是把 X、Y 两根轴互换,而是为两种排版方式分别提供所需的字形级数据和字体级参考。一组横排或纵排数据都可能同时包含沿 X 轴和 Y 轴测量的数值。

这张表只说明两种排版方向分别需要哪些基础关系,不代表其中某个数值可以直接决定最终字间距、行距或列距。advance 确定相邻 glyph 的基础推进距离,kerning、GPOS 或排版环境设置的额外字间距还可能继续调整它;字体级数据提供行或列之间的参考,最终距离仍由具体排版环境决定。OpenType 资源怎样保存这些数据,会在后面的"在字体资源中读取度量数据"章节说明;它们怎样被排版系统选择和组合,则留到后续文章中展开。

作用范围 典型对象 回答的问题
整个字体 baseline、ascent、descent、line gap、x-height、cap height、最大 advance 当前字体提供哪些共同参考或聚合范围?
单个 glyph outline extent、字形原点(origin)、advance、bearings 这个具体字形画到哪里、从哪里定位、默认前进多远?

字体级度量由整个字体统一提供,作用范围不会因为当前字符从 H 换成 x 就随之改变;字形级度量则属于某一个具体 glyph,会随选择结果改变。明确这条范围边界后,才能继续回答单个 glyph 的可见几何和排版占位。

五、字形度量

字体级度量为整套字体建立了共同参考,主要解决的是整套字体的字形在整体排版上的视觉统一,但它不能回答一个具体的 H 究竟画得多宽、轮廓从哪里开始,或者排完这个 glyph 之后下一个 glyph 应该从哪里继续。要回答这些问题,视角必须从整个字体收窄到某一个已经选定的 glyph。

字形度量描述或推导的是单个 glyph 的轮廓与基础排版关系。它至少需要把三件事分开:轮廓实际延伸到哪里;这套局部轮廓在排版坐标中从哪个参考点放置;放置完成后,下一个 glyph 的默认参考点在哪里。如果把这三件事都叫作"字符宽高",后面的任何测量结果都会失去明确含义。

日常观察可以从字符进入,字体数据层却必须固定一个具体 glyph。下面使用 HA 或"永"作为样本时,讨论的都是字体已经选定的 glyph,而不是那个字符在所有字体和上下文中的唯一形状。

5.1 字形边界

在可缩放轮廓字体中,许多可见 glyph 的外形由二维坐标中的矢量路径描述。要知道某个 glyph 的轮廓在 X、Y 两个方向分别延伸到哪里,可以沿坐标轴画出一个包住这组轮廓数据的矩形。这个矩形称为轮廓框(outline bounding box) ,行业资料中常缩写为 outline bbox ;它的左、右、下、上边界分别用 xMinxMaxyMinyMax 表示。

轮廓框与 em square 不是同一个框。em square 是整套字体共享的一 em 尺度参考,大小不会随着当前 glyph 改变;轮廓框则只围住当前 glyph 的轮廓数据,每换一个 glyph,位置和大小都可能不同。轮廓可以只占 em square 的一部分,也可以伸到它外面,轮廓框不会因此被裁切。

实际得到轮廓框时,需要找出轮廓数据在 X、Y 两个方向上的坐标范围。不同轮廓格式可能直接保存这四个边界值,也可能需要根据轮廓计算;具体来源会在"在字体资源中读取度量数据"章节说明。无论采用哪种方式,这些边界都需要包住轮廓数据,但不一定紧贴最终曲线。例如有些字体资源会存放 xMinxMaxyMinyMax,那就可以通过这四个值得到轮廓的宽和高:

text 复制代码
outline width  = xMax - xMin
outline height = yMax - yMin

由此得到的 outline width 和 outline height,只说明当前 glyph 的轮廓范围。它们不等于 glyph 的排版前进距离,也不等于整个字体的 ascent--descent 参考范围。空格一类没有可见轮廓的 glyph 仍然可以拥有 advance,因此"有排版位置"不等于"有可见边界"。

这里讨论的是字体资源中单个 glyph 的矢量轮廓框。浏览器中的测量接口可能返回其他对象:例如,getBoundingClientRect() 返回 DOM 元素经过页面布局后占据的矩形,Canvas measureText() 返回当前字体和字号下一段文本的度量结果;它们都不是单个 glyph 在字体资源中的原始轮廓框。文字最终覆盖哪些设备像素,则属于更后的栅格化结果。

5.2 字形原点 origin、推进距离 advance 与轮廓边距 side bearing

前面的字体级度量章节已经说明,Hxp 不能拿各自轮廓框的底边直接对齐,而要共同依照一条横排基线。基线确定了这一行文字共同的上下位置,但它仍然只是一条线。例如在横排场景下,当绘制一个 H 时,我们只知道它纵向上会被放在基线处,但是横向上还不清楚具体在哪里,而 originadvanceside bearing 就是在解决这个问题。

接下来,我们以横排场景下, H 的放置为例进行观察和讲解。假设在基线上选定一个点作为H 的横向放置点,并把它记为 P0。这个点就是当前 glyph 的字形原点(origin) ,也称 pen position

字形原点本身只是一个点,没有宽和高。它能够定位二维轮廓,是因为字体设计空间已经规定了 X、Y 轴的方向和内部单位。可以近似想象成一张带有坐标网格和字形轮廓的透明图纸:字形原点是图纸上的 (0,0)排版时把这个原点放在基线上某个位置,那么整张网格和轮廓也会一起移动。

为了理解原点与轮廓框之间的关系,我们可以暂时把 P0 记为相对坐标 (0,0),再用 design units 表示轮廓位于它的左边、右边、上方还是下方。轮廓框的 xMinxMaxyMinyMax 就说明轮廓相对这个原点延伸到哪里。字形原点不要求位于轮廓上,也不要求位于轮廓框的某个角;它既可能落在轮廓框内,也可能位于框外。FreeType 对 glyph metrics 的说明同样把 origin 定义为基线上用来定位 glyph 的虚拟 pen position。

当前 H 定位以后,排版系统不会把轮廓框的右边界直接当成下一 glyph 的起点,而会从 P0 沿基线向前移动一段推进距离(advance) ,得到下一 glyph 的字形原点 P1。横排中的这段距离称为 advance width。暂时不考虑 kerning 或其他位置调整时,一行 glyph 的默认位置就是由这些字形原点依次连接出来的;即使轮廓没有填满推进范围或已经伸出边界,下一字形原点仍然由 advance 决定。也就是说,advance width 和轮廓框并不一定相等,advance width 可能大于、等于或者小于轮廓框。

P0 → P1 原本只表示沿基线的一维推进距离,轮廓框却是一个二维范围。为了把两者放进同一幅图中比较,我们可以在 P0P1 的位置画两条竖线,再补上上下两条边,画出一个矩形的推进参考框(advance box)。这个参考框的宽度表示 advance width;上下边只是为了把一维推进范围画成矩形,没有独立的横排度量含义。推进参考框只是帮助理解的图示模型,不是字体资源中的正式对象,也不是 glyph 自己的坐标系。

把轮廓框叠加到推进参考框中,两个框便组成了一幅完整的位置示意。从 P0 所在边到轮廓框左边界,沿横排推进方向量出的距离,是左侧轮廓边距(left side bearing,LSB) ;从轮廓框右边界到 P1 所在边的距离,是右侧轮廓边距(right side bearing,RSB) 。它们统称为轮廓边距(side bearing)。推进参考框正是为了把轮廓框、推进边界和这些边距放在一起观察而引入的。

用具体数字看这幅图:P0 的横坐标记为 0H 的轮廓框从 x = 80 延伸到 x = 580,因此轮廓宽度(outline width)是 500 design units,LSB 是 80。如果 advance width 是 700P1 就位于 x = 700;轮廓框右边界到 P1 还剩 700 - 580 = 120 design units,所以 RSB 是 120

同理,纵排场景下遵循同样的规则,只是推进方向变为沿纵排基线上下移动。把当前与下一纵排字形原点分别记为 V0V1,二者之间的推进距离称为 advance height 。将 V0V1 延伸成上下两条边界,再补上没有独立度量含义的左右边,就得到纵排示意中的推进参考框。轮廓框与上下推进边界之间的距离,分别是上侧轮廓边距(top side bearing,TSB)下侧轮廓边距(bottom side bearing,BSB)

以纵排的"永"为例,为了只看沿推进方向的数量关系,可以把 V0 记为 0:轮廓框上边界位于向下 100 design units 处,轮廓高度是 500 design units,所以轮廓框下边界位于 600;如果 advance height 是 700V1 就位于 700,因此 TSB 和 BSB 都是 100。这里的 0 → 700 是沿页面纵排推进方向建立的观察方式,不是直接照抄字体设计坐标;字体坐标通常让 Y 值向上增加,与页面向下推进的方向相反。

图中的两组数值分别形成下面的关系:

text 复制代码
横排:700 = 80 + 500 + 120
advance width = LSB + outline width + RSB

纵排:700 = 100 + 500 + 100
advance height = TSB + outline height + BSB

这两个公式把字形原点、推进距离、轮廓框和轮廓边距的位置关系写成了数值关系,但不规定字体设计工具必须先产生哪一个值。设计者可以先调整轮廓两侧的距离,再得到 advance;也可以先确定 advance,再计算另一侧还剩多少。OpenType 资源保存哪些值、又从哪些值推导其他结果,会在"在字体资源中读取度量数据"章节说明。

轮廓边距也可以为零或负数。它的正负直接对应轮廓框是否越过推进参考框的相应边界:

  • LSB < 0:轮廓框向左越过 P0 所在的推进边界;
  • RSB < 0:轮廓框向右越过 P1 所在的推进边界;
  • TSB < 0:轮廓框向上越过 V0 所在的推进边界;
  • BSB < 0:轮廓框向下越过 V1 所在的推进边界。

负值并不自动说明字体数据错误。它只表示轮廓伸出了相应的推进范围;下一字形原点仍然由 advance 决定。一个没有可见轮廓的空格也可以拥有 advance,一个轮廓很窄的 glyph 则可以在推进范围中保留较大的空白。

上面讨论的还是以 design units 记录的字体内部关系。假设示例字体的 UPM 为 1000,font-size: 100px,所有长度都会按 100 / 1000 = 0.1 的比例缩放:横排的 LSB、轮廓宽度、RSB 和 advance width 会变成 8px50px12px70px;纵排的 TSB、轮廓高度、BSB 和 advance height 会变成 10px50px10px70px。缩放只改变单位和数值,不改变轮廓框相对字形原点与推进边界的位置。

进入排版空间后,系统会先把当前字形原点映射到页面中的实际位置,整套缩放后的二维网格和轮廓也随之定位;随后,横排根据 advance width 得到下一横排字形原点,纵排根据 advance height 得到下一纵排字形原点。字形原点由此把字体内部记录的二维关系与页面中的实际位置连接起来。

到这里,可以把本章出现的三个"框"放在一起区分:

对象 定义 与当前轮廓的关系
em square 整套字体共同使用多大的内部尺度 内部数据与外部数据进行换算的标尺,与轮廓本身没有直接关系
轮廓框(outline bbox) 当前 glyph 的轮廓相对字形原点延伸到哪里 轮廓的最小包围盒
推进参考框 在特定排版方向中,两次字形原点之间默认推进多远 把轮廓框、推进边界和轮廓边距放在一起观察,不是字体资源中的正式对象

因此,一个单 glyph 的基础视图已经建立完成:

  • 基线为一组 glyph 提供共同的对齐线;
  • 字形原点把当前 glyph 的二维坐标定位到这条基线上;
  • 轮廓框是轮廓的最小包围盒;
  • 推进距离连接当前字形原点与下一字形原点;
  • 推进参考框把一维推进距离和二维轮廓框放进同一幅图;
  • 轮廓边距说明轮廓框与推进边界之间还剩多少距离,或者已经越过多少。

这组关系仍然只是单 glyph 的基础数据。多个 glyph 组成序列后,shaping 会选择具体 glyph,kerning 或 GPOS 还可能根据上下文调整位置。字体度量可以告诉我们某个 glyph 默认前进多远,却不会独自决定整段文字最后怎样排列。

六、在字体资源中读取度量数据

前面的章节已经把共同尺度、字体级参考和单 glyph 几何分开。进入一份 OpenType 字体资源后,这些对象分别落在职责不同的数据表与轮廓数据中。这里不再重讲字体资源、文件载体和轮廓格式之间的完整关系,只把已经建立的度量对象对应到实际数据来源。

6.1 用检查工具观察真实数据

我们可以通过线上字体解析工具 opentype.js Font InspectorGlyph Inspector 观察真实字体数据。Font Inspector 会展开 headhheaOS/2 等字体级数据表;Glyph Inspector 会展示具体 glyph 的轮廓、边界与相关度量。

6.2 字体级字段

OpenType 中没有一组能够代表"唯一字体高度"的字段。这里所说的"上下范围",是整套字体相对 baseline 向上与向下提供的参考距离,不是某个 glyph 的轮廓边界。看起来都在描述上下距离的数据,可能分别用于排版参考、默认基线间距、裁切范围或字体设计特征。

这里的"字体建议的默认基线间距",是指排版环境没有另外指定行距时,可以从字体字段获得的相邻两行 baseline 之间的距离。它由 baseline 上方的参考距离、下方的参考距离和额外的 lineGap 共同形成,不是两行可见字形之间的空白,也不直接等于 CSS 最终计算出的 line-height

前文对象 OpenType 表 字段 这组数据描述什么
字体内部尺度 head unitsPerEm 一 em 被划分为多少个字体设计单位
横排字体级参考(默认基线间距候选) hhea ascenderdescenderlineGap ascender - descender + lineGap 得到一组默认基线间距候选值:即上一行的基线到下一行的基线之间的距离
横排字体级参考(默认基线间距候选) OS/2 sTypoAscendersTypoDescendersTypoLineGap 用同样的结构得到另一组默认基线间距候选值;当前规范优先推荐这一组
字体级裁切范围 OS/2 usWinAscentusWinDescent usWinAscent + usWinDescent 得到建议裁切跨度;它不是默认基线间距
字体设计特征 OS/2 sxHeightsCapHeight 小写主体与大写字母的近似参考高度
纵排字体级参考 vhea vertTypoAscendervertTypoDescendervertTypoLineGap 纵排时相对中心 baseline 的左右参考距离与间距建议

这三组字段可以同时存在于同一份字体资源中,但用途并不完全相同。hhea 表中的三个字段与 OS/2 表中的三个 sTypo* 字段回答的是同一类问题:默认情况下,相邻两行 baseline 之间应当保留多少距离。它们是两组候选来源,排版环境选择其中一组进行计算,不会把两组数值相加。

usWinAscentusWinDescent 回答的是另一个问题:为了不裁掉超出 baseline 上下的内容,绘制或编辑区域至少应当覆盖多大范围。因此,应用可以同时用 sTypo* 计算默认基线间距,再用 usWin* 确定裁切范围;这两种用途并不冲突。一些旧应用曾把 usWin* 也用于默认基线间距,但当前 OpenType 规范不建议这样做。

同一张 OS/2 表中的 fsSelection 是一个由多个开关位组成的字段,其中第 7 位名为 USE_TYPO_METRICS。它不保存任何距离。当这一位被设置为 1 时,字体是在建议应用:计算默认基线间距时,应优先使用 sTypo*,不要沿用旧实现中的 usWin*。它表达的是字体提供方的选择建议,不能保证每个排版环境都会遵循。

用于默认基线间距的两组字段并存,主要来自平台历史与兼容需要。hhea 中的字段具有 Apple 平台的历史背景,sTypo* 则用于提供更适合跨环境复现的排版度量。当前 OpenType 规范建议优先使用 sTypo*CSS Inline Layout也优先采用这组字段,缺失时再回退到 hhea 表中的字段。选定字段组后,其中的距离仍按前文建立的 font-size / UPM 比例进入排版空间。

6.3 单字形字段

具体 glyph 的轮廓边界、横排度量和纵排度量也来自不同位置。有些值直接保存在字体中,另一些值需要根据已知数据推导:

前文对象 OpenType 表 字段 读取或推导方式
轮廓框(outline bbox) TrueType glyf,或 CFFCFF2 glyfxMinxMaxyMinyMax;CFF/CFF2:没有直接保存单 glyph bbox 的字段 glyf 可以直接读取;CFF/CFF2 需要根据轮廓计算
advance width、LSB hmtx advanceWidthlsb 作为单 glyph 的横排度量直接读取
RSB hmtx 与轮廓所在的 glyf/CFF/CFF2 advanceWidthlsb,以及轮廓的 xMinxMax 或计算所得 bbox advance width - LSB - outline width 推导
advance height、TSB vmtx advanceHeighttopSideBearing 作为单 glyph 的纵排度量直接读取
vertical origin Y vmtx 与轮廓表;CFF/CFF2 字体还可以使用 VORG topSideBearingyMax;或 defaultVertOriginYvertOriginY 通常由 yMax + TSB 得到,或由 VORG 直接提供
BSB vmtx 与轮廓所在的 glyf/CFF/CFF2 advanceHeighttopSideBearing,以及轮廓的 yMinyMax 或计算所得 bbox advance height - TSB - outline height 推导

表中有些值可以直接读取,有些需要根据已知值计算。无论来源如何,读取后仍要先确认它描述的是字体级参考、轮廓范围还是排版占位,再按 font-size / UPM 缩放。到这里,前文的理论对象已经落到了字体资源中的具体数据;读者不需要继续跟随完整解析程序或工具操作,也能理解这些字段分别描述什么。

七、字体度量的结论与后续问题

相同 font-size 只会把不同字体各自的一 em 缩放到相同的排版长度,不会让其中的字形拥有相同的可见宽高、上下位置或推进距离。更换字体时,字体级参考、字形轮廓与排版占位都可能随数据来源一起变化,因此相同字号仍会产生不同的基础几何。

判断这些差异时,还要先分清正在测量什么。整套字体的基线、上下参考和间距建议作用于一组 glyph;单个 glyph 则拥有自己的轮廓边界、原点、advance 和 bearing。它们共享同一尺度换算关系,却回答不同问题,不能被合并成一个没有来源和用途限定的"字体高度"或"文字大小"。

进入 OpenType 字体资源后,这些对象分别对应 headhheaOS/2vheahmtxvmtx 以及具体轮廓数据。字段映射让我们知道字体提供了什么,也能判断某个差异是否已经存在于字体、尺度或测量对象中。到这里,本文已经说明字体交给排版系统的基础度量数据是什么,以及这些数据如何随字号换算。

但从这些数据到最终出现在屏幕或文档中的文字,排版系统还需要回答几个实际问题:

  • 一串字符进入排版系统后,怎样变成一组已经确定形状和位置的字形?
  • 相邻字符的组合或上下文改变时,为什么最终出现的字形和间距也可能改变?
  • 单个字形的大小已经确定后,为什么一整行或一整列文字仍会占据不同大小的空间,并在上下留下不同空白?
  • 同一段文字为什么会在浏览器、Canvas、SVG 或 PowerPoint 中得到不同的边界、位置和最终画面?

这些差异已经不再由某一个字体字段单独决定,需要继续追踪字符怎样变成排好位置的字形、字形怎样组成一行或一列,以及具体环境怎样测量和绘制文字。字体交给下游的不是一个可以包办所有结果的"字体高度",而是一组对象明确、来源可查、能够继续传递的度量数据。只有先认清这组输入,后面分析一串字符怎样被排版,以及同一段文字为什么会在不同环境中出现差异时,才不会把所有现象重新归因于字号。

相关推荐
深念Y15 天前
OnlyOffice 打开来自 Windows/WPS 的 Office 文档时,出现排版错乱、文字空白、字体显示异常。
linux·windows·debian·onlyoffice·字体·wps·office
KECCO717 天前
IP与文旅品牌如何打造专属字体?定制字体在文化表达与商业延展中的应用分析
人工智能·字体·设计·字体设计·定制字体
郝学胜_神的一滴22 天前
Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联
开源·计算机图形学
郝学胜-神的一滴25 天前
[简化版 GAMES 104] 现代游戏引擎 05:游戏引擎世界构建核心机制深度解析
c++·程序人生·unity·游戏引擎·计算机图形学·opengl
郝学胜_神的一滴1 个月前
Horse3D 游戏引擎研发笔记(五):Ferghana 编辑器——从场景 JSON 到可交互视口
计算机图形学·opengl
shawxlee1 个月前
vue3+elementplus+sass/scss安装配置教程:使用svg图标、引入外部字体、定义变量模块、修改组件样式、一键切换主题等
vue·sass·svg·字体·element·scss·主题样式
vivo互联网技术1 个月前
扩散模型自引导新范式 SSG:直接交换Token就能变强! | CVPR 2026 Oral
图像识别·计算机图形学
小孔龙1 个月前
Android 图形系统全景
android·计算机图形学
shawn03261 个月前
《疯狂动物城 2》背后的 Hybrid BVH:迪士尼如何让复杂场景跑进交互式 GPU 光追
前端·计算机图形学