中西文 OpenType 可变字体的映射匹配

Python FontTools NotoSerif 思源宋体

霞鹜文楷的新鲜过后,察觉这类由曲线构成的手写楷体并不适合做全文字体,多个段落文字堆积会显得参差不齐。若追求文字的笔锋美感,横平竖直的 Noto Serif SC 思源宋体更适合做博客的主字体,但应用后发现其西文部分的设计感明显逊色于独立的西文字体 Noto Serif,将后者与中文字体混合使用观感更佳。其实这正是 Apple 的字体机制,显示西文时采用 San Francisco,遇到中文时字体渲染管线发生回退,应用华康金刚黑的定制版苹方。

将不同字体混排首先面对的问题是 weight 字重粗细不匹配,Noto Serif 和 Noto Serif SC 并不同源,前者偏粗,后者笔画密集所以偏细,以中文 400 Regular 字重为基准,西文的匹配字重是 300。这两款开源字体都支持现代 OpenType 1.8 的可变字重,要解决的问题是如何将所需字重映射到实际字重。

// 西文 Noto Serif,可变字重
@font-face {
    font-family: "Noto Serif";
    src: url("https://assets.mudan.me/fonts/NotoSerif/compressed/NotoSerif-wght.woff2") format('woff2');
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
}
// 中文思源宋体,可变字重
@font-face {
    font-family: "Noto Serif SC";
    src: url("https://assets.mudan.me/fonts/NotoSerifSC-Variable/NotoSerifSC-wght.woff2")format("woff2");
    font-style: normal;
    font-weight: 200 900;
    font-display: swap;
}

TTX 映射表

以字重为例,可变字体的渲染从输入字重数字到输出字符轮廓由 5 步组成:

<!-- 可变字体的字重范围是 100 900 -->

1. 用户输入字重值
CSS: font-weight: 550

2. fvar 表,计算线性归一值
将 550 根据 [100, 400, 900] 规范化为 Normalized Coordinate
(550 - 400) / (900 - 400) = +0.30

3. avar 表,线性归一值映射
查询映射曲线,将 +0.30 修正为 Modified Coordinate
比如 +0.30 映射为 +0.38

4. gvar 表,轮廓矢量计算
根据坐标 +0.38 计算 glyf 各控制点的位移加权和:
目标点坐标 = 基准点坐标 + 0.38 × (Δx, Δy)

5. 光栅化引擎输出最终像素

其中 Glyph Variations 即gvar表中存储的是与字形轮廓各个控制点位置相关的底层数据,它接收上一步的归一化值,计算出控制点的实际位置进而确定字符的轮廓图形。这部分数据与字体图形直接相关,且十分复杂,不应在不具备字体设计能力的情况下修改。

Font Variables 即fvar表中存储的是可变轴的属性及变化范围,输入字重根据这里定义的MinValueDefaultValueMaxValue计算出一个在-1 ~ 1之间的归一化值:

