CSS行内布局(一):字体

1,569 阅读11分钟

〇、前言

现如今,说起CSS布局方式,你可能想到的是Flex布局,或者是最新的Grid布局。不过,仍然有许多前端工程师使用行内布局来实现多列自适应、图片垂直居中等效果,更别说,在处理与文字排版相关的功能时,行内布局仍然是不二之选。

然而,在我使用行内布局的时候,常常会遇到vertical-align: middle怎么也不居中,或者行内元素和容器之间有一些莫名其妙的空隙等问题。对于有些问题,即使能从网上找到解决方案,我也搞不清楚这么做的原因,例如:为什么加上font-size: 0就好了(多数情况下,写这个样式的人并不是为了隐藏文字)。

于是后来,我系统性地学习了一下行内布局,阅读了大量CSS规范文档和技术博客,最后整理成这个系列文章,希望能帮助你真正深入到行内布局之中。随着文章的推进,你会发现行内布局其实是一个相当复杂的知识点,其中有诸多你不曾注意的盲区。

本文是系列的开篇和基础,将带你走进字体内部,向你解释:

  • 现代字体设计的一些基本概念
  • font-size到底是什么意思,它是如何作用于文字大小的?
  • 行内元素的内容区域的高度由什么决定?
  • 为什么同一个行内元素,在不同操作系统或浏览器中的高度可能不同?

本文讨论的主题涉及到平台兼容性问题,特此说明,本文所有的描述和结论基于以下平台:

  1. MacOS 10.13:Chrome 104、Safari 13.1、Firefox Developer Edition 105
  2. Windows 7:Chrome 104
  3. iOS 15: Safari

一、引子

首先,还是让我们从一个简单的例子开始。

在这个例子中,我做了以下事情:

  1. 在CSS中引入Catamaran字体(感谢googlefonts.cn提供谷歌字体服务)
  2. 为HTML中唯一的<span>设置了font-familyfont-sizeoutline
  3. 等待Catamaran加载完成后,显示出<span>的高度

正常情况下,最后显示出来的<span>的高度应该是164px。那么问题来了:

  1. 你知道font-size: 100px里的100px表示的是哪一段距离吗?是A的高度吗?
  2. 你知道这个<span>的高度为什么是164px吗?为什么不是100px?

为了寻找上面两个问题的答案,你可能首先想到的就是去查文档。那么,我们就来看看CSS规范里对这两个问题的解释。

首先是font-size

This property indicates the desired height of glyphs from the font. For scalable fonts, the font-size is a scale factor applied to the EM unit of the font. (此属性用于设置字体中字形的高度。对于可缩放字体,font-size是字体中EM unit的缩放因子。)CSS Fonts Module Level 4

然后是<span>的高度问题(由于在例子中并未设置paddingborder之类的属性,所以<span>的高度就是内容区域的高度):

The content area of the inline box is sized and positioned to fit (possibly hypothetical) text from its first available font. This specification does not specify how. A UA may, e.g., use the maximum ascender and descender of the font. (行内框的内容区域的大小和位置由第一个可用字体决定。本规范没有具体说明如何决定。例如,UA可以使用字体的最大升部和降部。)CSS Inline Layout Module Level 3

看完CSS规范的解释,你可能依然觉得一脸懵逼。不过没关系,至少我们知道了这两个问题都和字体有关。那么接下来,就让我们进入字体内部,来看看CSS规范说的到底是什么意思。

二、走进字体

目前,比较常见的字体格式有OpenType(.otf, .ttf)、TrueType(.ttf)和Web Open Font Format(.woff, .woff2)。这几种格式虽有不同,但是就本文涉及的知识点,这三者的设计是一样的,故下文不作区分,且结论同时适用于三者。

以Catamaran字体为例,让我们用字体设计软件打开来看一下,我这里用的是FontLab。假设我们要修改A和x的字形,那么进入到字形设计界面之后,就可以看到类似下面这张图一样的界面。为了方便描述,我从原图中截取了最重要的部分,并绘制了坐标轴,加粗显示了参考线及其名称。

