背景介紹
Web 項(xiàng)目中,使用一個(gè)合適的字體能給用戶帶來(lái)良好的體驗(yàn)。但是字體文件太多,如果想要查看字體效果,只能一個(gè)個(gè)打開,非常影響工作效率。因此,需要實(shí)現(xiàn)一個(gè)功能,能夠根據(jù)固定文字以及用戶輸入預(yù)覽字體。在實(shí)現(xiàn)這一功能的過(guò)程中主要解決兩個(gè)問(wèn)題:
- 中文字體體積太大導(dǎo)致加載時(shí)間過(guò)長(zhǎng)
- 字體加載完成前不展示預(yù)覽內(nèi)容
現(xiàn)在將問(wèn)題的解決以及我的思考總結(jié)成文。
使用 web 自定義字體
在聊這兩個(gè)問(wèn)題之前,我們先簡(jiǎn)述怎樣使用一個(gè) Web 自定義字體。要想使用一個(gè)自定義字體,可以依賴 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
值為該字體的地址,最后將這個(gè)規(guī)則應(yīng)用在 Web 文字上,就可以實(shí)現(xiàn)字體的預(yù)覽效果。
但這么做我們可以明顯發(fā)現(xiàn)一個(gè)問(wèn)題,字體體積太大導(dǎo)致的加載時(shí)間過(guò)長(zhǎng)。我們打開瀏覽器的 Network 面板查看:
可以看到字體的體積為5.5 MB,加載時(shí)間為5.13 s。而夸克平臺(tái)很多的中文字體大小在20~40 MB 之間,可以預(yù)想到加載時(shí)間會(huì)進(jìn)一步增長(zhǎng)。如果用戶還處于弱網(wǎng)環(huán)境下,這個(gè)等待時(shí)間是不能接受的。
一、中文字體體積太大導(dǎo)致加載時(shí)間過(guò)長(zhǎng)
1. 分析原因
那么中文字體相較于英文字體體積為什么這么大,這主要是兩個(gè)方面的原因:
- 中文字體包含的字形數(shù)量很多,而英文字體僅包含26個(gè)字母以及一些其他符號(hào)。
- 中文字形的線條遠(yuǎn)比英文字形的線條復(fù)雜,用于控制中文字形線條的位置點(diǎn)比英文字形更多,因此數(shù)據(jù)量更大。
我們可以借助于 opentype.js
,統(tǒng)計(jì)一個(gè)中文字體和一個(gè)英文字體在字形數(shù)量以及字形所占字節(jié)數(shù)的差異:
字體名稱 | 字形數(shù) | 字形所占字節(jié)數(shù) |
---|---|---|
FZQingFSJW_Cu.ttf | 8731 | 4762272 |
JDZhengHT-Bold.ttf | 122 | 18328 |
夸克平臺(tái)字體預(yù)覽需要滿足兩種方式,一種是固定字符預(yù)覽, 另一種是根據(jù)用戶輸入的字符進(jìn)行預(yù)覽。但無(wú)論哪種預(yù)覽方式,也僅僅會(huì)使用到該字體的少量字符,因此全量加載字體是沒有必要的,所以我們需要對(duì)字體文件做精簡(jiǎn)。
2. 如何減小字體文件體積
unicode-range
unicode-range 屬性一般配合 @font-face
規(guī)則使用,它用于控制特定字符使用特定字體。但是它并不能減小字體文件的大小,感興趣的讀者可以試試。
fontmin
fontmin
是一個(gè)純 JavaScript
實(shí)現(xiàn)的字體子集化方案。前文談到,中文字體體積相較于英文字體更大的原因是其字形數(shù)量更多,那么精簡(jiǎn)一個(gè)字體文件的思路就是將無(wú)用的字形移除:
// 偽代碼
const text = '字體預(yù)覽'
const unicodes = text.split('').map(str => str.charCodeAt(0))
const font = loadFont(fontPath)
font.glyf = font.glyf.map(g => {
// 根據(jù)unicodes獲取對(duì)應(yīng)的字形
})
實(shí)際上的精簡(jiǎn)并沒有這么簡(jiǎn)單,因?yàn)橐粋€(gè)字體文件由許多
表(table)
構(gòu)成,這些表之間是存在關(guān)聯(lián)的,例如maxp
表記錄了字形數(shù)量,loca
表中存儲(chǔ)了字形位置的偏移量。同時(shí)字體文件以offset table(偏移表)
開頭,offset table
記錄了字體所有表的信息,因此如果我們更改了glyf
表,就要同時(shí)去更新其他表。
在討論 fontmin
如何進(jìn)行字體截取之前,我們先來(lái)了解一下字體文件的結(jié)構(gòu):
上面的結(jié)構(gòu)限于字體文件只包含一種字體,且字形輪廓是基于 TrueType
格式(決定 sfntVersion
的取值)的情況,因此偏移表會(huì)從字體文件的0字節(jié)
開始。如果字體文件包含多個(gè)字體,則每種字體的偏移表會(huì)在 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 |
對(duì)于一個(gè)字體文件,無(wú)論其字形輪廓是 TrueType 格式還是基于 PostScript 語(yǔ)言的 CFF 格式,其必須包含的表有 cmap
、head
、hhea
、htmx
、maxp
、name
、OS/2
、post
。如果其字形輪廓是 TrueType 格式,還有cvt
、fpgm
、glyf
、loca
、prep
、gasp
六張表會(huì)被用到。這六張表除了 glyf
和 loca
必選外,其它四個(gè)為可選表。
fontmin 截取字形原理
fontmin
內(nèi)部使用了 fonteditor-core
,核心的字體處理交給這個(gè)依賴完成,fonteditor-core
的主要流程如下:
1. 初始化 Reader
將字體文件轉(zhuǎn)為 ArrayBuffer
用于后續(xù)讀取數(shù)據(jù)。
2. 提取 Table Directory
前文我們說(shuō)到緊跟在 offset table(偏移表)
之后的結(jié)構(gòu)就是 table record(表記錄)
,而多個(gè) table record
叫做 Table Directory
。fonteditor-core
會(huì)先讀取原字體的 Table Directory
,由上文表記錄的結(jié)構(gòu)我們知道,每一個(gè) table record
有四個(gè)字段,每個(gè)字段占4個(gè)字節(jié),因此可以很方便的利用 DataView
進(jìn)行讀取,最終得到一個(gè)字體文件的所有表信息如下:
3. 讀取表數(shù)據(jù)
在這一步會(huì)根據(jù) Table Directory
記錄的偏移和長(zhǎng)度信息讀取表數(shù)據(jù)。對(duì)于精簡(jiǎn)字體來(lái)說(shuō),glyf
表的內(nèi)容是最重要的,但是 glyf
的 table record
僅僅告訴了我們 glyf
表的長(zhǎng)度以及 glyf
表相對(duì)于整個(gè)字體文件的偏移量,那么我們?nèi)绾蔚弥?glyf
表中字形的數(shù)量、位置以及大小信息呢?這需要借助字體中的 maxp
表和 loca(glyphs location)
表,maxp
表的 numGlyphs
字段值指定了字形數(shù)量,而 loca
表記錄了字體中所有字形相對(duì)于 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)
,也就是字體中找不到某個(gè)字符時(shí)出現(xiàn)的字符,這個(gè)字符通常用空白框或者空格表示,當(dāng)這個(gè)缺失字符不存在輪廓時(shí),根據(jù) loca
表的定義可以得到 loca[n] = loca[n+1]
。我們可以發(fā)現(xiàn)上文表格中多出了 extra
一項(xiàng),這是為了計(jì)算最后一個(gè)字形 loca[n-1]
的長(zhǎng)度。
上述表格中 Offset 字段值的單位是字節(jié),但是具體的字節(jié)數(shù)取決于字體
head
表的indexToLocFormat
字段取值,當(dāng)此值為0
時(shí),Offset 100 等于 200 個(gè)字節(jié),當(dāng)此值為1
時(shí),Offset 100 等于 100 個(gè)字節(jié),這兩種不同的情況對(duì)應(yīng)于字體中的Short version
和Long version
。
但是僅僅知道所有字形的偏移量還不夠,我們沒辦法認(rèn)出哪個(gè)字形才是我們需要的。假設(shè)我需要字體預(yù)覽
這四個(gè)字形,而字體文件有一萬(wàn)個(gè)字形,同時(shí)我們通過(guò) loca
表得知了所有字形的偏移量,但這一萬(wàn)里面哪四個(gè)數(shù)據(jù)塊代表了字體預(yù)覽
四個(gè)字符呢?因此我們還需要借助 cmap
表來(lái)確定具體的字形位置,cmap
表里記錄了字符代碼(unicode)
到字形索引的映射,我們拿到對(duì)應(yīng)的字形索引后,就可以根據(jù)索引獲得該字形在 glyf
表中的偏移量。
而一個(gè)字形的數(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
字段指定了這個(gè)字形的輪廓數(shù)量,緊跟在 Glyph Headers
后面的數(shù)據(jù)結(jié)構(gòu)為 Glyph Table
。
在字體的定義中,輪廓是由一個(gè)個(gè)位置點(diǎn)構(gòu)成的,并且每個(gè)位置點(diǎn)具有編號(hào),這些編號(hào)從0
開始按升序排列。因此我們讀取指定的字形就是讀取 Glyph Headers
中的各項(xiàng)值以及輪廓的位置點(diǎn)坐標(biāo)。
在 Glyph Table
中,存放了每個(gè)輪廓的最后一個(gè)位置點(diǎn)編號(hào)構(gòu)成的數(shù)組,從這個(gè)數(shù)組中就可以求得這個(gè)字形一共存在幾個(gè)位置點(diǎn)。例如這個(gè)數(shù)組的值為[3, 6, 9, 15]
,可以得知第四個(gè)輪廓上最后一個(gè)位置點(diǎn)的編號(hào)是15,那么這個(gè)字形一共有16個(gè)位置點(diǎn),所以我們只需要以16
為循環(huán)次數(shù)進(jìn)行遍歷訪問(wèn) ArrayBuffer 就可以得到每個(gè)位置點(diǎn)的坐標(biāo)信息,從而提取出了我們想要的字形,這也就是 fontmin
在截取字形時(shí)的原理。
另外,在提取坐標(biāo)信息時(shí),除了第一個(gè)位置點(diǎn),其他位置點(diǎn)的坐標(biāo)值并不是絕對(duì)值,例如第一個(gè)點(diǎn)的坐標(biāo)為[100, 100]
,第二個(gè)讀取到的值為[200, 200]
,那么該點(diǎn)位置坐標(biāo)并不是[200, 200]
,而是基于第一個(gè)點(diǎn)的坐標(biāo)進(jìn)行增量,因此第二點(diǎn)的實(shí)際坐標(biāo)為[300, 300]
因?yàn)橐粋€(gè)字體涉及的表實(shí)在太多,并且每個(gè)表的數(shù)據(jù)結(jié)構(gòu)也不一樣。這里無(wú)法一一列舉
fonteditor-core
是如何處理每個(gè)表的。
4. 關(guān)聯(lián)glyf信息
在使用了 TrueType 輪廓的字體中,每個(gè)字形都提供了 xMin
、xMax
、yMin
和 yMax
的值,這四個(gè)值也就是下圖的Bounding Box
。除了這四個(gè)值,還需要 advanceWidth
和 leftSideBearing
兩個(gè)字段,這兩個(gè)字段并不在 glyf
表中,因此在截取字形信息的時(shí)候無(wú)法獲取。在這個(gè)步驟,fonteditor-core
會(huì)讀取字體的 hmtx
表獲取這兩個(gè)字段。
5. 寫入字體
在這一步會(huì)重新計(jì)算字體文件的大小,并且更新偏移表(Offset table)
和表記錄(Table record)
有關(guān)的值, 然后依次將偏移表
、表記錄
、表數(shù)據(jù)
寫入文件中。有一點(diǎn)需要注意的是,在寫入表記錄
時(shí),必須按照表名排序進(jìn)行寫入。例如有四張表分別是 prep
、hmtx
、glyf
、head
、則寫入的順序應(yīng)為 glyf -> head -> hmtx -> prep
,而表數(shù)據(jù)
沒有這個(gè)要求。
fontmin 不足之處
fonteditor-core
在截取字體的過(guò)程中只會(huì)對(duì)前文提到的十四張表進(jìn)行處理,其余表丟棄。每個(gè)字體通常還會(huì)包含 vhea
和 vmtx
兩張表,它們用于控制字體在垂直布局時(shí)的間距等信息,如果用 fontmin
進(jìn)行字體截取后,會(huì)丟失這部分信息,可以在文本垂直顯示時(shí)看出差異(右邊為截取后):
fontmin 使用方法
在了解了 fontmin
的原理后,我們就可以愉快的使用它啦。服務(wù)器接受到客戶端發(fā)來(lái)的請(qǐng)求后,通過(guò) fontmin
截取字體,fontmin
會(huì)返回截取后的字體文件對(duì)應(yīng)的 Buffer,別忘了 @font-face
規(guī)則中字體路徑是支持 base64
格式的,因此我們只需要將 Buffer 轉(zhuǎn)為 base64
格式嵌入在 @font-face
中返回給客戶端,然后客戶端將該 @font-face
以 CSS 形式插入 <head></head>
標(biāo)簽中即可。
對(duì)于固定的預(yù)覽內(nèi)容,我們也可以先生成字體文件保存在 CDN 上,但是這個(gè)方式的缺點(diǎn)在于如果 CDN 不穩(wěn)定就會(huì)造成字體加載失敗。如果用上面的方法,每一個(gè)截取后的字體以 base64
字符串形式存在,則可以在服務(wù)端做一個(gè)緩存,就沒有這個(gè)問(wèn)題。利用 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()
對(duì)于固定預(yù)覽內(nèi)容我們可以預(yù)先生成好分割后的字體,對(duì)于用戶輸入的動(dòng)態(tài)預(yù)覽內(nèi)容,我們當(dāng)然也可以按照這個(gè)流程:
獲取輸入 -> 截取字形 -> 上傳 CDN -> 生成 @font-face -> 插入頁(yè)面
按照這個(gè)流程來(lái)客戶端需要請(qǐng)求兩次才能獲取字體資源(別忘了在 @font-face
插入頁(yè)面后才會(huì)去真正請(qǐng)求字體),并且截取字形
和上傳 CDN
這兩步時(shí)間消耗也比較長(zhǎng),有沒有更好的辦法呢?我們知道字形的輪廓是由一系列位置點(diǎn)確定的,因此我們可以獲取 glyf
表中的位置點(diǎn)坐標(biāo),通過(guò) SVG
圖像將特定字形直接繪制出來(lái)。
SVG
是一種強(qiáng)大的圖像格式,可以使用CSS
和JavaScript
與它們進(jìn)行交互,在這里主要應(yīng)用了path
元素
獲取位置信息以及生成 path
標(biāo)簽我們可以借助 opentype.js
完成,客戶端得到輸入字形的 path
元素后,只需要遍歷生成 SVG
標(biāo)簽即可。
3. 減小字體文件體積的優(yōu)勢(shì)
下面附上字體截取后文件大小和加載速度對(duì)比表格??梢钥闯?,相較于全量加載,對(duì)字體進(jìn)行截取后加載速度快了145
倍。
fontmin
是支持生成woff2
文件的,但是官方文檔并沒有更新,最開始我使用的woff
文件,但是woff2
格式文件體積更小并且瀏覽器支持不錯(cuò)
字體名稱 | 大小 | 時(shí)間 |
---|---|---|
HanyiSentyWoodcut.ttf | 48.2MB | 17.41s |
HanyiSentyWoodcut.woff | 21.7KB | 0.19s |
HanyiSentyWoodcut.woff2 | 12.2KB | 0.12s |
二、字體加載完成前不展示預(yù)覽內(nèi)容
這是在實(shí)現(xiàn)預(yù)覽功能過(guò)程中的第二個(gè)問(wèn)題。
在瀏覽器的字體顯示行為中存在阻塞期
和交換期
兩個(gè)概念,以 Chrome
為例,在字體加載完成前,會(huì)有一段時(shí)間顯示空白,這段時(shí)間被稱為阻塞期
。如果在阻塞期
內(nèi)仍然沒有加載完成,就會(huì)先顯示后備字體,進(jìn)入交換期
,等待字體加載完成后替換。這就會(huì)導(dǎo)致頁(yè)面字體出現(xiàn)閃爍,與我想要的效果不符。而 font-display
屬性控制瀏覽器的這個(gè)行為,是否可以更換 font-display
屬性的取值來(lái)達(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
是瀏覽器在加載字體的時(shí)候的默認(rèn)表現(xiàn)形式,其規(guī)則如前文所說(shuō)。
第二種策略是
FOUT(Flash of Unstyled Text)
,FOUT
會(huì)指示瀏覽器使用后備字體直至自定義字體加載完成,對(duì)應(yīng)的取值為swap
。
兩種不同策略的應(yīng)用:Google Fonts FOIT ?漢儀字庫(kù) FOUT
在夸克項(xiàng)目中,我希望的效果是字體加載完成前不展示預(yù)覽內(nèi)容,FOIT
策略最為接近。但是 FOIT
文本內(nèi)容不可見的最長(zhǎng)時(shí)間大約是3s
, 如果用戶網(wǎng)絡(luò)狀況不太好,那么3s
過(guò)后還是會(huì)先顯示后備字體,導(dǎo)致頁(yè)面字體閃爍,因此 font-display
屬性不滿足要求。
查閱資料得知,CSS Font Loading API 在 JavaScript
層面上也提供了解決方案:
FontFace、FontFaceSet
先看看它們的兼容性:
又是 IE,IE 沒有用戶不用管
我們可以通過(guò) FontFace
構(gòu)造函數(shù)構(gòu)造出一個(gè) FontFace
對(duì)象:
const fontFace = new FontFace(family, source, descriptors)
- family
- 字體名稱,指定一個(gè)名稱作為
CSS
屬性font-family
的值,
- 字體名稱,指定一個(gè)名稱作為
- source
- 字體來(lái)源,可以是一個(gè)
url
或者ArrayBuffer
- 字體來(lái)源,可以是一個(gè)
- descriptors
optional
- style:
font-style
- weight:
font-weight
- stretch:
font-stretch
- display:
font-display
(這個(gè)值可以設(shè)置,但不會(huì)生效) - unicodeRange:
@font-face
規(guī)則的unicode-ranges
- variant:
font-variant
- featureSettings:
font-feature-settings
- style:
構(gòu)造出一個(gè) fontFace
后并不會(huì)加載字體,必須執(zhí)行 fontFace
的 load
方法。load
方法返回一個(gè) promise
,promise
的 resolve
值就是加載成功后的字體。但是僅僅加載成功還不會(huì)使這個(gè)字體生效,還需要將返回的 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ù)覽功能時(shí)遇到的問(wèn)題和解決方案,限于 OpenType
規(guī)范條目很多,在介紹 fontmin
原理部分,僅描述了對(duì) glyf
表的處理,對(duì)此感興趣的讀者可進(jìn)一步學(xué)習(xí)。
本次工作的回顧和總結(jié)過(guò)程中,也在思考更好的實(shí)現(xiàn),如果你有建議歡迎和我交流。同時(shí)文章的內(nèi)容是我個(gè)人的理解,存在錯(cuò)誤難以避免,如果發(fā)現(xiàn)錯(cuò)誤歡迎指正。
感謝閱讀!
參考
- 前端字體截取
- Scalable Vector Graphics
- FontFace
- FontFaceSet
- fontmin
- fonteditor-core
- TrueType-Reference-Manual
- OpenType-Font-File
作者:林林
來(lái)源: 凹凸實(shí)驗(yàn)室