<!-- 例如一个包含字重和斜度 2 个可变轴的可变字体 -->
<fvar>
    <!-- Weight 字重 -->
    <Axis>
        <AxisTag>wght</AxisTag>
        <Flags>0x0</Flags>
        <MinValue>100.0</MinValue>
        <DefaultValue>400.0</DefaultValue>
        <MaxValue>900.0</MaxValue>
        <AxisNameID>258</AxisNameID>
    </Axis>
    <!-- Slant 斜度 -->
    <Axis>
        <AxisTag>slnt</AxisTag>
        <Flags>0x0</Flags>
        <MinValue>-10.0</MinValue>
        <DefaultValue>0.0</DefaultValue>
        <MaxValue>0.0</MaxValue>
        <AxisNameID>261</AxisNameID>
    </Axis>
    <!-- 一些默认属性与对应的值 -->
    <!-- 减少篇幅这里省略部分字重的属性 -->
    <!-- Light -->
    <!-- PostScript: GoogleSansFlex-Light -->
    <NamedInstance flags="0x0" postscriptNameID="287" subfamilyNameID="286">
        <coord axis="wght" value="300.0" />
        <coord axis="slnt" value="0.0" />
    </NamedInstance>
    <!-- Regular -->
    <!-- PostScript: GoogleSansFlex-Regular -->
    <NamedInstance flags="0x0" postscriptNameID="289" subfamilyNameID="288">
        <coord axis="wght" value="400.0" />
        <coord axis="slnt" value="0.0" />
    </NamedInstance>
    <!-- Medium -->
    <!-- PostScript: GoogleSansFlex-Medium -->
    <NamedInstance flags="0x0" postscriptNameID="291" subfamilyNameID="290">
        <coord axis="wght" value="500.0" />
        <coord axis="slnt" value="0.0" />
    </NamedInstance>
    <!-- Light Italic -->
    <!-- PostScript: GoogleSansFlex-LightItalic -->
    <NamedInstance flags="0x0" postscriptNameID="305" subfamilyNameID="304">
        <coord axis="wght" value="300.0" />
        <coord axis="slnt" value="-10.0" />
    </NamedInstance>
    <!-- Italic -->
    <!-- PostScript: GoogleSansFlex-Italic -->
    <NamedInstance flags="0x0" postscriptNameID="307" subfamilyNameID="306">
        <coord axis="wght" value="400.0" />
        <coord axis="slnt" value="-10.0" />
    </NamedInstance>
    <!-- Medium Italic -->
    <!-- PostScript: GoogleSansFlex-MediumItalic -->
    <NamedInstance flags="0x0" postscriptNameID="309" subfamilyNameID="308">
        <coord axis="wght" value="500.0" />
        <coord axis="slnt" value="-10.0" />
    </NamedInstance>
</fvar>

Axis Variables 即avar表中存储的是末端归一化值映射表,上一步计算的归一化值将在这里进行一次映射,然后作为最终值传递到gvar中计算字形轮廓:

<avar>
    <!-- Weight 轴映射 -->
    <segment axis="wght">
        <!-- -1.0 0.0 1.0 三个节点必须 1:1 映射,即 3 个节点固定,可再增加 map 节点进行精细控制 -->
        <mapping from="-1.0" to="-1.0" />
        <mapping from="0.0" to="0.0" />
        <mapping from="1.0" to="1.0" />
    </segment>
    <!-- Slant 轴映射 -->
    <segment axis="slnt">
        <mapping from="-1.0" to="-1.0" />
        <mapping from="0.0" to="0.0" />
        <mapping from="1.0" to="1.0" />
    </segment>
</avar>

OpenType 规范要求avar表中-1.00.01.0三个节点必须1:1映射,分别对应fvar表中的MinValueDefaultValueMaxValue,字体设计中一般是以中间regular字重为基准向两侧变化以避免单向跨度过大导致字体崩坏。若保持avar不变,做字重映射可将fvar的DefaultValue设为与实际400 regular字重对应的输入值,计算出偏移量,然后将两端的MinValueMaxValue整体偏移,这样可实现regular字重从原始的400偏移到另一个值,输入该字重时计算出归一化值与未偏移前输入400计算出的值一致,底层渲染的字重即是原本的 400。

比如偏粗的 Noto Serif 字体:

# fvar 原本 min default max
100 400 900
# 期望将输入的 weight 映射为更小的实际值,650 -> 400
# 即要求 fvar 偏移后输入 650 计算的归一化值与偏移前 400 计算出的一致
# fvar 可整体偏移 250
350 650 1150

应注意可变字体的字重并非线性变化,与另一款字体匹配时以regular为基准整体偏移映射并不能保证两者偏移同样的字重后依然视觉和谐。

一种方式是以最常用的实际regular字重为中心向两侧以100为步长扩展几步,得出该区域的输入步长,用它来计算MinValue和MaxValue,这样可保证regular附近字重的匹配度。