fontlab

在图中,每个字形都置于一个直角坐标系中,且有5条参考线(需要强调的是,这些参考线不是我自己加的,而是所有字体都有这5个属性,只不过最终以参考线的形式显示在坐标系中而已):

  1. baseline:也就是基线,应该大家都比较熟悉。基线处于纵坐标0的位置,所以永远和横轴重合。
  2. ascent:字体中的所有字形从基线往上,最高不能超过的值,可以说是理论上的字形上边界,也就是CSS规范中提到的maximum ascender(ascender是指其他字形高于小写字母x的部分)。
  3. descent:字体中的所有字形从基线往下,最低不能超过的值,可以说是理论上的字形下边界,也就是CSS规范中提到的maximum descender(descender是指所有字形低于基线的部分)。
  4. x height:字体中小写字母x的高度。
  5. capital height:大写字母的高度。

所以我们可以从图中直观看出,A的高度为680,而x的高度为485。另外,由于这些字形都是矢量图形,所以这里的680和485等数字的单位不是像素、厘米或者磅,而是一个相对单位——unit

那么,这些矢量的字形是怎么和font-size对应起来的呢?这时候就需要用到字体中的另一个单位——em(读音与字母M相同),它是矢量字形与font-size之间的一座桥梁(其实更像是现实中美元的作用)。

一方面,在字体中有一个属性叫做UPM(Units Per EM)。顾名思义,UPM指定多少个unit组成一个em,这个值一般是1000或者2048。例如:在Catamaran字体中,UPM等于1000,也就是说 1000unit=1em1000unit = 1em

而另一方面,CSS规范中所说的“font-size是em的缩放因子”,其实是指font-size的值是多少,那么1em的值就是多少。例如:font-size: 100px,就是指 1em=100px1em = 100px ;而如果font-size: 16px,那么1em=16px1em = 16px

有没有发现,这与CSS单位中的em在很大程度上其实是同一个概念。例如:父元素的font-size等于16px,那么对于子元素来说,1em就等于16px。所以如果子元素设置font-size: 2em,那么最后渲染出来的字体大小就是:162=32px16 * 2 = 32px

综上,对于Catamaran这个字体,如果设置font-size: 100px,我们就可以得到:

1000unit=1em=100px1000unit = 1em = 100px

也就是:

1unit=0.1px1unit = 0.1px

有了这个转换公式,我们就可以很简单地计算出Catamaran字体中的字母A,在font-size等于100px时的高度等于:

680unit×0.1=68px680unit × 0.1 = 68px

类似的,还可以计算出,小写字母x的高度为48.5px;ascent的高度为110px;descent的高度为54px。

至此,我们差不多搞清楚了从矢量字形向实际字体大小转换的过程。

最后,还留下一个问题:<span>的高度为什么是164px。其实答案已经呼之欲出了。根据CSS规范中所说“UA可以使用字体的最大升部(ascender)和降部(descender)作为行内框的内容区域的高度”,而上文提到最大升部和降部其实就是ascent和descent的意思。那么,<span>的高度就是:

ascent+descent=110px+54px=164pxascent + descent = 110px + 54px = 164px

这其实是很合理的选择,因为理论上一个字体中的所有字形都应该在ascent和descent之间。不过实际上,ascent和descent仅仅是参考,并不是强制性的边界,所以设计师出于排版美观等因素的考虑,仍可以将某些字形超出ascent或descent之外,例如:

三、同一字体在不同平台显示的差异

我们现在已经知道,在现代浏览器中都是采用ascent+descentascent+descent的方式来计算行内元素的内容区域的高度。但是,有些字体在不同操作系统和浏览器中显示的高度仍然可能不同。这是因为在字体中,可以用来设置ascent和descent的字段有好几组,而设计师出于某些原因可能为这几组字段分别设置了不同的值。

