中文字库嵌入 Web 时的分割与裁剪

2025年07月25日更新:新鲜过后阅读“手写楷体”组成的长段文本会逐渐产生不整齐的凌乱感,作者提及的不适合长文显示并非空穴来风,已切换到规整的思源黑体。

可能注意到这个博客站点的字体有点奇怪,不是常见的无衬线黑体,感觉接近接近宋体但又像手写楷书。其实是一款兼顾屏显排版和中文笔触美感的霞鹜文楷开源字体,但要将它嵌入博客中,需要解决一些应用中文字库的典型问题。

霞鹜文楷

分割

其中最大的问题是中文字库的庞大体积,英文字库无非字母和符号,如 JetBrains Mono 的 WOFF2 文件体积仅为 92KB,在 Web 中可以轻松的无感加载。但中文字库收录汉字数以千计,而且笔画复杂,比如霞鹜文楷支持 8000 字《通用规范汉字表》的 GB Lite 版本,Regular 字重 TTF 文件为 18MB,加上常用的 Bold 字重体积已经超过 36MB,这么大的单体资源对网页加载是不可接受的。

所以中文字库必须分割,按常用字顺序以百 KB 为单位分割成多个 WOFF2 文件,再由页面按需加载。

/* CSS支持为字体文件指定其包含的字符编码范围 */
@font-face {
    font-family: "LXGW WenKai GB";
    src: url("https://hosturl.com/01.woff2") format("woff2");
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    /* 字符编码范围 */
    unicode-range: U+305e3, U+305f6, U+3067d ...;
}
@font-face {
    font-family: "LXGW WenKai GB";
    src: url("https://hosturl.com/02.woff2") format("woff2");
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    /* 字符编码范围 */
    unicode-range: U+2ce29-2ce2a, U+2ce31, U+2ce7c ...;
}

其实用过 Google Fonts 应该知道它对中文字库也是这么处理的,所以现在问题变成如何分割字库。

搜寻发现中文网字计划cn-font-split 项目,一个简单命令能把 TTF 文件分割为数百个 60KB 的 WOFF2 文件并生成对应 CSS 代码,同时支持限定字符范围,只生成包含所需字符的字库文件。

# 执行分割,指定输入 TTF 文件和输出目录
cn-font-split -i=/Users/apqx/Downloads/Input.ttf -o=/Users/apqx/Downloads/Output

配合浏览器缓存策略,每个页面加载的字体资源至多不过几百 KB,已属正常范围。这个问题解决后,选择字体不用再为此纠结,可以放心使用喜欢的中文字库。

裁剪

博客文章经常嵌入代码块,之前一直使用等宽的 JetBrains Mono 字体,但应用霞鹜文楷后发现英文黑体和中文楷体混在一起并不协调。所幸霞鹜文楷提供等宽版本,但却是包含完整 8000 汉字的 TTF 字库,这些汉字与已有字库重叠,而我所需只是其中的英文和符号。

借助 cn-font-split 的裁剪功能,可以从 TTF 中提取出指定范围的字符单独生成 WOFF2 文件。

import fs from 'fs';
import { fontSplit } from 'cn-font-split';

const inputFile = `/Users/apqx/Downloads/Input.ttf`;
const outputDir = `/Users/apqx/Downloads/Output/`;
const subsetCharacter = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`~!@#$%^&*()-_=+\\|{}[];:\'"<>,.?/';

const subsetCodeArray = subsetCharacter
    .split('')
    .filter(Boolean)
    .map((i) => i.charCodeAt(0));

const inputBuffer = new Uint8Array(
    fs.readFileSync(inputFile).buffer,
);

export async function split() {
    console.time('node');
    await fontSplit({
        input: inputBuffer,
        outDir: outputDir,
        subsets: [subsetCodeArray],
        // 是否启用语言区域优化,将同一语言的字符分到一起
        // 必须关闭才会把 subsets 单独分为一个包
        languageAreas: false,
        // 当分包超过指定大小时是否自动拆分
        // 不影响 subsets 的分包
        autoSubset: false,
        // 是否保留字体特性(如 Code 字体的连字、字距调整等)
        // 必须关闭才会把 subsets 单独分为一个包
        fontFeature: false,
        // 是否减少碎片分包,合并小分包以减少请求数
        // 必须关闭才会把 subsets 单独分为一个包
        reduceMins: false,
        silent: false,
    });
    console.timeEnd('node');
}

此外博客中存在一些使用手写字体的中文片段,没必要为它们引入整个字库,同样裁剪处理。

arrow_upward