Unicode bidi control characters explained
Learn how invisible bidi marks, isolates and overrides affect strings, how to inspect them, and how to test formatted output without deleting meaning.
By Omran Khleifat, Founder of Ritla
Guide13 min read
A temperature reading can look like -37.6 in a test report and still fail comparison with the JavaScript string "-37.6". The missing difference may be an invisible directional mark inserted by the number formatter. Changing the font or reversing the string will not explain that failure. Inspecting its code points will.
Unicode bidi controls are characters stored in the string that influence bidirectional display. Some supply a strong directional context; others open and close isolated ranges, embeddings or overrides. They can arrive through formatters, pasted content, translation resources or a plain-text rendering layer that deliberately inserts them.
This guide follows an observatory dashboard that displays signed sensor readings and exports observation summaries. The useful questions are where a control entered the string, whether it belongs there, and what contract the test or destination actually requires. Invisible does not mean accidental, and intentional formatting does not make every control appropriate in every field.
Reproduce the string difference before changing the display#
Start with an explicit formatter configuration rather than relying on the runtime's default locale:
const temperatureFormat = new Intl.NumberFormat("ar-EG", { numberingSystem: "latn", useGrouping: false, maximumFractionDigits: 1}); const displayed = temperatureFormat.format(-37.6);console.log(displayed === "-37.6");console.log(Array.from(displayed, character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`));In the runtime used to verify this example, Node.js 24.16.0 with ICU 78.3, equality is false. The code-point list is:
U+200E U+002D U+0033 U+0037 U+002E U+0036The first character is LEFT-TO-RIGHT MARK. Written with a visible JavaScript escape, the result is "\u200E-37.6". That observation is specific to this formatter configuration and runtime. Do not hard-code it as the universal output for Arabic negative numbers.
Locale data and implementations can change the exact formatting. Other configurations may produce different controls, digits, spaces or signs. MDN's NumberFormat format documentation explicitly warns that output can contain bidirectional controls and should not be compared with hard-coded constants.
The mark is not an extra visible unit or a different numeric measurement. It supplies directional context for presentation. Store the sensor value as the number -37.6; format it when producing display text. Parsing the localized display string back into the measurement creates an unnecessary dependency on the formatter's presentation choices.
Recognize the controls by family#
Use names and code points in diagnostics. A screenshot of an invisible character cannot identify it, and the abbreviation alone can hide whether the character starts a range or ends one.
| Abbreviation | Code point | Unicode name | Role |
|---|---|---|---|
| LRM | U+200E | LEFT-TO-RIGHT MARK | Strong LTR context, no paired closer |
| RLM | U+200F | RIGHT-TO-LEFT MARK | Strong RTL context, no paired closer |
| ALM | U+061C | ARABIC LETTER MARK | Strong Arabic directional context, no paired closer |
| LRI | U+2066 | LEFT-TO-RIGHT ISOLATE | Opens an LTR isolate |
| RLI | U+2067 | RIGHT-TO-LEFT ISOLATE | Opens an RTL isolate |
| FSI | U+2068 | FIRST STRONG ISOLATE | Opens an isolate whose direction is inferred |
| PDI | U+2069 | POP DIRECTIONAL ISOLATE | Closes the matching isolate |
| LRE | U+202A | LEFT-TO-RIGHT EMBEDDING | Opens an LTR embedding |
| RLE | U+202B | RIGHT-TO-LEFT EMBEDDING | Opens an RTL embedding |
| LRO | U+202D | LEFT-TO-RIGHT OVERRIDE | Opens an LTR override |
| RLO | U+202E | RIGHT-TO-LEFT OVERRIDE | Opens an RTL override |
| U+202C | POP DIRECTIONAL FORMATTING | Closes an embedding or override |
These are the characters with the Unicode Bidi_Control property, listed in the Unicode Character Database property file. The property is more precise than a label such as “invisible Unicode.”
Ordinary Arabic letters are not controls. Neither are all zero-width characters bidi controls: joiners, non-joiners and other format characters can serve different purposes. A cleanup rule that removes every invisible-looking character is a much broader text transformation than removing this particular property.
For the algorithm behind these roles, see the Unicode Bidirectional Algorithm. For the dashboard, the immediate distinction is between supplying context, establishing a boundary and forcing order.
Marks supply context without opening a range#
LRM, RLM and ALM behave as strong directional characters for bidi processing, despite having no visible glyph. They do not bracket a value, so there is no closing LRM or closing RLM to find.
Their effect depends on position. A mark before a sign or beside punctuation changes the context available to the algorithm at that point. Adding a mark at both ends of every string is not equivalent to isolating that string from its surroundings.
ALM and RLM are also not interchangeable spellings of RTL. ALM has the Arabic bidi class, while RLM has the right-to-left class. That distinction can affect the treatment of nearby Western digits. If a formatter supplies ALM, do not replace it with RLM merely because both are invisible and point in the same broad direction.
The observatory formatter example produced LRM, not ALM. Let inspection establish which character exists in a failing sample. Inferring the mark from the locale name would have misdiagnosed that case.
Single marks are useful for particular contextual repairs, but they do not express the full scope of an arbitrary inserted title. When a plain-text summary combines independent fields, a paired isolate is generally easier to reason about than an accumulation of marks chosen around today's punctuation. W3C's Unicode control guidance explains the difference between marks and delimited ranges.
Use paired isolates at plain-text presentation boundaries#
The observation summary export is plain text. It includes a reference with a defined LTR format inside an Arabic sentence. The reader should see its groups in their recorded order, rather than interpreting them as a right-to-left range.
This constructs the export without isolating the reference:
const observationRef = "5082971-JQ";const summary = `مرجع الرصد: ${observationRef} جاهز للتنزيل`;The reference has a digit-first, Latin-last shape that can reorder after Arabic text. The supplied brief measured that shape in Chromium 151 on 2026-09-24. Verify the actual export in its destination viewer; those browser measurements do not establish how every text application renders it.
At the export boundary, isolate the known-format reference:
const observationRef = "5082971-JQ";const LRI = "\u2066";const PDI = "\u2069";const summary = `مرجع الرصد: ${LRI}${observationRef}${PDI} جاهز للتنزيل`;The opener precedes the complete reference, and the closer follows it. The label and surrounding sentence remain outside. Keep observationRef itself free of presentation controls so lookup and copying can use the authoritative value.
For a plain-text observation title of unknown direction, FSI followed by the title and PDI supplies an automatic isolate. FSI uses the first eligible strong directional character, not a majority-language vote. An initial Latin project name can therefore select LTR even if Arabic follows.
That heuristic has a limit: digit-only or punctuation-only content supplies no strong letter. If a field's required direction is already defined, choose the explicit isolate matching that contract instead of relying on automatic inference.
Treat this construction as a boundary helper for controlled, single-paragraph values. Arbitrary external text containing controls or paragraph separators requires an input policy before wrapping. A trusted outer pair does not sanitize controls already inside the value.
Match the closer to the opener#
Use PDI for LRI, RLI or FSI. Use PDF for LRE, RLE, LRO or RLO. The PDF abbreviation here names a Unicode formatting character; it is unrelated to a PDF document.
A pair such as LRI followed by content and PDF is not balanced isolation. PDF does not close the isolate. The resulting scope can include more of the surrounding text than the developer intended, until a matching closer or the paragraph boundary is encountered.
For a diagnostic fixture, spell every control visibly in source:
const mismatched = "\u2066" + "5082971-JQ" + "\u202C";const paired = "\u2066" + "5082971-JQ" + "\u2069";These are test strings for inspecting the difference, not alternative production implementations. Confirm the control sequence in each before checking its display inside the complete exported sentence.
Nested isolates need nested closing order, just like nested scopes in markup. Do not validate complex content by counting openers and closers alone: the counts can match while the types or scope relationships are wrong. A simple regular expression that finds bidi controls is an inventory tool, not an implementation of Unicode's matching rules.
Paragraph boundaries also matter. These controls do not establish document-wide direction or a layout hierarchy. If a summary contains several paragraphs, establish the direction and boundaries appropriate to each paragraph. Truncation, concatenation or splitting a string after controls have been inserted can change that structure.
Embeddings and overrides are different operations#
LRE and RLE open embeddings, which still permit directional interaction with surrounding text. They are not isolation controls. When the requirement is to keep an inserted field independent, use isolates rather than choosing an older embedding pair because a copied example happened to use it.
LRO and RLO go further: they force directional treatment of the affected characters instead of preserving normal runs. That can make a Latin filename or an Arabic phrase display in an order the author did not intend. An override is not a stronger general-purpose fix for mixed text.
An imported observation title may contain an override intentionally, accidentally or as a misleading display sequence. Inventory it and review the field's contract. Its presence does not by itself establish intent, but it does mean a screenshot alone is insufficient evidence of the stored text.
Do not repair such a value by reversing its characters in application code. First establish whether the logical string is correct and whether the override belongs in this field. A reversal can corrupt data while making one viewer temporarily appear correct.
For source code, enable editor diagnostics that expose directional controls. Review tools need to show the underlying structure of strings and comments rather than trusting a plain-text visual sequence. Unicode Source Code Handling describes the display and diagnostic issues. That is a reason for visible review evidence, not a reason to reject ordinary Arabic comments.
Make controls visible in debugging output#
Logging a string directly can reproduce its directional effects in the console. JSON.stringify adds quotes and escapes some characters, but does not promise to turn every bidi control into a visible \u escape. Use an explicit code-point representation.
For a quick inspection:
function codePoints(text) { return Array.from(text, character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` ).join(" ");} console.log(codePoints("\u200E-37.6"));To retain the readable text while labelling only bidi controls:
const controlNames = new Map([ [0x061C, "ALM"], [0x200E, "LRM"], [0x200F, "RLM"], [0x202A, "LRE"], [0x202B, "RLE"], [0x202C, "PDF"], [0x202D, "LRO"], [0x202E, "RLO"], [0x2066, "LRI"], [0x2067, "RLI"], [0x2068, "FSI"], [0x2069, "PDI"]]); function exposeBidiControls(text) { return text.replace(/\p{Bidi_Control}/gu, character => { const point = character.codePointAt(0); const hex = point.toString(16).toUpperCase().padStart(4, "0"); return `[${controlNames.get(point)} U+${hex}]`; });} console.log(exposeBidiControls("\u200E-37.6"));The final line prints [LRM U+200E]-37.6. This helper is for diagnostics; keep its labelled output separate from text intended for users. It makes the character identity visible but does not validate balanced ranges or prove that the remaining text displays correctly.
Inspect the same value at successive boundaries: raw API data, formatted output, assembled summary and export payload. The first stage whose code-point sequence changes identifies where to investigate. A control in the final file does not prove it was present in the original sensor record.
Distinguish characters from their escaped representation#
An API response, a debugger and a source file can show the same character differently. A visible escape is not evidence of corruption, and a string that looks empty is not evidence that it contains nothing.
Compare these two JavaScript values:
const actualMark = "\u200F";const escapeText = "\\u200F"; console.log(actualMark.length); // 1console.log(escapeText.length); // 6console.log(actualMark === escapeText); // falseThe first contains RLM. The second contains a backslash followed by five ordinary characters. Sending the second to a text node prints the escape spelling rather than supplying directional context.
JSON adds a serialization layer. A JSON document can represent a control using an escape that the JSON parser decodes into the character. That is different from storing the six-character escape spelling in the parsed value. When investigating an export, inspect the decoded field as well as the serialized payload.
This round trip preserves the character:
const original = { reading: "\u200E-37.6" };const serialized = JSON.stringify(original);const restored = JSON.parse(serialized); console.assert(restored.reading === original.reading);A serializer's choice to show or escape a code point does not, by itself, change the decoded string. Avoid manually replacing escape spellings in an already parsed object; that can decode text twice or alter content that was meant to show an escape literally.
For the observatory, inspect the sensor display field after parsing the API response, then inspect it again after constructing the observation summary. If the character was already present in the decoded field, the browser's HTML boundary did not insert it into that field. If it appears only in the summary, investigate the summary builder and its formatter.
Keep diagnostic transformations one-way. The labelled output from exposeBidiControls() is evidence for an engineer to read. Do not pass it back through a generic “unescape” function and use the result as production data. A diagnostic format is not a storage schema.
Preserve literal formatter parts#
formatToParts() is useful when the observatory wants different styling for a sign, integer and fraction. It is not an invitation to discard every part whose type is literal.
Using the formatter from the opening example:
const parts = temperatureFormat.formatToParts(-37.6);console.table(parts.map(part => ({ type: part.type, codePoints: codePoints(part.value)}))); const reconstructed = parts.map(part => part.value).join("");console.assert(reconstructed === temperatureFormat.format(-37.6));In the verified runtime, the first part is a literal containing LRM, followed by the minus sign, integer, decimal and fraction parts. Filtering to digits and signs would silently change the formatter's output. Literal parts can also contain spaces or other separators needed by a format.
When styling parts in HTML, preserve their values and order while creating text nodes or escaped template content. Check the reconstructed string separately from its appearance inside the Arabic component. DOM wrappers can introduce their own direction behavior even when concatenating the part values produces the original string.
MDN's formatToParts reference documents the part types and how the pieces represent the formatted output. If an export needs machine-readable measurements, export the underlying numbers rather than a selectively reconstructed display string.
Choose a cleanup policy by field#
There is no useful application-wide rule saying all bidi controls are either valid or corrupt. The formatted sensor reading has a presentation requirement. An observation reference used as a lookup key has a different contract. A free-text observer note has another.
For known machine identifiers, validate against the identifier's actual grammar. If controls are forbidden, reject or explicitly correct the affected input at the agreed boundary. Silently stripping them before matching can make two distinct submitted strings collapse into the same key without explaining the change.
For localized display strings, preserve formatter output unless a specific destination requires a documented transformation. If a downstream system needs ASCII numeric data, provide a machine-readable field. Removing bidi controls alone does not also translate Arabic-Indic digits, decimal separators or grouping conventions.
For free-text notes, inspect unexpected controls and decide which are allowed based on the destination. Preserve the original when investigating an import. If a derived search field ignores particular controls, keep that operation distinct from the text displayed to readers.
Do not use broad ranges such as “delete all format characters” as an Arabic text repair. That reaches beyond Bidi_Control and can remove characters serving other writing functions. Even a narrowly targeted removal needs a reason, because it can undo deliberate punctuation handling.
Normalization is not a bidi sanitizer. In the verified formatter example, NFC and NFKC both retain the LRM, and trim() retains it too. Normalization addresses defined Unicode equivalences, as described in MDN's normalize reference. If a test requires a particular comparison policy, name and implement that policy explicitly.
Prefer markup when the destination accepts HTML#
The plain-text summary needs characters to carry its inline boundary because it cannot contain elements. The dashboard's HTML does not share that constraint:
<p dir="rtl">مرجع الرصد: <span dir="ltr">5082971-JQ</span></p>This keeps directional structure in markup and the reference in its own text value. For an unknown-direction observation title, use bdi or an existing element with dir="auto". W3C's inline markup guidance covers that approach.
An HTML attribute that contains text is different from HTML child content. A native tooltip's title string cannot contain a working bdi element; it can require plain-text controls around an inserted phrase. Keep that construction in the tooltip-rendering path rather than changing the stored observation title.
Escaping also depends on the destination. In a JavaScript string literal, "\u2066" produces LRI. Assigning "⁦" through textContent produces the literal character-reference spelling, not an isolate. An HTML parser can decode that numeric reference in HTML source. Do not mix these serialization layers when generating exports or attributes.
Test characters, display and transport separately#
A useful regression set includes the signed sensor reading, a paired reference isolate, a mismatched closer and an imported title containing an override. Keep controls escaped in fixture source so a reviewer can see exactly what the test contains.
| Test layer | What to establish |
|---|---|
| Raw data | The measurement stays numeric and the reference matches its defined grammar |
| Formatter | Options are explicit and diagnostic output reveals any controls |
| Part reconstruction | Joining every part value reproduces the formatter output |
| Plain-text construction | The intended field has the correct opener and closer |
| Visual rendering | Signs, punctuation and field order match the reading requirement |
| Copy and export | The destination receives the intended string, including any deliberate controls |
For formatter tests, avoid a manually typed expected string that merely resembles the display. Test the numeric source and options independently; use formatToParts() to inspect structure. If exact output is part of your product contract, pin the relevant runtime and locale-data assumptions and include escaped diagnostics in failures.
Keep visual tests in the full sentence. Replacing surrounding Arabic with English or showing the value alone can remove the context that exposed the defect. For tooltips and exported summaries, test the real destination viewer as well as the browser page.
Make failure messages expose both expected and actual control sequences. Two ordinary console strings can look identical even when the assertion correctly reports inequality. Include the readable text with control labels and the code-point list so the person investigating can identify whether the difference is an added mark, a missing closer or a different sign.
Include a fixture that contains no bidi controls. A detector that reports Arabic letters themselves, or treats the visible spelling \\u200F as an actual RLM, is testing the wrong thing. Also include a deliberate control from the formatter path, so a blanket assertion banning the property cannot silently replace the field-specific policy.
Exercise truncation and copy actions deliberately. If an export truncates a value, do so before adding its trusted outer pair, then verify that the completed output still has the intended scope. Decide whether a reference-copy action returns the raw identifier while a whole-summary copy returns formatted prose, and test each contract.
A free Ritla scan can report , punctuation rendering at the wrong visual end of Arabic text. Pair that visual finding with code-point inspection of the affected string, then continue with Arabic and English mixed text in HTML for markup boundaries or how browsers render mixed RTL and LTR text for the surrounding bidi context.