App下載

Web 中文字體處理總結(jié)

猿友 2021-01-05 15:42:46 瀏覽數(shù) (3866)
反饋

背景介紹

Web 項目中,使用一個合適的字體能給用戶帶來良好的體驗。但是字體文件太多,如果想要查看字體效果,只能一個個打開,非常影響工作效率。因此,需要實現(xiàn)一個功能,能夠根據(jù)固定文字以及用戶輸入預(yù)覽字體。在實現(xiàn)這一功能的過程中主要解決兩個問題:

  • 中文字體體積太大導(dǎo)致加載時間過長
  • 字體加載完成前不展示預(yù)覽內(nèi)容

現(xiàn)在將問題的解決以及我的思考總結(jié)成文。

img

使用 web 自定義字體

在聊這兩個問題之前,我們先簡述怎樣使用一個 Web 自定義字體。要想使用一個自定義字體,可以依賴 CSS Fonts Module Level 3 定義的 @font-face 規(guī)則。一種基本能夠兼容所有瀏覽器的使用方法如下:

@font-face {
    font-family: "webfontFamily"; /* 名字任意取 */
    src: url('webfont.eot');
         url('web.eot?#iefix') format("embedded-opentype"),
         url("webfont.woff2") format("woff2"),
         url("webfont.woff") format("woff"),
         url("webfont.ttf") format("truetype");
    font-style:normal;
    font-weight:normal;
}
.webfont {
    font-family: webfontFamily;   /* @font-face里定義的名字 */
}

由于 woff2、woff、ttf 格式在大多數(shù)瀏覽器支持已經(jīng)較好,因此上面的代碼也可以寫成:

@font-face {
    font-family: "webfontFamily"; /* 名字任意取 */
    src: url("webfont.woff2") format("woff2"),
         url("webfont.woff") format("woff"),
         url("webfont.ttf") format("truetype");
    font-style:normal;
    font-weight:normal;
}

有了@font-face 規(guī)則,我們只需要將字體源文件上傳至 cdn,讓 @font-face 規(guī)則的 url 值為該字體的地址,最后將這個規(guī)則應(yīng)用在 Web 文字上,就可以實現(xiàn)字體的預(yù)覽效果。

但這么做我們可以明顯發(fā)現(xiàn)一個問題,字體體積太大導(dǎo)致的加載時間過長。我們打開瀏覽器的 Network 面板查看:

img

可以看到字體的體積為5.5 MB,加載時間為5.13 s。而夸克平臺很多的中文字體大小在20~40 MB 之間,可以預(yù)想到加載時間會進(jìn)一步增長。如果用戶還處于弱網(wǎng)環(huán)境下,這個等待時間是不能接受的。

一、中文字體體積太大導(dǎo)致加載時間過長

1. 分析原因

那么中文字體相較于英文字體體積為什么這么大,這主要是兩個方面的原因:

  1. 中文字體包含的字形數(shù)量很多,而英文字體僅包含26個字母以及一些其他符號。
  2. 中文字形的線條遠(yuǎn)比英文字形的線條復(fù)雜,用于控制中文字形線條的位置點(diǎn)比英文字形更多,因此數(shù)據(jù)量更大。

我們可以借助于 opentype.js,統(tǒng)計一個中文字體和一個英文字體在字形數(shù)量以及字形所占字節(jié)數(shù)的差異:

字體名稱 字形數(shù) 字形所占字節(jié)數(shù)
FZQingFSJW_Cu.ttf 8731 4762272
JDZhengHT-Bold.ttf 122 18328

夸克平臺字體預(yù)覽需要滿足兩種方式,一種是固定字符預(yù)覽, 另一種是根據(jù)用戶輸入的字符進(jìn)行預(yù)覽。但無論哪種預(yù)覽方式,也僅僅會使用到該字體的少量字符,因此全量加載字體是沒有必要的,所以我們需要對字體文件做精簡。

2. 如何減小字體文件體積

unicode-range

unicode-range 屬性一般配合 @font-face 規(guī)則使用,它用于控制特定字符使用特定字體。但是它并不能減小字體文件的大小,感興趣的讀者可以試試。

fontmin

