深入理解 CSS:字体度量、line-height 和 vertical-align

翻译自Deep dive CSS: font metrics, line-height and vertical-alignopen in new window

line-heightvertical-align都是简单的 CSS 属性,简单到我们绝大多数人都自信已经完全明白它们是如何工作的,以及如何使用它们。但是,事实却不是这样。它们确实很复杂,甚至是最难理解的,因为它们在 CSS 鲜为人知的特性————行内格式化上下文(inline formatting context,简称 IFC)的创建中承担着主要的角色。

举例来说,line-height的值可以设置为一个单位为px的长度值,或者是一个无单位的数字,但是默认值是normal。但是,normal是什么呢?我们经常了解到它可能是1,可能是1.2,甚至CSS 规则也没有说清楚这一点open in new window。我们知道,若设置line-height为无单位的数字,其计算值为相对于font-size的倍数,但问题是,100px在不同的字体族表现不一致,那么line-height是相同的呢还是不同的呢?它真的是在11.2之间吗?以及,vertical-alignline-height存在什么样的联系呢?

让我们一起深入了解下不是如此简单的 CSS 机制。

术语中英文对照

  • line-box: 行框
  • content-area: 内容区域
  • virtual-area: 实际区域

font-size

如下的代码里,p标签包含了 3 个span标签,每一个span标签都设置了不同的字体。

<p>
    <span class="a">Ba</span>
    <span class="b">Ba</span>
    <span class="c">Ba</span>
</p>
1
2
3
4
5
p { font-size: 100px }
.a { font-family: Helvetica }
.b { font-family: Gruppo }
.c { font-family: Catamaran }
1
2
3
4

相同的span元素,使用了相同的font-size,不同的font-family,但最终高度却不一样。

图 1 图 1: 相同的font-size,不同的font-family,高度不一样

实验结果:

BaBaBa

译者注:这几款字体你的设备上可能没有,导致显示的结果与图 1 略有区别

即使我们意识到这种行为,但是为什么font-size: 100px没创建出100px的高度呢?我已经测量了每个span元素最终的高度值: Helvetica,115px; Gruppo,97px以及 Catamaran,164px

图 2 图 2: font-size: 100px的元素的高度分别为97px/115px/164px

尽管一开始我们会认为有些怪异,但是这完全是符合预期的。原因就在于字体自身,如下说明了它是如何工作的:

  • 每个字体都定义了em-square(或 UPM, units per em),这是一种容器,字符将在这个容器里绘制。这个em-square使用的是相对单位,并且通常是 1000 个相对单位,但是相对单位数也可以是 1024、2048 等。
  • 字体的度量也是基于这种相对单位,比如ascenderdescendercapital heightx-height等等。注意,有些度量值可以会超出em-square方框
  • 浏览器里,相对单位将依据font-size进行相应的缩放,比如font-size: 100px,则 1000 个相对单位的大小即为100px,500 个相对单位即为50px

让我们在FontForgeopen in new window里打开 Catamaran 字体,以获取它的一些度量值。

  • em-square是 1000 个相对单位
  • ascender是 1100 个相对单位,descender是 540 个相对单位。经过测试发现,Mac OS 上的浏览器使用的是HHead Ascent/Descent值,Windows 使用的是Win Ascent/Descent值,而这两种取值可能还不一样。我们还注意到capital height是 680 个相对单位,x height是 485 个相对单位。

