back

Unicode//6 min

String Interpolation Has a Direction

A token symbol written in Hebrew turned one of our push notifications back to front. The template was correct and the bytes came out in the order we wrote them.

Alpha Engineering

Here is a push notification our backend sent, redrawn the way it landed on the lock screen:

is trending on Alpha Radar מלו 🐋

And here is the template that produced it:

format!("🐋 {} is trending on Alpha Radar", symbol)

The template is fine. The bytes came out of format! in the order they went in. What differs is the order the device drew them in.

Paragraph direction comes from the first strong character

UAX #9, the Unicode Bidirectional Algorithm, describes how text mixing left-to-right and right-to-left scripts gets laid out. Rules P2 and P3 settle the top-level question of which way the paragraph itself runs, and they are blunt about it. Scan from the start of the text, find the first strong directional character, take its direction.

Strong is doing a lot of work in that sentence. Latin letters are strong LTR. Hebrew, Arabic, Persian, Urdu, Yiddish, Syriac and Thaana letters are strong RTL. Very little else counts for anything. Digits are weak. Punctuation, whitespace, currency symbols and emoji are all neutral, and neutrals never establish direction.

So walk the template the way the algorithm does. It starts on 🐋, neutral, keeps going. A space, also neutral. Then it reaches the interpolated value, and if the symbol happens to begin with a Hebrew letter the scan stops right there on a strong RTL character. The paragraph is now RTL. Every English word after it is still English and each one still draws left to right, but their order along the line is reversed.

It looks like a concatenation bug

The emoji has moved to the far right and the English clause has moved left, so the first guess is that something built the pieces in the wrong order.

That guess costs you an afternoon in the string builder. The format! is right. Logging the bytes shows them exactly as expected. Nothing in the pipeline is reordering anything, because the reordering is not happening in the pipeline. It happens at draw time, on a device you are not attached to, from bytes that were correct when they left.

What separates the two is what the broken output looks like. A concatenation bug usually produces garbage, or the same wrong order for every input. This produces a grammatical sentence with its clauses in reverse, for some inputs and not others.

Token symbols come from the chain

For most products this sits in a localization path and shows up rarely. For us the interpolated value is a token symbol, and token symbols come from chain metadata, which means anyone who can deploy a token decides what goes in that slot. A single RTL character anywhere in the value does it, including one buried in the middle of an otherwise Latin name.

Which makes it a category rather than an incident. Notification titles and bodies, agent transcripts, plan step copy, anything shaped like "{symbol} is up 12%". All of them mix hardcoded English with a value we do not control, and none of them had been looked at as a text-direction decision, because nobody had thought of them as making one.

FSI and PDI

There are several Unicode mechanisms in this area and most of them are the wrong reach.

U+200E LEFT-TO-RIGHT MARK is a strong LTR character with no width. Put one at the front of a line and the paragraph resolves LTR. We do use it, on iOS, but it asserts something about the paragraph rather than about the value, and it does nothing for a value sitting in the middle of a sentence.

U+202A LRE and the rest of the embedding controls are deprecated. They also do not isolate, so surrounding text can still get pulled into the embedding.

That leaves the isolate pair added in Unicode 6.3: U+2068 FIRST STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE. Content between them resolves its own direction from its own first strong character, and the surrounding paragraph treats the whole span as one neutral object. Hebrew inside the brackets still renders right to left. It just stops getting a vote on the sentence around it.

shared/common/src/bidi.rs
pub const FSI: char = '\u{2068}';
pub const PDI: char = '\u{2069}';
 
pub fn isolate(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 6);
    out.push(FSI);
    for c in s.chars() {
        if !is_bidi_format(c) {
            out.push(c);
        }
    }
    out.push(PDI);
    out
}

Every compose site that mixes external text with hardcoded copy now runs through it:

format!("🐋 {} is trending on Alpha Radar", isolate(symbol))

The wrap has to sanitize its own input

That loop does two things, and the filter half matters as much as the brackets.

Say a token symbol contains a literal U+2069 PDI. It closes our isolate early, and everything after it in the symbol is outside the brackets and voting on paragraph direction again. Same bug, now reachable deliberately by anyone who can name a token. The override controls are worse. U+202E RIGHT-TO-LEFT OVERRIDE is the character behind the old filename-spoofing trick, and there is no reason for it to appear in a ticker.

So isolate strips the whole bidi format set before it wraps: LRM and RLM, the U+202A..U+202E embedding and override controls, and the U+2066..U+2069 isolate controls.

shared/common/src/bidi.rs
fn is_bidi_format(c: char) -> bool {
    matches!(
        c,
        '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
    )
}

Where to wrap, and where not to

Wrapping belongs at compose time and nowhere near storage. The isolators are real characters that change the bytes, so they change equality, hashes, sort order and dedup keys along with them. Anything that has been through isolate is display output. It never gets persisted, compared, or used to build a cache key.

The other half is knowing when to leave it alone. A $BTC chip rendered on its own is a self-contained run, and bidi already handles it correctly, right to left where that is right. Brackets there are noise. The wrap is for a value embedded in a phrase whose other words you wrote yourself.

The iOS side

Some strings reach the client already composed, so iOS guards at the render boundary. That is a paragraph-level assertion, which puts LRM back on the table:

Alpha/Utilities/BidiIsolation.swift
extension String {
    static let bidiLeftToRightMark: Character = "\u{200E}"
 
    var bidiLTRBaseDirection: String {
        guard !isEmpty else { return self }
        if first == String.bidiLeftToRightMark { return self }
        return "\(String.bidiLeftToRightMark)\(self)"
    }
}

It only covers the first paragraph. Bidi resolution restarts at every newline, so a multi-line body with RTL content on the third line will still flip that line. Every notification body we send today happens to be a single line, which is the only reason one mark is enough. That is a property of the current copy, not of the code, so it is written down in the module docs rather than left for whoever adds the first two-line body to discover.

What the rule is now

Interpolating a value you do not control into copy you wrote hands that value a say in how the result is laid out, unless you take it away. On our side that is a function call at the compose site and a mark at the iOS render boundary.

The part we have not solved is the multi-line case. When a notification body first needs two lines, bidiLTRBaseDirection has to split on \n and mark each line, and there is nothing in the type system that will remind us.

New posts when we ship something worth explaining.

No schedule, no newsletter filler.