App下載

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

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

背景介紹

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é)成文。

img

使用 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里定義的名字 */
}

由于 woff2woff、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 面板查看:

img

可以看到字體的體積為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è)方面的原因:

  1. 中文字體包含的字形數(shù)量很多,而英文字體僅包含26個(gè)字母以及一些其他符號(hào)。
  2. 中文字形的線條遠(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):

img

上面的結(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、headhhea、htmxmaxp、nameOS/2、post。如果其字形輪廓是 TrueType 格式,還有cvt、fpgm、glyf、locaprep、gasp 六張表會(huì)被用到。這六張表除了 glyfloca 必選外,其它四個(gè)為可選表。

fontmin 截取字形原理

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

img

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è)字體文件的所有表信息如下:

img

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

在這一步會(huì)根據(jù) Table Directory 記錄的偏移和長(zhǎng)度信息讀取表數(shù)據(jù)。對(duì)于精簡(jiǎn)字體來(lái)說(shuō),glyf 表的內(nèi)容是最重要的,但是 glyftable 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 versionLong 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 表中的偏移量。

img

而一個(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、yMinyMax 的值,這四個(gè)值也就是下圖的Bounding Box。除了這四個(gè)值,還需要 advanceWidthleftSideBearing 兩個(gè)字段,這兩個(gè)字段并不在 glyf 表中,因此在截取字形信息的時(shí)候無(wú)法獲取。在這個(gè)步驟,fonteditor-core 會(huì)讀取字體的 hmtx 表獲取這兩個(gè)字段。

img

5. 寫入字體

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

fontmin 不足之處

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

img

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)大的圖像格式,可以使用 CSSJavaScript 與它們進(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

先看看它們的兼容性:

img

img

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

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

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

  • family
    • 字體名稱,指定一個(gè)名稱作為 CSS 屬性 font-family 的值,
  • source
    • 字體來(lái)源,可以是一個(gè) url 或者 ArrayBuffer
  • 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

構(gòu)造出一個(gè) fontFace 后并不會(huì)加載字體,必須執(zhí)行 fontFaceload 方法。load 方法返回一個(gè) promise,promiseresolve 值就是加載成功后的字體。但是僅僅加載成功還不會(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ò)誤歡迎指正。

感謝閱讀!

參考

作者:林林

來(lái)源: 凹凸實(shí)驗(yàn)室

1 人點(diǎn)贊