(译者注:

  • 这里的ascender值指的是基线到字符顶端的距离
  • 这里的descender值指的是基线到字符底部的距离
  • 这里的capital height值指的是大写字母的高度
  • 这里的x height值指的是小写字母x的高度

图 3 图 3: FontForge 上 Catamaran 的字体度量值

这意味着尽管em-square只有 1000 个相对单位, 但 Catamaran 字体却使用了 1100 + 540 个相对单位,也就是说,当设置font-size: 100px时,Catamaran 字体的文字高度为164px。这个计算高度定义了元素的内容区域content-area,我将在之后解释这个概念。你可以认为,内容区域content-area就是背景属性应用的地方。

我们还可以看到,大写字母的高度是68px(680 个相对单位),小写字母(x-height)是49px(485 个相对单位)。因此,1ex = 49px,而1em = 100px,而不是164px。(值得感谢的是,em是相对于font-size,而不是计算高度)

图 4 图 4: Catamaran 字体,UPM(Units Per Em)和使用font-size: 100px时对应的像素

在继续深入之前,我们先来看看这解决了什么问题。当p元素渲染在屏幕上时,它会包含多行,具体行数由其内容的宽度决定。每一行都由一或多个行内元素(HTML 标签或匿名行内文本元素)组成,每一行都成为line-boxline-box的高度是基于它子元素的高度。浏览器会计算这一行里每一个行内元素的高度,最后计算出line-box的高度,即取最高的子元素的上边界和最低子元素的下边界之间的距离。因此默认情况下,line-box总是足够高以包含它所有的子元素。

每一个 HTML 元素实际上都是由一到多个line-box组成的,只要你知道每一个line-box的高度,你就能知道这个元素的高度。

如果我们像下面这样修改之前的 HTML 代码:

<p>
    Good design will be better.
    <span class="a">Ba</span>
    <span class="b">Ba</span>
    <span class="c">Ba</span>
    We get to make a consequence.
</p>
1
2
3
4
5
6
7

这将生成 3 个line-box:

  • 第一个和最后一个line-box都包含了一个简单的匿名行内文本元素
  • 第二个line-box包含了两个匿名行内文本元素,和 3 个span元素

图 5 图 5: p元素(黑色边框)有多个line-box(白色边框)组成,line-box里包含了行内元素(实线边框)和匿名行内元素(虚线边框)

我们可以明显的看到,第二个line-box比其他两个要更高一些,因为它子元素的content-area,更确切地说,使用了 Catamaran 字体的子元素的content-area

关于line-box的创建最难的是,我们无法看见,也无法通过 CSS 控制。即使对::first-line伪类添加背景也不能给我们任何关于第一个line-box高度的视觉效果。

line-height

到现在为止,我介绍了两个概念:content-arealine-box。如果你仔细阅读,你会发现我说过line-box的高度是根据它子元素的高度计算而来的,但并没有说是它子元素content-area的高度,这二者区别甚大。

一个行内元素有两个不同的高度:内容区域content-area高度和实际区域virtual-area高度(我发明了virtual-area这个术语,作为对于我们来说不可见的高度,你在规格文档里找不到这个术语)。

  • content-area高度通过字体度量定义
  • virtual-area高度就是line-height,并且它会被用于计算line-box的高度

(译者注:这里的content-area高度是实际上绘制出来的高度,但是virtual-area高度是元素实际占据位置的高度。针对font-size: 16px; line-height: 12px的元素,其最终绘制出来的content-area高度是通过font-size: 16px和字体度量计算出来的,即用户能看到的字体最顶端到最底端的距离;而这个元素的virtual-area只占据了12px的高度。因此,在这种line-height小于font-size的情况下,我们可能会看到文字重叠在一起)

图 6 图 6: 行内元素有两个不同的高度

也就是说,这打破了“line-height是基线之间的距离”这一广受欢迎的说法,在 CSS 里,不是这样。

图 7 图 7: CSS 里,line-height不是基线之间的距离

virtual-area高度和content-area高度的差值,称之为leadingleading的一半添加到content-area的顶部,另一半添加到content-area的底部。因此,content-area总是在virtual-area的中间位置。

经过计算之后,line-height(也就是virtual-area的高度)可以与content-area高度相同,也可以更大或更小。若是leading是负数,则virtual-area高度就比content-area高度要小,而且line-box实际上也会比其子元素要小。

这里罗列了其他几种行内元素:

  • 可替换行内元素(imginputsvg元素等)
  • displayinline-blockinline-*的元素
  • 参与特定格式化上下文的行内元素(比如,flex布局元素的所有直接子元素的display都是blockified

对于上面这些特定的行内元素,其高度是基于它们的heightmarginborder属性来计算的(译者注: 漏掉了padding?另外,这里行内元素的高度应该是指包括heightborderpadding在内的元素的总高度,即行内框inline box的高度)。若是将height设为auto,则line-height将被使用,且content-area严格等于line-height(译者注: 即此时height的计算值等于line-height的计算值)。

图 8 图 8: 行内替换元素、inline-block元素、inline-*以及blocksified行内元素,其内容区域等同于heightline-height

然而,我们现在仍然没弄明白line-height: normal的值到底是什么。这个答案,关系到content-area高度的计算,就藏在字体度量里面。让我们回到 FontForge,Catamaran 字体的em-square是 1000,但是我们见到了很多ascender/descender的值:

  • generals Ascent/Descent: ascender是 770,descender是 230,用于绘制字符。(table “OS/2”)
  • metrics Ascent/Descent: ascender是 1100,descender是 540,用于计算content-area的高度。(table “hhea” and table “OS/2”)
  • metric Line Gap: 用于line-height: normal, 通过将这个值添加到metrics Ascent/Descent . (table “hhea”)

在这个场景里,Catamaran 字体定义的Line Gap是 0,那么line-height: normal将等于content-area,即 1640 个相对单位,或者说是 1.64(即em-square的 1.64 倍)。

作为比较,Arial 字体的em-square是 2048 个相对单位,ascender是 1854 个相对单位,descender是 434 个相对单位,Line Gap是 67 个相对单位。这也就是说,font-size: 100px时,

  • content-area的高度为 112px,即 (1854 + 434) / 2048 * 100px = 111.72px
  • line-height: normal的值为 115px,即 (1854 + 434 + 67) / 2048 * 100px = 115px

所有这些度量都是与指定字体相关的,也是由字体设计师设置的。

如此看来,设置line-height: 1是个很糟糕的实践。设置line-height为无单位的数字,其值是相对于font-size的,而不是相对于content-area,因此导致virtual-areacontent-area要小,这也是很多问题的原因。

图 9 图 9: 使用line-height: 1会创建一个比content-area小的line-box

但不仅是line-height: 1有问题。我电脑上从 Google Web Fonts 安装的 1117 款字体,其中 95% 约 1059 款字体计算出的line-height要比 1 大,它们计算出的line-height最小是 0.618,最大是 3.378。你没看错,是 3.378!

行框line-box计算的一些小细节:

  • 对于行内元素,paddingborder增大了背景区域,但是不会增大content-area的高度(也不会增大line-height的高度)。因此你通常在屏幕上看到的并不是content-areamargin-topmargin-bottom也不会起作用。
  • 对于可替换的行内元素、inline-block元素和blocksified行内元素,paddingmarginborder会增大heightcontent-area,以及行框line-box的高度。(译者注: paddingmarginborder应该只会影响到line-box的高度,不会影响heightcontent-area

vertical-align

我还没提到vertical-align属性,尽管它是计算行框line-box高度的必要因素。我们甚至可以说,vertical-align可能占据着行内格式化上下文 IFC 中是主导性的位置。

vertical-align属性的默认值是baseline。你还记得字体度量的ascenderdescender?这些值决定了baseline的位置,以及比率。由于ascendersdescender的比率很少有 1:1 的,这将导致一些超出预期的结果,比如相邻的两个元素:

<p>
    <span>Ba</span>
    <span>Ba</span>
</p>
1
2
3
4
p {
    font-family: Catamaran;
    font-size: 100px;
    line-height: 200px;
}
1
2
3
4
5

p标签有两个相邻的span元素,继承了font-familyfont-size、以及固定的line-height。它们将基于基线对齐,并且行框line-box的高度等于spanline-height

图 10 图 10: 相同的font-family、相同的baseline,一切看起来都正常

但是给第二个span设置一个更小的font-size呢?

span:last-child {
    font-size: 50px;
}
1
2
3

这看起来有些奇怪,默认的基线对齐导致产生了更高的行框line-box,如下图所示。我之前说过,行框line-box高度是由最高子元素的顶边到最低子元素的底边计算而来的。

图 11 图 11: 子元素设置了更小的font-size,导致产生了更高的行框line-box

这个例子可以作为一个论据,以支持“line-height的值应该使用无单位的数字”,但是有时候你需要将line-height写成固定值以创建更好的排版。实话说,不管你选择哪种方式,你都将在行内对齐上遇到麻烦。

让我们再来看个例子。一个p标签,line-height: 200px,包含了单个span标签,并继承了line-height

<p>
    <span>Ba</span>
</p>
1
2
3
p {
    line-height: 200px;
}
span {
    font-family: Catamaran;
    font-size: 100px;
}
1
2
3
4
5
6
7

那么行框line-box是多高呢?我们期望是200px,但是实际上不是。问题就在于p元素有自己的font-family(默认是serif),这与spanfont-family不一样。p元素和span元素的baseline位置是不一样的,因此行框line-box的高度比期望的高。这种问题的发生,是因为浏览器会认为每一个行框line-box的起始位置有一个零宽度的字符,规格文档里称为strut,这个字符将参与行框line-box高度的计算。

不可见的字符,但是会导致可见的影响。

让我们继续之前的问题。

图 12 图 12: 每个子元素对齐时,会认为行框line-box起始位置有一个不可见的零宽度的字符

基线baseline对齐,令人费解。那么vertical-align: middle会不会好一些呢?就像你从规则文档里了解到的,middle是“对齐行内框的垂直中心点,对齐点是parent boxbaseline加上parent boxx-height的一半的高度”。基线baseline的位置与字体有关,x-height也是,因此middle对齐也是不可靠的。更糟糕的是,在绝大多数场景里,middle并不是真正的“中心点”,这个过程里有太多因素(比如x-height, ascender/descender的比率等)参与,因此不能通过 CSS 来实现。

顺便说一下,还有四个其他值,在一些场景里可能有用:

  • vertical-align: top / bottom: 以行框line-box的顶部或底部对齐
  • vertical-align: text-top / text-bottom: 以content-area的顶部或底部对齐

图 13 图 13: vertical-align: top/bottom/text-top/text-bottom

但你仍然需要小心,在大多数情况下,对齐的是实际区域virtual-area,也就是不可见的高度。如下这个示例使用了vertical-align: top,不可见的line-height可能产生奇怪的但是意料之中的结果。

图 14 图 14: vertical-align可能产生奇怪的结果,但是当考虑到line-height后,就理所当然了

最后,vertical-align的值也可以是数字,这将相对于基线baseline提升或降低整个行内框,不到万不得已的时候,最好别用。

总结

  • 行内格式化上下文 IFC 真的很难理解
  • 所有的行内元素有两种高度:
    • 内容区域content-area高度(基于字体度量)
    • 实际区域virtual-area高度(line-height
    • 这两种高度,你都是不可见的
  • line-height: normal基于字体度量
  • line-height: 1可能创建导致实际区域virtual-area比内容区域content-area要小
  • vertical-align不是特别可靠
  • 行框line-box的高度是基于子元素的line-heightvertical-align属性计算得出的
  • 我们无法轻易通过 CSS 获取或设置字体度量