docs/guide/text-engine
Kernkonzepte · Unicode 17

Text, Bidi und IME

Von Bytes über Graphem-Cluster und bidirektionale Absätze bis zur IME-Komposition: zenit behandelt Text als Systemfähigkeit, nicht als Reihe von Glyphen, die pro Codepoint ausgelegt werden.

12 Min. Lesezeit · mit Aufnahmen echter Fenster

Eine Zeile, vier Schichten

Speicherung, Bearbeitungsgrenzen, Richtungsauflösung und Plattform-Shaping haben jeweils genau eine Autorität und ergeben zusammen visuelle Zeilen, die sich per Hit-Test treffen und auswählen lassen. Keine Schicht entscheidet für eine andere.

TEXT STACK
Die ersten beiden Schichten liegen in src/text_core, der Bidi-Algorithmus in src/i18n, und das Shaping übernimmt unter macOS CoreText.
SchichtZuständig fürOrt
UTF-8-DokumentkoordinatenStabile Byte-Offsets, an jedem Bearbeitungseinstieg auf Grenzen normalisiertsrc/text_core
UAX-#29-GraphemeCursor, Löschen und Auswahl zerteilen nie ZWJ-Emoji, kombinierende Zeichen oder Flaggensrc/text_core/grapheme.zig
UAX-#9-BidiAbsatzrichtung, Isolates, Embeddings, Klammerpaare, Zuordnung logisch ↔ visuellsrc/i18n/bidi.zig
CoreText-ShapingFont-Fallback, Ligaturen, Glyph-Runs, visuelle Platzierung und Caret-xsrc/render/text_renderer.zig

Grapheme statt Codepoints

Ein Familien-Emoji besteht aus sieben Unicode-Scalars (vier Personen und drei ZWJs), é kann e plus ein kombinierender Akut sein, und eine Flagge besteht aus zwei regionalen Indikatoren. Für Nutzer ist jedes davon ein einziges Zeichen. zenit verwendet erweiterte Graphem-Cluster als Atom für Cursorbewegung, Löschen, Auswahl und Kürzen an der Kapazitätsgrenze.

UAX #29
cursor_pos ist ein UTF-8-Byte-Offset und landet immer nur auf einer Clustergrenze. Selbst das Kürzen an der 2048-Byte-Kapazität des einzeiligen Input hinterlässt nie ein halbes Graphem.
Farbige Emoji laufen über eine BGRA-Atlasseite und den Farbzweig des Shaders; die Graustufen-Kontrollgruppe bleibt grau, und gemischte Runs teilen die normale Text-Pipeline.
Alt+← / → springt über ganze griechische und kyrillische Wörter, Doppelklick wählt ein Wort aus, und das Familien-Emoji bleibt ein Cluster.

Bidirektionaler Text

Text wird in logischer Reihenfolge gespeichert und in visueller Reihenfolge angezeigt. UAX #9 löst zunächst für jedes Zeichen eine Einbettungsebene auf, dann kehrt Regel L2 die Runs von der höchsten Ebene abwärts um. Unten steht abc אבג 123 in einem LTR-Absatz: Das Hebräische wird zu Ebene 1 aufgelöst, die folgenden Ziffern zu Ebene 2.

UAX #9
Die Ziffern werden weiterhin von links nach rechts gelesen, aber der gesamte RTL-Abschnitt – Ziffern eingeschlossen – wird als Block von rechts nach links ausgelegt.

Der UAX-#9-Resolver ist zenits eigene portable Implementierung in src/i18n/bidi.zig, mit Eigenschaftstabellen, die aus fest gepinnten Unicode-17.0.0-Daten generiert werden. Unter macOS werden das finale Glyph-Shaping und die RTL-Verbindung weiterhin run-weise an CoreText übergeben – der Text-Renderer zerlegt einen RTL-Run nie pro Codepoint.

zenit Storybook showing mixed Arabic, Hebrew and Latin text
Das echte Storybook: Arabisch, Hebräisch und Mischungen aus latin + arabic + latin, ausgelegt von einer einzigen Text-Pipeline; der virtuelle Cursor ruht auf der RTL-Story.
Cursorbewegung und Auswahl in RTL-Absätzen und Absätzen mit gemischter Richtung.

