docs/guide/text-engine
核心概念 · Unicode 17

文本、Bidi 与 IME

从字节、字素簇、双向段落到输入法合成:zenit 把文本当作系统能力,而不是一串按码点排开的 glyph。

预计阅读 12 分钟 · 含真实窗口录像

一条文本,四层职责

存储、编辑边界、方向解析和平台塑形各有唯一的 authority,最后统一成可命中、可选择的视觉行。任何一层都不替另一层做决定。

TEXT STACK
前两层在 src/text_core,双向算法在 src/i18n,塑形在 macOS 上交给 CoreText。
层负责位置
UTF-8 文档坐标稳定保存 byte offset,所有编辑入口做边界归一化src/text_core
UAX #29 字素簇光标、删除、选区不会切开 ZWJ emoji、组合附加符或旗帜src/text_core/grapheme.zig
UAX #9 Bidi段落方向、isolate、embedding、括号对与逻辑 / 视觉映射src/i18n/bidi.zig
CoreText 塑形字体 fallback、连字、glyph run、视觉放置与 caret xsrc/render/text_renderer.zig

字素不是码点

一个家庭 emoji 是 7 个 Unicode scalar(4 个人加 3 个 ZWJ),é 可以是 e 加一个组合重音,一面旗帜是两个区域指示符。用户眼里它们各是一个字符。zenit 用扩展字素簇作为光标移动、删除、选区和容量截断的原子。

UAX #29
cursor_pos 是 UTF-8 字节偏移;它只会停在簇边界上。单行 Input 的 2048 字节容量截断也不会留下半个字素。
彩色 emoji 走 BGRA 图集页与着色器的彩色分支;灰度对照组保持灰色,混排行与普通文本同一管线。
Alt+← / → 整词跳过希腊文与西里尔文单词,双击选中整词;家庭 emoji 始终是一个簇。

双向文本

文本在内存里按逻辑顺序存放,屏幕上按视觉顺序显示。UAX #9 先为每个字符解析嵌入层级,再按规则 L2 从最高层级开始逐级反转。下例是 LTR 段落中的 abc אבג 123:希伯来文得到层级 1,紧随其后的数字得到层级 2。

UAX #9
数字自身仍从左到右读,但整个 RTL 片段(含数字)作为一块从右到左排列。

UAX #9 解析器是 zenit 自研的可移植实现,位于 src/i18n/bidi.zig,属性表由固定的 Unicode 17.0.0 数据生成。macOS 上最终的 glyph 塑形与 RTL 连字仍整段交给 CoreText,文本渲染器不会把 RTL 片段逐码点拆开。

zenit Storybook showing mixed Arabic, Hebrew and Latin text
真实 Storybook:阿拉伯文、希伯来文与 latin + arabic + latin 混排由同一条文本管线布局;虚拟鼠标停在 RTL story 上。
RTL 与混排段落中的光标移动和选区。

Unicode 17 一致性门禁

运行时属性表由仓库固定的 Unicode 17.0.0 数据生成;官方测试文件原样放在 vendor/unicode/17.0.0,并在 SHA256SUMS 中用 SHA-256 固定。这里的「支持」不是挑几个 emoji 写单元测试,而是跑完整的官方用例。

490,846BidiTest 类型序列
770,241段落方向判定
91,707BidiCharacterTest 序列
766GraphemeBreakTest 用例
Unicode conformance gates
python3 tools/generate_grapheme_data.py --check
python3 tools/generate_bidi_data.py --check
(cd vendor/unicode/17.0.0 && shasum -a 256 -c SHA256SUMS)
zig build test-text-core
zig build test-bidi-conformance

--check 确认生成的 Zig 表与数据一致;test-text-core 跑完 GraphemeBreakTest 全部用例;test-bidi-conformance 跑两个完整的官方 bidi 文件(到 UAX #9 规则 L2),它也包含在 zig build test-headless 中。

IME 是一等公民

IME 不是把最终汉字伪装成一次键盘输入。平台事件保留阶段语义:ime_preedit 携带合成文本和合成区内的光标偏移,ime_commit 携带最终文本。合成期间 Input 与 Textarea 渲染带下划线的 marked text,但 canonical buffer 只在提交时改变。没有单独的「取消」事件:空的 preedit 就表示取消合成。

IME LIFECYCLE
preedit 与 commit 是两种事件,不是同一个字符串的中间快照。提交后短暂处于 commit_pending_end,用来吞掉平台随后可能重复送来的同一段文本,然后回到 idle。
一等公民体现在具体契约
独立事件ime_preedit / ime_commit,不丢阶段信息;空 preedit 取消合成
重新转换两种事件都可带 replacement range(UTF-8 字节区间),把已提交文本拉回合成态或就地替换
视觉合成marked range 下划线与高亮、合成光标、候选窗位置跟随视觉行
编辑安全光标、删除、选区和容量截断都落在扩展字素边界
可测试Harness 可分别注入 preedit / commit,并回读 ime_phase、ime_preedit_len、buffer、cursor_pos、anchor
真机门禁scripts/verify_ime.sh 用系统拼音 / 日文输入源走完整的 NSTextInputClient 路径
Input showing underlined nihongo preedit text
Preedit:可见带下划线的 marked text 与 I-beam,canonical buffer 尚未改变。
Input showing committed 日本語
Commit:日本語进入 canonical buffer,preedit 归零。

测试输入法

E2E 客户端把两个阶段作为独立的 RPC 暴露出来,inputState 按 test id 回读输入框的真实状态。

ime.test.ts
import { imeCommit, imePreedit, inputState } from "./client";

// The input must already have focus (e.g. click it first).

await imePreedit("nihongo");
let s = await inputState("story.input.name");
// s.ime_phase === "composing", s.ime_preedit_len === 7, s.buffer === ""

await imeCommit("日本語");
s = await inputState("story.input.name");
// s.buffer === "日本語", s.ime_preedit_len === 0

await imePreedit("");   // empty preedit cancels an active composition
Harness 在真实 Retina 窗口里聚焦 Input,分别注入 preedit 与 commit,再切到 RTL 和 Checkbox;I-beam、手型与点击脉冲都由虚拟鼠标画进 drawable。

Harness 的完整用法见 E2E 自动化。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30