# Noto Serif 的字重映射,以实际 regular 为基准向两侧扩展几步
# 以该区域为基准计算出输入步长,
650:400 actual regular 400
600:380 point
500:340 input regular 500
400:300 point

# 步长
100:40
250:100
# 计算出端点,显然 -100 ~ 1900 远超实际字重范围,在 font-face 中限定常用范围即可,一般是 regular +- 100
1900:900
-100:100
@font-face {
    ...
    font-weight: 400 600
}

若追求完美,只修改fvar是不够的,需要先通过视觉比对获取与实际字重对应的MinValueDefaultValueMaxValue确定fvar表,导出字体,再次视觉比对得出从实际100~900的完整输入映射表,将中间节点计算出的归一化值映射填充到avar表中。

# 解包导出控制字体属性的 TTX 配置文件
fonttools ttx -t fvar -t avar ./NotoSerif-wght.ttf
# 合并 TTX 到字体文件中,应用配置
fonttools ttx -m ./NotoSerif-wght.ttf ./NotoSerif-wght.ttx -o ./NotoSerif-wght-remap.ttf
# 压缩为 woff2 格式
fonttools ttLib.woff2 compress "NotoSerif-wght-remap.ttf" -o "NotoSerif-wght-remap.woff2"

固化切片

若不想处理复杂的可变字体 TTX 映射表,另一种思路是借助 FontTools 将可变字体固化为多个离散的固定字重字体,在font-face中将输入字重映射到对应的字体文件。

可变字体的轮廓形变数据记录在 Glyph Variations 即gvar表中,变体轴数量每增加一个,字形在多维设计空间中的 Deltas 插值偏移量都会成倍增加,所以固化可变轴可以大幅降低字体文件体积。

# FontTools 是 python 工具,通过 Homebrew 安装的 python 被系统视为 externally-managed-environment,禁止通过全局 pip 随意覆盖系统级包,以防破坏包依赖图谱。
# 安装 python 包的推荐方式是为指定目录 project 定义 venv 虚拟环境,独立管理。
# 在当前目录创建名为 .venv 的虚拟环境
python3 -m venv .venv

# 激活虚拟环境,激活后命令提示符前会出现 (.venv)
source .venv/bin/activate

# 此时可正常使用 pip 安装,不受 PEP 668 限制
pip install fonttools brotli

# 执行可变轴固化,未被设置的可变轴将保留,否则被固化为固定值
fonttools varLib.instancer \
    NotoSerif-wght_GRAD,ROND,opsz,slnt,wdth,wght.ttf \
    GRAD=0 ROND=0 slnt=0 wdth=100 wght=400 \
    -o NotoSerif-400.ttf

# 操作完成后退出虚拟环境
deactivate

SCSS 中为每一个字重文件设置font-face:

