Featured image of post fontconfig:Linux下的字体配置

fontconfig:Linux下的字体配置

配置Linux环境下的字体,解决字体样式错误、异形字、全角引号以及合成字体

# 字体的分类

字体的数量可以说是成千上万,但一般在电脑上显示的基本为以下这三类1

  • monospace [等宽]

    等宽字体是指字符宽度相同的字体,用于需要字符严格对齐的场合,例如控制台和源代码。与此相对,字符宽度各不相同的字体称为比例字体(其余四类字体都是)。不过,对于中文字体而言,并不存在等宽与比例的差别,因为所有中文字都是等宽的。中文字体中的“等宽”指的是字体的西文部分是等宽的,2个字母对应1个汉字。

  • sans-serif [无衬线]

    是指笔画末端没有修饰(衬线)的字体,通常用于屏幕显示。中文的黑体与圆体就属于此类字体。

  • serif [有衬线]

    是指笔画末端有修饰(衬线)的字体,通常用于打印。中文的宋体与仿宋就属于此类字体。

我们要做的字体配置主要就是针对上面这三类字体。

# 选字体

有了目标,下面就是选一个自己喜欢的字体了。不过,对于中文字体,目前现在 Linux 上常用的、在维护的开源中文字体就一套——“思源黑体”和“思源宋体”,同时被 Noto思源两个项目收录。Noto 系列字体是 Google 主导的,思源系列字体是 Adobe 主导的。2

对于编程字体,可以选择的余地就多多了,像是Source Code Pro,Consolas,Menlo等等。我一开始选择的是 Iosevka 。但是,这款字体给我感觉太瘦了,看起来很是别扭。最后,我选择了广受好评的 Fira Code

小结一下,我的选择是:

  • 无衬线:西文 Noto Sans,中文 Noto Sans CJK
  • 衬线:西文 Noto Serif,中文 Noto Serif CJK
  • 等宽:西文 Fira Code,中文 Noto Sans Mono CJK

在ArchLiunx上,我们只需要安装noto-fontsnoto-fonts-cjk这两个包即可,他们分别提供了西文字体 Noto Sans / Noto Serif和中文字体 Noto Sans CJK / Noto Serif CJK / Noto Sans Mono CJK 。3

关于 emoji,我选择了 Twitter 推出的字体 Twemoji。

  • ttf-twemoji

另外,少不了人见人爱的图标字体 Nerd Fonts。

  • ttf-nerd-fonts-symbols

这里就有小伙伴开始好奇了,如何让西文和中文使用不同的字体呢?

在 Windows 下,我们可以选择合成字体,即将各类字体打包到一起。例如更纱黑体就是由思源黑体和西文字体 Iosevka 整合而来的。这种字体的好处就是方便,直接选择使用即可。但是缺点也是显而易见,就是打包太麻烦了,引入 Iosevka 要打一次包,想要支持 Nerd Fonts,又要打一次包。如果是别人帮你提供好的合成字体那还好说, 从网上下载、从软件仓库安装就完事了,自己打包的话真的工作量巨大。

而在 Liunx 下,我们只需要配置 fontconfig 就好了,无论想怎么搭配都可以实现,听起来是不是特别酷😎。可惜的是,有一些程序对 fontconfig 支持并不完善,这就达不到我们想要的效果。(说的就是你,Chrome😠)

# fontconfig

在我们开始正式配置前,还是有必要了解一些基本的知识。这里我就简单介绍一下,如果想要深入了解的话可以看看双猫大佬的这篇文章,里面详细介绍了Linux fontconfig 的字体匹配机制。

# 字体的属性

字体有很多属性,常用的有字族(family)、倾斜(slant)、字重(weight)。后两者合一起叫样式(style)。

字族就是它的名字啦。一个字体文件,可以提供多个字体族名 (family)。比如 Arch Linux 用户在安装 wqy-microhei 后,系统端增加了 wqy-microhei.ttc 这个字体文件,分别提供「WenQuanYi Micro Hei」「文泉驛微米黑」,「文泉驿微米黑」三个字体族名,它们是一个意思。我们可以运行 fontconfig 提供的命令行工具 fc-list 去查看系统上已安装的字体已经它们对应的字体族名。