Unicode-17-Konformität

Die Laufzeit-Eigenschaftstabellen werden aus im Repo gepinnten Unicode-17.0.0-Daten generiert; die offiziellen Testdateien liegen unverändert unter vendor/unicode/17.0.0 und sind per SHA-256 in SHA256SUMS fixiert. „Unterstützt“ heißt hier nicht eine Handvoll Emoji-Unit-Tests – es heißt, die vollständigen offiziellen Suites auszuführen.

490,846BidiTest-Typsequenzen
770,241Auswertungen der Absatzrichtung
91,707BidiCharacterTest-Sequenzen
766GraphemeBreakTest-Fälle
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 bestätigt, dass die generierten Zig-Tabellen zu den Daten passen; test-text-core führt jeden GraphemeBreakTest-Fall aus; test-bidi-conformance führt beide vollständigen offiziellen Bidi-Dateien aus (bis UAX #9 Regel L2) und ist auch Teil von zig build test-headless.

IME als Bürger erster Klasse

IME ist nicht das fertige Zeichen, getarnt als Tastendruck. Plattform-Events behalten die Phasen: ime_preedit trägt den Kompositionstext plus einen Cursor-Offset darin, ime_commit trägt den finalen Text. Während der Komposition rendern Input und Textarea unterstrichenen Marked Text, doch der kanonische Buffer ändert sich erst beim Commit. Es gibt kein separates „Verwerfen“-Event – ein leeres Preedit bricht die Komposition ab.

IME LIFECYCLE
Preedit und Commit sind zwei Arten von Events, keine Momentaufnahmen eines Strings. Nach einem Commit verharrt das Input kurz in commit_pending_end, um ein Duplikat desselben Texts zu schlucken, das die Plattform eventuell nachschickt, und kehrt dann zu idle zurück.
Was „erster Klasse“ bedeutetKonkreter Vertrag
Getrennte Eventsime_preedit / ime_commit behalten die Phase; ein leeres Preedit bricht ab
RekonvertierungBeide Events können eine Replacement Range (UTF-8-Bytebereich) tragen, um bestätigten Text zurück in die Komposition zu holen oder an Ort und Stelle zu ersetzen
Visuelle KompositionUnterstreichung und Hervorhebung der Marked Range, Kompositions-Cursor und Position des Kandidatenfensters folgen der visuellen Zeile
Sichere BearbeitungCursor, Löschen, Auswahl und Kapazitätskürzung landen stets auf erweiterten Graphemgrenzen
TestbarDer Harness injiziert Preedit / Commit getrennt und liest ime_phase, ime_preedit_len, buffer, cursor_pos, anchor zurück
Gate auf echtem Systemscripts/verify_ime.sh durchläuft den vollständigen NSTextInputClient-Pfad mit den System-Eingabequellen Pinyin / Japanisch
Input showing underlined nihongo preedit text
Preedit: Unterstrichener Marked Text und der I-Beam sind sichtbar; der kanonische Buffer hat sich nicht geändert.
Input showing committed 日本語
Commit: 日本語 gelangt in den kanonischen Buffer, das Preedit wird geleert.

IME testen

Der E2E-Client stellt beide Phasen als getrennte RPCs bereit, und inputState liest den echten Zustand eines Eingabefelds per Test-ID zurück.

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
Der Harness fokussiert ein Input in einem echten Retina-Fenster, injiziert Preedit und Commit getrennt und wechselt dann zu RTL und Checkbox; I-Beam, Hand-Cursor und Klickpuls zeichnet der virtuelle Cursor in das Drawable.

Den vollständigen Harness-Workflow finden Sie unter E2E-Harness.

zenit · DoppellizenzKostenlos für Open-Source-Projekte unter GPL-3.0-only; Closed-Source- oder kommerzielle Produkte benötigen eine kommerzielle Lizenz.Kontakt zum Autor: zongyi.xzy#gmail.com (# durch @ ersetzen)zenit 5f9add5+wip 2026-09-30