环境: Ubuntu 26.04,英文 locale(
LANG=en_US.UTF-8)、GNOME 50、默认 Wayland。
系统语言保持英文(LANG=en_US.UTF-8),但希望中文按简体(SC)字形显示。结果界面里部分汉字看着很别扭——字形偏窄、左右留白偏多(典型如「复」字),可占位宽度又一样。这不是排版宽度问题,是字形本身选错了。本文记录从取证到根因再到最终修复的完整过程,不改系统语言、不影响英文。
一条关键线索:正文对,地址栏错
仔细观察会发现:网页正文里的中文是正常的,而浏览器地址栏、应用 UI、终端里的中文不正常。同一个字,在不同地方长得不一样——说明正文和 UI 走的是两条不同的字体选择路径。
根因
系统装的 Noto Sans CJK 是一个合并字库,含 JP / KR / SC / TC / HK 五个地区字形。Ubuntu 的 /etc/fonts/conf.d/64-language-selector-cjk-prefer.conf 给通用字族(sans-serif / serif / monospace)排的 CJK 顺序是:
Noto Sans CJK JP ← 排第一
Noto Sans CJK KR
Noto Sans CJK SC ← 简体排第三
Noto Sans CJK TC
Noto Sans CJK HK
这些 CJK 字体是用 <prefer> 插在拉丁主字体(Noto Sans)之后的,所以英文一直正常;但当文本**没有语言标记(no lang hint)**时,汉字会落到排在最前的 JP,于是共享汉字显示成了日文字形。
这就解释了"正文对、UI 错":浏览器渲染正文时会按内容把语言标成 zh-cn,命中简体;而地址栏、应用 UI、终端这些路径不带 lang 提示,在英文 locale 下没有任何中文倾向,默认就吃了排第一的 JP。
取证:别猜,要证据
用 fc-match 指定单个码位,直接看系统给「复」(U+590D)挑哪个字体:
# 无语言提示(地址栏 / UI 走的路径)
$ fc-match 'sans-serif:charset=590d'
NotoSansCJK-Regular.ttc: "Noto Sans CJK JP" "Regular" # ← 日文!
# 带 zh-cn(网页正文走的路径)
$ fc-match 'sans-serif:charset=590d:lang=zh-cn'
NotoSansCJK-Regular.ttc: "Noto Sans CJK SC" "Regular" # ← 简体,正常
终端类程序(如 Ghostty)可以用它自带的工具确认每个码位实际用了哪个 face:
$ ghostty +show-face --string="复习abc"
U+590D « 复 » found in face "Noto Sans Mono CJK JP". # 修复前是 JP
走过的弯路
下面几种直觉做法都不行,值得记下来避免重复踩:
- 按
lang=zh限定规则——救不了地址栏,因为地址栏根本不带lang。 - 用
mode="append"追加 SC——SC 被加在已存在的 JP 之后,JP 仍然胜出,无效。 - 用
<alias><prefer>或不带显式拉丁的prepend把 SC 提到最前——SC 被插到了拉丁主字体之前,导致英文也被 SC 渲染(污染拉丁),还会误伤日文。
教训:fontconfig 里 prepend / prefer / append 的最终排序受加载顺序和 binding(weak / strong)共同影响,不要靠猜,改完必须用 fc-match 验证。
解决方案
确定性写法:为每个通用字族用**强绑定(binding="strong")**显式前插一个 [拉丁主字体, 然后 SC] 的头部。拉丁字体在前,英文 / 希腊 / 西里尔仍用它;只有它不含的汉字才落到紧随其后的 SC;而 SC 因为是强绑定,排在系统那条弱绑定的 JP 之前。
新建 ~/.config/fontconfig/conf.d/80-prefer-sc-default.conf:
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="pattern">
<test name="family"><string>sans-serif</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>Noto Sans</string>
<string>Noto Sans CJK SC</string>
</edit>
</match>
<match target="pattern">
<test name="family"><string>serif</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>Noto Serif</string>
<string>Noto Serif CJK SC</string>
</edit>
</match>
<match target="pattern">
<test name="family"><string>monospace</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>DejaVu Sans Mono</string>
<string>Noto Sans Mono CJK SC</string>
</edit>
</match>
</fontconfig>
然后刷新缓存:
fc-cache -f
几个要点:文件名前缀 80 让它在系统的 64-... 之后加载;把拉丁字体(Noto Sans / Noto Serif / DejaVu Sans Mono)写在第一位,是保证英文不被污染的关键;如果你的默认等宽不是 DejaVu,按需替换。
验证
# 目标:无语言提示的汉字 → SC
fc-match 'sans-serif:charset=590d' # 期望 Noto Sans CJK SC
fc-match 'monospace:charset=590d' # 期望 Noto Sans Mono CJK SC
# 安全:英文 / 拉丁一字不动
fc-match 'sans-serif' # 期望 Noto Sans
fc-match 'monospace' # 期望 DejaVu Sans Mono
fc-match 'sans-serif:charset=41' # 字母 A,期望 Noto Sans
让各程序生效
fontconfig 在程序启动时读取,改完必须彻底重启对应程序,刷新页面或重开标签页都没用:
- 浏览器(Chrome / Edge 等):完全退出所有窗口再重开。
- Ghostty:默认
gtk-single-instance = false,结束旧进程再启动新进程即可。注意Ctrl+Shift+,的"重载配置"只重读 Ghostty 自身配置,不一定会重建 fontconfig 回退,所以要完整重启。可用ps -eo pid,lstart,cmd | grep ghostty核对进程启动时间是否早于改配置的时间。 - 一般 GTK / Qt 应用:重启该应用,或注销重新登录最稳。
副作用与权衡
本方案用强绑定压过了"按语言指定字形"的规则,代价是:lang=ja(日文)和 lang=zh-tw(繁体)的网页,其共享汉字也会显示成简体字形。对纯简体用户通常无所谓;若需要保留日文 / 繁体字形,可以再补按语言的强规则单独覆盖,并同样用 fc-match 逐一验证。
撤销
rm ~/.config/fontconfig/conf.d/80-prefer-sc-default.conf && fc-cache -f
小结
这个问题的本质,是英文 locale 下"无语言提示的汉字"被默认匹配到了 Noto CJK 的日文字形。排查的关键不是凭经验下结论,而是用 fc-match 把"系统到底给这个字挑了谁"摆出来;修复的关键是认清 fontconfig 的排序由 binding 强弱和加载顺序决定,用确定性的强绑定显式前插,并且每改一次都验证"中文修好"且"英文没坏"。