---
title: "文本、Bidi 与 IME — zenit Zig UI 文档"
description: "从字节、字素簇、双向段落到输入法合成：zenit 把文本当作系统能力，而不是一串按码点排开的 glyph。"
url: https://zenit.z.express/zh/docs/guide/text-engine
language: zh-CN
alternate_en: https://zenit.z.express/docs/guide/text-engine.md
alternate_es: https://zenit.z.express/es/docs/guide/text-engine.md
alternate_ja: https://zenit.z.express/ja/docs/guide/text-engine.md
alternate_ko: https://zenit.z.express/ko/docs/guide/text-engine.md
alternate_fr: https://zenit.z.express/fr/docs/guide/text-engine.md
alternate_de: https://zenit.z.express/de/docs/guide/text-engine.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 文本、Bidi 与 IME

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

## 一条文本，四层职责

存储、编辑边界、方向解析和平台塑形各有唯一的 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 x | `src/render/text_renderer.zig` |

## 字素不是码点

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

UAX #29

cursor\_pos 是 UTF-8 字节偏移；它只会停在簇边界上。单行 Input 的 2048 字节容量截断也不会留下半个字素。

[Video](https://zenit.z.express/media/stories/emoji.mp4?v=5f65f98444)

彩色 emoji 走 BGRA 图集页与着色器的彩色分支；灰度对照组保持灰色，混排行与普通文本同一管线。

[Video](https://zenit.z.express/media/stories/wordnav.mp4?v=1fd0aa8709)

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](https://zenit.z.express/media/unicode-bidi.png?v=d1e0e89e51)](https://zenit.z.express/media/unicode-bidi.png?v=d1e0e89e51)

真实 Storybook：阿拉伯文、希伯来文与 latin + arabic + latin 混排由同一条文本管线布局；虚拟鼠标停在 RTL story 上。

[Video](https://zenit.z.express/media/stories/rtl.mp4?v=bc200dee27)

RTL 与混排段落中的光标移动和选区。

## Unicode 17 一致性门禁

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

**490,846**BidiTest 类型序列

**770,241**段落方向判定

**91,707**BidiCharacterTest 序列

**766**GraphemeBreakTest 用例

```sh
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](https://zenit.z.express/zh/components/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](https://zenit.z.express/media/ime-preedit.png?v=6c12ff351b)](https://zenit.z.express/media/ime-preedit.png?v=6c12ff351b)

Preedit：可见带下划线的 marked text 与 I-beam，canonical buffer 尚未改变。

[![Input showing committed 日本語](https://zenit.z.express/media/ime-commit.png?v=f665179c69)](https://zenit.z.express/media/ime-commit.png?v=f665179c69)

Commit：日本語进入 canonical buffer，preedit 归零。

## 测试输入法

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

`ime.test.ts`

```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
```

[Video](https://zenit.z.express/media/e2e-text-ime.mp4?v=efa93e6b3f)

Harness 在真实 Retina 窗口里聚焦 Input，分别注入 preedit 与 commit，再切到 RTL 和 Checkbox；I-beam、手型与点击脉冲都由虚拟鼠标画进 drawable。

> TIP
> 
> **测试注入与真输入法各司其职。** Harness 注入让阶段、buffer 与像素断言可重复；候选窗、输入源切换和 NSTextInputClient 集成仍由 `scripts/verify_ime.sh` 的真系统路径验收（需要 GUI 会话、辅助功能权限，运行期间约 8 秒不要碰键盘）。

Harness 的完整用法见 [E2E 自动化](https://zenit.z.express/zh/docs/advanced/e2e)。
