Linux 在字体层面实现标点挤压

前言

标点挤压示意

标点挤压示意(图片来自 east_asian_spacing 项目)

使用 fontconfig 调整 Linux 字体只能解决字体替换的问题,不能直接实现标点挤压等与单字相关的问题。目前,除了专业的排版软件和 Chrome 及 Chromium 内核的软件(在 M123 开始默认启用软件实现的标点挤压)外,没有什么软件默认支持它。(但是假如大部分软件高级文本排版的基础⸺HarfBuzz这个 Issue 被解决,每个软件就不必要自己适配了。)但我们也可以通过自己给字体加上 chws 相关的 OpenType 特性,并在 fontconfig 中给支持的字体开启,来在完好支持 fontconfig 的软件中开启标点挤压(然而 Qt 程序会忽略该设置,参见 QTBUG-78645)。

这篇文章没有深入讲解,所以很短(

前置知识

本文不会解释 fontconfig 的有关操作(可以看我的另一篇文章)。

若使用 Noto CJK

由于我使用的是 Noto CJK 字体,所以我给字体加上字体特性后打包成了两个新字体文件(Sans 和 Serif):noto-cjk-chwsnoto-cjk-chws-patch。其中 noto-cjk-chws 用于直接替换原有的字体,但字体非常大;而 noto-cjk-chws-patch 只保留了其中更改的标点部分(因此在 fontconfig 中需要排在原字体之前),且可以通过在 patch 和原 Noto CJK 字体之间夹一个英文字体来更改英文字体并不影响引号、撇号与间隔号(“·”)的显示。如果使用 Arch Linux,可以分别通过 AUR 包 noto-fonts-cjk-chws(直接替换 noto-fonts-cjk 包)与 noto-fonts-cjk-chws-patch(需手动配置 fontconfig)直接安装。使用说明我的存储库里有,这里不再赘述。

使用其他字体

如果字体本身支持 chwsvchw 特性(极少),直接改 fontconfig 在字体 fontfeatures 属性中加入就好。

示例~/.config/fontconfig/fonts.conf<fontconfig> 元素中):

    <match target="font">
        <test name="family">
            <string>*你的字体名称*</string>
        </test>
        <edit binding="strong" name="fontfeatures">
            <string>chws</string>
            <string>vchw</string>
        </edit>
    </match>

如果字体不支持这些特性,需要利用 chws_tool(基于 east_asian_spacing)手动加入。如果是 Google Fonts 中的字体,安装后直接执行 add-chws +字体路径就行,如果不是,则在 src/chws_tool/config.py_get_factory_by_name 函数加入自己字体的 config(其实一般不用加直接用 default 就行)。

另外如果需要子集化字体的话可以使用 fontTools 子集化的同时更改字体名称。我的 subsetter.py 的代码如下:

#!/usr/bin/env python3

from fontTools.ttLib import TTFont, TTCollection
from fontTools.subset import Subsetter
import sys

subsetter = Subsetter()
subsetter.options.name_IDs = "*"         # 保留所有 nameID
# 只有保留所有 nameID(默认只保留 nameID 1~6)才能使 fontconfig 正确识别子集化后的字体,因为 Noto CJK 在 nameID=16/17(排版字族名/样式名)存储正确的字族与样式(如 Black、DemiLight、Light 等),nameID=1/2(基本的字族名/样式名)只能存储基本的 Regular、Bold 变体名。(见 https://learn.microsoft.com/en-us/typography/opentype/spec/name#name-ids。)
subsetter.options.name_languages = "*"   # 保留所有语言
# 保留所有语言的记录(默认只保留英文),但实际上名称都是英文的,主要是想让 fontconfig 正确识别字体的语言⸺实际上 fontconfig 还是会识别成英文,但还是先留着比较好(
subsetter.populate(text="‘“〈《「『【〔〖〘〚〝([{⦅([·‧・;:’”〉》」』】〕〗〙〛〞〟)]}⦆、。,.!?)]—…")

