[Tech] FontKit:字型拆包套件


對於任何一個已經封裝完成,也就是格式為 *.otf 或是 *.woff 的字型來說,想要將其逆向拆包,可以使用 Python 的 fontTools 工具,或是 javascript 的 Fontkit 套件——換句話說,如果想要在瀏覽器上進行拆包,便是 Fontkit 大展神威的時候。

安裝

1
npm install fontkit

Webfont 分析

在這裡,我們選擇 Google & Adobe 的思源系列(Noto)作為示範,畢竟可以算是目前最廣為人知的開源字型,沒有之一。以思源黑體繁體中文(Noto Sans CJK TC)為例,可以在 Google Font 裡選擇 想要顯示的字重(font weight),以 Medium 500 這個字重為例,在我們 select 之後,可以在旁邊的側欄裡看到一串 css 代碼:

1
2
3
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@500&display=swap');
</style>

只要把這段貼到 <head> 裡面,就可以讓網頁直接使用了。不過,我們想要了解的是,其背後究竟是如何實現的?

當我們直接用瀏覽器打開 url 裡面的 Stylesheet 網址後,會看到一大串被分割的 @font-face ,像是這樣的格式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
/* [0] */
@font-face {
  font-family: 'Noto Sans TC';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosanstc/v35/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz75Ky_C9Otma2sNSJtzYHliyxLND4wBzNt2rB9nIRpa7KOq33sn8BIAlj5iM.0.woff2) format('woff2');
  unicode-range: U+1f921-1f930, U+1f932-1f935, U+1f937-1f939, U+1f940-1f944, U+1f947-1f94a, U+1f950-1f95f, U+1f962-1f967, U+1f969-1f96a, U+1f980-1f981, U+1f984-1f98d, U+1f990-1f992, U+1f994-1f996, U+1f9c0, U+1f9d0, U+1f9d2, U+1f9d4, U+1f9d6, U+1f9d8, U+1f9da, U+1f9dc-1f9dd, U+1f9df-1f9e2, U+1f9e5-1f9e6, U+20024, U+20487, U+20779, U+20c41, U+20c78, U+20d71, U+20e98, U+20ef9, U+2107b, U+210c1, U+22c51, U+233b4, U+24a12, U+2512b, U+2546e, U+25683, U+267cc, U+269f2, U+27657, U+282e2, U+2898d, U+29d5a, U+f0001-f0005, U+f0019, U+f009b, U+f0101-f0104, U+f012b, U+f01ba, U+f01d6, U+f0209, U+f0217, U+f0223-f0224, U+fc355, U+fe327, U+fe517, U+feb97, U+fffb4;
}

/* [6] */
@font-face {
  font-family: 'Noto Sans TC';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosanstc/v35/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz75Ky_C9Otma2sNSJtzYHliyxLND4wBzNt2rB9nIRpa7KOq33sn8BIAlj5iM.6.woff2) format('woff2');
  unicode-range: U+ff78-ff7e, U+ff80-ff86, U+ff89-ff94, U+ff97-ff9e, U+ffb9, U+ffe0-ffe3, U+ffe9, U+ffeb, U+ffed, U+fffc, U+1d7c7, U+1f004, U+1f0cf, U+1f141-1f142, U+1f150, U+1f154, U+1f158, U+1f15b, U+1f15d-1f15e, U+1f162-1f163, U+1f170-1f171, U+1f174, U+1f177-1f178, U+1f17d-1f17f, U+1f192-1f195, U+1f197-1f19a, U+1f1e6-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f22f, U+1f232-1f237, U+1f239-1f23a, U+1f250-1f251, U+1f300, U+1f302-1f319;
}

為什麼會出現這麼多的 @font-face 屬性?雖然「思源黑體」是 一套 字型,但因為其包含的字數太多了(全部共有 65536 個字)。裡面有很多罕用字,而且也不是每一個常用字都會出現在網頁上。如果我們的網頁內文沒有某某字符,卻還是把整包字型抓下來,就白白浪費了下載和等待的時間。

因此,Google 將一套字型拆分成多個子字型檔案(subset font),並且透過 unicode-range 的方式限制取用,只有當想要顯示的字,出現在某個 @font-face 裡指定的 unicode-range 範圍時,才會真的從伺服器端下載 src*.woff 檔案;而那些沒有用到的字,自然而然就不需要浪費時間和空間載入了。

Emoji

同理,我們也可以用同樣的方法去查詢 Noto Color Emoji,在:

1
2
3
4
5
<style>
  @import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap');
  // 或是 fetch('https://fonts.googleapis.com/css?family=<FontFamilyName>&text=<TextYouWant>')

</style>

的 stylesheet 裡面,透過直連,找到像是這樣的東西:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* [8] */
@font-face {
  font-family: 'Noto Color Emoji';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notocoloremoji/v25/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabts6diysYTngZPnMC1MfLd4gw.8.woff2) format('woff2');
  unicode-range: U+200d, U+2640, U+2642, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+fe0f, U+1f33e, U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f466-1f478, U+1f47c, U+1f481-1f483, U+1f486-1f487, U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3, U+1f6b4-1f6b6, U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa82, U+1fac3-1fac5;
}

接著,我們可以拿出 src 裡面的 woff 檔案,透過前天介紹Wakamai Fond 、或是上面提到的 fontkit,分析這個 *.woff2 的字型檔案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const fontkit = require("fontkit");
const fontURL = "https://fonts.gstatic.com/s/notocoloremoji/v25/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabts6diysYTngZPnMC1MfLd4gw.8.woff2";

async function loadFont(fontPath) {
    const response = await fetch(fontPath);
    const arrayBuffer = await response.arrayBuffer();
    const buf = new Buffer(arrayBuffer);
    const font = fontkit.create(buf);
    console.log(font)
}

