深入CSS:字体指标、line-height、vertical-align

3 分钟读完

本文翻译自《Deep dive CSS: font metrics, line-height and vertical-align》

line-heightvertical-align 都是很简单的CSS属性,简单到我们相信自己能够完全理解它们怎么工作以及怎样使用它们。其实了解这两个属性不是件易事,因为它们在CSS中起着一个鲜为人知却十分重要的作用:内联格式化上下文

例如,line-height 可以被设为长度或者无单位的值,默认值为 normal。但normal值具体是多少呢?通常应该是1或者1.2,甚至 CSS规范 也没有明确。我们知道无单位的 line-height 是相对于字体大小来确定的,但问题是 font-size: 100px 对于不同的字体来说渲染的结果不尽相同,这时 line-height 的表现是否也是不一样呢?是否真的是介于1到1.2之间呢?还有 vertical-align 会对 line-height 有什么影响呢?

先从 font-size 谈起

下面是简单的HTML代码,一个 <p> 标签包含三个不同 font-family<span>

<p>
  <span class="a">Ba</span>
  <span class="b">Ba</span>
  <span class="c">Ba</span>
</p>
p { font-size: 100px }
.a { font-family: Helvetia }
.b { font-family: Gruppo }
.c { font-family: Catamaran }

不同的字体使用相同的 font-size 导致不同的元素高度

不同的font-family,相同的font-size, 结果高度不同

即使我们已经知道到这个结果,但是为什么 font-size: 100px 不是创建高为100px的元素呢?测量到不同字体的不同高度:Helvetica:115px,Gruppo:97px,Catamaran:164px

font-size: 100px 的元素高度从97px到164px不等

尽管乍一看起来有点奇怪,但完全可以预料。原因就在字体本身。它的工作原理如下:

  • 字体定义它的 em-square(也被称作“EM size”或者“UPM”),在一个字体中,每个字符都放置在一个方块空间容器内。方块使用相对单位,通常为1000个单位,也可以是1024或者2048或者其他。

  • 基于它的相对单位,设置字体的指标(ascender 顶高、descender 底深、capital height 大写高度、x-height x高度等等,顶高和底深分别在基线的上下)。注意有些值是可以大于em-square的。

  • 在浏览器中,相对单位会被缩放以适应所需字体的大小

FontForge 查看Catamaran字体的指标

  • em-square设置为1000

  • 顶高为1100,底深为540。经过一些测试,浏览器在Mac OS上使用 HHead Ascent/Descent,Windows上使用 Win Ascent/Descent(这些值可能会不一样)。我们也可以注意到 capital height 为680以及 x height 为485。

Catamaran字体的指标

这意味着Catamaran字体设置 font-size: 100px 时,其在1000单位的em-square中使用1100 + 540个单位,得到实际高度164px。此结果定义元素的 content-area 的高度。你可以把 content-area 视为 background 属性应用的区域。

你也可以看出大写字母高68px(680单位)以及小写字母高49px(485单位)。1ex = 49px、1em = 100px,而不是164px(幸运的是,em 是基于 font-size 而不是计算高度)

Catamaran字体使用font-size: 100px对应的UPM和等效像素

在更深入之前,简单介绍它涉及的内容。当一个 <p> 元素被渲染,根据它的宽度,它可以由若干行组成。每行由一个或多个 line-box 的内联元素(HTML标签或文本内容的匿名内联元素)组成。line-box 的高度取决于它的子元素高度。因此,浏览器计算每个内联元素的高度,从而计算线框的高度(从其子元素的最高点到最低点)。最终 line-box 的高度(默认)足以包含其所有子元素。

每个HTML元素其实就是一堆 line-box,如果你知道了该元素每个 line-box 的高度,你就可以知道这个元素的高度了。

更新之前的HTML代码:

<p>
  Good dedign will be better.
  <span class="a">Ba</span>
  <span class="b">Ba</span>
  <span class="c">Ba</span>
  We get to make a conswquence.
</p>

这会生成三个 line-boxs

  • 第一个和最后一个包含着一个匿名内联元素(文本内容)

  • 第二个包含两个匿名内联元素以及三个 <span>

一个 p 元素由包含内联元素(实线边框)和匿名元素(虚线边框)的 line-box 组成

很明显看到第二个 line-box 高于其他的,由于计算其子元素的 content-area,更具体来说是那个使用了Catamaran字体的子元素。

创建 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 的高度

内联元素有两个不同的高度

可以看出 line-height 并不是基线之间的距离。

