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
٠١ · 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.
٠٢ · 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.
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() 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.
٠٤ · 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.
import { rtlText } from "bidi-shaper/jspdf";
doc.text(rtlText("مرحبا بالعالم"), 20, 40);
import { textBidi } from "bidi-shaper/pdfkit";
// shapes + reorders; features:[] so fontkit won't re-shape
textBidi(doc, "سلام دنیا", 72, 80);
import { shapeDocDefinition } from "bidi-shaper/pdfmake";
pdfMake.createPdf(shapeDocDefinition(def)).download();
import { fillTextBidi } from "bidi-shaper/canvas";
fillTextBidi(ctx, "قائمة (أ)", x, y);
import { prepareText } from "bidi-shaper/three";
const label = prepareText("اردو میں خوش آمدید");
// → feed to TextGeometry / troika-text
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.