fontmin 是一個純 JavaScript 實現(xiàn)的字體子集化方案。前文談到,中文字體體積相較于英文字體更大的原因是其字形數(shù)量更多,那么精簡一個字體文件的思路就是將無用的字形移除:

// 偽代碼
const text = '字體預(yù)覽'
const unicodes = text.split('').map(str => str.charCodeAt(0))
const font = loadFont(fontPath)
font.glyf = font.glyf.map(g => {
 // 根據(jù)unicodes獲取對應(yīng)的字形
})

實際上的精簡并沒有這么簡單,因為一個字體文件由許多表(table)構(gòu)成,這些表之間是存在關(guān)聯(lián)的,例如 maxp 表記錄了字形數(shù)量,loca 表中存儲了字形位置的偏移量。同時字體文件以 offset table(偏移表) 開頭,offset table記錄了字體所有表的信息,因此如果我們更改了 glyf 表,就要同時去更新其他表。

在討論 fontmin 如何進(jìn)行字體截取之前,我們先來了解一下字體文件的結(jié)構(gòu):

img

上面的結(jié)構(gòu)限于字體文件只包含一種字體,且字形輪廓是基于 TrueType 格式(決定 sfntVersion 的取值)的情況,因此偏移表會從字體文件的0字節(jié)開始。如果字體文件包含多個字體,則每種字體的偏移表會在 TTCHeader 中指定,這種文件不在文章的討論范圍內(nèi)。

偏移表(offset table):

Type Name Description
uint32 sfntVersion 0x00010000
uint16 numTables Number of tables
uint16 searchRange (Maximum power of 2 <= numTables) x 16.
uint16 entrySelector Log2(maximum power of 2 <= numTables).
uint16 rangeShift NumTables x 16-searchRange.

表記錄(table record):

Type Name Description
uint32 tableTag Table identifier
uint32 checkSum CheckSum for this table
uint32 offset Offset from beginning of TrueType font file
uint32 length Length of this table

對于一個字體文件,無論其字形輪廓是 TrueType 格式還是基于 PostScript 語言的 CFF 格式,其必須包含的表有 cmap、headhhea、htmx、maxp、nameOS/2、post。如果其字形輪廓是 TrueType 格式,還有cvt、fpgm、glyf、loca、prepgasp 六張表會被用到。這六張表除了 glyfloca 必選外,其它四個為可選表。

fontmin 截取字形原理

fontmin 內(nèi)部使用了 fonteditor-core,核心的字體處理交給這個依賴完成,fonteditor-core 的主要流程如下:

img

1. 初始化 Reader

將字體文件轉(zhuǎn)為 ArrayBuffer 用于后續(xù)讀取數(shù)據(jù)。

2. 提取 Table Directory

前文我們說到緊跟在 offset table(偏移表) 之后的結(jié)構(gòu)就是 table record(表記錄),而多個 table record 叫做 Table Directory。fonteditor-core 會先讀取原字體的 Table Directory,由上文表記錄的結(jié)構(gòu)我們知道,每一個 table record 有四個字段,每個字段占4個字節(jié),因此可以很方便的利用 DataView 進(jìn)行讀取,最終得到一個字體文件的所有表信息如下:

img

3. 讀取表數(shù)據(jù)

在這一步會根據(jù) Table Directory 記錄的偏移和長度信息讀取表數(shù)據(jù)。對于精簡字體來說,glyf 表的內(nèi)容是最重要的,但是 glyftable record 僅僅告訴了我們 glyf 表的長度以及 glyf 表相對于整個字體文件的偏移量,那么我們?nèi)绾蔚弥?glyf 表中字形的數(shù)量、位置以及大小信息呢?這需要借助字體中的 maxp 表和 loca(glyphs location) 表,maxp 表的 numGlyphs 字段值指定了字形數(shù)量,而 loca 表記錄了字體中所有字形相對于 glyf 表的偏移量,它的結(jié)構(gòu)如下:

Glyph Index Offset Glyph Length
0 0 100
1 100 150
2 250 0
n-1 1170 120
extra 1290 0

