Arabic letters stop joining the moment you split the word
If you have ever rendered Arabic text in a browser and found the letters sitting apart like beads on a string instead of joined into a word, there is a good chance nothing is wrong with your font, your encoding, or your dir attribute. The chance is that something in your code split the string.
I hit this building right-to-left support for ReadKinetic, a speed reader that shows one word at a time. This post is what I measured, including the fix that is recommended everywhere and does not work.
Why splitting is the natural thing to do
Speed readers of the RSVP kind position each word by its optimal recognition point — a letter slightly left of centre that your eye should land on. The word is placed so that letter sits on a fixed mark, and because the mark never moves, your eye never has to.
The obvious implementation splits the word into three: everything before the pivot, the pivot, everything after. You right-align the first piece against the mark, put the pivot on it, left-align the last piece after it. You get exact positioning and a coloured pivot letter for free, because it is now its own element.
In Latin this is invisible. read cut into re, a, d renders identically to read.
In Arabic it is ruinous
Arabic letters are contextual. Most have four forms — isolated, initial, medial, final — and which one the shaper draws depends on the letters beside it. The letter ه is ه alone, هـ at the start, ـهـ in the middle, and ـه at the end. Same character, same code point, four shapes.
When you split a word into separate elements, each fragment is shaped on its own. The shaper has no idea the other pieces exist. A letter that should be medial is now the last letter of its fragment, so it is drawn in its final form, and the next fragment starts a new word as far as the shaper is concerned.
The word does not just look slightly off. It comes apart.
What it costs, measured
I took المكتبة (al-maktaba, “the library”), rendered it whole, then rendered it as three fragments split at the pivot, and measured both.
The fragments came out 11.8% wider than the same word drawn in one piece.
That extra width is the letters disconnecting. Joined Arabic letters share their connecting strokes; isolated and final forms each carry their own terminal, plus the tail that a connected form does not have. Eleven per cent is the visual difference between a word and a pile of letters.
It is also a good signal to test against, because it does not require you to read the script. If splitting a string changes its rendered width, the shaping changed.
Why this ships and stays shipped
Because it is invisible to the person who wrote it.
A developer who does not read Arabic looks at the output and sees Arabic-looking glyphs in the right order, right-to-left, in a reasonable font. Nothing about it says “broken”. It takes a reader of the script about a quarter of a second, and they will usually screenshot it rather than file a bug, because explaining it is more work than it is worth.
So it survives review, it survives QA, and it ships. This is the general shape of the problem: the bug is silent to everyone empowered to fix it, and obvious to everyone affected by it.
The fix that is recommended everywhere
Search for this and you will be told to use U+200D ZERO WIDTH JOINER. Append it to the fragment before the cut and prepend it to the fragment after, and each fragment believes it has a neighbour, so the shaper draws connected forms.
For measuring, the same trick is supposed to let you ask “how wide is the first N letters of this word, as they will actually be drawn” by measuring the slice with a joiner stuck on the end.
I implemented it. Then I measured whether it had done anything.
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = '48px "Geeza Pro", "Noto Naskh Arabic", serif';
const ZWJ = String.fromCharCode(0x200D); // U+200D, the recommended fix
ctx.measureText('المك').width; // 44.1
ctx.measureText('المك' + ZWJ).width; // 44.1
Identical. To the tenth of a pixel, with and without the joiner. Canvas measureText was giving me the same number either way, which meant the joiner was not changing the shaping — it was being ignored.
And 44.1 is wrong. Measured in the DOM, where the browser shapes the complete word and I ask where the pivot actually landed inside it, the answer is 28.1 pixels. The canvas was placing my fixation mark sixteen pixels — more than half a letter — away from where the letter was going to be drawn.
Two failures stacked: the fragments were shaped wrong, and the measurement of them was wrong in the same direction, so nothing disagreed with anything and the whole thing looked like it worked.
Ask the browser instead
The only thing that knows where a letter will be drawn is the thing that draws it. So the word goes into a hidden element, complete and unmodified, and a Range over the pivot character reports where it ended up.
probe.textContent = word; // whole, never sliced
const range = document.createRange();
range.setStart(probe.firstChild, at);
range.setEnd(probe.firstChild, at + pivot.length);
const box = range.getBoundingClientRect();
const whole = probe.getBoundingClientRect();
// distance from the edge the word STARTS at, which in
// a right-to-left word is the right-hand one
const offset = direction === 'rtl'
? whole.right - box.right
: box.left - whole.left;
The shaper sees a complete word, so it shapes it correctly, and the range reports the position of the pivot within that correct shaping. There is no fragment anywhere in this, which is the point.
Ranges force layout, so this is not free the way canvas is, and it is cached per word. In a reader showing fifteen words a second, the same words come round constantly, and the cache hit rate does the rest.
Then move the word instead of cutting it
With the offset known, the word never needs to be split at all. It is drawn whole in one element and translated so the pivot lands on the mark — the shift is the pivot’s own centre, measured from the word’s centre.
The shaper only ever sees a complete word. The reader still gets a fixed fixation point. Measured in the running app, the pivot lands within 0.01px of the fixation mark.
Colouring one letter without splitting the text
The remaining problem is that highlighting the pivot letter is also a split. Wrapping it in a span to colour it puts an element boundary inside the word.
It is worth knowing exactly which boundaries hurt, because they are not all the same. Measured just now in Chrome 148, on المكتبة at 48px:
| what sits inside the word | rendered width |
|---|---|
| nothing — the whole word | 136.28px |
<span> around one letter | 136.28px |
<span style="color:red"> around one letter | 136.28px |
three display:inline-block pieces | 152.31px — 11.8% wider |
So a plain or coloured inline span does not break shaping in current Blink. What breaks it is taking the pieces out of the inline flow — inline-block, a float, a block, or three separate elements positioned by hand, which is exactly what the naive RSVP implementation does.
I still did not want to rest on the span. Browsers have genuinely differed here — W3C ISSUE-358 is the working group arguing about whether cursive scripts should connect across inline boundaries at all, with the note that “browsers differ in their behavior” and Firefox possibly only joining when the font matches. A highlight that silently breaks a word on one engine is precisely the bug this whole exercise is about.
So the word is drawn twice. The lower copy is the word in its normal colour. The upper copy is the same word in the highlight colour, absolutely positioned in the same place, and clipped with clip-path: inset(...) to the pivot’s own column — the left inset being the pivot’s offset, the right inset being everything past it.
Both copies are complete words, shaped independently and identically, so they land on exactly the same pixels. The top one shows through only where the pivot is. One letter is coloured and nothing was ever cut.
What to take from this
If you are rendering Arabic, Persian or Urdu — the three joining scripts I handle — the rule is that a word is atomic. Anything that slices it changes what is drawn. That includes measuring it in slices, which is the version that will get you, because it fails silently and looks like arithmetic.
And do not trust the zero-width joiner without measuring whether it did anything. On canvas, in my testing, it did not.
Hebrew, for what it is worth, is the easy case. It runs right-to-left but its letters do not join, so the ordinary split-at-the-pivot machinery is correct for it with the geometry mirrored.
One honest caveat, which I also put on the Arabic and Hebrew feature page: all of the above is verified by measurement — glyph widths, pivot offsets, page mapping — and not by a person who reads the script. Those are not the same thing. If you read Arabic, Persian, Urdu or Hebrew and something looks wrong, I would genuinely like to hear about it.
Related: Arabic and Hebrew speed reader · Speed reading Japanese, Chinese and Thai · Anticipatory pacing · How a PDF page knows its words