loadFont(fontURL);

這裡面就有我們想要的東西了。

Table

如 log 出來的結果所示,一個 OpenType 格式的檔案其實是由多個被稱作 table 的資料庫所組成。

舉例來説,和字型名稱、廠商、版權宣告、版本等有關的訊息,都會被放在 name table 裡面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~ console.log(font.name)
> {version: 0, count: 8, stringOffset: 102, records: {…} ...}

// 字型名稱
~ console.log(font.name.records.fullName.en)
> Noto Color Emoji

// 字型版本
~ console.log(font.name.records.copyright.en)
> Copyright 2022 Google Inc.

而跟「替換」有關的 feature,像是我們提到的供 Emoji 使用的 ccmp feature,則是放在 GSUB 裡面:

1
2
3
4
5
6
~ console.log(font.GSUB)
> {version: 65536, scriptList: Array(2), featureList: Array(1), lookupList: LazyArrayValue ...}

// feature 的屬性名字
~ console.log(font.GSUB.featureList[0].tag)
> ccmp

至於彩色字型的 COLR/CPAL 的色版呢?則是在 COLRCPAL 的 table 裡面:

1
2
~ console.log(font.CPAL)
> {version: 0, numPaletteEntries: 1356, numPalettes: 1, numColorRecords: 1356 ...}

有沒有看到一些很熟悉的東西呢?像是基本色盤(base-palette)的數量(numPalettes)、每個色盤有幾個顏色(numPaletteEntries),所有色盤共有幾個顏色(numColorRecords)。如果我們拿 前天的 Rocher 來拆包的話,就會得到 11 個色盤、每色盤 4 色、共 44 色,和 [Wakamaifondue](https://wakamaifondue.com/) 的分析一樣!

1
2
~ console.log(font.CPAL)
> {version: 0, numPaletteEntries: 4, numPalettes: 11, numColorRecords: 44 ...}

而各個色盤的顏色(RGBA),則可以透過 colorRecords 這個 array 得到:

1
2
3
4
5
~ console.log(font.CPAL.colorRecords)
> (1356) { 0: {blue: 0, green: 0, red: 0, alpha: 255, parent: {…}, …}
           1: {blue: 102, green: 0, red: 0, alpha: 255, parent: {…}, …}
           ...
         }

有了字型資訊與色盤資訊,我們便有機會透過 @font-palette-values 屬性來玩顏色了。

實作

在這裡,我們還是以老朋友 Noto Color Emoji 為例,並再次請來我們的老朋友 🐬(U+1F42C)為例,能透過這個網址直接抓取:

1
https://fonts.googleapis.com/css?family=Noto+Color+Emoji&text=🐬

內容為一個 Stylesheet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@font-face {
  font-family: "Noto Color Emoji";
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/l/font?kit=Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabts6diywYkdG2YmD0U&skey=a373f7129eaba270&v=v25)
    format("woff2");
}

body {
  --google-font-color-notocoloremoji: colrv1;
}

其中 src 來源的 woff2 檔案,就是 僅包含 🐬 的 Noto Color Emoji 字型檔

我們先寫一個超簡單的網頁來顯示這隻可愛的小海豚 🐬:

1
2
3
4
<div class="dolphin">
  🐬
  <div></div>
</div>

並按照 Google Font API 回傳的 stylesheet 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@font-face {
  font-family: "Noto Color Emoji";
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/l/font?kit=Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabts6diywYkdG2YmD0U&skey=a373f7129eaba270&v=v25)
    format("woff2");
}

body {
  --google-font-color-notocoloremoji: colrv1;
}

.dolphin {
  font-family: "Noto Color Emoji", sans-serif;
}

此時,我們可以看到一隻可愛的海豚!

另一方面,藉由 FontKit 套件,讓我們直接拆開這個 僅包含 🐬 的 Noto Color Emoji 字型檔

1
2
3
4
5
6
const fontPath =
  "https://fonts.gstatic.com/l/font?kit=Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabts6diywYkdG2YmD0U&skey=a373f7129eaba270&v=v25";
const response = await fetch(fontPath);
const arrayBuffer = await response.arrayBuffer();
const buf = new Buffer(arrayBuffer);
const font = fontkit.create(buf);

並印出該字型檔的色盤資訊:

1
2
3
4
5
~ console.log(font.CPAL.colorRecords)
> (4) 0: {blue: 166, green: 109, red: 0, alpha: 255, parent: {}, …}
      1: {blue: 48, green: 43, red: 45, alpha: 255, parent: {}, …}
      2: {blue: 225, green: 180, red: 54, alpha: 255, parent: {}, …}
      3: {blue: 245, green: 236, red: 221, alpha: 255, parent: {}, …}

太好了,我們現在知道了兩件事:

  1. 這隻小海豚便是由這 四個顏色 所組成的。
  2. 四個顏色與其對應的圖層編號

DAY 19 的 OpenType Color Font:實作 裡,我們知道只要能替換色版的顏色與圖層編號,就能藉由 override-colors 取代原本的字符顏色,所以我們也可以對這隻小海豚 🐬 進行顏色覆寫的操作,舉例來説,將編號 2 的圖層由 rgba(54, 180, 225, 255) 的藍色換成 rgba(93, 172, 129, 1) 的綠色:

1
2
3
4
5
6
7
8
.dolphin {
  font-palette: --customize;
}

@font-palette-values --customize {
  font-family: Noto Color Emoji;
  override-colors: 2 rgba(93, 172, 129, 1);
}

如此一來,我們得到了什麼呢?一隻綠色的海豚!


本文同步刊於 iThome。詳見DAY 21 | FontKit (1):字型拆包套件DAY 22 | FontKit (2):CPLR 與 CPAL table