根據(jù)規(guī)范,索引0指向缺失字符(missing character),也就是字體中找不到某個字符時出現(xiàn)的字符,這個字符通常用空白框或者空格表示,當(dāng)這個缺失字符不存在輪廓時,根據(jù) loca 表的定義可以得到 loca[n] = loca[n+1]。我們可以發(fā)現(xiàn)上文表格中多出了 extra 一項,這是為了計算最后一個字形 loca[n-1] 的長度。

上述表格中 Offset 字段值的單位是字節(jié),但是具體的字節(jié)數(shù)取決于字體 head 表的 indexToLocFormat 字段取值,當(dāng)此值為0時,Offset 100 等于 200 個字節(jié),當(dāng)此值為1時,Offset 100 等于 100 個字節(jié),這兩種不同的情況對應(yīng)于字體中的 Short versionLong version。

但是僅僅知道所有字形的偏移量還不夠,我們沒辦法認(rèn)出哪個字形才是我們需要的。假設(shè)我需要字體預(yù)覽這四個字形,而字體文件有一萬個字形,同時我們通過 loca 表得知了所有字形的偏移量,但這一萬里面哪四個數(shù)據(jù)塊代表了字體預(yù)覽四個字符呢?因此我們還需要借助 cmap 表來確定具體的字形位置,cmap 表里記錄了字符代碼(unicode)到字形索引的映射,我們拿到對應(yīng)的字形索引后,就可以根據(jù)索引獲得該字形在 glyf 表中的偏移量。

img

而一個字形的數(shù)據(jù)結(jié)構(gòu)以 Glyph Headers 開頭:

Type Name Description
int16 numberOfContours the number of contours
int16 xMin Minimum x for coordinate data
int16 yMin Maximum y for coordinate data
int16 xMax Minimum x for coordinate data
int16 yMax Maximum x for coordinate data

numberOfContours 字段指定了這個字形的輪廓數(shù)量,緊跟在 Glyph Headers 后面的數(shù)據(jù)結(jié)構(gòu)為 Glyph Table。

在字體的定義中,輪廓是由一個個位置點(diǎn)構(gòu)成的,并且每個位置點(diǎn)具有編號,這些編號從0開始按升序排列。因此我們讀取指定的字形就是讀取 Glyph Headers 中的各項值以及輪廓的位置點(diǎn)坐標(biāo)。

Glyph Table 中,存放了每個輪廓的最后一個位置點(diǎn)編號構(gòu)成的數(shù)組,從這個數(shù)組中就可以求得這個字形一共存在幾個位置點(diǎn)。例如這個數(shù)組的值為[3, 6, 9, 15],可以得知第四個輪廓上最后一個位置點(diǎn)的編號是15,那么這個字形一共有16個位置點(diǎn),所以我們只需要以16為循環(huán)次數(shù)進(jìn)行遍歷訪問 ArrayBuffer 就可以得到每個位置點(diǎn)的坐標(biāo)信息,從而提取出了我們想要的字形,這也就是 fontmin 在截取字形時的原理。

另外,在提取坐標(biāo)信息時,除了第一個位置點(diǎn),其他位置點(diǎn)的坐標(biāo)值并不是絕對值,例如第一個點(diǎn)的坐標(biāo)為[100, 100],第二個讀取到的值為[200, 200],那么該點(diǎn)位置坐標(biāo)并不是[200, 200],而是基于第一個點(diǎn)的坐標(biāo)進(jìn)行增量,因此第二點(diǎn)的實際坐標(biāo)為[300, 300]

因為一個字體涉及的表實在太多,并且每個表的數(shù)據(jù)結(jié)構(gòu)也不一樣。這里無法一一列舉 fonteditor-core 是如何處理每個表的。

4. 關(guān)聯(lián)glyf信息

在使用了 TrueType 輪廓的字體中,每個字形都提供了 xMin、xMaxyMinyMax 的值,這四個值也就是下圖的Bounding Box。除了這四個值,還需要 advanceWidthleftSideBearing 兩個字段,這兩個字段并不在 glyf 表中,因此在截取字形信息的時候無法獲取。在這個步驟,fonteditor-core 會讀取字體的 hmtx 表獲取這兩個字段。

img

5. 寫入字體

