Web 項目中,使用一個適合的字體能給用戶帶來良好的體驗。可是字體文件這麼多,若是設計師或者開發人員想要查詢字體,只能一個個打開,很是影響工做效率。所以,夸克平臺須要實現一個功能,可以支持根據固定文字以及用戶輸入預覽字體。在實現這一功能的過程當中主要解決兩個問題:javascript
如今將問題的解決以及個人思考總結成文。css
在聊這兩個問題以前,咱們先簡述怎樣使用一個 Web 自定義字體。要想使用一個自定義字體,能夠依賴 CSS Fonts Module Level 3 定義的 @font-face
規則。一種基本可以兼容全部瀏覽器的使用方法以下:html
@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 文字上,就能夠實現字體的預覽效果。java
但這麼作咱們能夠明顯發現一個問題,字體體積太大致使的加載時間過長。咱們打開瀏覽器的 Network 面板查看:git
能夠看到字體的體積爲5.5 MB,加載時間爲5.13 s。而夸克平臺不少的中文字體大小在20~40 MB 之間,能夠預想到加載時間會進一步增加。若是用戶還處於弱網環境下,這個等待時間是不能接受的。github
那麼中文字體相較於英文字體體積爲何這麼大,這主要是兩個方面的緣由:web
咱們能夠藉助於 opentype.js
,統計一箇中文字體和一個英文字體在字形數量以及字形所佔字節數的差別:數組
字體名稱 | 字形數 | 字形所佔字節數 |
---|---|---|
FZQingFSJW_Cu.ttf | 8731 | 4762272 |
JDZhengHT-Bold.ttf | 122 | 18328 |
夸克平臺字體預覽須要知足兩種方式,一種是固定字符預覽, 另外一種是根據用戶輸入的字符進行預覽。但不管哪一種預覽方式,也僅僅會使用到該字體的少許字符,所以全量加載字體是沒有必要的,因此咱們須要對字體文件作精簡。promise
unicode-range 屬性通常配合 @font-face
規則使用,它用於控制特定字符使用特定字體。可是它並不能減少字體文件的大小,感興趣的讀者能夠試試。
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
內部使用了 fonteditor-core
,核心的字體處理交給這個依賴完成,fonteditor-core
的主要流程以下:
將字體文件轉爲 ArrayBuffer
用於後續讀取數據。
前文咱們說到緊跟在 offset table(偏移表)
以後的結構就是 table record(表記錄)
,而多個 table record
叫作 Table Directory
。fonteditor-core
會先讀取原字體的 Table Directory
,由上文表記錄的結構咱們知道,每個 table record
有四個字段,每一個字段佔4個字節,所以能夠很方便的利用 DataView
進行讀取,最終獲得一個字體文件的全部表信息以下:
在這一步會根據 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
是如何處理每一個表的。
在使用了 TrueType 輪廓的字體中,每一個字形都提供了 xMin
、xMax
、yMin
和 yMax
的值,這四個值也就是下圖的Bounding Box
。除了這四個值,還須要 advanceWidth
和 leftSideBearing
兩個字段,這兩個字段並不在 glyf
表中,所以在截取字形信息的時候沒法獲取。在這個步驟,fonteditor-core
會讀取字體的 hmtx
表獲取這兩個字段。
在這一步會從新計算字體文件的大小,而且更新偏移表(Offset table)
和表記錄(Table record)
有關的值, 而後依次將偏移表
、表記錄
、表數據
寫入文件中。有一點須要注意的是,在寫入表記錄
時,必須按照表名排序進行寫入。例若有四張表分別是 prep
、hmtx
、glyf
、head
、則寫入的順序應爲 glyf -> head -> hmtx -> prep
,而表數據
沒有這個要求。
fonteditor-core
在截取字體的過程當中只會對前文提到的十四張表進行處理,其他表丟棄。每一個字體一般還會包含 vhea
和 vmtx
兩張表,它們用於控制字體在垂直佈局時的間距等信息,若是用 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
標籤便可。
下面附上字體截取後文件大小和加載速度對比表格。能夠看出,相較於全量加載,對字體進行截取後加載速度快了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
屬性的取值來達到咱們的目的呢?
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
層面上也提供瞭解決方案:
先看看它們的兼容性:
又是 IE,IE 沒有用戶不用管
咱們能夠經過 FontFace
構造函數構造出一個 FontFace
對象:
const fontFace = new FontFace(family, source, descriptors)
family
CSS
屬性 font-family
的值,source
url
或者 ArrayBuffer
descriptors optional
font-style
font-weight
font-stretch
font-display
(這個值能夠設置,但不會生效) @font-face
規則的 unicode-ranges
font-variant
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
表的處理,對此感興趣的讀者可進一步學習。
本次工做的回顧和總結過程當中,也在思考更好的實現,若是你有建議歡迎和我交流。同時文章的內容是我我的的理解,存在錯誤難以免,若是發現錯誤歡迎指正。
感謝閱讀!