前言

前陣子在 Windows 上遇到一個很謎的狀況。

我習慣讓網頁中文字走 Noto Sans CJK TC,字比較全,看久了也順眼。結果 Chrome 不管怎麼調字體設定,好像都沒什麼用。

一開始以為是 Chrome 設定壞了,後來發現不是。

很多網站 CSS 長這樣:

1
font-family: Arial, Helvetica, sans-serif;

看起來沒什麼問題。但 Arial 沒有中文字,照理說應該往後 fallback 到 sans-serif,再套用我設定好的中文字體。實際上在 Windows + Chrome 卻常常變成:

1
2
英文 → Arial
中文 → 系統自己 fallback 到某個 CJK font

然後那個「某個 CJK font」不一定是我想用的。有時候是 Noto Sans TC,有時候看起來像微軟正黑體,總之一種「你以為設定了但其實沒有」的感覺。

Chrome 字體設定到底在設什麼

Chrome 設定裡可以調 Sans-serif / Serif / Fixed-width 這些字體。但它主要管的是網頁真的用 generic family 的情況,例如:

1
font-family: sans-serif;

如果網站明確寫了:

1
font-family: Arial, Helvetica, sans-serif;

Chrome 會先用 ArialArial 沒有中文字的時候,中文 glyph 進瀏覽器 / 系統的 fallback 流程,這時候就不一定會理 Chrome 設定裡的 Sans-serif 了。

第一個嘗試:直接蓋 font-family

一開始想說用 UserScript 掃 DOM,把字體直接改成:

1
font-family: "Noto Sans CJK TC", "Noto Sans TC", sans-serif !important;

有解,但副作用很大。這樣會把整個元素的字體都換掉,英文字也跟著跑了。原本網站可能用 Inter / Roboto / Arial 排英文,結果全變成 Noto。中文是對了,但整個網站的味道也變了。有些地方連 icon font 跟特殊 UI 字體都被波及。

第二個嘗試:只處理有中文的元素

後來改成比較保守的做法:只掃有 CJK 文字的元素,命中黑名單才處理,把 Arial / Inter / Roboto 這類拉丁字體從 stack 裡拿掉,再插入 Noto Sans CJK TC

概念大概:

1
2
3
if (element.textContent has CJK && fontFamily includes Arial) {
rewrite font-family
}

比第一版好多了,但還是有些問題。

getComputedStyle() 看到的東西不一定是原始 CSS,Chrome 有時候已經算完 fallback 了,拿到的跟想的不一樣。再來現代網頁很動態,React / Vue 一直改 DOM、改 class、改 inline style,UserScript 要追著跑。還有更煩的:有時候 script 明明把 style 寫進去了,字體沒立刻換,但在 DevTools 隨便 toggle 一個 CSS property 就突然好了。

看起來像是 Chrome 沒有馬上重做 font resolution / repaint。用 reflow、MutationObserver、反覆 patch 可以硬推,但越寫越像在跟瀏覽器打架,很笨。

換個思路:覆蓋 @font-face

後來發現比較穩的做法不是改 DOM,也不是改每個元素的 font-family。而是直接對那些常見的拉丁字體建立 CJK-only 的 @font-face

網站寫:

1
font-family: Arial, sans-serif;

我們就注入:

1
2
3
4
5
@font-face {
font-family: "Arial";
src: local("Noto Sans CJK TC");
unicode-range: U+4E00-9FFF;
}

意思是:當頁面想用 Arial 顯示 CJK 範圍的字元時,改用本機的 Noto Sans CJK TC。unicode-range 不含基本 Latin,所以英數字不會被這個 @font-face 接管。

1
Hello 中文

變成:

1
2
Hello → 原本的 Arial
中文 → Noto Sans CJK TC

這樣不用掃 DOM,不用改 inline style,不用把整個網站字體換掉,也比較不會誤傷 icon font 或奇怪的 UI 元件。

UserScript

最後整理成一個 UserScript:

https://gist.githubusercontent.com/lekoOwO/87cd98cb7bd5951039b07a71b62abe01/raw/CJK-Font-Replacer.userscript.js

做的事情:

  1. 宣告想用的 CJK 字體,例如 Noto Sans CJK TC
  2. 列出想覆蓋的 font family,例如 ArialInterRobotoSegoe UI
  3. 對每個 target family 生一份 CJK-only 的 @font-face
  4. local(...) 指向本機字體
  5. unicode-range 限制只處理 CJK 字元

概念像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@font-face {
font-family: "Arial";
src:
local("Noto Sans CJK TC"),
local("Noto Sans CJK TC Regular"),
local("NotoSansCJKtc-Regular");
font-style: normal;
font-weight: 100 900;
unicode-range:
U+3000-303F,
U+3400-4DBF,
U+4E00-9FFF,
U+F900-FAFF,
U+FF00-FFEF;
}

腳本裡對這些字體各生一份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
targetFamilies: [
'Arial',
'Helvetica',
'Helvetica Neue',
'Roboto',
'Inter',
'Segoe UI',
'Verdana',
'Tahoma',
'Open Sans',
'Noto Sans',
'system-ui',
'-apple-system',
]

如果有網站用奇怪的字體,把名字加進 targetFamilies 就好了。

為什麼不直接蓋 sans-serif

想過直接寫:

1
2
3
4
5
@font-face {
font-family: "sans-serif";
src: local("Noto Sans CJK TC");
unicode-range: U+4E00-9FFF;
}

但不太可靠。serifsans-serif 這些 generic family 不是一般字體名稱,瀏覽器有自己的處理方式,不一定會把你定義的 @font-face 當同一件事。

所以還是覆蓋實體 family name 比較實際:ArialInterRobotoSegoe UIHelveticaNoto Sans 這些。

local() 名稱的雷

Windows 裡看到的字體名稱,不一定等於 CSS local() 可以命中的名稱。同一套字體可能有很多名字:

1
2
3
4
local("Noto Sans CJK TC")
local("Noto Sans CJK TC Regular")
local("NotoSansCJKtc-Regular")
local("NotoSansCJKtc")

腳本裡一次列多個讓瀏覽器自己從上往下找。如果裝的版本不同可能還要自己加名字。

小缺點

只會影響 targetFamilies 裡有列的字體,如果網站用沒列到的 webfont 就不會生效。如果網站自己載入了同名的 @font-face,順序可能影響結果,所以腳本會盡量把 <style> 放到後面,也會做幾輪 reinject。canvas、圖片、closed shadow DOM 裡的東西不會處理,但一般網頁文字夠用了。


這件事情本來只是想解決「中文字 fallback 到不想要的字體」,結果一路踩到 Chrome、Windows、CSS font fallback、@font-face local()unicode-range 的各種細節。

結論大概是:不要跟 Chrome 的 fallback 流程硬幹,直接在它想用的 font family 上補一個 CJK-only face。英文維持網站原本的設計,中文穩定走自己想要的字體。

繞了一圈但最後還算乾淨。yay