A prototype of a lazy-evaluated calligraphic Arabic IME, targeting the structurally demanding Aref Ruqaa font.
npm install
npm run dev # http://localhost:5173
npm test # 101 tests incl. bit-exact HarfBuzz oracle comparison
npm run buildStandard text engines shape Arabic eagerly: every keystroke re-resolves the whole word, so the tail letter snaps into its closed final form on each keypress. In a vertically cascading style like Ruqaa there is a second, nastier effect: the font's cursive-attachment chain anchors the last glyph on the baseline and stacks earlier glyphs upward, so each new letter shoves the entire word up the line. Type مممممم into the top pane and watch it climb out of the box.
While a word is uncommitted, the FST refuses to close the tail:
- Two flags per character (
connectsRight,connectsLeft) — Unicode joining types, no combinatorial form matrix. resolveBuffer(tokens, isCommitted)evaluates connection links dynamically. The core trick: whenisCommitted === false, the terminal token's left link is treated as still-connecting, forcing the openinit/mediglyph form (the "expectant" state). (The original spec said "evaluates its leftLink as false" — open forms require the link held connected; the tail refuses to close it.)- Letters that can never connect leftward (ا د ذ ر ز و ة أ إ آ ؤ ء) are intrinsic terminators: they seal the buffer the moment they are typed. Usefully, these are exactly the letters that break the cursive cascade — commit segments and cursive chains coincide, which is why the sealed prefix never moves.
- The canvas anchors the active word's first glyph at the cursor anchor. Appending letters steps the cascade down-left organically; on commit (space/enter) the word settles onto the line baseline and "cools" from molten amber to ink.
The plan of "map GSUB init/medi/fina tags to per-glyph substitutions" dies on contact
with the actual font: Aref Ruqaa implements its positional forms as type-2 multiple
substitutions over decomposed skeleton+mark glyphs (ccmp splits ب into a tooth and a
dot), layers a 29-lookup contextual calt on top to pick cascade variants, and does its
positioning with GPOS cursive attachment, contextual kerning chains, and mark
attachment — none of which opentype.js can apply (it cannot even parse GPOS types
3/4/6/8), and its built-in shaper no-ops on this font.
So src/otl/ is a compact OpenType layout engine:
| piece | role |
|---|---|
reader.ts |
binary readers: coverage, classdef, anchors, value records |
gdef.ts |
glyph classes, mark-attachment classes, mark filtering sets |
gpos.ts |
own GPOS parser: lookup types 1, 2, 3, 4, 6, 8 (+9 extension) |
gpos-apply.ts |
HarfBuzz-faithful application: RTL cursive attachment, resolve_cross_offset mark semantics, late mark-advance zeroing, attachment propagation |
gsub-apply.ts |
GSUB types 1/2/6 over opentype.js's parsed tables, with lookupFlag skipping |
shaper.ts |
the pipeline: cmap → ccmp → FST-controlled isol/init/medi/fina masks → calt → GPOS |
The FST hands the shaper one form per character instead of running eager joining
analysis — that is the whole hijack. The lazy tail is just a medi mask where an eager
shaper would have said fina.
Verification: tests/shaper-oracle.test.ts and tests/shaper-fuzz.test.ts require
the engine's output (glyph ids, advances, offsets) to equal real HarfBuzz (via
harfbuzzjs, dev-only) exactly — 43 real words plus 600 seeded random strings, in
both committed form and lazy form (validated against HarfBuzz shaping of text + ZWJ).
A stability test proves appending a letter never disturbs glyphs more than two tokens
back, relative to the word's first glyph — the invariant that makes cursor-anchored
rendering flicker-free.
- Top pane — a plain
<textarea>in Aref Ruqaa: the browser's eager shaping, jumping and re-anchoring as you type. - Bottom pane — HTML5 canvas driven by the engine. No browser text layout touches it; glyph outlines come straight from opentype.js paths at engine-computed positions.
- Bench — on-screen keyboard (standard Arabic 101 layout, QWERTY-mapped:
f→ب,d→ي,j→ت …) and a debug panel showing the composition buffer, per-token FST forms, theHELD OPENlazy override,isCommitted, buffer glyph ids, and commit events.
Typing works three ways: physical keys through the QWERTY map, a real Arabic keyboard layout, or the on-screen keys.
- Pen-anchored raw frame: the shaped output roots each cursive chain's newest glyph at dy 0, so drawing it raw puts the caret on the input line while earlier glyphs rise (tweened) as the cascade grows.
- Seal segments are shaping boundaries while molten (
src/render/compose.ts): sealed segments are frozen HarfBuzz-exact runs; cross-seal interactions (kern tucks + cascade elevation) arrive together at commit, carried by the settle. Without this, the tuck applies immediately while its elevation is deferred — measured as a real 41.5 px² ink crossing between a sealed dal and the next lam. Committed words remain a single untouched HarfBuzz-exact run (tests/seal-composition.test.ts).
public/fonts/ArefRuqaa-Regular.ttf is vendored from google/fonts (SIL OFL, see
public/fonts/OFL.txt) so the demo is offline-deterministic; the same bytes feed both
panes.