docs/guide/text-engine
Conceptos clave · Unicode 17

Texto, Bidi e IME

De los bytes a los clústeres de grafemas, los párrafos bidireccionales y la composición del IME: zenit trata el texto como una capacidad del sistema, no como una fila de glifos colocados por punto de código.

12 min de lectura · incluye grabaciones de ventanas reales

Una línea, cuatro capas

El almacenamiento, los límites de edición, la resolución de dirección y el shaping de la plataforma tienen cada uno una única autoridad, y juntos producen líneas visuales en las que puedes hacer hit-test y seleccionar. Ninguna capa decide por otra.

TEXT STACK
Las dos primeras capas viven en src/text_core, el algoritmo bidi en src/i18n, y en macOS el shaping es cosa de CoreText.
CapaResponsable deDónde
Coordenadas de documento UTF-8Offsets de bytes estables, normalizados a límites en cada punto de entrada de ediciónsrc/text_core
Grafemas UAX #29El cursor, el borrado y la selección nunca parten emoji ZWJ, marcas combinantes ni banderassrc/text_core/grapheme.zig
Bidi UAX #9Dirección de párrafo, isolates, embeddings, pares de paréntesis, mapeo lógico ↔ visualsrc/i18n/bidi.zig
Shaping con CoreTextFallback de fuentes, ligaduras, glyph runs, colocación visual y caret xsrc/render/text_renderer.zig

Grafemas, no puntos de código

Un emoji de familia son siete escalares Unicode (cuatro personas y tres ZWJ), é puede ser e más un acento agudo combinante, y una bandera son dos indicadores regionales. Para el usuario, cada uno es un solo carácter. zenit usa clústeres de grafemas extendidos como átomo para el movimiento del cursor, el borrado, la selección y el truncado por capacidad.

UAX #29
cursor_pos es un offset de bytes UTF-8 y solo se detiene en límites de clúster. Ni siquiera el truncado a la capacidad de 2048 bytes del Input de una línea deja medio grafema.
Los emoji en color pasan por una página de atlas BGRA y la rama de color del shader; el grupo de control en escala de grises sigue gris, y los tramos mixtos comparten la pipeline de texto normal.
Alt+← / → salta palabras griegas y cirílicas completas, el doble clic selecciona una palabra y el emoji de familia sigue siendo un solo clúster.

Texto bidireccional

El texto se almacena en orden lógico y se muestra en orden visual. UAX #9 primero resuelve un nivel de incrustación para cada carácter y luego la regla L2 invierte los tramos desde el nivel más alto hacia abajo. Abajo, abc אבג 123 está en un párrafo LTR: el hebreo resuelve al nivel 1 y los dígitos que lo siguen al nivel 2.

UAX #9
Los dígitos se siguen leyendo de izquierda a derecha, pero todo el tramo RTL —dígitos incluidos— se coloca de derecha a izquierda como un bloque.

El resolvedor UAX #9 es una implementación portable propia de zenit en src/i18n/bidi.zig, con tablas de propiedades generadas a partir de datos fijados de Unicode 17.0.0. En macOS, el shaping final de glifos y la unión RTL se siguen entregando a CoreText un tramo completo a la vez: el renderizador de texto nunca divide un tramo RTL por punto de código.

zenit Storybook showing mixed Arabic, Hebrew and Latin text
El Storybook real: árabe, hebreo y mezclas latin + arabic + latin maquetados por una única pipeline de texto; el cursor virtual descansa sobre la story RTL.
Movimiento del cursor y selección en párrafos RTL y de dirección mixta.

Conformidad con Unicode 17

Las tablas de propiedades en tiempo de ejecución se generan a partir de datos de Unicode 17.0.0 fijados en el repositorio; los archivos de prueba oficiales se incluyen sin modificar en vendor/unicode/17.0.0 y se fijan por SHA-256 en SHA256SUMS. Aquí, “compatible” no significa un puñado de tests unitarios de emoji: significa ejecutar las suites oficiales completas.

490,846secuencias de tipos de BidiTest
770,241evaluaciones de dirección de párrafo
91,707secuencias de BidiCharacterTest
766casos de 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 confirma que las tablas Zig generadas coinciden con los datos; test-text-core ejecuta todos los casos de GraphemeBreakTest; test-bidi-conformance ejecuta los dos archivos bidi oficiales completos (hasta la regla L2 de UAX #9) y también forma parte de zig build test-headless.

IME como ciudadano de primera clase

El IME no son los caracteres finales disfrazados de pulsación de tecla. Los eventos de la plataforma conservan las fases: ime_preedit lleva el texto en composición más un offset del cursor dentro de él, e ime_commit lleva el texto final. Durante la composición, Input y Textarea renderizan texto marcado subrayado, pero el buffer canónico solo cambia al hacer commit. No hay un evento “descartar” aparte: un preedit vacío cancela la composición.

IME LIFECYCLE
Preedit y commit son dos tipos de evento, no instantáneas de una misma cadena. Tras un commit, el input permanece brevemente en commit_pending_end para absorber un duplicado del mismo texto que la plataforma pueda enviar a continuación, y luego vuelve a idle.
Qué significa primera claseContrato concreto
Eventos distintosime_preedit / ime_commit conservan la fase; un preedit vacío cancela
ReconversiónAmbos eventos pueden llevar un replacement range (intervalo de bytes UTF-8) para devolver texto confirmado a la composición o reemplazarlo en su sitio
Composición visualEl subrayado y resaltado del marked range, el cursor de composición y la posición de la ventana de candidatos siguen la línea visual
Edición seguraCursor, borrado, selección y truncado por capacidad caen siempre en límites de grafemas extendidos
TesteableEl harness inyecta preedit / commit por separado y lee de vuelta ime_phase, ime_preedit_len, buffer, cursor_pos, anchor
Verificación en sistema realscripts/verify_ime.sh recorre la ruta completa de NSTextInputClient con las fuentes de entrada Pinyin / japonés del sistema
Input showing underlined nihongo preedit text
Preedit: se ven el texto marcado subrayado y el I-beam; el buffer canónico no ha cambiado.
Input showing committed 日本語
Commit: 日本語 entra en el buffer canónico y el preedit se vacía.

Probar el IME

El cliente E2E expone ambas fases como RPC separadas, e inputState lee de vuelta el estado real de un input por su 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
El harness enfoca un Input en una ventana Retina real, inyecta preedit y commit por separado y luego pasa a RTL y Checkbox; el I-beam, el cursor de mano y el pulso de clic los dibuja el cursor virtual en el drawable.

Consulta Harness E2E para ver el flujo completo del harness.

zenit · Doble licenciaGratis para proyectos de código abierto bajo GPL-3.0-only; los productos cerrados o comerciales necesitan una licencia comercial.Contacta con el autor: zongyi.xzy#gmail.com (cambia # por @)zenit 5f9add5+wip 2026-09-30