UAX #9 reorderingArabic shapingpure TypeScript · 0 deps

Stored one way.
Drawn another.

Text is stored in the order you read it and drawn in the order you see it — and those are not the same order. Between them sit two passes your renderer almost certainly skips. This is the bench they run on.

يُخزَّن النصُّ بترتيب القراءة ويُرسَم بترتيب الرؤية. بينهما مرحلتان خفيّتان — وهنا منضدتهما.

$ npm install bidi-shaper
★ Star on GitHub
Instrument المِجهر base · 0 glyphs · max level 0
input

LTR · even level RTL · odd level level ≥ 2
drawn — each glyph placed left-to-right, the way a naive renderer does المُخرَج

٠١ · the readings

Correct isn't a vibe here. It's measured.

The whole reordering core is checked against the complete official Unicode test suites on every build — not a sample, the whole thing.

861,948 official UAX #9 conformance cases pass — both Unicode suites, every build BidiTest.txt + BidiCharacterTest.txt · Unicode 17.0.0
~15 kB min + gzip, Unicode tables included — tree-shakeable ESM + CJS + types vs ~1 MB for a HarfBuzz WASM build
0 runtime dependencies · no WASM, no native modules, no fonts shipped pure TypeScript · string in, string out

٠٢ · the bench

Set your own line

Type anything. The left canvas places each code point left-to-right exactly like a renderer that can't shape or reorder; the right one is the same renderer fed render(). The map shows where every character moved.

Bench المنضدة render(text, options)
raw string, glyph-by-glyph broken
render() output, same renderer set

The output is in visual order — its code points are already shaped presentation forms in drawing sequence. Hand it to anything that lays glyphs out left-to-right.

analyze() — where each character lands visual ↔ logical

analyze() returns the embedding level and the visual↔logical index maps — the geometry for cursors, selection rectangles and hit-testing. Lines that cross are characters the algorithm moved.

٠٣ · contextual shaping

One letter, four glyphs

Arabic is cursive: a letter takes a different form when it starts, continues, or ends a word — chosen by its neighbours. Pick a letter to see the forms shape() substitutes before reordering even begins.

Shaping التشكيل Unicode core spec §9.2 · forms U+FB50–FEFF
the special case — ل+ا lam + alef fuse into one mandatory ligature, and the alef leaves the index map.

٠٤ · drop-in adapters

Wire it to your renderer

Each adapter is a separate entry point and is structurally typed — it never imports the host library, so it adds nothing to your bundle. Same engine underneath.

jsPDFbidi-shaper/jspdf
import { rtlText } from "bidi-shaper/jspdf";

doc.text(rtlText("مرحبا بالعالم"), 20, 40);
PDFKitbidi-shaper/pdfkit
import { textBidi } from "bidi-shaper/pdfkit";

// shapes + reorders; features:[] so fontkit won't re-shape
textBidi(doc, "سلام دنیا", 72, 80);
pdfmakebidi-shaper/pdfmake
import { shapeDocDefinition } from "bidi-shaper/pdfmake";

pdfMake.createPdf(shapeDocDefinition(def)).download();
Canvas 2Dbidi-shaper/canvas
import { fillTextBidi } from "bidi-shaper/canvas";

fillTextBidi(ctx, "قائمة (أ)", x, y);
three.jsbidi-shaper/three
import { prepareText } from "bidi-shaper/three";

const label = prepareText("اردو میں خوش آمدید");
// → feed to TextGeometry / troika-text
anything elsebidi-shaper
import { render } from "bidi-shaper";

// the universal move: one string, visual order
const drawable = render(text);

The two passes — one plain string.

No DOM, no font files, no WASM. Just the two passes every other stack hides — in pure TypeScript, ready for wherever your glyphs land.