Skip to content

Repository files navigation

Matbaa · مطبعة

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 build

The problem

Standard 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.

The lazy engine

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: when isCommitted === false, the terminal token's left link is treated as still-connecting, forcing the open init/medi glyph 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.

Why there is a real shaping engine in here

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.

Layout

  • 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, the HELD OPEN lazy 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.

Presentation model

  • 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).

Font

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.

About

Lazy-evaluated calligraphic Arabic IME prototype — an FST holds the tail letter open until commit; custom GSUB/GPOS mini-shaper for Aref Ruqaa, verified bit-exact against HarfBuzz

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages