背景介紹
Web 專案中,使用一個適合的字型能給使用者帶來良好的體驗。但是字型檔案這麼多,如果設計師或者開發人員想要查詢字型,只能一個個開啟,非常影響工作效率。因此,我負責的平臺專案剛好需要實現一個功能,能夠支援根據固定文字以及使用者輸入預覽字型。在實現這一功能的過程中主要解決兩個問題:
- 中文字型體積太大導致載入時間過長
- 字型載入完成前不展示預覽內容
現在將問題的解決以及我的思考總結成文。
使用 web 自定義字型
在聊這兩個問題之前,我們先簡述怎樣使用一個 Web 自定義字型。要想使用一個自定義字型,可以依賴 CSS Fonts Module Level 3 定義的 @font-face
規則。一種基本能夠相容所有瀏覽器的使用方法如下:
@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
格式在大多數瀏覽器支援已經較好,因此上面的程式碼也可以寫成:
@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
規則,我們只需要將字型原始檔上傳至 cdn,讓 @font-face
規則的 url
值為該字型的地址,最後將這個規則應用在 Web 文字上,就可以實現字型的預覽效果。
但這麼做我們可以明顯發現一個問題,字型體積太大導致的載入時間過長。我們開啟瀏覽器的 Network 皮膚檢視:
可以看到字型的體積為5.5 MB,載入時間為5.13 s。而夸克平臺很多的中文字型大小在20~40 MB 之間,可以預想到載入時間會進一步增長。如果使用者還處於弱網環境下,這個等待時間是不能接受的。
一、中文字型體積太大導致載入時間過長
1. 分析原因
那麼中文字型相較於英文字型體積為什麼這麼大,這主要是兩個方面的原因:
- 中文字型包含的字形數量很多,而英文字型僅包含26個字母以及一些其他符號。
- 中文字形的線條遠比英文字形的線條複雜,用於控制中文字形線條的位置點比英文字形更多,因此資料量更大。
我們可以藉助於 opentype.js
,統計一箇中文字型和一個英文字型在字形數量以及字形所佔位元組數的差異:
字型名稱 | 字形數 | 字形所佔位元組數 |
---|---|---|
FZQingFSJW_Cu.ttf | 8731 | 4762272 |
JDZhengHT-Bold.ttf | 122 | 18328 |
夸克平臺字型預覽需要滿足兩種方式,一種是固定字元預覽, 另一種是根據使用者輸入的字元進行預覽。但無論哪種預覽方式,也僅僅會使用到該字型的少量字元,因此全量載入字型是沒有必要的,所以我們需要對字型檔案做精簡。
2. 如何減小字型檔案體積
unicode-range
unicode-range 屬性一般配合 @font-face
規則使用,它用於控制特定字元使用特定字型。但是它並不能減小字型檔案的大小,感興趣的讀者可以試試。
fontmin
fontmin
是一個純 JavaScript
實現的字型子集化方案。前文談到,中文字型體積相較於英文字型更大的原因是其字形數量更多,那麼精簡一個字型檔案的思路就是將無用的字形移除:
// 虛擬碼
const text = '字型預覽'
const unicodes = text.split('').map(str => str.charCodeAt(0))
const font = loadFont(fontPath)
font.glyf = font.glyf.map(g => {
// 根據unicodes獲取對應的字形
})
複製程式碼
實際上的精簡併沒有這麼簡單,因為一個字型檔案由許多
表(table)
構成,這些表之間是存在關聯的,例如maxp
表記錄了字形數量,loca
表中儲存了字形位置的偏移量。同時字型檔案以offset table(偏移表)
開頭,offset table
記錄了字型所有表的資訊,因此如果我們更改了glyf
表,就要同時去更新其他表。
在討論 fontmin
如何進行字型擷取之前,我們先來了解一下字型檔案的結構:
上面的結構限於字型檔案只包含一種字型,且字形輪廓是基於 TrueType
格式(決定 sfntVersion
的取值)的情況,因此偏移表會從字型檔案的0位元組
開始。如果字型檔案包含多個字型,則每種字型的偏移表會在 TTCHeader 中指定,這種檔案不在文章的討論範圍內。
偏移表(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
、head
、hhea
、htmx
、maxp
、name
、OS/2
、post
。如果其字形輪廓是 TrueType 格式,還有cvt
、fpgm
、glyf
、loca
、prep
、gasp
六張表會被用到。這六張表除了 glyf
和 loca
必選外,其它四個為可選表。
fontmin 擷取字形原理
fontmin
內部使用了 fonteditor-core
,核心的字型處理交給這個依賴完成,fonteditor-core
的主要流程如下:
1. 初始化 Reader
將字型檔案轉為 ArrayBuffer
用於後續讀取資料。
2. 提取 Table Directory
前文我們說到緊跟在 offset table(偏移表)
之後的結構就是 table record(表記錄)
,而多個 table record
叫做 Table Directory
。fonteditor-core
會先讀取原字型的 Table Directory
,由上文表記錄的結構我們知道,每一個 table record
有四個欄位,每個欄位佔4個位元組,因此可以很方便的利用 DataView
進行讀取,最終得到一個字型檔案的所有表資訊如下:
3. 讀取表資料
在這一步會根據 Table Directory
記錄的偏移和長度資訊讀取表資料。對於精簡字型來說,glyf
表的內容是最重要的,但是 glyf
的 table record
僅僅告訴了我們 glyf
表的長度以及 glyf
表相對於整個字型檔案的偏移量,那麼我們如何得知 glyf
表中字形的數量、位置以及大小資訊呢?這需要藉助字型中的 maxp
表和 loca(glyphs location)
表,maxp
表的 numGlyphs
欄位值指定了字形數量,而 loca
表記錄了字型中所有字形相對於 glyf
表的偏移量,它的結構如下:
Glyph Index | Offset | Glyph Length |
---|---|---|
0 | 0 | 100 |
1 | 100 | 150 |
2 | 250 | 0 |
... | ... | ... |
n-1 | 1170 | 120 |
extra | 1290 | 0 |
根據規範,索引0
指向缺失字元(missing character)
,也就是字型中找不到某個字元時出現的字元,這個字元通常用空白框或者空格表示,當這個缺失字元不存在輪廓時,根據 loca
表的定義可以得到 loca[n] = loca[n+1]
。我們可以發現上文表格中多出了 extra
一項,這是為了計算最後一個字形 loca[n-1]
的長度。
上述表格中 Offset 欄位值的單位是位元組,但是具體的位元組數取決於字型
head
表的indexToLocFormat
欄位取值,當此值為0
時,Offset 100 等於 200 個位元組,當此值為1
時,Offset 100 等於 100 個位元組,這兩種不同的情況對應於字型中的Short version
和Long version
。
但是僅僅知道所有字形的偏移量還不夠,我們沒辦法認出哪個字形才是我們需要的。假設我需要字型預覽
這四個字形,而字型檔案有一萬個字形,同時我們通過 loca
表得知了所有字形的偏移量,但這一萬里面哪四個資料塊代表了字型預覽
四個字元呢?因此我們還需要藉助 cmap
表來確定具體的字形位置,cmap
表裡記錄了字元程式碼(unicode)
到字形索引的對映,我們拿到對應的字形索引後,就可以根據索引獲得該字形在 glyf
表中的偏移量。
而一個字形的資料結構以 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
欄位指定了這個字形的輪廓數量,緊跟在 Glyph Headers
後面的資料結構為 Glyph Table
。
在字型的定義中,輪廓是由一個個位置點構成的,並且每個位置點具有編號,這些編號從0
開始按升序排列。因此我們讀取指定的字形就是讀取 Glyph Headers
中的各項值以及輪廓的位置點座標。
在 Glyph Table
中,存放了每個輪廓的最後一個位置點編號構成的陣列,從這個陣列中就可以求得這個字形一共存在幾個位置點。例如這個陣列的值為[3, 6, 9, 15]
,可以得知第四個輪廓上最後一個位置點的編號是15,那麼這個字形一共有16個位置點,所以我們只需要以16
為迴圈次數進行遍歷訪問 ArrayBuffer 就可以得到每個位置點的座標資訊,從而提取出了我們想要的字形,這也就是 fontmin
在擷取字形時的原理。
另外,在提取座標資訊時,除了第一個位置點,其他位置點的座標值並不是絕對值,例如第一個點的座標為[100, 100]
,第二個讀取到的值為[200, 200]
,那麼該點位置座標並不是[200, 200]
,而是基於第一個點的座標進行增量,因此第二點的實際座標為[300, 300]
因為一個字型涉及的表實在太多,並且每個表的資料結構也不一樣。這裡無法一一列舉
fonteditor-core
是如何處理每個表的。
4. 關聯glyf資訊
在使用了 TrueType 輪廓的字型中,每個字形都提供了 xMin
、xMax
、yMin
和 yMax
的值,這四個值也就是下圖的Bounding Box
。除了這四個值,還需要 advanceWidth
和 leftSideBearing
兩個欄位,這兩個欄位並不在 glyf
表中,因此在擷取字形資訊的時候無法獲取。在這個步驟,fonteditor-core
會讀取字型的 hmtx
表獲取這兩個欄位。
5. 寫入字型
在這一步會重新計算字型檔案的大小,並且更新偏移表(Offset table)
和表記錄(Table record)
有關的值, 然後依次將偏移表
、表記錄
、表資料
寫入檔案中。有一點需要注意的是,在寫入表記錄
時,必須按照表名排序進行寫入。例如有四張表分別是 prep
、hmtx
、glyf
、head
、則寫入的順序應為 glyf -> head -> hmtx -> prep
,而表資料
沒有這個要求。
fontmin 不足之處
fonteditor-core
在擷取字型的過程中只會對前文提到的十四張表進行處理,其餘表丟棄。每個字型通常還會包含 vhea
和 vmtx
兩張表,它們用於控制字型在垂直佈局時的間距等資訊,如果用 fontmin
進行字型擷取後,會丟失這部分資訊,可以在文字垂直顯示時看出差異(右邊為擷取後):
fontmin 使用方法
在瞭解了 fontmin
的原理後,我們就可以愉快的使用它啦。伺服器接受到客戶端發來的請求後,通過 fontmin
擷取字型,fontmin
會返回擷取後的字型檔案對應的 Buffer,別忘了 @font-face
規則中字型路徑是支援 base64
格式的,因此我們只需要將 Buffer 轉為 base64
格式嵌入在 @font-face
中返回給客戶端,然後客戶端將該 @font-face
以 CSS 形式插入 <head></head>
標籤中即可。
對於固定的預覽內容,我們也可以先生成字型檔案儲存在 CDN 上,但是這個方式的缺點在於如果 CDN 不穩定就會造成字型載入失敗。如果用上面的方法,每一個擷取後的字型以 base64
字串形式存在,則可以在服務端做一個快取,就沒有這個問題。利用 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: '字型預覽'
}))
.use(Fontmin.ttf2woff2())
.dest('./dist')
await Promise.promisify(fontmin.run, { context: fontmin })()
}
extractFontData()
複製程式碼
對於固定預覽內容我們可以預先生成好分割後的字型,對於使用者輸入的動態預覽內容,我們當然也可以按照這個流程:
獲取輸入 -> 擷取字形 -> 上傳 CDN -> 生成 @font-face -> 插入頁面
按照這個流程來客戶端需要請求兩次才能獲取字型資源(別忘了在 @font-face
插入頁面後才會去真正請求字型),並且擷取字形
和上傳 CDN
這兩步時間消耗也比較長,有沒有更好的辦法呢?我們知道字形的輪廓是由一系列位置點確定的,因此我們可以獲取 glyf
表中的位置點座標,通過 SVG
影像將特定字形直接繪製出來。
SVG
是一種強大的影像格式,可以使用CSS
和JavaScript
與它們進行互動,在這裡主要應用了path
元素
獲取位置資訊以及生成 path
標籤我們可以藉助 opentype.js
完成,客戶端得到輸入字形的 path
元素後,只需要遍歷生成 SVG
標籤即可。
3. 減小字型檔案體積的優勢
下面附上字型擷取後檔案大小和載入速度對比表格。可以看出,相較於全量載入,對字型進行擷取後載入速度快了145
倍。
fontmin
是支援生成woff2
檔案的,但是官方文件並沒有更新,最開始我使用的woff
檔案,但是woff2
格式檔案體積更小並且瀏覽器支援不錯
字型名稱 | 大小 | 時間 |
---|---|---|
HanyiSentyWoodcut.ttf | 48.2MB | 17.41s |
HanyiSentyWoodcut.woff | 21.7KB | 0.19s |
HanyiSentyWoodcut.woff2 | 12.2KB | 0.12s |
二、字型載入完成前不展示預覽內容
這是在實現預覽功能過程中的第二個問題。
在瀏覽器的字型顯示行為中存在阻塞期
和交換期
兩個概念,以 Chrome
為例,在字型載入完成前,會有一段時間顯示空白,這段時間被稱為阻塞期
。如果在阻塞期
內仍然沒有載入完成,就會先顯示後備字型,進入交換期
,等待字型載入完成後替換。這就會導致頁面字型出現閃爍,與我想要的效果不符。而 font-display
屬性控制瀏覽器的這個行為,是否可以更換 font-display
屬性的取值來達到我們的目的呢?
font-display
Block Period | Swap Period | |
---|---|---|
block | Short | Infinite |
swap | None | Infinite |
fallback | Extremely Short | Short |
optional | Extremely Short | None |
字型的顯示策略和 font-display
的取值有關,瀏覽器預設的 font-display
值為 auto
,它的行為和取值 block
較為接近。
第一種策略是
FOIT(Flash of Invisible Text)
,FOIT
是瀏覽器在載入字型的時候的預設表現形式,其規則如前文所說。
第二種策略是
FOUT(Flash of Unstyled Text)
,FOUT
會指示瀏覽器使用後備字型直至自定義字型載入完成,對應的取值為swap
。
兩種不同策略的應用:Google Fonts FOIT 漢儀字型檔 FOUT
在夸克專案中,我希望的效果是字型載入完成前不展示預覽內容,FOIT
策略最為接近。但是 FOIT
文字內容不可見的最長時間大約是3s
, 如果使用者網路狀況不太好,那麼3s
過後還是會先顯示後備字型,導致頁面字型閃爍,因此 font-display
屬性不滿足要求。
查閱資料得知,CSS Font Loading API在 JavaScript
層面上也提供瞭解決方案:
FontFace、FontFaceSet
先看看它們的相容性:
又是 IE,IE 沒有使用者不用管
我們可以通過 FontFace
建構函式構造出一個 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
(這個值可以設定,但不會生效) -
unicodeRange:
@font-face
規則的unicode-ranges
-
variant:
font-variant
-
featureSettings:
font-feature-settings
構造出一個 fontFace
後並不會載入字型,必須執行 fontFace
的 load
方法。load
方法返回一個 promise
,promise
的 resolve
值就是載入成功後的字型。但是僅僅載入成功還不會使這個字型生效,還需要將返回的 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)
}
複製程式碼
因此,在客戶端我們可以先設定文字內容的 CSS 為 opacity: 0
,
等待 await loadFont(path)
執行完畢後,再將 CSS 設定為 opacity: 1
, 這樣就可以控制在自定義字型載入未完成前不顯示內容。
最後總結
本文介紹了在開發字型預覽功能時遇到的問題和解決方案,限於 OpenType
規範條目很多,在介紹 fontmin
原理部分,僅描述了對 glyf
表的處理,對此感興趣的讀者可進一步學習。
本次工作的回顧和總結過程中,也在思考更好的實現,如果你有建議歡迎和我交流。同時文章的內容是我個人的理解,存在錯誤難以避免,如果發現錯誤歡迎指正。
感謝閱讀!
參考
- 前端字型擷取
- Scalable Vector Graphics
- FontFace
- FontFaceSet
- fontmin
- fonteditor-core
- TrueType-Reference-Manual
- OpenType-Font-File
寫在最後
JDC凹凸實驗室 春季校園招聘開始啦!
我們是一支熱愛創造、不斷嘗試新技術、新體驗、新產品的團隊。
2020年實習招聘名額開放,快快加入吧!
- 聯絡郵箱:aotu[AT]jd.com
- 工作地點:深圳
基本要求
- 2020年10月1日至2021年9月30日期間畢業,全日制本科及以上學歷;
- 基礎紮實,邏輯性強,具備良好的溝通能力和團隊協作精神;
- 熱愛計算機程式設計,關注新事物、新技術,有創造力,有較強的學習能力;
- 對 Web 標準有一定的理解,對可用性、可訪問性等相關知識有所瞭解;
- 瞭解如何使用 HTML、CSS、JavaScript 構建高效能的 Web 應用程式;
- 瞭解主流前端框架(React / Vue / Angular 等),瞭解多端開發框架(Taro / uni-app / Chameleon / MpVue 等),追求良好的程式碼風格,對介面設計與程式架構有所瞭解;
優先考慮
- 瞭解 Node.js / Java 服務端開發;
- 瞭解微信小程式 / 移動端開發;
- 有個人開源專案或技術部落格且更新頻率較高。
請註明來源[掘金]