前端字型擷取

奇舞週刊發表於2018-12-17

編者按:本文作者李鬆峰,資深技術圖書譯者,翻譯出版過40餘部技術及互動設計專著,現任360奇舞團高階前端開發工程師,360前端技術委員會委員、W3C AC代表

為什麼要擷取字型?

眾所周知,相對於英文字型,中文字型天生是“龐然大物”。英文字型兩三百KB已經很大了,而中文字型幾MB十幾MB都算小的。一方面,中文字型包含的字形數量極多,動輒數以千計甚至萬計,而英文字型則只需包含幾十個基本字元和符號,哪怕支援多種語言及字元變體,容量達到三千多個字形已經算非常龐大的了。另一方面,中文字形的曲折變化複雜度高,在基於輪廓的向量字型設計中,用於控制中文字形曲線的控制點普遍比英文更多,因而需要的資料量更大,也會導致字型檔案膨脹。

前端開發實踐中,為了實現一些特殊視覺效果,經常需要使用某些特殊字型,而使用者電腦上幾乎不太可能安裝這些字型,這時候通常需要使用Web字型技術,讓瀏覽器動態下載我們的自定義字型。可是中文字型非常龐大,很多時候“全量”載入某個字型檔案是不現實的。特別是對於一些動態頁面且每個頁面只有少量字元用到該字型的情況下。當然,也不是每個頁面都會用到一個字型檔案中的所有字元,全量載入本身也極其浪費。

研究表明,3500常用中文漢字(中國義務教育9年級需要掌握的漢字數量)即可覆蓋日常使用漢字的99.8%:

  • 500 字(78.53202%)
  • 1000字(91.91527%)
  • 1500字(96.47563%)
  • 2000字(98.38765%)
  • 2500字(99.24388%)
  • 3000字(99.63322%)
  • 3500字(99.82015%)

可見,最常用的前500個漢字的覆蓋率已經達到78%。因此,“全量”載入某個字型,特別是中文字型,在當前網路環境下不僅浪費流量和時間,而且也是完全沒有必要的。這時候,我們可以根據網頁用到的字元來擷取字型的片段,這個技術英文叫subset,也就是“取子集”。

本文首先簡單回顧Web自定義字型的技術規範,然後通過例項介紹兩種前端常用的擷取字型的技術。首先是CSS中的unicode-range屬性,我們稱之為“軟擷取技術”,因為它只是在本地既有字型或者瀏覽器已經下載的字型基礎上做一個指向子集的“軟連結”,並不能真正減小瀏覽器下載檔案的大小。其次是Node命令列工具glyphhanger,我們稱之為“硬擷取技術”,即在服務端從“全量”字型中分離出一個體積相對極小的字型子集,做成Web字型通過Web伺服器或CDN下發給瀏覽器。

無論是“軟擷取”,還是“硬擷取”,都會用到Web字型和@font-face規則。因此,我們需要先來了解一下這個基礎的Web標準語法。

Web字型與@font-face

為了超越“Web安全字型”的侷限,在網頁上使用一些使用者電腦上不太可能會安裝的字型,微軟曾率先提出了@font-face規則。這個規則後來進入W3C的CSS Fonts Module Level 3模組,於是就有了前端常用的Web自定義字型技術:

@font-face {
  font-family: 'MyWebFont';
  src: url('webfont.eot'); /* 相容IE9 */
  src: url('webfont.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
       url('webfont.woff2') format('woff2'), /* 最新瀏覽器 */
       url('webfont.woff') format('woff'), /* 較新瀏覽器 */
       url('webfont.ttf')  format('truetype'), /* Safari、Android、iOS */
       url('webfont.svg#svgFontName') format('svg'); /* 早期iOS */
}
複製程式碼

示例程式碼出處:https://css-tricks.com/snippets/css/using-font-face/

當然,上面的程式碼是幾乎可以相容所有瀏覽器的方案。大約在兩年前,也就是2016年,由於瀏覽器版本的快速更迭,寫成下面這樣已經是比較現實的了:

@font-face {
  font-family: 'MyWebFont';
  src:  url('myfont.woff2') format('woff2'),
        url('myfont.woff') format('woff');
}
複製程式碼

如果要相容更多瀏覽器,那再加上一種幾乎所有瀏覽器都支援的ttf格式則似乎更穩妥:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2'),
       url('myfont.woff') format('woff'),
       url('myfont.ttf') format('truetype');
}
複製程式碼

