1Pixel的字到底有多高?
在還原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 floattop; /** * The recommended distance above the baseline for singled spaced text. */ public floatascent; /** * The recommended distance below the baseline for singled spaced text. */ public floatdescent; /** * The maximum distance below the baseline for the lowest glyph in * the font at a given text size. */ public floatbottom; /** * The recommended additional space to add between lines of text. */ public floatleading; }
對於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 : trueTextHeight = 1.32714844 TextSize Android - IncludePadding : false TextHeight = 1.17187502 TextSize