首先,让我们来看下有哪几组字段可以用来设置ascent和descent。

  1. 下表列出的是在字体文件的metadata中保存的原始的字段名,而不是字体设计软件的UI上显示的名字,这是因为不同字体设计软件会为这几个字段取不同的名字。
  2. 字体文件的metadata分成好几个表。
表名ascent字段descent字段说明
Horizontal Header table (hhea)ascenderdescender默认情况下,MacOS系统中的浏览器会使用这两个字段
Windows Metrics table (OS/2)usWinAscentusWinDescent默认情况下,Windows系统中的浏览器会使用这两个字段
Windows Metrics table (OS/2)sTypoAscendersTypoDescender这俩字段其实是为了解决上面MacOS和Windows取不同字段而新加的统一字段,需要通过设置fsSelection里的bit位才能开启。目前,各平台对这俩字段的支持度还不够。在MacOS中,只有Firefox会读取这俩字段。

接着,以Roboto这个字体为例,让我们用opentype.js的Font Inspector来查看一下Roboto的metadata。

image.png

如图,由于Roboto并未开启sTypoAscender和sTypoDescender,所以如果我们将本文开头的例子中的Catamaran字体换成Roboto字体,那么在MacOS中,<span>的高度应为:

100÷2048×(1900+500)117.19px100 ÷ 2048 × (1900 + 500) ≈ 117.19px

而在Windows中,<span>的高度应为:

100÷2048×(1946+512)120.02px100 ÷ 2048 × (1946 + 512) ≈ 120.02px

不过由于各浏览器对px浮点数取整的逻辑略有差异,所以最终渲染出来的高度可能有1px的偏差。

四、第一个可用字体

接下来,我还想讲一下,在计算行内元素的内容区域高度的时候,CSS规范里强调的使用第一个可用字体是什么意思。

还是让我们来看一个例子。

<span style="
  font-family: 'Catamaran', 'PingFang SC', sans-serif;
  font-size: 100px;
  outline: 1px solid;
"></span>
<span style="
  font-family: 'PingFang SC', sans-serif;
  font-size: 100px;
  outline: 1px solid;
"></span>
<!-- 由于PingFang SC只在Mac和iOS上可用,-->
<!-- 所以对于使用Windows或Android的读者可自行改成对应平台的中文字体,如:微软雅黑  -->

image.png

我在本文最开始的例子的基础上做了一点修改。下面的两个<span>唯一的区别就在于font-family。我们知道,如果在font-family后设置多个字体,那么优先级是从左往右依次降低的。换句话说,如果较左边的字体不包含某个字符,那么就会尝试使用较右边的字体,直至找到包含那个字符的字体为止。而一般来说,英文字体中并不包含中文字符。所以实际上,上图中的两个“哈”字最终都是使用PingFang SC字体来渲染的,但是,对应<span>的高度却并不相同。

这是因为第一个<span>的第一个可用字体是Catamaran,所以它的内容区域高度基于Catamaran的ascent和descent计算得到;而第二个<span>的第一个可用字体是PingFang SC,所以它的内容区域高度基于PingFang SC的ascent和descent计算得出。

五、总结

至此,本文的主要内容就结束了。让我们再来总结下本文的知识点:

  1. font-size: 100px中的100px等于1em的大小,并不是直观表示为页面上某段肯定的距离。
  2. 行内元素的内容区域的高度由字体中的ascent和descent相加得到。
  3. 由于不同平台读取字体中不同的ascent和descent字段,所以导致行内元素的内容区域高度有所不同。
  4. 浏览器根据第一个可用字体的ascent和descent来计算行内元素的内容区域高度,跟实际渲染字符所用的字体无关。

六、参考资料

  1. Deep dive CSS: font metrics, line-height and vertical-align
  2. Font Sizes and the Coordinate System
  3. Line Metrics
  4. CSS Fonts Module Level 4
  5. CSS Inline Layout Module Level 3