# 字体渲染 TUI 宿主中的文本通过动态增长的字形缓存绘制:一个小型启动图集,加上按需从字体链中栅格化的码点页,因此已安装字体覆盖的任何字符都能渲染,无需重新构建(甚至无需重启)宿主。东亚宽字符脚本(CJK、假名、谚文、全角形式)每个字形占用两个单元格——与终端所用的 wcwidth 风格布局相同。本文解释该缓存所服务的单元格网格模型、图集与回退链的组织方式、配置接口以及已知限制——使诸如“为什么新符号开箱即用”或“如何测量包含 CJK 的文本”之类的问题读起来是设计行为。 这是设计层面的文档。函数层面的说明见预置文件(`:font`)、set-font(实时应用)和 TUI 概览;关于表面上的像素空间文本,见图形。 ## 单元格网格模型 每个 GL 宿主(retro、hydra)都把文本渲染为由等宽单元格组成的网格——每个单元格一个码点,按从主字体度量推导出的固定单元格宽度前进。没有可变步进、没有整形、没有逐字形字距调整:网格本身就是布局引擎。这正是聊天日志、编辑器缓冲区和电子表格能共用一个渲染器的原因,也是等宽探测所保护的东西(比例字体会明显使网格错位;探测规则见预置文件)。 由此产生两个后果: - 宽字符脚本占用两个单元格。 东亚宽/全角码点(CJK 表意文字、假名、谚文、全角形式、宽 emoji)占用一个单元格对:左单元格携带字形标志,右侧是共享其颜色的空白延续单元格。写入方(`draw/text`、终端模拟器、`putString`)对每个宽码点前进两列,而会跨越边界(行尾、适应宽度、裁剪边缘)的单元格对会被整体丢弃,而不是只画一半。在对混合脚本文本居中或截断时,用 `[width $s]`——而不是 `len`——测量显示宽度。 - 只有主字体决定度量。 回退字体面只贡献字形,绝不贡献单元格大小或行高——因此混合字体(拉丁等宽主字体 + CJK 回退字体)绝不会扰乱网格几何。 ## 图集架构 每个渲染表面拥有一个私有的 `FontAtlas`:retro 的主网格、每个弹出面板窗口、每个 hydra 窗口,以及每个 SDL surface(每个 GL 上下文一个图集——纹理 id 按上下文区分)。一个图集分两层: | 层 | 内容 | 构建 | |---|---|---| | 基础页 | ASCII 32–126、制表符 U+2500–U+257F | 启动时,来自主字体面 | | 扩展页 | 其余全部,按码点哈希 | 惰性构建,最多 8 个方形 R8 页(约 8 MB),按基础单元格大小网格打包 | UI 装饰符号(✕ ▾ ↗ ● █ ▶ …)在启动时就被预置到扩展缓存中,因此首帧的界面装饰永远不会承受栅格化卡顿;但它们是普通的缓存条目——并非编译进去的。 码点查找先检查静态范围,再检查缓存哈希映射。未命中时,图集用 FreeType 栅格化该字形,将其 blit 到下一个扩展槽,上传该槽的矩形区域,并缓存结果。东亚宽码点会获得一个双宽槽(页中两个相邻单元格),使字形以自然比例跨越其单元格对——单元格网格的写入方已经预留了该对(见上)。没有字体面覆盖的码点落入负缓存,因此不会每帧重试(它们渲染为空白单元格)。当 8 页全部填满时,新字形会记录一次警告并渲染为空白——按典型单元格大小,该预算约为 16k 字形,一个会话中足以容纳 CJK 加符号。图集是重建而非调整大小:字体更改或缩放会创建全新图集(以及全新的空缓存)并销毁旧的。 ## 回退链 查找先尝试主字体面,然后按声明顺序最多尝试四个回退字体面,使用每个字体面的字符映射来决定覆盖情况。第一个映射该码点的字体面负责栅格化它。正是这一点让一个拉丁等宽字体加一个 CJK 字体集几乎覆盖一切——也正是这一点让新的界面符号成为配置问题,而不是宿主重建问题。 回退字体面不受等宽探测约束(只有主字体驱动网格),这一点很重要,因为 CJK 字体集和符号字体常常是可变字体或未声明为等宽的。注意字体族匹配器评分的是文件主干名,而非字体元数据:以 `NotoSansMonoCJK-VF.ttc` 发布的字体集不会匹配查询 “Noto Sans Mono CJK SC”——这类字体请用精确路径引用。 ## 配置接口 一切都在预置文件中声明,按项目配置(见预置文件):
:font %{
    :family "JetBrains Mono"          # or :path "/abs/x.ttf" (:path wins)
    :size 16                          # 6..128, default 18
    :fallback @{ "/usr/share/fonts/.../NotoSansMonoCJK-VF.ttc" }
    :ranges   @{ "0x0400-0x04FF" }     # pre-rasterize hot scripts
}
- `:fallback` 和 `:ranges` 纯粹是对动态缓存的补充——`:ranges` 的存在只是为了把栅格化从首次使用提前到启动时(避免卡顿,而非覆盖门槛)。无论如何,一切内容都按需渲染。 - CLI `--font` / `--size` 按字段覆盖主字体面;回退和范围始终来自预置文件。 - 每个解析出的文件(主字体和回退字体)都会进入 landlock 沙箱的只读授权,因此惰性栅格化在 `DEFT_SANDBOX=1` 下仍能工作。 - 交互式缩放(Ctrl+= / - / 0)和 `[tui/set-font ...]`(见 set-font)会在运行时用相同的回退/范围配置重建图集——所应用的更改只影响主字体面和大小,且两者都不会持久化(持久化正是预置文件的用途)。 retra(ANSI)忽略所有这些——它的渲染器把 UTF-8 转发给终端,覆盖范围由用户终端字体负责。不过宽单元格语义是共享的:宿主写入相同的单元格对,其差异渲染器只发出宽字形一次,跳过延续单元格(发出它会擦掉字形的右半部分——终端已经前进了两列)。 ## 渲染纪律 字形四边形会被批处理,而四边形采样的是在刷新时绑定到纹理单元 0 的纹理——而不是入队时的纹理。因此渲染器跟踪当前活动的字形页,并在切换前刷新(`bindGlyphPage`);其他任何帧中途的纹理上传都会保留调用方的绑定,使跟踪保持可靠。按需栅格化(包括其 GL 上传)在主线程上以内联方式执行,且 GL 上下文为当前上下文——该线程同时拥有图集,因此缓存中任何地方都不涉及加锁。 ## 限制与路线图 - 没有组合标记或整形——组合字符渲染为独立的宽度为 1 的字形;RTL 和复杂脚本超出单元格网格模型的范围。 - 显示宽度与索引保持分离。 字符串索引基于码点(见上),但屏幕列仍然需要 `[width]`/`[cells]`——编辑器通过行的单元格把光标的字符索引映射到显示列。 - 缓存按图集隔离。 Hydra 实例和多窗口会话中,同一码点每个窗口栅格化一次。这是有意为之(上下文相互隔离);成本受页预算约束。 - Emoji:彩色字体渲染为单色轮廓(图集只存储单个覆盖通道)。