中文、日文和韩文字体往往包含成千上万个字形,但一个落地页可能只用到一句标题和几个按钮。字体裁剪就是删除未使用字形,只让浏览器下载页面真正需要的字符。
一个可用的在线字体裁剪工具,并不是给字体库外面套一个文件上传框。它还要正确处理 Unicode、判断字体格式、解压和压缩 WOFF、报告缺失字形、隔离预览字体、释放 Object URL,并限制内存占用。下面这套方案来自 fonteditor-core 与 fflate 的浏览器本地实现。
字体裁剪到底裁掉了什么?
字体文件不仅包含字形轮廓,还包含字符映射、度量、命名、hinting、kerning 等表。子集化会保留目标 Unicode 码点对应的字形,并重写相关字体表。它和普通压缩不是一回事:ZIP 或 Brotli 只能压缩传输字节,不能从解码后的字体中删除几千个没用到的汉字。
收益最明显的场景通常是 CJK 大字库、图标字体、活动专题字体和只用于品牌标题的字体。如果拉丁字体本来就很小,或者必须保留 kerning、hinting 等表,体积下降可能没有想象中大。
| 输入字体场景 | 字符来源 | 常见输出 |
|---|---|---|
| 营销页标题字体 | 最终标题和按钮文案 | 单页使用的小体积 WOFF |
| 中文 UI 字体 | 产品文案与允许的标点 | 按语言拆分的 WOFF |
| 图标字体 | 当前前端包真正使用的图标 | 裁剪后的图标字体 |
| 文档内嵌字体 | 从目标文档提取的字符 | 便于携带的 TTF 子集 |
先设计浏览器本地处理链路
页面只负责选文件、设置选项、显示进度、预览和错误;工具函数接收 File 与明确参数,返回判别联合结果。把字体解析从 React 组件中移出去后,失败分支更容易测试,转换逻辑也不会被 UI state 绑死。
普通字体裁剪没有上传文件和字符文本的必要。File.arrayBuffer 在当前标签页读取用户选择的文件,fonteditor-core 完成解析与重写,结果再包装成 Blob 供预览和下载。
- 读取完整文件前先检查字节大小和格式。
- 同时参考 MIME 与扩展名,因为浏览器经常不给字体文件填写 MIME。
- 返回 unsupported_input、empty_chars、parse_failed、no_glyphs 等明确错误码。
- 设置最大输入体积,避免误选超大字体集合导致标签页内存耗尽。
按 Unicode 码点收集字符,不要直接 split
JavaScript 字符串底层使用 UTF-16。基本多文种平面之外的字符可能由一对代理项组成,text.split('') 会把一个字符拆成两个无效单元。应该使用 for...of 或 Array.from 遍历,再调用 codePointAt(0),最后用 Set 对数字码点去重。
C0、C1 控制字符可以忽略,但不要粗暴删除所有空白;普通空格本身也是字体需要保留的字形。源字体不存在的字符也应明确列出,不能只靠浏览器回退字体制造“看起来都支持”的假象。
用 fonteditor-core 生成字体子集
fonteditor-core 可以解析 TTF、WOFF,按 Unicode 码点选择字形,再写出 TTF 或 WOFF。WOFF 的字体表使用压缩数据,因此需要用 fflate 提供 inflate 与 deflate 适配器。解析阶段和写出阶段的 hinting、kerning 参数应保持一致。
import { createFont } from 'fonteditor-core';
import { deflateSync, inflateSync } from 'fflate';
const codePoints = [...new Set(
Array.from(text, char => char.codePointAt(0)!)
)];
const input = await file.arrayBuffer();
const font = createFont(input, {
type: 'ttf',
subset: codePoints,
hinting: false,
kerning: false,
inflate: data => Array.from(inflateSync(new Uint8Array(data))),
});
const output = font.write({
type: 'woff',
deflate: data => Array.from(deflateSync(new Uint8Array(data))),
});- 网页字体如果不依赖小字号屏显优化,可默认关闭 hinting 以减小体积。
- 排版质量敏感时保留 kerning,但要用真实字体对比体积差异。
- 输出文件名保持可预测,例如 brand.subset.woff。
- WOFF2 是另一项能力;这条 fonteditor-core 链路输出的是 WOFF,不是 WOFF2。
验证字符覆盖率,而不是只判断写出成功
文件成功生成,不代表请求字符全部存在。应读取结果字形上的 Unicode 映射,与请求码点逐一比对,分别返回已包含字符和缺失字符。这样用户看到的是可操作的检测结果,而不是一块可能偷偷使用系统回退字体的预览区。
界面可以展示原始体积、输出体积、节省字节、节省比例、请求字符数、命中字符数和字形总数。对本来很小的字体,或者转换为开销不同的格式,输出甚至可能变大,因此不要承诺“裁剪后一定更小”。
用临时字体预览,并正确释放资源
生成结果不必上传也能预览。把 ArrayBuffer 包装为 Blob,创建 Object URL,再通过 FontFace 或动态 @font-face 加载,并给每次结果分配唯一 font-family。预览内容最好只使用确认已经包含的字符,避免系统回退字体掩盖缺字。
const blob = new Blob([output], { type: 'font/woff' });
const url = URL.createObjectURL(blob);
const face = new FontFace('SubsetPreview', `url(${url})`);
await face.load();
document.fonts.add(face);
// Revoke the previous URL when regenerating or leaving the page.
URL.revokeObjectURL(url);- 重新生成前先 revoke 上一个 Object URL。
- 预览不再使用时,从 document.fonts 中移除对应 FontFace。
- 每份结果使用唯一字体名,避免浏览器缓存造成预览错乱。
- 下载完成后立即移除临时 a 标签。
上线前必须考虑的字体边界
可变字体、彩色 Emoji、OpenType 布局规则、连字和复杂文字塑形,都比“一个码点对应一个字形”复杂。某段文本可能依赖没有直接 Unicode 映射的替代字形。正式上线前应使用目标语言、目标浏览器和真实字体授权做验证。
对于静态网站,构建阶段使用成熟字体工具通常更容易复现。在线裁剪适合探索、一次性素材、内部工作流和不方便上传的字体文件,但不能取代字体授权与排版质量检查。
总结
可靠的在线字体裁剪需要同时做好 Unicode 遍历、类型化校验、真实字形覆盖检测、字体表选项和 Blob 生命周期管理。把解析写进独立工具层,把缺字明确展示出来,并用复杂字体做真实测试,才能让它从演示代码变成可用工具。