docs/guide/text-engine
Concepts clés · Unicode 17

Texte, Bidi et IME

Des octets aux clusters de graphèmes, des paragraphes bidirectionnels à la composition IME : zenit traite le texte comme une capacité du système, pas comme une rangée de glyphes posés point de code par point de code.

12 min de lecture · avec enregistrements de fenêtres réelles

Une ligne, quatre couches

Le stockage, les limites d’édition, la résolution de direction et le shaping de la plateforme ont chacun une seule autorité, et produisent ensemble des lignes visuelles que l’on peut hit-tester et sélectionner. Aucune couche ne décide à la place d’une autre.

TEXT STACK
Les deux premières couches vivent dans src/text_core, l’algorithme bidi dans src/i18n, et sur macOS le shaping revient à CoreText.
CoucheResponsabilitéEmplacement
Coordonnées de document UTF-8Offsets d’octets stables, normalisés sur les limites à chaque point d’entrée d’éditionsrc/text_core
Graphèmes UAX #29Le curseur, la suppression et la sélection ne coupent jamais les emoji ZWJ, les diacritiques combinants ni les drapeauxsrc/text_core/grapheme.zig
Bidi UAX #9Direction du paragraphe, isolates, embeddings, paires de crochets, correspondance logique ↔ visuelsrc/i18n/bidi.zig
Shaping CoreTextFallback de polices, ligatures, glyph runs, placement visuel et caret xsrc/render/text_renderer.zig

Graphèmes, pas points de code

Un emoji famille compte sept scalaires Unicode (quatre personnes et trois ZWJ), é peut être e suivi d’un accent aigu combinant, et un drapeau se compose de deux indicateurs régionaux. Pour l’utilisateur, chacun est un seul caractère. zenit utilise les clusters de graphèmes étendus comme atome pour le déplacement du curseur, la suppression, la sélection et la troncature à la capacité.

UAX #29
cursor_pos est un offset d’octets UTF-8 et ne s’arrête que sur une limite de cluster. Même la troncature à la capacité de 2048 octets de l’Input sur une ligne ne laisse jamais un demi-graphème.
Les emoji en couleur passent par une page d’atlas BGRA et la branche couleur du shader ; le groupe témoin en niveaux de gris reste gris, et les runs mixtes partagent le pipeline de texte normal.
Alt+← / → saute des mots grecs et cyrilliques entiers, le double-clic sélectionne un mot, et l’emoji famille reste un seul cluster.

Texte bidirectionnel

Le texte est stocké dans l’ordre logique et affiché dans l’ordre visuel. UAX #9 résout d’abord un niveau d’enchâssement pour chaque caractère, puis la règle L2 inverse les runs du niveau le plus élevé vers le bas. Ci-dessous, abc אבג 123 se trouve dans un paragraphe LTR : l’hébreu est résolu au niveau 1 et les chiffres qui le suivent au niveau 2.

UAX #9
Les chiffres se lisent toujours de gauche à droite, mais tout le segment RTL — chiffres compris — est disposé de droite à gauche comme un bloc.

Le résolveur UAX #9 est une implémentation portable propre à zenit, dans src/i18n/bidi.zig, avec des tables de propriétés générées à partir de données Unicode 17.0.0 figées. Sur macOS, le shaping final des glyphes et la liaison RTL restent confiés à CoreText un run entier à la fois — le moteur de rendu de texte ne découpe jamais un run RTL point de code par point de code.

zenit Storybook showing mixed Arabic, Hebrew and Latin text
Le vrai Storybook : arabe, hébreu et mélanges latin + arabic + latin mis en page par un seul pipeline de texte ; le curseur virtuel repose sur la story RTL.
Déplacement du curseur et sélection dans des paragraphes RTL et à direction mixte.

Conformité Unicode 17

Les tables de propriétés d’exécution sont générées à partir de données Unicode 17.0.0 figées dans le dépôt ; les fichiers de test officiels sont vendorisés sans modification sous vendor/unicode/17.0.0 et figés par SHA-256 dans SHA256SUMS. Ici, « pris en charge » ne veut pas dire une poignée de tests unitaires sur des emoji — cela veut dire exécuter les suites officielles complètes.

490,846séquences de types BidiTest
770,241évaluations de direction de paragraphe
91,707séquences BidiCharacterTest
766cas GraphemeBreakTest
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 vérifie que les tables Zig générées correspondent aux données ; test-text-core exécute tous les cas de GraphemeBreakTest ; test-bidi-conformance exécute les deux fichiers bidi officiels complets (jusqu’à la règle L2 d’UAX #9) et fait aussi partie de zig build test-headless.

L'IME, citoyen de première classe

L’IME, ce n’est pas les caractères finaux déguisés en frappe clavier. Les événements de la plateforme conservent les phases : ime_preedit transporte le texte en composition plus un offset de curseur à l’intérieur, et ime_commit transporte le texte final. Pendant la composition, Input et Textarea affichent un texte marqué souligné, mais le buffer canonique ne change qu’au commit. Il n’y a pas d’événement « annuler » distinct : un preedit vide annule la composition.

IME LIFECYCLE
Preedit et commit sont deux types d’événements, pas des instantanés d’une même chaîne. Après un commit, l’input reste brièvement dans commit_pending_end pour absorber un doublon du même texte que la plateforme pourrait envoyer ensuite, puis revient à idle.
Ce que « première classe » signifieContrat concret
Événements distinctsime_preedit / ime_commit conservent la phase ; un preedit vide annule
ReconversionLes deux événements peuvent porter un replacement range (plage d’octets UTF-8) pour ramener du texte validé en composition ou le remplacer sur place
Composition visuelleLe soulignement et la surbrillance du marked range, le curseur de composition et la position de la fenêtre de candidats suivent la ligne visuelle
Édition sûreCurseur, suppression, sélection et troncature à la capacité tombent tous sur des limites de graphèmes étendus
TestableLe harness injecte preedit / commit séparément et relit ime_phase, ime_preedit_len, buffer, cursor_pos, anchor
Validation sur système réelscripts/verify_ime.sh parcourt tout le chemin NSTextInputClient avec les sources de saisie Pinyin / japonais du système
Input showing underlined nihongo preedit text
Preedit : le texte marqué souligné et le I-beam sont visibles ; le buffer canonique n’a pas changé.
Input showing committed 日本語
Commit : 日本語 entre dans le buffer canonique et le preedit est vidé.

Tester l'IME

Le client E2E expose les deux phases comme des RPC distinctes, et inputState relit l’état réel d’un champ par son 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
Le harness met le focus sur un Input dans une vraie fenêtre Retina, injecte preedit et commit séparément, puis passe à RTL et Checkbox ; le I-beam, le curseur main et l’impulsion de clic sont dessinés dans le drawable par le curseur virtuel.

Consultez Harness E2E pour le workflow complet du harness.

zenit · Double licenceGratuit pour les projets open source sous GPL-3.0-only ; les produits propriétaires ou commerciaux nécessitent une licence commerciale.Contacter l’auteur : zongyi.xzy#gmail.com (remplacez # par @)zenit 5f9add5+wip 2026-09-30