1Pixel的字到底有多高?

作者: 周兔子 | 来源:发表于2019-03-12 17:05 被阅读4次

    在还原UI的时候我们常会发现一个问题,按照Sketch标注的尺寸去还原设计稿中的文字会产生几个Px的误差,字符上下有些许空白,以致于后期设计审查时频繁手动微调。

    font

    如上图为Android设备上100Px的不同字体显示的真实高度(includeFontPadding设为false,下同),不同的字体的实际高度均不一致。

    所以,为了精确还原我们需要了解1Px的字体到底有多高?

    FontMetrics

    在TrueType字体文件中,每一款字体文件都会定义一个em-square,它被存放于ttf文件中的'head'表中,一个em-square值可以为1000、1024或者2048等。


    em

    em-square相当于字体的一个基本容器,也是textSize缩放的相对单位。金属时代一个字符不能超过其所在的容器,但是在数字时代却没有这个限制,一个字符可以扩展到em-square之外,这也是设计一些字体时候挺方便的做法。

    后续的ascent、descent以及lineGap等值都是相对于em-square的相对值。

    asecent

    ascent代表单个字符最高处至baseLine的推荐距离,descent代表单个字符最低处至baseLine的推荐距离。字符的高度一般由ascent和descent共同决定,对于em-square、ascent与descent我们可以通过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]

      <unitsPerEm value="2048"/>
      <ascent value="1950"/>
      <descent value="-494"/>
      <lineGap value="0"/>
    

    TextHeight = 1.193359375 TextSize

    Android默认字体 - [Roboto - Regular]

      <unitsPerEm value="2048"/>
      <ascent value="1900"/>
      <descent value="-500"/>
      <lineGap value="0"/>
      <yMax value="2163"/>
      <yMin value="-555"/>
    

    TextHeight = 1.17187502 TextSize

    UI适配误区

    image

    如上图Sketch设计稿中,字体为28px,字体居上下边框为32px,如果按照这样的参数进行UI还原的话,以Android默认设备为例,Android中外围背景会比原来高28 * (1.17 - 1) = 4.76个像素(Android IncludeFontPadding = false)。

    这是因为该设计稿中框选的lineHeight = textSize,这在一般的字体中是不正确的!会导致一些文字显示不下或者两行文字的上下端部分叠加。同理,用字的高度去得出TextSize也是不正确的!框选文字的时候不能刚刚够框选中文,实际上这种做法输入框输入个'j'便会超出选框,虽然仍能显示。

    正确做法应该将lineHeight设置为 28 * 1.17 = 33,然后再测出上下边距。


    image

    如图,文字的实际位置并没有变化,但是文字的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源码中的Paint_Delegate.java

    @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官网论坛给出了答案

    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

    总结

    相同testSize的字体,高度由字体文件决定

    字体公式

    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

    Open JDK

    AndroidXRef

    Deep dive CSS: font metrics, line-height and vertical-align

    相关文章

      网友评论

        本文标题:1Pixel的字到底有多高?

        本文链接:https://www.haomeiwen.com/subject/dgvnpqtx.html