把 .woff2 字体打包进 macOS 应用,CoreText 静默回退到系统字体
最近在做一个 macOS 原生应用,设计上要和配套 Web 版保持一致的排版风格,用的是同一套编程字体(Maple Mono)和 CJK 字体(LXGW WenKai)。Web 端字体文件是 .woff2,直接从 @fontsource 包里拿到的,理所当然地把这几个 .woff2 文件丢进了 Xcode 的 Resources。
写好注册代码,build 一跑——所有自定义字体都静默回退成了系统字体 PingFang。CTFontManagerRegisterFontsForURL 没有抛出任何错误,甚至有时候返回 true,但 Font.custom("Maple Mono", size: 13) 渲染出来明显是错的。
现象
- 字体文件已在 Bundle 内(
Bundle.main.url(forResource:withExtension:)能找到路径) CTFontManagerRegisterFontsForURL(url, .process, nil)有时返回false,有时返回true但字体仍不生效Font.custom("Maple Mono", size: 13)和NSFont(name: "Maple Mono", size: 13)均回退到系统字体- 没有任何 crash 或运行时警告,完全静默失败
根因
CoreText 只接受 .ttf 或 .otf 格式,不识别 .woff2。
.woff2 是 Web 专用的压缩容器格式(用 Brotli 压缩包裹一个 TrueType/OpenType 字体),浏览器的字体引擎内置了解包逻辑。但 macOS 的 CoreText 根本不认识这个容器——它期待的是裸 TTF/OTF 二进制,遇到 woff2 magic bytes 直接跳过,要么报错要么静默失败。
把 .woff2 文件注册进 CoreText,有时返回 false,有时甚至返回 true——但字体实际上从未被加载。没有任何可见的警告或异常,文本直接回退到系统字体,极难察觉是格式问题在作怪。
正解
用 fontTools 把 .woff2 转为裸 TTF,再打包进应用。转换过程只是去掉 woff2 的外层容器,字体的 family name、字形、glyph coverage 完全保留。
第一步:安装并转换(macOS PEP-668 保护下用 venv)
python3 -m venv /tmp/fontvenv
/tmp/fontvenv/bin/pip install fonttools brotli # brotli 是 woff2 解压的依赖,不可漏from fontTools.ttLib import TTFont
f = TTFont("maple-mono.woff2")
f.flavor = None # 关键:将 flavor 置 None,去掉 woff2 容器,输出裸 TTF
f.save("MapleMono-Regular.ttf")
# 确认 family name,Font.custom 传这个字符串
print(f["name"].getDebugName(1)) # → "Maple Mono"第二步:打包并注册
将 .ttf 拖入 Xcode 目标的 Copy Bundle Resources(或 xcodegen 的 sources 树会自动识别)。启动时注册:
if let url = Bundle.main.url(forResource: "MapleMono-Regular", withExtension: "ttf") {
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
// 之后按 family name 使用,不是文件名
Font.custom("Maple Mono", size: 13).process 作用域不需要在 Info.plist 里声明 ATSApplicationFontsPath,直接注册即可,且是幂等的。
fontTools 把 .woff2 的 flavor 置 None 再 save,输出裸 TTF。打包进 Bundle 后 CTFontManagerRegisterFontsForURL 稳定成功,Font.custom 按 family name 正确渲染。
附:几个值得注意的细节
- family name 用转换后打印的那个:
f["name"].getDebugName(1)输出的就是Font.custom的参数,和文件名无关。 - Web 子集化不影响转换:如果 Web 端做了字符子集裁剪,转换出来的 TTF 同样是裁剪版,glyph coverage 不变。
- CJK 字体体积大:LXGW WenKai 转换后约 12 MB,Sarasa 约 6 MB,要纳入 Bundle 大小预算考量。
- SwiftUI 没有 CSS 字体栈:
Font.custom只能指定单一 family,CJK 回退(如 latin 用 Maple Mono、中文用 LXGW WenKai)需要在 SwiftUI 层自行按语种分配,不像 CSS 的font-family列表那样自动 fallback。
一句话外卖
CoreText 只认 .ttf/.otf,把 Web 字体(.woff2)打包进原生应用前必须先用 fontTools 去掉 woff2 容器,否则注册会静默失败且毫无提示。