Text Reversal and Bidirectional Unicode

May 28, 20266 min read

Reversing a string sounds trivial — swap characters from both ends until you reach the middle. But the moment your text contains emojis, combining marks, or right-to-left scripts, the simple approach breaks. What you see as a single character on screen might be multiple code points under the hood, and naïve reversal scrambles them into gibberish.

The Problem with Naïve Reversal

Consider this string: "noe\u0308l" — the word "noël" written with a combining diaeresis on the e. It contains five code points:

IndexCode PointCharacter
0U+006En
1U+006Fo
2U+0065e
3U+0308◌̈ (combining diaeresis)
4U+006Cl

A naïve reversal that operates on code points produces "l\u0308eon" — the combining mark now attaches to the l instead of the e, rendering as "l̈eon". The diaeresis hopped to the wrong letter because the algorithm did not respect grapheme boundaries.

Grapheme Clusters

A grapheme cluster is the smallest unit of text that a user perceives as a single character. It may consist of:

  • A base character (e.g., e)
  • Zero or more combining marks (e.g., diaeresis, accent, cedilla)
  • Zero or more variation selectors or joiners

The family emoji 👨‍👩‍👧‍👦 illustrates this dramatically. It is a single grapheme cluster composed of seven code points:

U+1F468  👨  Man
U+200D        Zero-Width Joiner
U+1F469  👩  Woman
U+200D        Zero-Width Joiner
U+1F467  👧  Girl
U+200D        Zero-Width Joiner
U+1F466  👦  Boy

Reversing these code points individually splits the joiner sequences, producing a string of disconnected emoji that no longer renders as a family.

Proper Reversal with Grapheme Clusters

To reverse text correctly, you must segment it into grapheme clusters first, then reverse the cluster order:

function reverseGraphemes(str) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  const clusters = [...segmenter.segment(str)].map(s => s.segment);
  return clusters.reverse().join('');
}
import unicodedata

def reverse_graphemes(s):
    clusters = []
    current = ''
    for char in s:
        if unicodedata.combining(char) and current:
            current += char
        else:
            if current:
                clusters.append(current)
            current = char
    if current:
        clusters.append(current)
    return ''.join(reversed(clusters))

Note that the Python version above is a simplified heuristic. Full grapheme segmentation requires following the Unicode Text Segmentation algorithm (UAX #29), which handles emoji ZWJ sequences, regional indicators, and Hangul jamo. Libraries like grapheme (Python) or graphemer (JavaScript) implement this correctly.

Bidirectional Text and Reversal

Right-to-left (RTL) scripts like Arabic and Hebrew add another layer of complexity. The Unicode Bidirectional Algorithm (UAX #9) determines the display order of mixed-direction text.

Consider: "Hello و globalization". The word و (Arabic "and") is RTL. The visual display order differs from the logical storage order:

Logical order:  H e l l o   و   g l o b a l i z a t i o n
Display order:  H e l l o   و   n o i t a z i l a b o l g

Reversing the logical code points reverses the storage order, but the bidirectional algorithm then re-applies, producing unexpected visual output. The relationship between logical reversal and visual reversal is not obvious and depends on the surrounding text direction.

Surrogate Pairs in UTF-16

JavaScript and Java store strings as UTF-16 code units. Characters outside the BMP (like most emoji) are encoded as surrogate pairs — two code units representing one code point.

const emoji = '😊';
console.log(emoji.length);          // 2 (surrogate pair)
console.log([...emoji].length);     // 1 (code point via spread)

Reversing at the code unit level splits surrogate pairs, producing invalid UTF-16 and rendering as replacement characters (�). Always iterate by code point or grapheme cluster, never by code unit.

Comparison of Reversal Granularities

LevelPreservesBreaks
Code unit (UTF-16)ASCIISurrogate pairs, combining marks, emoji
Code pointBMP charactersCombining marks, ZWJ sequences
Grapheme clusterCombining marks, emoji, ZWJ sequencesRTL visual order, edge cases

For most practical purposes, grapheme-cluster reversal is the correct granularity.

Key Takeaways

  • Naïve code-point reversal breaks combining marks, separating diaereses from their base letters.
  • Emoji with ZWJ sequences (family, skin tones) are multi-code-point grapheme clusters that must not be split.
  • Use Intl.Segmenter (JavaScript) or a grapheme library (Python) to segment text before reversing.
  • RTL scripts interact with reversal in non-obvious ways because the bidirectional algorithm re-applies to the reversed logical order.
  • UTF-16 code-unit reversal is always wrong for text containing emoji or other supplementary characters.

Try It Yourself

Curious how reversal handles emoji and accented characters? Test any string in the Text Reverser and see the difference proper Unicode handling makes.