在這一步會重新計算字體文件的大小,并且更新偏移表(Offset table)表記錄(Table record)有關(guān)的值, 然后依次將偏移表表記錄、表數(shù)據(jù)寫入文件中。有一點(diǎn)需要注意的是,在寫入表記錄時,必須按照表名排序進(jìn)行寫入。例如有四張表分別是 prephmtx、glyf、head、則寫入的順序應(yīng)為 glyf -> head -> hmtx -> prep,而表數(shù)據(jù)沒有這個要求。

fontmin 不足之處

fonteditor-core 在截取字體的過程中只會對前文提到的十四張表進(jìn)行處理,其余表丟棄。每個字體通常還會包含 vheavmtx 兩張表,它們用于控制字體在垂直布局時的間距等信息,如果用 fontmin 進(jìn)行字體截取后,會丟失這部分信息,可以在文本垂直顯示時看出差異(右邊為截取后):

img

fontmin 使用方法

在了解了 fontmin 的原理后,我們就可以愉快的使用它啦。服務(wù)器接受到客戶端發(fā)來的請求后,通過 fontmin 截取字體,fontmin 會返回截取后的字體文件對應(yīng)的 Buffer,別忘了 @font-face 規(guī)則中字體路徑是支持 base64 格式的,因此我們只需要將 Buffer 轉(zhuǎn)為 base64 格式嵌入在 @font-face 中返回給客戶端,然后客戶端將該 @font-face 以 CSS 形式插入 <head></head> 標(biāo)簽中即可。

對于固定的預(yù)覽內(nèi)容,我們也可以先生成字體文件保存在 CDN 上,但是這個方式的缺點(diǎn)在于如果 CDN 不穩(wěn)定就會造成字體加載失敗。如果用上面的方法,每一個截取后的字體以 base64 字符串形式存在,則可以在服務(wù)端做一個緩存,就沒有這個問題。利用 fontmin 生成字體子集代碼如下:

const Fontmin = require('fontmin')
const Promise = require('bluebird')


async function extractFontData (fontPath) {
  const fontmin = new Fontmin()
    .src('./font/senty.ttf')
    .use(Fontmin.glyph({
      text: '字體預(yù)覽'
    }))
    .use(Fontmin.ttf2woff2())
    .dest('./dist')


  await Promise.promisify(fontmin.run, { context: fontmin })()
}
extractFontData()

對于固定預(yù)覽內(nèi)容我們可以預(yù)先生成好分割后的字體,對于用戶輸入的動態(tài)預(yù)覽內(nèi)容,我們當(dāng)然也可以按照這個流程:

獲取輸入 -> 截取字形 -> 上傳 CDN -> 生成 @font-face -> 插入頁面

按照這個流程來客戶端需要請求兩次才能獲取字體資源(別忘了在 @font-face 插入頁面后才會去真正請求字體),并且截取字形上傳 CDN 這兩步時間消耗也比較長,有沒有更好的辦法呢?我們知道字形的輪廓是由一系列位置點(diǎn)確定的,因此我們可以獲取 glyf 表中的位置點(diǎn)坐標(biāo),通過 SVG 圖像將特定字形直接繪制出來。

SVG 是一種強(qiáng)大的圖像格式,可以使用 CSSJavaScript 與它們進(jìn)行交互,在這里主要應(yīng)用了 path 元素

獲取位置信息以及生成 path 標(biāo)簽我們可以借助 opentype.js 完成,客戶端得到輸入字形的 path 元素后,只需要遍歷生成 SVG 標(biāo)簽即可。

3. 減小字體文件體積的優(yōu)勢

下面附上字體截取后文件大小和加載速度對比表格。可以看出,相較于全量加載,對字體進(jìn)行截取后加載速度快了145 倍。

fontmin 是支持生成 woff2 文件的,但是官方文檔并沒有更新,最開始我使用的 woff 文件,但是 woff2 格式文件體積更小并且瀏覽器支持不錯

字體名稱 大小 時間
HanyiSentyWoodcut.ttf 48.2MB 17.41s
HanyiSentyWoodcut.woff 21.7KB 0.19s
HanyiSentyWoodcut.woff2 12.2KB 0.12s

二、字體加載完成前不展示預(yù)覽內(nèi)容

這是在實現(xiàn)預(yù)覽功能過程中的第二個問題。