在CSS中,line-height并不是基线之间的距离

virtual-areacontent-area 的差值平均分配到 content-area 的顶部和底部。 因此 content-area 总是位于 virtual-area 的中部

line-heightvirtual-area)可以等于、大于或者小于 content-area。如果 virtual-area 的值更小,那么差值就为负数,line-box 视觉上就会小于其子元素。

其他类型的内联元素:

  • replaced-inline-element(<img><input><svg>等等)

  • inline-block 以及所有的 inline-* 元素

  • 参与特定格式化上下文的内联元素(例如:包含在 flexbox 里以及所有的 flex items 都是 blocksified

对于这些特殊的内联元素,高度的计算基于其 heightmargin以及border属性。如果 heightauto,则使用 line-heightcontent-area 严格等于 line-height

inline-replaced-elements、inline-block/inline-* 以及blocksified-inline-element的content-area等于其height/line-height

我们面临的问题始终是 line-heightnormal 值是多少。首先需要通过字体指标计算 content-area 的高度。

回到 FontForge,Catamaran 的 em-square 为1000,我们可以看到很多 ascender/descender 的值:

  • 一般 Ascent/Descent:ascender 为770,descender 为230。用于字体绘制

  • 指标 Ascent/Descent:ascender 为1100,descender 为540。用于 content-area 的高度

  • 指标 Line Gap(行间隙),通过加上 Ascent/Descent 的值计算出 line-heigh: normal 的值

在这个例子中,Catamaran 字体定义 line gap 值为0,所以 line-height: normal 等于 content-area 的1640单位或者1.64

为了方便对比,Arial 字体的 em-square 为2048单位,ascender 为1854,descender 为434,line gap 为67。所以当 font-size: 100px 时,content-area 的高为112px((1854 + 434) / 2048 * 100px),line-height: normal 为115px((1854 + 434 + 67) / 2048 * 100px)。不同的字体指标可能不一样,由字体设计人员设置。

很显然,将 line-height 设为无单位值可能达不到预期效果。无单位值是相对于 font-size 而不是 content-area,当 virtual-area 的高度小于 content-area 的高度会导致很多问题。例如 line-height: 1 时可能会导致 line-box 的高度小于 content-area 的高度:

line-height: 1时可能会导致 *line-box* 的高度小于 *content-area* 的高度

计算 line-box 的一些小细节

  • 对于内联元素, padding 以及 border 增大了 background 的区域,但不会增加 content-area 的高度(也不会增加 line-box 的高度)。因此,content-area 不总是你在屏幕上看到的。margin-topmargin-bottom 也不会对其造成影响。

  • 对于 replaced-inline-element、inline-block 以及 blocksified 内联元素:paddingmarginborder 会增加 height,所以会增加 content-area 以及 line-box 的高度。

vertical-align

到目前为止,我还没有提及 vertical-align 属性,尽管它是是计算 line-box 高度的重要因素,甚至可以说它在内联格式化上下文起着主导作用。

它的默认值是 baseline,字体指标 ascender 和 descender 决定了 baseline 的位置。ascender 和 descender 的占比很少五五开,例如对于兄弟元素,可能导致意想不到的效果。请看如下代码:

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

一个 <p> 元素包含着两个继承了 font-familyfont-size 和固定的 line-height 的兄弟元素 <span>。它们的基线相同,而且 line-box 的高度也等于它们的 line-height

相同的字体值,相同的基线,看起来并无大碍

如果第二个元素的 font-size 改为比较小的呢?

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

如下图所示,默认的基线对齐可能会导致更高的 line-box。正如前面所说的 line-box 的高度取决于它的子元素的最高点和最低点。

一个较小的子元素可能导致 line-box 的高度增加

这可能是 line-height 支持无单位值的论据,但有时你需要确切的值才能完美实现垂直居中其实无论你选择那种方式,你总会遇到内联对齐问题

另外一个例子,设置一个 <p> 标签 line-height: 200px,其子元素 <span> 继承它的 line-height

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

line-box 的高度是多少?期望是200px,但结果并不是。问题在于 <p> 拥有它自己的、不同的 font-family(默认是 serif)。<p><span> 的基线可能会不一样,因此结果的高度高于预期的高度。出现这个结果的原因是浏览器在对齐 line-box 的子元素时,以一个零宽度的字符开始,这个零宽度字符称作支柱(strut)。

*line-box*的子元素对齐以一个不可见的零宽度字符开始

根据规范,middle 使元素的中部与父元素的基线加上父元素的 x-height 的一半对齐。基线比率不同,x-height 的比率也不同,所以 middle 对齐不是真的“在中部”。涉及的因素(x-height,ascender/descender 比率等等)太多,而且这些因素不能通过CSS设置。

不过 vertical-align 还有四个其他属性在某些情况下或许有用。

  • vertical-align: top / bottomline-box 的顶部或底部对齐

  • vertical-align: text-top / text-bottomcontent-area 的顶部或底部对齐

Vertical-align: top, bottom, text-top, text-bottom

但要注意的是在所有情况下,设置该属性的元素都会与父元素的 virtual-area 对齐,也就是不可视高度。以下是一个使用 vertical-align: top 的简单例子。不可视的 line-height 可能会导致奇怪的结果

不可视的 line-height 可能会导致奇怪的结果

最后,vertical-align 还接受具体的长度值,表示使元素的基线对齐到父元素的基线之上的给定长度,可以是负数。

令人惊叹的CSS

我们已经了解了 line-heightvertical-align 之间是如何工作的,也知道字体指标不可以使用 CSS 控制。但是每种字体指标是固定值,或许可以用来进行某些运算。

例如,我们使用 Catamaran 字体,要使其大写字母高度恰好为100px,该怎么办?

首先,我们将所有字体指标设置为CSS自定义属性,然后计算高度为100px的大写字母所需的 font-size

p {
  /* 字体指标 */
  --font: Catamaran;
  --fm-capitalHeight: 0.68;
  --fm-descender: 0.54;
  --fm-ascender: 1.1;
  --fm-linegap: 0;

  /* 期望的大写字母高度的 font-size */
  --capital-height: 100;

  /* 应用 font-family */
  font-family: var(--font);

  /* 计算 font-size,使大写字母高度等于100px */
  --computedFontSize: (var(--capital-height) / var(--fm-capitalHeight));
  font-size: calc(var(--computedFontSize) * 1px);
}

大写字母高度为100px

接下来我们想要文本在可视区域的中部,剩下的空间平均分布在大写字母“B”的顶部和底部。所以我们需要基于 ascender/descender 比率来计算 line-height

首先计算 line-height: normalcontent-area 的高度:

p {
  /* ... */
  --lineheightNormal: (var(--fm-ascender) + var(--fm-descender) + var(--fm-linegap));
  --contentArea: (var(--lineheightNormal) * var(--computedFontSize));
}

然后我们需要计算:

  • 大写字母底部到底部边缘的距离

  • 大写字母顶部到顶部边缘的距离

p {
  /* ... */
  --distanceBottom: (var(--fm-descender));
  --distanceTop: (var(--fm-ascender) - var(--fm-capitalHeight));
}

现在我们可以计算 vertical-align,它的值等于以上两个距离的差值乘于 font-size。(该值需应用到一个行内子元素)

p {
  /* ... */
  --valign: ((var(--distanceBottom) - var(--distanceTop)) * var(--computedFontSize));
}
span {
  vertical-align: calc(var(--valign) * -1px);
}

最后,我们设置所需的行高并计算它,同时保持垂直居中

p {
  /* ... */
  --line-height: 3;
  line-height: calc(((var(--line-height) * var(--capital-height)) - var(--valign)) * 1px);
}

不同的 line-height 结果,文本始终垂直居中

现在很方便地添加一个与字母“B”同样高度的图标了:

span::before {
  content: '';
  display: inline-block;
  width: calc(1px * var(--capital-height));
  height: calc(1px * var(--capital-height));
  margin-right: 10px;
  background: url('https://cdn.pbrd.co/images/yBAKn5bbv.png');
  background-size: cover;
}

图标与字母 B 的高度一致

例子演示

注意这些例子仅供演示,不应直接应用,原因:

  • 除非字体指标是常量,否则浏览器的计算结果可能不一样

  • 如果未加载字体,则回退的字体可能具有不同的字体指标

总结

这篇文章讲了:

  • 内联格式化上下文有点难以理解

  • 所有内联元素拥有两个高度:

    • content-area(基于字体指标)

    • virtual-arealine-height

  • line-height: normal 准确值的计算基于字体指标

  • line-height: 1 可能会创建一个高度小于 content-areavirtual-area

  • vertical-align 并不是真的“在中部”

  • line-box 的高度的计算基于其子元素的 line-heightvertical-align

  • 无法使用 CSS 获取或设置字体指标

留下评论