倾斜就是斜不斜,英文叫「Roman」「Italic」或者「Oblique」,Italic 是专门的斜体写法(更接近手写样式), Oblique 是把常规写法倾斜一下完事。

字重就更简单了,就是笔划的粗细。常见的有 Regular、Normal、Medium、Bold、Semibold、Black、Thin、Light、Extralight 等。

# 通用字族名

很多时候,程序并不在乎用户具体使用的是哪款字体,像很多网站的 CSS 那样把各个平台的常见字体全部列出来太傻了,又容易出问题。所以,人们发明了「通用字族名」,也就是 sans-serif (sans)、serif 和 monospace (mono) 这些。它们不是真实存在的字体,而是分别指示程序去使用无衬线、衬线、等宽字体。那么桌面程序又是如何知道具体使用哪些字体呢?它只需要去查询 fontconfig 就行了。由于它们必定要经过 fontconfig 的查询流程后才能使用字体,所以我们可以通过 fontconfig 的配置去精准控制程序使用的字体。

# 如何调试

传入环境变量FC_DEBUG=4即可,例如:

1
FC_DEBUG=4 kitty

fontconfig 就会打印调试信息,其中可以看到:

1
2
3
4
5
6
7
FcConfigSubstitute Pattern has 6 elts (size 16)
    family: "monospace"(s)
    slant: 0(i)(s)
    weight: 80(i)(s)
    pixelsize: 19.1667(f)(s)
    lang: "en"(w)
    prgname: "kitty"(s)

除了启动一个程序来看它字体的调用日志,我们也可以手动调用。例如,我想看 monospace 在系统里被修改成了什么字体,就可以执行:

1
FC_DEBUG=4 fc-match 'monospace'

打印出的调试信息会很长,我们主要看几个部分:

第一部分,Add Rule,指已添加的配置文件规则。这里面也包含了家目录下的配置文件,可以找来看看被解析成了什么。

第二部分,在 Add Rule 之后,迎来了最关键的、我们应当关心的 FcConfigSubstitute Pattern,它包含了 font pattern。(s) 和 (w) 分别代表强弱绑定;prgname 代表程序名,此时就是 fc-match。至于 lang,由于没有对 fc-match 指定语言,所以默认是 en。

接下来有很多条 FcConfigSubstitute editPattern,代表对 font pattern 的替换操作。但是必须当规则匹配的时候,也就是 Rule Set 不是 No match 的情况下,才执行 FcConfigSubstitute editPattern。那么,又应该怎么看 FcConfigSubstitute editPattern 呢?主要看 family,因为 family 代表着字体匹配顺序。它就是配置文件中的<edit target="pattern">操作。

最后应该关心 FcConfigSubstitute donePattern,这是 fontconfig 执行完字体替换后的结果。

# 配置文件

整个配置文件由如下几个部分依次拼接而成:

  1. 目录设置(<dir>, <cachedir>, <include>)
  2. 杂项设置(<config>)
  3. 扫描阶段(<match target="scan">)
  4. 匹配阶段(<alias>, <match target="pattern">)
  5. 渲染阶段(<match target="font">)

想要实现合成字体的效果,一个最简单的思路,本文也基于该思路:不让程序使用某个具体的字体,而是使用通用字体族名 (Generic Font Family)。比如,让程序使用 sans-serif,也就是默认的无衬线字体。

我们要关心第四个部分,即匹配阶段,使用 fontconfig 配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<match target="pattern">
  <test name="family">
    <string>sans-serif</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans CJK SC</string>
    <string>Noto Sans</string>
    <string>Twemoji</string>
  </edit>
</match>

这种 font stack 的方式,即可让程序按照以下顺序渲染字体:

1
Noto Sans CJK SC —> Noto Sans -> Twemoji

这里的<test>就是条件判断,mode="prepend"指在前添加,binding="strong"则是强绑定

# 开始配置

我们的思路就是就是修改默认的字族,让其成为我们想要指定的字体。然后将所有程序的字体配置改为通用字体族名:sans-serif,serif,monospace。

我们的配置都放在此目录中~/.config/fontconfig/fonts.conf,我的完整配置在此仓库中。

