在还原 UI 的时候我们常会发现一个问题, 按照 Sketch 标注的尺寸去还原设计稿中的文字会产生几个 Px 的误差, 字符上下有些许空白, 以致于后期设计审查时频繁微调.
如上图为 Android 设备上 100Px 的不同字体显示的真实高度(
includeFontPadding:false
, 下同), 不同的字体的实际高度均不一致.
所以, 为了精确还原我们需要了解 1Px 的字体到底有多高?
FontMetrics
在 TrueType 字体文件中, 每一款字体文件都会定义一个 em-square, 它被存放于 ttf 文件中的'head'表中, 一个 em-square 值可以为 1000,1024 或者 2048 等.
em-square 相当于字体的一个基本容器, 也是 textSize 缩放的相对单位. 金属时代一个字符不能超过其所在的容器, 但是在数字时代却没有这个限制, 一个字符可以扩展到 em-square 之外, 这也是设计一些字体时候挺方便的做法.
后续的 ascent,descent 以及 lineGap 等值都是相对于 em-square 的相对值.
ascent 代表单个字符最高处至 baseLine 的推荐距离, descent 代表单个字符最低处至 baseLine 的推荐距离. 字符的高度一般由 ascent 和 descent 共同决定, 对于 em-square,ascent 与 descent 我们可以通过 FontTools 解析字体文件获得.
FontTools https://github.com/fonttools/fonttools
FootTools 是一个完善易用的 Python 字体解析库, 可以很方便地将 TTX,TTF 等文件转成文本编辑器打开的 xml 描述文件.
FontTools
安装
pip install fonttools
转码
ttx Songti.ttf
转码后会在当前目录生成一个 Songti.ttx 的文件, 我们用文本编辑器打开并搜索'head'.
- <head>
- <!-- Most of this table will be recalculated by the compiler -->
- <tableVersion value="1.0"/>
- <fontRevision value="1.0"/>
- <checkSumAdjustment value="0x7550297b"/>
- <magicNumber value="0x5f0f3cf5"/>
- <flags value="00000000 00001011"/>
- <unitsPerEm value="1000"/>
- <created value="Thu Nov 11 14:47:27 1999"/>
- <modified value="Tue Nov 14 03:02:03 2017"/>
- <xMin value="-99"/>
- <yMin value="-150"/>
- <xMax value="1032"/>
- <yMax value="860"/>
- <macStyle value="00000000 00000000"/>
- <lowestRecPPEM value="12"/>
- <fontDirectionHint value="1"/>
- <indexToLocFormat value="1"/>
- <glyphDataFormat value="0"/>
- </head>
其中 unitsPerEm 便代表 em-square, 值为 1000. 在 Windows 系统中, Ascent 与 Descent 由'OS_2'表中的 usWinAscent 与 usWinDescent 决定. 但是在 MacOS,iOS 以及 Android 中, Ascent 与 Descent 由'hhea'表中的 ascent 与 descent 决定.
- <hhea>
- <tableVersion value="0x00010000"/>
- <ascent value="1060"/>
- <descent value="-340"/>
- <lineGap value="0"/>
- <advanceWidthMax value="1000"/>
- <minLeftSideBearing value="-99"/>
- <minRightSideBearing value="-50"/>
- <xMaxExtent value="1032"/>
- <caretSlopeRise value="1"/>
- <caretSlopeRun value="0"/>
- <caretOffset value="0"/>
- <reserved0 value="0"/>
- <reserved1 value="0"/>
- <reserved2 value="0"/>
- <reserved3 value="0"/>
- <metricDataFormat value="0"/>
- <numberOfHMetrics value="1236"/>
- </hhea>
Ascent 与 Descent 的值为以 baseLine 作为原点的坐标, 根据这三个值, 我们可以计算出字体的高度.
- TextHeight = (Ascent - Descent) / EM-Square * TextSize
- LineHeight = (Ascent - Descent + LineGap) / EM-Square * TextSize
上表中, 我们已知宋体 - 常规的 ascent 为 1060,descent 为 - 340.
TextSize 为 100Pixcel 的宋体常规字符高度为
height = (1060 - (-340)) / 1000 * 100 = 140px
所以对于宋体, 1Px 的字高为 1.4Px.
常见字体 LineGap 一般均为 0, 所以一般 lineHeight = textHeight.
常用字体参数
iOS 默认字体 - [San Francisco] https://developer.apple.com/fonts/
- <unitsPerEm value="2048"/>
- <ascent value="1950"/>
- <descent value="-494"/>
- <lineGap value="0"/>
TextHeight = 1.193359375 TextSize
Android 默认字体 - [Roboto - Regular] https://en.wikipedia.org/wiki/Roboto
- <unitsPerEm value="2048"/>
- <ascent value="1900"/>
- <descent value="-500"/>
- <lineGap value="0"/>
- <yMax value="2163"/>
- <yMin value="-555"/>
TextHeight = 1.17187502 TextSize
UI 适配误区
如上图 Sketch 设计稿中, 字体为 28px, 字体居上下边框为 32px, 如果按照这样的参数进行 UI 还原的话, 以 Android 默认设备为例, 外围背景会比原来高
28 * (1.17 - 1) = 4.76
个像素(
- Android IncludeFontPadding = false
- ).
这是因为该设计稿中框选的 lineHeight = textSize, 这在一般的字体中是不正确的! 会导致一些文字显示不下或者两行文字的上下端部分叠加. 同理, 用字的高度去得出 TextSize 也是不正确的! 框选文字的时候不能刚刚够框选中文, 实际上这种做法输入框输入个'j'便会超出选框, 虽然仍能显示.
正确做法应该将 lineHeight 设置为 28 * 1.17 = 33, 然后再测出上下边距.
如图, 文字的实际位置并没有变化, 但是文字的 lineHeight 变大了, 上下边距相应减少为 29px 与 30px.
对于设计稿中 LineHeight> 字体实际高度 (如 1.17 * textSize) 的情况下, 我们可以设置 lineSpace = lineHeight - 1.17 textSize 去精确还原行间距.
结论: UI 中字体还原不到位一般是对字体高度理解有误解, 实际上 1Px 的字体在客户端中一般不等于 1Px, 而等于 1.19(iOS) or 1.17 (Android) 个 Px.
- Android IncludeFontPadding
- /**
- * Set whether the TextView includes extra top and bottom padding to make
- * room for accents that go above the normal ascent and descent.
- * The default is true.
- *
- * @see #getIncludeFontPadding()
- *
- * @attr ref Android.R.styleable#TextView_includeFontPadding
- */
- public void setIncludeFontPadding(boolean includepad) {
- if (mIncludePad != includepad) {
- mIncludePad = includepad;
- if (mLayout != null) {
- nullLayouts();
- requestLayout();
- invalidate();
- }
- }
- }
Android TextView 默认 IncludeFontPadding 为开启状态, 会在每一行字的上下方留出更多的空间.
- if (getIncludeFontPadding()) {
- fontMetricsTop = fontMetrics.top;
- } else {
- fontMetricsTop = fontMetrics.ascent;
- }
- if (getIncludeFontPadding()) {
- fontMetricsBottom = fontMetrics.bottom;
- } else {
- fontMetricsBottom = fontMetrics.descent;
- }
我们通过 Textview 的源码可以发现, 只有 IncludeFontPadding = false 的情况下, textHeight 计算方式才与 iOS 端与前端相统一. 默认 true 情况会选取 top 与 bottom, 这两个值在一般情况下会大于 ascent 和 descent, 但也不是绝对的, 在一些字体中会小于 ascent 和 descent.
- public static class FontMetrics {
- /**
- * The maximum distance above the baseline for the tallest glyph in
- * the font at a given text size.
- */
- public float top;
- /**
- * The recommended distance above the baseline for singled spaced text.
- */
- public float ascent;
- /**
- * The recommended distance below the baseline for singled spaced text.
- */
- public float descent;
- /**
- * The maximum distance below the baseline for the lowest glyph in
- * the font at a given text size.
- */
- public float bottom;
- /**
- * The recommended additional space to add between lines of text.
- */
- public float leading;
- }
对于 top 和 bottom, 这两个值在 ttc/ttf 字体中并没有同名的属性, 应该是 Android 独有的名称. 我们可以寻找获取 FontMetrics 的方法 (getFontMetrics) 进行溯源.
- public float getFontMetrics(FontMetrics metrics) {
- return nGetFontMetrics(mNativePaint, metrics);
- }
- @FastNative
- private static native float nGetFontMetrics(long paintPtr, FontMetrics metrics);
Paint 的 getFontMetrics 最终调用了 native 方法 nGetFontMetrics,nGetFontMetrics 的实现在 Android 源码中的类
- @LayoutlibDelegate
- /*package*/
- static float nGetFontMetrics ( long nativePaint, long nativeTypeface,FontMetrics metrics){
- // get the delegate
- Paint_Delegate delegate = sManager.getDelegate(nativePaint);
- if (delegate == null) {
- return 0;
- }
- return delegate.getFontMetrics(metrics);
- }
- private float getFontMetrics (FontMetrics metrics){
- if (mFonts.size()> 0) {
- java.awt.FontMetrics javaMetrics = mFonts.get(0).mMetrics;
- if (metrics != null) {
- // Android expects negative ascent so we invert the value from Java.
- metrics.top = -javaMetrics.getMaxAscent();
- metrics.ascent = -javaMetrics.getAscent();
- metrics.descent = javaMetrics.getDescent();
- metrics.bottom = javaMetrics.getMaxDescent();
- metrics.leading = javaMetrics.getLeading();
- }
- return javaMetrics.getHeight();
- }
- return 0;
- }
由上可知 top 和 bottom 实际上取得是 Java FontMetrics 中的 MaxAscent 与 MaxDescent, 对于 MaxAscent 的取值 OpenJDK 官网论坛 https://bugs.openjdk.java.net/browse/JDK-6623223 给出了答案:
- Ideally JDK 1.2 should have used the OS/2 table value for usWinAscent,
- or perhaps sTypoAscender (so there's at least three choices here,
- see http://www.microsoft.com/typography/otspec/recom.htm#tad for
- more info).
- For max ascent we could use the yMax field in the font header.
- In most fonts I think this is equivalent to the value we retrieve from the hhea table,
- hence the observation that both methods return the max ascent.
所以我们可以获知, Android 默认取的是字体的 yMax 高度, 通过查找 Apple Font 手册我们可以知道 yMax 是字符的边界框范围, 所以我们可以得出以下公式:
- includeFontPadding default true
- TextHeight = (yMax - yMin) / EM-Square * TextSize
- includeFontPadding false
- TextHeight = (ascent - descent) / EM-Square * TextSize
Android 默认字体 roboto 在默认 includeFontPadding = true 情况下, textHeight = 1.32714844 textSize.
所以 Android UI 适配, 如果不改变 includeFontPadding, 可以将系数调整为 1.327
总结
相同 textSize 的字体, 高度由字体本身决定
字体公式
- TextHeight = (Ascent - Descent) / EM-Square * TextSize
- LineHeight = (Ascent - Descent + LineGap) / EM-Square * TextSize
- Android - includeFontPadding true
- TextHeight = (yMax - yMin) / EM-Square * TextSize
客户端默认字体下, 1 个 Px 的高度值并不为 1Px
- iOS TextHeight = 1.193359375 TextSize
- Android - IncludePadding : true TextHeight = 1.32714844 TextSize
- Android - IncludePadding : false TextHeight = 1.17187502 TextSize
参考资料
- Apple - TrueTypeReference Manual
- Microsoft - TrueType
- GitHub - FontTools https://github.com/fonttools/fonttools
- Open JDK https://bugs.openjdk.java.net/browse/JDK-6623223
- AndroidXRef http://androidxref.com/
- Deep dive CSS: font metrics, line-height and vertical-align
来源: https://juejin.im/post/5c875eb8e51d4511501e70e5