Midad is an Arabic calligraphy editor built around one idea: the shaper is the single source of truth. A document never stores glyph positions. It stores the text plus a short list of the calligrapher’s decisions (alternate shapes, offsets, kashida lengths), and every frame is recomputed from those by a real OpenType shaper. That is what lets a document survive text edits, style updates and different front-ends.
┌──────────────────────────── styles/<id>/ ───────────────────────────┐
│ source/*.ttf ──► tools/midad_style (Python) ──► dist/*.ttf (+ salt) │
│ style.toml ─────────────────────────────────────────┐ │
└──────────────────────────────────────────────────────┼──────────────┘
▼
┌──────────────────────── crates/midad-core (Rust) ─────────────────────────────────┐
│ Style = Manifest + FontData (harfrust shaper, skrifa outlines, alternates map) │
│ Document (text + overrides) ──► compose() ──► Scene (positioned glyphs) │
│ Editor = Document + History + validation │ svg::scene_to_svg() │
└───────────────┬──────────────────────────────┴────────────────────────────────────┘
│ │
crates/midad-wasm (wasm-bindgen) crates/midad-cli (clap)
│
apps/web (React + Vite) ──────► apps/desktop (Tauri: same web app in a native window)
| Path | What lives there |
|---|---|
crates/midad-core |
The engine. No I/O, no platform code; compiles to native and to WebAssembly. |
crates/midad-wasm |
MidadEngine / MidadEditor classes exported to JavaScript. |
crates/midad-cli |
midad command: render to SVG, inspect styles and alternates, dump scenes. |
apps/web |
The editor UI (React 19 + TypeScript + Vite). Runs the engine in WebAssembly. |
apps/desktop |
Tauri 2 shell around apps/web (native dialogs, file writes, installers). |
styles/ |
Style packages: style.toml, the source font, the compiled font in dist/. |
tools/midad_style |
Python style compiler + font inspector (fontTools). |
scripts/ |
build-wasm.mjs, sync-styles.mjs (copies styles into the web app), smoke-wasm.mjs. |
docs/ |
This documentation. docs/ar/ holds the Arabic guides. |
midad-core)| Module | Responsibility |
|---|---|
manifest |
style.toml schema (serde). Unknown keys are ignored so the Python compiler can have its own. |
font |
FontData: shaping handle, metrics, GDEF mark classes, alternates table, outline cache. |
shape |
Thin wrapper over HarfRust. Owns the cluster numbering convention (below). |
kashida |
Calligraphic rules for elongation ([kashida] in the manifest). |
text |
Arabic helpers: marks, words, stripping diacritics. |
compose |
The pipeline: document + style → Scene. |
scene |
Output types (what renderers draw). |
document |
The document model, .midad serialisation, override remapping on text edits. |
history |
Undo/redo by snapshots; gestures group a drag into one step. |
editor |
The API every front-end uses: edits, validation, lazy recomposition, export. |
svg |
Standalone SVG export (plain paths, no font dependency). |
geometry |
Rect, Affine (SVG matrix order). |
harfrust) — the official Rust port of HarfBuzz, maintained by
the HarfBuzz project, pure Rust, compiles to WebAssembly without C.read-fonts, so the font bytes are
parsed by one stack.unicode-bidi for mixed Arabic/Latin/digit lines, serde + toml +
serde_json for data.The engine passes its own cluster values to the shaper:
2·i2·i + 1Clusters stay monotonic (HarfBuzz requires it), and every output glyph is
traceable to a real character (even) or an elongation (odd). Never feed the
shaper byte offsets or reuse a cluster value; shape.rs is the only place
that builds buffers.
Overrides are addressed by GlyphKey { cluster, index }:
cluster = char index of the first character of the glyph’s cluster,
index = position of the glyph inside that cluster in logical order
(0 = the base letter, then marks). For RTL runs the shaper returns glyphs in
visual order, so assign_keys walks them backwards.
compose.rs)For each line (split on \n), the line is split into directional runs with
the Unicode Bidirectional Algorithm (paragraph direction RTL). For each run:
SAFE_TO_INSERT_TATWEEL when a tatweel can go before them
without breaking the joins; the style rules (kashida.rs) then filter
those positions.max_per_word per word).salt) with value N on that cluster only.Then finish() aligns lines, applies user transforms (offset, scale and
rotation around the glyph centre; marks follow their base letter’s offset)
and computes bounds and kashida handle positions.
Why alternates go through the shaper. Swapping glyph ids after shaping
would leave harakat, kerning and cursive attachment computed for the old
glyph. Selecting the alternate inside the shaper (a feature value on one
cluster, exactly like salt=3 in CSS/HarfBuzz) keeps all GPOS positioning
correct for the glyph that is displayed.
Shaping cost. Each run is shaped two to three times. On the Basmala a full composition takes ~70 µs natively and < 1 ms in WebAssembly, so the editor simply recomposes on every edit.
PlacedGlyph.transform is the full glyph→scene matrix in SVG order
[a b c d e f].Error::Rule(message) (e.g. kashida in an
excluded word). The UI shows them; the document is unchanged.Warning (stale_alternate,
kashida_not_allowed, kashida_limit, conflicting_alternates).A style package is a folder with style.toml, the source font and the
compiled font (see STYLE_FORMAT.md). The compiler adds a
GSUB type 3 (alternate substitution) lookup under the feature named in
[alternates] feature (salt by default). The engine then only needs
standard OpenType: any font that already ships alternates in a type-3
feature works without compilation.
For fonts like Amiri, whose variety lives in contextual rules rather than in an alternates feature, the compiler derives the alternates:
edge_features (including lookups called by contextual
rules). Each A → B is an edge; connected components are families
(every shape of one letter).alternates[g] = the other members of g’s (family, signature) group.The compiled font is renamed (OFL), the build is deterministic, and
build-info.json records hashes so CI can detect a stale dist/.
apps/web)src/engine/engine.ts — loads the wasm module and the styles listed in
public/styles/index.json, keeps a per-style outline cache. Outlines are
never sent with scenes; the UI asks only for glyph ids it has not seen.src/engine/useMidad.ts — the wasm Editor is the source of truth; React
keeps a revision counter and re-reads scene()/document() after each
edit. Autosaves the document to localStorage.src/components/Sheet.tsx — SVG drawing sheet: selection, letter drag
(one undo step per drag), kashida handles, pan/zoom. Pointer positions are
mapped to scene units through the flipped group’s screen matrix.src/components/Panel.tsx — text, letter shapes, kashida, offset (in
calligraphic dots), size, slant, style options, layout.src/lib/platform.ts — save/open through downloads on the web, native
dialogs + Rust commands in the desktop app.src/i18n.ts — Arabic (reference) and English strings; the layout mirrors
with dir.Lengths shown to users are in nuqat (rhombic pen dots), the unit
calligraphers measure with; [metrics] nuqta in the manifest gives its size
in font units.
apps/desktop)Tauri 2 loads the built web app. The engine still runs as WebAssembly in the
WebView, so web and desktop behave identically. The Rust side adds two
commands (write_file, read_text_file) used with the dialog plugin. The
CSP allows wasm-unsafe-eval and nothing remote.
midad-core has no I/O and no platform dependencies.shape.rs creates shaping buffers; only compose.rs decides glyph positions.Document never contains positions computed by the engine.Editor, which records history before mutating.apps/web/src/engine/types.ts mirrors scene.rs, document.rs and
StyleSummary — change them together.crates/midad-wasm pins wasm-bindgen to the exact CLI version used in CI.