# 设置默认字体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
  <!-- Default system-ui fonts -->
  <match target="pattern">
    <test name="family">
      <string>system-ui</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>sans-serif</string>
    </edit>
  </match>

  <!-- Default sans-serif fonts-->
  <match target="pattern">
    <test name="family">
      <string>sans-serif</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Sans CJK SC</string>
      <string>Noto Sans</string>
      <string>Twemoji</string>
    </edit>
  </match>

  <!-- Default serif fonts-->
  <match target="pattern">
    <test name="family">
      <string>serif</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Serif CJK SC</string>
      <string>Noto Serif</string>
      <string>Twemoji</string>
    </edit>
  </match>

  <!-- Default monospace fonts-->
  <match target="pattern">
    <test name="family">
      <string>monospace</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Sans Mono CJK SC</string>
      <string>Symbols Nerd Font</string>
      <string>Twemoji</string>
    </edit>
  </match>
  <match target="pattern">
    <test name="family" compare="contains">
      <string>Source Code</string>
    </test>
    <edit name="family" binding="strong">
      <string>Fira Code</string>
    </edit>
  </match>

对 system-ui,sans-serif,serif,monospace 设置优先显示的字体。在这里我让 system-ui 默认为无衬线。注意,system-ui 必须在最前。由于 fontconfig 对 font pattern 的操作是按顺序执行的,所以必须先让 system-ui 能优先以 sans-serif 显示,然后才是对 sans-serif 的操作。

所有的 Noto CJK 字体都以 SC 结尾,因为我希望在没指定语言的默认情况下,以简体中文显示中文字形。

# 设置异形字

什么是异形字?Noto Sans CJK 中的异体字,是在 相同的 Unicode 码位下,不同的语言会使用不同的字形。

可以在双猫大佬的这个测试网站中看到,不同的语言环境下,这些字的显示是不同的。

异形字

我们想要实现在保留异体字的情况下,让它默认显示中国大陆字形;只在特定语言下显示异体字,比如用浏览器查看一个日文页面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Replace fonts for Japanese -->
<match target="pattern">
  <test name="lang">
    <string>ja</string>
  </test>
  <test name="family">
    <string>Noto Sans CJK SC</string>
  </test>
  <edit name="family" binding="strong">
    <string>Noto Sans CJK JP</string>
  </edit>
</match>

在字体匹配时,将 Noto Sans CJK SC 替换成 Noto Sans CJK JP。除了 ,你还需要照葫芦画瓢分别指定 zh-TW、zh-HK、ja、ko,以及对宋体和等宽字体进行重复的步骤。在此我就不重复了。

# 解决全角引号

英文中的单引号有两种,'(U+0027) 和(U+2019)。可能会觉得前一种出现在英文文本中,后一种出现在中文文本中,并且宽度和中文等宽。然而,英语世界中的一些人在英文文章中也是会使用后一种的。所以,字体在显示后一种引号的时候,究竟是和英文字母一样窄,还是该和中文字体等宽呢?如果在英文文章中显示成全宽字符则会显得突兀。

比如,思源黑体其实也包含了拉丁字母的字形,可以完全使用思源黑体去显示西文。此时,后一种单引号的宽度会显示成中文字形的宽度。导致即使在全英文环境中,单引号也会突兀地在文本中过宽。

同样地,(U+2018) (U+201C) (U+201D) 也有类似的情况。

我们的目标是在不同语言环境下字形会有所区别,让引号只在中文文本中全宽。

同样,在双猫大佬的这个测试网站中可以进行测试:

全角引号

利用fontconfig,可以方便的解决此问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- 解决全角引号 -->
<match target="pattern">
  <test name="lang" compare="contains">
    <string>en</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans CJK</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans</string>
  </edit>
</match>

# 覆盖西文字体

如果去观察 Noto Sans CJK 这个中文字体,会发现它的西文部分的字形其实和 Noto Sans 不一样,虽然它们都以 Noto 自称。中文字体携带的英文字符有可能十分糟糕,特别是 Windows 自带的 SimHei,也就是中易黑体,它的英文相当糟糕。另外,微软雅黑的字重实在是太少了,对于设计师来说很不友好。而各种流行的英文字体支持很多字重。