不過,我們的最終目標還是寫成這樣,即只使用woff2這種自帶壓縮的格式:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2');
}
複製程式碼

從技術角度講,除了直接使用@font-face,還可以使用@import規則或link元素匯入或載入包含@font-face宣告的外部檔案:

// 匯入
@import url(//fonts.googleapis.com/css?family=Open+Sans);
// 或者引用
<link href='//fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css'>
// 實際使用
body {
  font-family: 'Open Sans', sans-serif;
}
複製程式碼

開啟Google Fonts看一看:https://fonts.googleapis.com/css?family=Open+Sans

以上都是技術規範,至於什麼時候可以過渡到只使用專門針對Web字型優化的壓縮格式woff2,應該只是一個時間問題。

回顧完了基礎的技術規範和語法,明確了未來的方向,接下來我們進入實戰。先看一下CSS Fonts Module Level 3定義的與@font-face規則配合使用的unicode-range屬性,然後再給大家介紹一家有名的國外Web開發公司Filament Group, Inc.推出的字型擷取工具glyphhanger

unicode-range

unicode-range屬性雖然可以算作“字型擷取”技術,但它是“軟擷取”,不是“硬擷取”。它類似於一種快捷方式,而不能真正減少瀏覽器需要下載的字型檔案大小。

顧名思義,unicode-range用於指定自定義字型中包含的字元的Unicode碼點範圍,語法如下:

// CSS
@font-face {
  font-family: 'Ampersand';
  src: local('Times New Roman');
  unicode-range: U+26;
}
div {
  font-size: 4em;
  font-family: Ampersand, Helvetica, sans-serif;	
}
// HTML
<div>Me & You = Us</div>
複製程式碼

示例程式碼出處:https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range

以上@font-face規則自定義了一個名為“Ampersand”(英文&符號)的字型,這個字型“擷取”自本地字型Times New Roman,而這個字型只包含一個字元:U+2626是英文&符號的十六進位制Unicode碼點,對應的十進位制值是38)。

HTML中div元素根據font-family的指令,依次會應用自定義字型Ampersand(Times New Roman,襯線字型)、Helvetica(無襯線字型)和sans-serif(無襯線)字型族。實際應用效果如下:

前端字型擷取

Unicode編碼擴容到了17個編碼平面,每個平面的容量為65,536,總容量為1,114,112個碼點,其中實際分配使用的只有128,237個,約佔12%。因此在可以預見的未來,Unicode有足夠的空間包含地球上所有文明的字元。

看一箇中文字型的例子。假設我們要用特殊字型突出顯示“初唐四傑”之一王勃的千古名篇《滕王閣序》中最有名的那句:“落霞與孤鶩齊飛,秋水共長天一色。”

前端字型擷取

可以先把這句名句(包括標點)轉換成Unicode碼點:

字串轉碼點可以使用以下JavaScript函式:

function text2point(t) {
   return t.split('').map(c => 'u+'+c.charCodeAt().toString(16)).join(',')
}
複製程式碼

然後以“隸變”(Libian SC)作為源字型,自定義一個名叫custom的字型,把它應用到.emphasis元素:

// CSS
@font-face {
  font-family: custom;
  src: local(Libian SC);
  unicode-range: u+843d,u+971e,u+4e0e,u+5b64,u+9e5c,u+9f50,u+98de,u+ff0c,
      			u+79cb,u+6c34,u+5171,u+957f,u+5929,u+4e00,u+8272,u+3002;
  font-weight: 500;
}
.emphasis {
  font-family: custom;
}
// HTML
<!--其他句子-->
<span class="emphasis">落霞與孤鶩齊飛,秋水共長天一色。</span>
<!--其他句子-->
複製程式碼

注意,上面程式碼中的碼點列表為排版閱讀方便而人為換了行,實際使用中不要人為換行,以免造成語法錯誤。下面的程式碼示例也一樣。

結果如下:

前端字型擷取

