RTL bidi
A page that serves a reader in Tehran or Tel Aviv has to put the first glyph on the right and step left, line after line — and still handle the stray English URL, the ASCII parenthesis, the Latin name dropped into a Persian sentence. That is bidirectional layout, and Pretext handles it inside prepare() so your wrap code never has to care which way the script runs.
Drag the width below. The Persian couplet reflows to the right edge; the mixed paragraph keeps each run in its natural direction while the line itself still wraps cleanly.
بشنو این نی چون شکایت میکند، از جداییها حکایت میکند. کز نیستان تا مرا ببریدهاند، از نفیرم مرد و زن نالیدهاند.
The Arabic word for heart — القلب — shares a root with the verb to turn, قلب, as if the organ were named for what attention keeps doing. When you wrap this line at any width, the Arabic run stays in its own direction and the surrounding English stays in its.
Mechanism
Inside prepare(), Pretext runs a simplified bidirectional algorithm derived from pdf.js. It classifies every codepoint into a bidi type — strong L, strong R, Arabic letter, Arabic number, European number, neutrals — and resolves an embedding level for every run. The segmenter and the canvas measurer then work on runs whose direction is already known.
Line breaking itself doesn't read those levels; the browser already reorders glyphs at render time the same way it always would. What Pretext earns by doing the bidi pass is correctness: Arabic no-space punctuation clusters stay glued to their word, Arabic marks stay attached to the base letter they modify, and width measurements come back right the first time instead of inflated by a stray neutral stuck on the wrong side of a run.
The consequence for you: RTL text is not a special case. One prepare() call, one layout() call, identical return type. You point at the paragraph, you get the height and line count, and the browser's renderer handles the mirror.
Application
Most web apps that ship "also in Arabic" discover that bidi is where their layout plans quietly fall apart. Line-break positions look reasonable but the last-line punctuation wraps alone; a virtualized list's cached heights are off because the ellipsis at end-of-run was measured in the wrong direction; a chat bubble grows by one line the second time it renders. Each of those is a measurement bug, not a rendering bug.
- A multilingual CMS where an English headline and an Arabic headline share the same card component — same
prepare()/layout()code path, same tight fit. - A chat app whose Hebrew messages pre-size correctly on first render, without a reflow pass that nudges the scroll.
- Editorial prose that drops a Persian phrase inline — the line wraps where you'd expect, the Persian glyphs sit in their own direction, nothing re-ladders when the window resizes.
- Search snippets that mix RTL and LTR scripts and still return a correct
maxLineWidthfor shrinkwrap.
"Listen to the reed how it tells a tale, complaining of separations — saying, 'Ever since I was parted from the reed-bed, my lament hath caused man and woman to moan.'"
Jalāl al-Dīn Rūmī, Masnavi-ye Ma'navi, opening lines (13th c.); trans. Reynold A. Nicholson (1926)Direct Claude
prepare() — no special-case code
"mixed scripts inline"
→
bidi preserves reading order inside a single prepare()/layout()
"same card component for English and Arabic"
→
reuse the height from layout(); pair with dir="rtl" on the container
"Hebrew UI that pre-sizes on first render"
→
call setLocale('he') at boot, then prepare() as usual
import { prepare, layout, setLocale } from '@chenglou/pretext';
// Optional: set the word-segmenter locale for the paragraph's primary script.
setLocale('fa');
// Persian. Pretext runs the pdf.js-derived bidi pass inside prepare().
const rtlHandle = prepare(
"بشنو این نی چون شکایت میکند، از جداییها حکایت میکند.",
"400 22px 'Noto Naskh Arabic', serif"
);
// Mixed English + Arabic. Same function, same return type.
const mixedHandle = prepare(
"The Arabic word for heart — القلب — shares a root with قلب.",
"400 21px 'EB Garamond', serif"
);
// Both lay out the same way. No RTL branch in the consumer.
widthSlider.addEventListener('input', (e) => {
const width = parseFloat(e.target.value);
const { height: hR } = layout(rtlHandle, width, 22 * 1.7);
const { height: hM } = layout(mixedHandle, width, 21 * 1.55);
// Use hR / hM as container heights — the browser handles glyph order at paint.
});