返回博客

把 .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 直接跳过,要么报错要么静默失败。

坑:CoreText 不识别 .woff2,且失败是静默的

.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,直接注册即可,且是幂等的。

正解:woff2 → ttf 转换后注册,CoreText 正常加载

fontTools.woff2flavorNonesave,输出裸 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 容器,否则注册会静默失败且毫无提示。