此時,我們發現標點(逗號和句號)的樣式與其他文字不統一,而其他文字使用的是“蘋方”(PingFang SC)字型(在Mac上)。是不是可以簡單地從前面的碼點列表中刪除逗號和句號的碼點u+ff0cu+3002?這個方案在Safari 12、Firefox 62中可行,刪除碼點之後的逗號和句號會繼承使用“蘋方”字型,但是在Chrome 69中並不奏效。

此外,Chrome似乎還有一個bug。假設不刪除上述碼點,而直接在標點左側輸入一個自定義字型中並不包含的字元,Chrome會強制把這個字元顯示成自定義字型。看來瀏覽器的實現還是有不一致的地方。時間關係,Windows平臺下的IE和Edge沒有測試,讀者可以自行測試一下。

無論如何,我們可以再定義一個只包含逗號和句號兩個字元的自定義字型來解決這個問題:

@font-face {
  font-family: punc;
  src: local(PingFang SC);
  unicode-range: u+ff0c,u+3002;
}
.emphasis {
  font-family:punc, custom;
}
複製程式碼

這樣,即使不刪除custom宣告中的碼點,Chrome、Safari和Firefox也都可以將逗號和句號顯示為“蘋方”字型了:

前端字型擷取

注意,不要試圖基於英文字型自定義punc字型,因為英文字型中不包含對中文標點符號對應碼點的對映。