@font-face {
    font-family: $font-family;
    src: url("https://assets.mudan.me/fonts/NotoSerif/compressed/NotoSerif-400.woff2") format('woff2');
    font-weight: 650;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: $font-family;
    src: url("https://assets.mudan.me/fonts/NotoSerif/compressed/NotoSerif-300.woff2") format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

一段处理字体的自动脚本,输出文件名包含字重映射关系,同时生成 SCSS 参考:

import os
from pathlib import Path
from typing import Optional, Union
from fontTools.ttLib import TTFont
from fontTools.varLib.mutator import instantiateVariableFont

def generate_static_fonts(
    path: Union[str, Path],
    min_weight: int = 400,
    max_weight: int = 600,
    min_weight_actual: int = 300,
    step: int = 10,
    step_actual: int = 4,
    font_style: str = "normal",
    slnt: Optional[Union[int, float]] = None,
    output_dir: Optional[Union[str, Path]] = None,
    font_family: Optional[str] = None,
    generate_scss: bool = True,
    scss_path: Optional[Union[str, Path]] = None,
) -> None:
    """
    根据可变字体实例化导出指定字重范围的静态字体文件(TTF 与 WOFF2),并可同时生成对应的 SCSS @font-face 配置。

    :param path: 输入可变字体路径
    :param min_weight: 输入字重的起始值
    :param max_weight: 输入字重的结束值
    :param min_weight_actual: 映射后的物理字重起始值(即 font-face 的 font-weight)
    :param step: 输入字重的步长
    :param step_actual: 映射物理字重的步长
    :param slnt: 可选倾斜度数,与 wght 并列传给 instantiateVariableFont;生效时输出文件名末尾追加 '-italic',且 font-style 为 italic
    :param output_dir: 可选输出目录,默认与输入文件在同一目录
    :param font_family: 可选字体家族名称,未指定时自动从字体元数据读取
    :param generate_scss: 是否生成 SCSS 配置文件,默认 True
    :param scss_path: 可选 SCSS 文件输出路径,未指定时默认保存在 output_dir 下
    """
    input_path = Path(path)
    out_dir = Path(output_dir) if output_dir is not None else input_path.parent
    stem = input_path.stem

    is_italic = slnt is not None and slnt != 0
    italic_suffix = "-italic" if is_italic else ""

    resolved_scss_path = (
        Path(scss_path)
        if scss_path is not None
        else out_dir / f"{stem}{italic_suffix}.scss"
    )

    current_weight_actual = min_weight_actual
    scss_rules = []
    detected_family_name = font_family

    for weight in range(min_weight, max_weight + 1, step):
        font = TTFont(str(input_path))

        # 尝试从字体元数据中读取字体家族名称
        if detected_family_name is None:
            try:
                detected_family_name = font["name"].getBestFamilyName()
            except Exception:
                detected_family_name = stem

        # 实例化指定字重(及倾斜度)
        coordinates = {"wght": current_weight_actual}
        if slnt is not None:
            coordinates["slnt"] = slnt

        static_font = instantiateVariableFont(font, coordinates)

        ttf_out = out_dir / f"{stem}-{weight}-to-{current_weight_actual}{italic_suffix}.ttf"
        woff2_out = out_dir / f"{stem}-{weight}-to-{current_weight_actual}{italic_suffix}.woff2"

        static_font.save(str(ttf_out))
        # 转换为 woff2
        static_font.flavor = "woff2"
        static_font.save(str(woff2_out))
        print(f"Generated: {woff2_out}")

        if generate_scss:
            rel_woff2 = os.path.relpath(woff2_out, resolved_scss_path.parent).replace("\\", "/")
            rel_ttf = os.path.relpath(ttf_out, resolved_scss_path.parent).replace("\\", "/")
            if not rel_woff2.startswith("."):
                rel_woff2 = f"./{rel_woff2}"
            if not rel_ttf.startswith("."):
                rel_ttf = f"./{rel_ttf}"

            rule = (
                f"@font-face {{\n"
                f"  font-family: $font-family;\n"
                f"  src: url('{rel_woff2}') format('woff2');\n"
                f"  font-weight: {weight};\n"
                f"  font-style: {font_style};\n"
                f"  font-display: swap;\n"
                f"}}"
            )
            scss_rules.append(rule)

        current_weight_actual += step_actual

    # 生成 SCSS 配置文件
    if generate_scss and scss_rules:
        family_name = detected_family_name or stem
        header = f'$font-family: "{family_name}" !default;\n\n'
        resolved_scss_path.write_text(header + "\n\n".join(scss_rules) + "\n", encoding="utf-8")
        print(f"Generated SCSS: {resolved_scss_path}")


if __name__ == "__main__":
    var_font_path = "./Noto_Serif/compressed/NotoSerif-Italic-wght.ttf"

    min_weight = 400
    max_weight = 650
    min_weight_actual = 300
    # 以 10 为步长切片文件
    step = 10
    step_actual = 4

    generate_static_fonts(
        path=var_font_path,
        min_weight=min_weight,
        max_weight=max_weight,
        min_weight_actual=min_weight_actual,
        step=step,
        step_actual=step_actual,
        font_style="italic",
        slnt=None,
    )