前言
前陣子在 Windows 上遇到一個很謎的狀況。
我習慣讓網頁中文字走 Noto Sans CJK TC,字比較全,看久了也順眼。結果 Chrome 不管怎麼調字體設定,好像都沒什麼用。
一開始以為是 Chrome 設定壞了,後來發現不是。
很多網站 CSS 長這樣:
1 | font-family: Arial, Helvetica, sans-serif; |
看起來沒什麼問題。但 Arial 沒有中文字,照理說應該往後 fallback 到 sans-serif,再套用我設定好的中文字體。實際上在 Windows + Chrome 卻常常變成:
1 | 英文 → Arial |
然後那個「某個 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 會先用 Arial。Arial 沒有中文字的時候,中文 glyph 進瀏覽器 / 系統的 fallback 流程,這時候就不一定會理 Chrome 設定裡的 Sans-serif 了。
第一個嘗試:直接蓋 font-family
一開始想說用 UserScript 掃 DOM,把字體直接改成:
1 | font-family: "Noto Sans CJK TC", "Noto Sans TC", sans-serif ; |
有解,但副作用很大。這樣會把整個元素的字體都換掉,英文字也跟著跑了。原本網站可能用 Inter / Roboto / Arial 排英文,結果全變成 Noto。中文是對了,但整個網站的味道也變了。有些地方連 icon font 跟特殊 UI 字體都被波及。
第二個嘗試:只處理有中文的元素
後來改成比較保守的做法:只掃有 CJK 文字的元素,命中黑名單才處理,把 Arial / Inter / Roboto 這類拉丁字體從 stack 裡拿掉,再插入 Noto Sans CJK TC。
概念大概:
1 | if (element.textContent has CJK && fontFamily includes Arial) { |
比第一版好多了,但還是有些問題。
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 | @font-face { |
意思是:當頁面想用 Arial 顯示 CJK 範圍的字元時,改用本機的 Noto Sans CJK TC。unicode-range 不含基本 Latin,所以英數字不會被這個 @font-face 接管。
1 | Hello 中文 |
變成:
1 | Hello → 原本的 Arial |
這樣不用掃 DOM,不用改 inline style,不用把整個網站字體換掉,也比較不會誤傷 icon font 或奇怪的 UI 元件。
UserScript
最後整理成一個 UserScript:
做的事情:
- 宣告想用的 CJK 字體,例如
Noto Sans CJK TC - 列出想覆蓋的 font family,例如
Arial、Inter、Roboto、Segoe UI - 對每個 target family 生一份 CJK-only 的
@font-face - 用
local(...)指向本機字體 - 用
unicode-range限制只處理 CJK 字元
概念像這樣:
1 | @font-face { |
腳本裡對這些字體各生一份:
1 | targetFamilies: [ |
如果有網站用奇怪的字體,把名字加進 targetFamilies 就好了。
為什麼不直接蓋 sans-serif
想過直接寫:
1 | @font-face { |
但不太可靠。serif、sans-serif 這些 generic family 不是一般字體名稱,瀏覽器有自己的處理方式,不一定會把你定義的 @font-face 當同一件事。
所以還是覆蓋實體 family name 比較實際:Arial、Inter、Roboto、Segoe UI、Helvetica、Noto Sans 這些。
local() 名稱的雷
Windows 裡看到的字體名稱,不一定等於 CSS local() 可以命中的名稱。同一套字體可能有很多名字:
1 | local("Noto Sans CJK TC") |
腳本裡一次列多個讓瀏覽器自己從上往下找。如果裝的版本不同可能還要自己加名字。
小缺點
只會影響 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