Noto Sans CJK 的英文还是比较不错的,为了字体的样式统一,这里就不修改sans-serif和seri了。主要将编程用的等宽字体换成Fira Code。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Replace english fonts-->
<match target="pattern">
  <test name="prgname" compare="not_eq">
    <string>msedge</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans Mono CJK</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Fira Code</string>
  </edit>
</match>

这里为什么要多加一句判断呢?这是因为有些程序居然只使用 font pattern 结果中的首个字体,比如 Chrome(以及衍生的Chromium),虽然 Chrome 接受了我们指定的西文字体,但是它忽略了紧接其后的中文字体,即使配置采用了强绑定!然后中文字体又不知道它 fallback 到哪去了,可能会出现你想要的中文字体,也可能不是。

这里是 msedge 主要是我平时都用 edge 而不是 Chrome。可惜由于 Chromium 在 Linux 上小问题实在是太多了,还是老老实实的用 firefox 吧。

在所有情况下,除了程序名为 msedge 的情况下,优先使用 Fira Code 显示西文,再用 Noto Sans Mono CJK 显示中文。虽然我不能让 msedge 使用 Fira Code,但它一定能用上 Noto Sans Mono CJK 显示中文。

# 替换任意字体

当系统里已经安装了一些不需要的字体,但又不想删除或者屏蔽它怎么办呢?替换掉 font pattern 就可以了。

我这里则是用来替换思源字体,毕竟它就是 Noto 嘛

1
2
3
4
5
6
7
8
<match target="pattern">
  <test qual="any" name="family">
    <string>Source Han Sans</string>
  </test>
  <edit name="family" mode="assign" binding="same">
    <string>Noto Sans CJK SC</string>
  </edit>
</match>

# 字体渲染参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!--rendering options-->
<match target="font">
  <edit name="autohint" mode="assign">
    <bool>false</bool>
  </edit>
  <edit name="hinting" mode="assign">
    <bool>true</bool>
  </edit>
  <edit name="hintstyle" mode="assign">
    <const>hintslight</const>
  </edit>
  <edit name="antialias" mode="assign">
    <bool>true</bool>
  </edit>
  <edit name="lcdfilter" mode="assign">
    <const>lcddefault</const>
  </edit>
  <edit name="rgba" mode="assign">
    <const>rgb</const>
  </edit>
</match>

这里主要设置了一些字体的渲染方式:

  • autohint:优先使用内嵌微调
  • hinting:开启微调
  • hintstyle:微调的程度,轻微
  • antialias:开启抗锯齿功能
  • lcdfilter:LCD filter 的风格,默认
  • rgba:LCD 子像素的排列顺序,rgb

这里就直接抄作业了。

# 遇到的问题

# 全角引号设置无效

一开始我遇到了这个问题,无论怎么设置都是半角,经过排查以及网友的留言后发现,这是由于系统变量中的LANGen_US.utf-8,这会导致传递给 fontconfig 的 lang 是 lang: zh-cn(s) "en"(w)。于是就一直匹配西文字体。

解决办法有两个,一个是直接修改LANG环境变量为zh_CN.UTF-8,我目前采用的就是这种形式。

或者就是默认渲染英文,当网页声明是中文时再换字体,相关配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- Default sans-serif fonts-->
<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>
    <string>Noto Color Emoji</string>
  </edit>
</match>

<!-- cn -->
<match target="pattern">
  <test name="lang" compare="contains">
    <string>cn</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans CJK SC</string>
  </edit>
</match>

这样如果网页声明了lang=zh-cn,就会用Noto Sans CJK SC字体进行覆盖,也就实现了全宽引号。假如网页没有声明lang或者错误地声明了lang=en,这时就会出现半角引号+汉字的组合了。

# 不能解决的问题

Linux 不强迫程序必须使用特定的依赖,而是程序主动选择了约定俗成的依赖。老话重谈,程序可以自由选择完全遵守 fontconfig,也可以选择部分使用 fontconfig 的配置,或者完全不遵守它。这也导致了对一些程序无法实现字体的修改。以及上面提到的 chrome 对 fontconfig 并不是很好,或许面对这种程序,就需要合成字体的出场了。

# 参考文献