tran = {
    "Noto Sans CJK": "Noto Sans CJK CHWS Patch",
    "Noto Sans Mono CJK": "Noto Sans Mono CJK CHWS Patch",
    "Noto Serif CJK": "Noto Serif CJK CHWS Patch",
    "Noto Sans": "Noto Sans CHWS Patch",
    "Noto Sans Mono": "Noto Sans Mono CHWS Patch",
    "Noto Serif": "Noto Serif CHWS Patch",
    "NotoSansCJK": "NotoSansCJKChwsPatch",
    "NotoSansMonoCJK": "NotoSansMonoCJKChwsPatch",
    "NotoSerifCJK": "NotoSerifCJKChwsPatch",
    "NotoSans": "NotoSansChwsPatch",
    "NotoSansMono": "NotoSansMonoChwsPatch",
    "NotoSerif": "NotoSerifChwsPatch"
    }

def namer(arg):
    if type(arg) == bytes:
        return namer(arg.decode("utf-16-be")).encode("utf-16-be")
    if type(arg) == str:
        for before in tran:
            if tran[before] in arg:
                return arg
            elif before in arg:
                return arg.replace(before, tran[before])
    return arg

def list_namer(li):
    for i in range(len(li)):
        li[i] = namer(li[i])

def dict_namer(di):
    for k in di:
        di[k] = namer(di[k])

def modify(font):
    subsetter.subset(font)

    for record in font['name'].names:
        record.string = namer(record.string)

    if "CFF " in font.keys():
        cff = font["CFF "].cff
        list_namer(cff.strings.strings)
        list_namer(cff.fontNames)
        for dic in cff:
            dict_namer(dic.rawDict)

path = sys.argv[1]

if path.endswith("ttc"):
    ttc = TTCollection(path)
    for font in ttc:
        modify(font)
    ttc.save(namer(path))
else:
    font = TTFont(path)
    modify(font)
    font.save(namer(path))

局限性

用这种方式可以完美地解决字与字之间的标点挤压,但其实行首和行尾的标点也应该实现标点挤压,但这些挤压(至少目前)不能够从字体层面解决,只能通过软件或者字体渲染库的适配(安卓端的微信实现了,但只是在发送和接收的消息中)。

现行国标(GB/T 25834—2011)不符

行首、行尾的标点

#局限性

叠用的问号、叹号

5.1.2 问号、叹号均置于相应文字之后,占一个字位置,居左,不出现在一行之首。两个问号(或叹号)叠用时,占一个字位置;三个问号(或叹号)叠用时,占两个字位置;问号与叹号连用时,占一个字位置

对于加粗部分(使用全角符号直接叠用),在添上 chws 特性后,均显示为 1.5 个汉字位置。[这不比国标的规定好看?但国标就是这么规定的,虽然一个新的国标(计划号 20240034-T-360)正在起草。]

间隔号(“·”)

5.1.7 间隔号标在需要隔开的项目之间,占半个字位置,上下居中,不出现在一行之首。

(这其实只与字体有关。)

根据 W3C 的中文排版需求,间隔号为 U+00B7 MIDDLE DOT[·]。而日本使用来自日文 JIS 编码的 U+30FB KATAKANA MIDDLE DOT[・],需占全宽,U+00B7 MIDDLE DOT[·]在日文排版中用于英文或数字中,应与英文相适应。

这里国标要求半个汉字宽,台湾、香港要求占全宽。例如在 Noto Sans CJK SC 中,该间隔号占一个汉字位置(与 TC、HK 变体相同,KR、JP 变体约占半个汉字位置);Noto Serif CJK SC 中,该间隔号约占半个汉字位置(其他变体也如此,与台湾、香港的需求不符)。但一个字宽的间隔号可能更加适合中文(毕竟大部分中文字体都这么做的)。

另请参阅

版权声明

本作品的原始版本及截至 2024 年 8 月 1 日 0:00 UTC 之前的所有版本均遵循 CC BY 4.0 许可证。从 2024 年 8 月 1 日 0:00 UTC 开始的所有更新版本遵循 CC BY-SA 4.0 许可证。文章开头仅标注创作开始日期。