雖然這個例子明顯是自造的,“對中文內容中的某部分中文字元做特殊字型處理,或者是英文字型中部分字元做特殊字型處理”正是unicode-range這種“軟擷取技術”最適合的應用場景。更多unicode-range的內容,推薦大家看一看張鑫旭老師的文章“CSS unicode-range特定字元使用font-face自定義字型”:(https://www.zhangxinxu.com/wordpress/2016/11/css-unicode-range-character-font-face/)

使用unicode-range的注意事項:

  • unicode-range可以接收
    • 單個碼點:U+26(或u+26)
    • 碼點範圍:U+0-7FU+0025-00FF
    • 萬用字元範圍:U+4??,相當於U+400-U+4FF
    • 逗號分隔的多個值:U+0025-00FF, U+4??
  • unicode-range預設值為:U+0-10FFFF,即全部Unicode字元編碼
  • unicode-range的值是碼點的字面值或字面值列表,不是字串
    • 正確:unicode-range: u+ff0c,u+3002;
    • 錯誤:unicode-range: "u+ff0c,u+3002";
  • unicode-range的值不能有語法錯誤,比如上面說的不是字串,以及不能出現多餘的逗號:u+ff0c,u+3002,;(末尾多了一個逗號)等,出現語法錯誤的後果是自定義字型會變成源字型的別名,而非基於源字型擷取的子集。(當然,通過@font-face定義已有字型全集的別名,也是一種實用的CSS技術,可以參考前面張老師的文章。)
  • 轉換為碼點時確保使用正確的字元,比如前面例子中的“鶩”(u+9e5c)不要錯誤地使用“騖”(u+9a9b)。

關於unicode-range這種“軟擷取技術”的使用就介紹這些。接下來我們介紹“硬擷取工具”:glyphhanger。

glyphhanger

glyphhanger是Zach Leatherman(https://www.zachleat.com/web/)Filament Group(https://www.filamentgroup.com)寫的一個.ttf轉WOFF/WOFF2等Web字型格式的命令列工具,可以:

  • 抓取遠端或本地檔案並分析其中包含的文字
  • 將分析結果去重排序並轉換為Unicode碼點
  • 根據指定的源字型生成對應格式的子集(需要安裝另一個工具,稍後介紹)
  • 同時也生成包含@font-face規則的CSS檔案

這個工具非常實用方便,下面我們就來演示在製作Web字型過程中glyphhanger的幾個典型用法。

首先,全域性安裝:

npm install -g glyphhanger
複製程式碼

用法一:把王勃的千古名句改成“思源宋體”

進入包含例子頁面的目錄fontSubsetInAction,執行如下命令:

➜ fontSubsetInAction glyphhanger http://127.0.0.1:8080/index.html --family='custom' --subset=SourceHanSerifCN-Light.ttf --formats=woff2

U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Writing CSS file: SourceHanSerifCN-Light.css
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 3.57 KB)
複製程式碼

這裡給glyphhanger傳入了4個引數。

  • 要分析的遠端檔案(這裡是一個本地Web服務):http://127.0.0.1:8080/index.html
  • --family='custom'指定只分析以上頁面中應用了font-family: custom;規則的元素
  • --subset=SourceHanSerifCN-Light.ttf指定使用的源字型,這裡為“思源宋體”(Source Han Serif)
  • --formats=woff2指定想要生成的字型子集的目標格式,這裡是WOFF2

glyphhanger首先輸出了“落霞與孤鶩齊飛,秋水共長天一色。”對應的Unicode碼點(包含逗號和句號)。緊接著在當前目錄建立了一個名為“SourceHanSerifCN-Light.css”的檔案。之後的輸出顯示,擷取的字型叫“SourceHanSerifCN-Light-subset.woff2”,且源字型檔案有12.44 MB,子集檔案3.57 KB。16個漢字字元就用了3.57 KB,平均每個字元佔228位元組,嚇人吧?!

不過,比起12.44 MB,3.57 KB已經算極小了。下面,看看glyphhanger幫我們生成的CSS檔案:

/* This file was automatically generated by GlyphHanger 3.0.3 */
@font-face {
  font-family: custom;
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2");
  unicode-range: U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,
      			U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}
複製程式碼

直接使用即可,相比之前手工生成碼點,這樣省事多了。結果如下:

前端字型擷取

用法二:分析網頁用到漢字子集

可能有讀者沒有注意到,上面例子中glyphhanger輸出的碼點是按照每個字元在Unicode編碼中的順序從小到大排序過的。而且,這些碼點是在自動去重之後排的序。

“落霞與孤鶩齊飛,秋水共長天一色。”沒有重複的字,我們再看下面這個例子:

➜  fontSubsetInAction glyphhanger https://lisongfeng.cn/post/dive-into-async-function.html --string

 "#$&'()*+,-./0123456789:;<=>?ACDEFGHIJKLMNOPQRSTUVWXY[]`abcdefghijklmnopqrstuvwxy{|} ©«»—“”…、。一丁三上下不與且兩個中串為主麼義之乎乘也書了事二於互些交人什僅今介從代以們件任會偉傳似但位低住體何作你使例供依便信修倍候值假做停催像兒充先兌入全兮關其典內冊再寫決況準出函分切列則剛創初利別到制刻前力辦功加務動助努包化區升半協單佔即卻原去參又及反發取受變口句另只叫可各合同名後向嗎吧含啟呀員呢周味命和品哈哉響哎哥哦啊啥啦嘍嘛器回因困圍圖在地坑塊型基塞境增處備復外多大夫失頭奇套好如始姐媒媲子字存它完定實家容對導封將小少爾嘗就尾層屈展屬山崩嵌工己已布帶幫常幹年並序庫應底度建開異式引張強當徹往徵待很得循微心必快念態怎思急性總恢息悉悖悲情想意感成我或戶所手才打執擴擾找承把拋搶護抽拒括拼拿持按撓捕換據探接控推描提摸操擎支收改效救數文料斷新方無既早時明易是顯普景智暫更替最月有未末本機雜束條來構析果某查標樣核根格案夢檢概模次止正此步段每比畢毫永求沒法注洞活流瀏消澀深添潰滿漏演漫點煩燙然照熟版特狀獨獄環現理甚甜生用由界疼疾白的目直相省看真眼著知碼礎確示神種秒積稱程稍窮立竟端筆符第籠等答簡算管箭類糖系索級純線組細終紹經結給絕絞統繼續維綜編網置美翻考者而聰肉背能腳自至致節芋苦荒獲雖蠻行補表被裝要見規覽解觸計認讓議記講論設訪證評譯試話該詳語誤說請諾讀調謀象負責敗質費資賦越足踩身轉載較輯達迅過邁運返還這進遠迭述退送適逆逐遞通速造邏遍道那部都釋裡重量鑑針鏈錯鍵長問閒間隊阻際限除隨隱集需非靠面頁順須題風飾飽首香駕驗高麻默!(),:;?~
複製程式碼

第一個引數是一個“真正的”遠端網頁:https://lisongfeng.cn/post/dive-into-async-function.html,是我之前寫的文章“小哥哥小姐姐,來嚐嚐Async函式這塊語法糖”的網址。那篇文章全文接近5000字,但可以看到,經過分析去重之後,實際用到的只有604個漢字。另外,這裡也使用另一個引數--string讓glyphhanger把Unicode碼點轉換為字串輸出,是按照碼點從小到大排序的。

字串去重其實很簡單,下面這個簡單的JavaScript函式就可以搞定:

function textEliminateDuplicationAndSorting(text) {
    return text.split('').filter((value, index, self) => { 
      return self.indexOf(value) === index;
    }).sort().join('')
}
複製程式碼

用法三:指定文字或碼點生成字型子集

當然,如果你有現成的文字或碼點,也可以只讓glyphhanger幫你生成相應源字型的子集和CSS檔案。比如我想把“漁舟唱晚,響窮彭蠡之濱,雁陣驚寒,聲斷衡陽之浦。”也顯示為“思源宋體”:

➜  fontSubsetInAction glyphhanger --whitelist="落霞與孤鶩齊飛,秋水共長天一色。漁舟唱晚,響窮彭蠡之濱,雁陣驚寒,聲斷衡陽之浦。" --subset=SourceHanSerifCN-Light.ttf --css

U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.ttf (was 12.44 MB, now 13.02 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.zopfli.woff (was 12.44 MB, now 9.13 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 7.45 KB)
Writing CSS file: SourceHanSerifCN-Light.css

@font-face {
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2"), 
  		url(SourceHanSerifCN-Light-subset.zopfli.woff) format("woff"), 
  		url(SourceHanSerifCN-Light-subset.ttf) format("truetype");
  unicode-range: U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,
  			U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,
			U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,
			U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}
複製程式碼

這一次使用--whitelist引數傳入了要擷取的漢字,省略了--formmats,增加了--css引數。

從結果可以看到,glyphhanger還是對文字進行了去重、轉碼點和排序。而且,在沒有指定--formats的情況下,生成了.ttfwoffwoff2三種格式的字型子集,這是為了提高對瀏覽器的相容性。最後,除了例行生成CSS檔案,--css選項還讓glyphhanger把CSS檔案的內容輸出到了控制檯,便於複製。

但是要注意,CSS檔案和輸出都沒有包含font-family屬性,也就是沒有自定義字型的名字(custom),使用時必須自己手工加上。好,結果如下:

前端字型擷取

安裝pyftsubset

glyphhanger本身只做了網頁抓取和分析,實際的字型擷取使用的是一個著名的Python包fonttoolsgithub.com/fonttools/f…。安裝方法如下:

pip install fonttools

# Additional installation for --flavor=woff2
git clone https://github.com/google/brotli
cd brotli
python setup.py install

# Additional installation for --flavor=woff --with-zopfli
git clone https://github.com/anthrotype/py-zopfli
cd py-zopfli
git submodule update --init --recursive
python setup.py install
複製程式碼

文章最後,為了便於大家參考,我們給出glyphhanger的幫助資訊,大家可以自己去探索更多好玩的用法:

➜  fontSubsetInAction glyphhanger -h
glyphhanger error: requires at least one URL or whitelist.

usage: glyphhanger ./test.html
       glyphhanger http://example.com
       glyphhanger https://google.com https://www.filamentgroup.com
       glyphhanger http://example.com --subset=*.ttf
       glyphhanger --whitelist=abcdef --subset=*.ttf

arguments:
  --version
  --whitelist=abcdef
       A list of whitelist characters (optionally also --US_ASCII).
  --string
       Output the actual characters instead of Unicode code point values.
  --family='Lato,monospace'
       Show only results matching one or more font-family names (comma separated, case insensitive).
  --json
       Show detailed JSON results (including per font-family glyphs for results).
  --css
       Output a @font-face block for the current data.
  --subset=*.ttf
       Automatically subsets one or more font files using fonttools `pyftsubset`.
  --formats=ttf,woff,woff2,woff-zopfli
       woff2 requires brotli, woff-zopfli requires zopfli, installation instructions: https://github.com/filamentgroup/glyphhanger#installing-pyftsubset

  --spider
       Gather local URLs from the main page and navigate those URLs.
  --spider-limit=10
       Maximum number of URLs gathered from the spider (default: 10, use 0 to ignore).
  --timeout
       Maximum navigation time for a single URL.
複製程式碼

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。

前端字型擷取

相關文章