中文字库嵌入 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');
}
此外博客中存在一些使用手写字体的中文片段,没必要为它们引入整个字库,同样裁剪处理。