在瀏覽器的字體顯示行為中存在阻塞期交換期兩個概念,以 Chrome 為例,在字體加載完成前,會有一段時間顯示空白,這段時間被稱為阻塞期。如果在阻塞期內(nèi)仍然沒有加載完成,就會先顯示后備字體,進(jìn)入交換期,等待字體加載完成后替換。這就會導(dǎo)致頁面字體出現(xiàn)閃爍,與我想要的效果不符。而 font-display 屬性控制瀏覽器的這個行為,是否可以更換 font-display 屬性的取值來達(dá)到我們的目的呢?

font-display

Block Period Swap Period
block Short Infinite
swap None Infinite
fallback Extremely Short Short
optional Extremely Short None

字體的顯示策略和 font-display 的取值有關(guān),瀏覽器默認(rèn)的 font-display 值為 auto,它的行為和取值 block 較為接近。

第一種策略是 FOIT(Flash of Invisible Text)FOIT 是瀏覽器在加載字體的時候的默認(rèn)表現(xiàn)形式,其規(guī)則如前文所說。

第二種策略是 FOUT(Flash of Unstyled Text),FOUT 會指示瀏覽器使用后備字體直至自定義字體加載完成,對應(yīng)的取值為 swap。

兩種不同策略的應(yīng)用:Google Fonts FOIT ?漢儀字庫 FOUT

在夸克項目中,我希望的效果是字體加載完成前不展示預(yù)覽內(nèi)容,FOIT 策略最為接近。但是 FOIT 文本內(nèi)容不可見的最長時間大約是3s, 如果用戶網(wǎng)絡(luò)狀況不太好,那么3s過后還是會先顯示后備字體,導(dǎo)致頁面字體閃爍,因此 font-display 屬性不滿足要求。

查閱資料得知,CSS Font Loading API JavaScript 層面上也提供了解決方案:

FontFace、FontFaceSet

先看看它們的兼容性:

img

img

又是 IE,IE 沒有用戶不用管

我們可以通過 FontFace 構(gòu)造函數(shù)構(gòu)造出一個 FontFace 對象:

const fontFace = new FontFace(family, source, descriptors)

  • family
    • 字體名稱,指定一個名稱作為 CSS 屬性 font-family 的值,
  • source
    • 字體來源,可以是一個 url 或者 ArrayBuffer
  • descriptors optional
    • style:font-style
    • weight:font-weight
    • stretch:font-stretch
    • display: font-display (這個值可以設(shè)置,但不會生效)
    • unicodeRange:@font-face 規(guī)則的 unicode-ranges
    • variant:font-variant
    • featureSettings:font-feature-settings

構(gòu)造出一個 fontFace 后并不會加載字體,必須執(zhí)行 fontFaceload 方法。load 方法返回一個 promise,promiseresolve 值就是加載成功后的字體。但是僅僅加載成功還不會使這個字體生效,還需要將返回的 fontFace 添加到 fontFaceSet。

使用方法如下:

/**
  * @param {string} path 字體文件路徑
  */
async function loadFont(path) {
  const fontFaceSet = document.fonts
  const fontFace = await new FontFace('fontFamily', `url('${path}') format('woff2')`).load()
  fontFaceSet.add(fontFace)
}

因此,在客戶端我們可以先設(shè)置文字內(nèi)容的 CSS 為 opacity: 0, 等待 await loadFont(path) 執(zhí)行完畢后,再將 CSS 設(shè)置為 opacity: 1, 這樣就可以控制在自定義字體加載未完成前不顯示內(nèi)容。

最后總結(jié)

本文介紹了在開發(fā)字體預(yù)覽功能時遇到的問題和解決方案,限于 OpenType 規(guī)范條目很多,在介紹 fontmin 原理部分,僅描述了對 glyf 表的處理,對此感興趣的讀者可進(jìn)一步學(xué)習(xí)。

本次工作的回顧和總結(jié)過程中,也在思考更好的實現(xiàn),如果你有建議歡迎和我交流。同時文章的內(nèi)容是我個人的理解,存在錯誤難以避免,如果發(fā)現(xiàn)錯誤歡迎指正。

感謝閱讀!

參考

作者:林林

來源: 凹凸實驗室

1 人點(diǎn)贊