
Bidi bugs are not rendering bugs. They are ordering bugs, and they only appear when a line mixes scripts. A Hebrew invoice line with an account number in parentheses can look correct in the database, correct in the JSON, and wrong on screen — and the reason is that Unicode resolves visual order with a four-pass algorithm that almost no application implements by hand. This guide walks the pipeline rule family by rule family, using the two worked examples embedded in the explainer tool, so the next time a bracket lands on the wrong side you can name the rule that put it there.
You paste text into a database in logical order: the order a reader types it. The screen then shows visual order, left to right or right to left. Between those two orders sits the Unicode Bidirectional Algorithm, published as UAX #9. It is not a library you call; it is a contract that every text stack in the world agrees to follow, and it is long enough that almost everyone who debugs it is really debugging one specific rule.
The Pipeline: Four Passes Over One Line
UAX #9 runs in four stages, and each stage changes the data the next stage sees. First, rules P2 and P3 pick the paragraph embedding level from the first strong character, unless the direction is forced. Second, rules X1 through X8 walk the explicit formatting characters and push, pop, and record embedding levels on a directional status stack. Third, the weak and neutral rules resolve types: W1 through W7 for weak types, BD16 plus N0 for paired brackets, N1 and N2 for the leftovers. Fourth, rules I1 and I2 assign the final levels, and rules L1, L2, and L4 reorder the line and mirror glyphs.
The ordering matters more than the individual rules. A bracket resolved before you know the embedding direction on either side of it will be typed wrongly, which is exactly what rule N0 exists to prevent. You can watch each of these stages in the walkthrough output at Elysia Tools.
Explicit Embeddings Build a Directional Status Stack
The X pass is the part developers meet first. Characters like RLE, LRE, RLO, LRO, PDF and the newer isolates LRI, RLI, FSI, PDI are the only characters in the text that actually change direction; everything else just inherits. The parser keeps a stack, pushes a new entry when it meets an initiator, and pops when it meets a terminator, with a hard maximum depth of 125. Exceeding that depth is defined behaviour, not an error: the algorithm stops pushing and keeps rendering.

Two tokens are worth internalising. <RLI> opens a right-to-left isolate and <PDI> closes it. Unlike the older embedding controls, an isolate also acts as a neutral boundary for the surrounding text, which is why it produces a cleaner result in most real content. You can enter these as named tokens, or as backslash-u four-hex-digit escapes such as \u2067 for an RLI, and the tool reports the stack after every character.
Isolating Run Sequences Are the Unit of Resolution
After X, the line is cut into isolating run sequences. A run sequence is a maximal stretch of characters at one embedding level, bounded by isolates or by the paragraph edges, and it always resolves against exactly two synthetic strong characters: sos at the start and eos at the end. Those two synthetic types are what let the weak rules work locally instead of leaking direction across an isolate boundary.
This is the single most useful mental model in the whole specification. Everything downstream — W1, N1, the bracket rules — operates per sequence, never per paragraph. A line containing an isolate typically splits into two sequences, and each one can end up at a different final level, which is why two reversal passes show up in the output for a short example.
Weak Types Resolve Against Their Neighbours
The W rules convert weak types into strong ones by looking at the surrounding text. W1 turns a European or Arabic number that follows an Arabic letter into an Arabic number. W2 changes the European number after an Arabic letter. W3 turns European separators into common separators. W4 resolves single common separators between two numbers of the same type into that type. W5 runs a sequence of European terminators adjacent to a European number into that number. W6 turns the rest of the separators and terminators into common types. W7 turns European numbers into Arabic numbers when the preceding strong type is Arabic.
The practical consequence is that a trailing period after a Latin account number stays Latin, while the same period after a Hebrew-typed number becomes Arabic and is placed by the neutral rules instead. Since a number can carry an implicit direction that differs from the paragraph, digits are one of the most common sources of the wrong-sided punctuation you see in bilingual invoices.
Paired Brackets Follow BD16 Before Neutrals
This is the rule most implementations skip, and skipping it is what produces the classic bug: a parenthesis that should hug the text it encloses but lands on the far side of the line. Rule BD16 builds a list of bracket pairs from the text using the Unicode Bidi_Paired_Bracket property, limited to 63 elements. Rule N0 then looks at each pair and asks whether the text inside it contains a strong type that matches or opposes the surrounding embedding direction.

There are four clauses. N0a applies if a bracket pair contains no strong type at all, leaving both brackets as neutral. N0b examines the strong type enclosed inside the pair. N0c1 tests the preceding context, and N0c2 tests the following context. If either context matches the embedding direction, the brackets are set to that direction. N0d is the last resort, leaving the brackets for the neutral rules. Once typed, rule L4 mirrors the glyphs so a left parenthesis still reads as a left parenthesis on screen.
Neutrals N1 and N2 Close Out the Line
Once the bracket pairs have been typed, only the neutrals are unresolved: spaces, tabs, and the punctuation that sits between two strong types. Rule N1 resolves a neutral sequence sitting between two strong types of the same direction by taking that direction, and leaves it alone when the two neighbours disagree. Rule N2 then resolves everything left over, including the runs at the start and end of each isolating run sequence, by falling back to the embedding direction.
The N rules are where paragraph edges get decided. A trailing period on a Hebrew-first paragraph is a neutral with strong Hebrew on one side and the synthetic eos on the other, so N2 hands it to the paragraph direction and it renders on the left. Switch the paragraph direction to LTR and the same character flips sides, which is why the direction option changes the answer for an identical input.
Implicit Levels, Reversal Passes, and Mirroring
Rule I1 raises the level of every right-to-left character by one; I2 raises runs of European or Arabic numbers and their trailing separators by two. Rule L1 resets the line, isolates any sequence of whitespace or isolate formatting characters at the paragraph edges, and restores the trailing whitespace. Rule L2 then reverses contiguous runs at each level from the highest down to the lowest odd level, which is why a mixed line needs several passes.
The reversal is the step people find unintuitive. Because the pass runs highest level first and the highest level is the innermost isolate, the inner content is already flipped when the outer pass runs. Mirroring is a separate and much smaller step: only characters at odd levels with an odd mirrored variant, and in practice only the bracket glyphs, get swapped. If you are reproducing this by hand, mirroring too much is a common bug; if you are debugging a missing swap, check the level before you check the font.
Two Worked Examples, Rule by Rule
The first example wraps Latin text and a Hebrew run inside an RLI isolate, inside an otherwise left-to-right paragraph. P2 finds no strong character before the isolate, so the paragraph level is 0. The isolate splits the line into two sequences, so sequence count is 2. The Latin words inside the isolate rise to level 2 while the Hebrew run stays at level 1, and two reversal passes, at level 2 then level 1, produce a visual line where the period sits on the far left and the Hebrew is flipped around the Latin: .def, then the reversed Hebrew, then abc.

The second example is an RTL paragraph, level 1, with brackets enclosing only Latin strong characters. N0 finds the pair, sees a strong type that opposes the embedding direction inside, scans back to the preceding Hebrew, finds that it matches rather than opposes the embedding, and applies N0c2 to type both brackets as R. L4 then mirrors the glyphs so the parentheses still read correctly in the visual line. Both examples are shipped with assertions on the paragraph level, sequence count, reversal pass count, bracket pair count, and the final display string, so you can check your own implementation against exact expected values rather than by eye.
If you need the visual result without the walkthrough, Mirror Text reverses a line for quick inspection, and for documents the question shifts from characters to fonts and embedding, which PDF RTL/LTR Support Tester checks separately.
Stop blaming the renderer. When a bilingual line comes out wrong, the defect is almost always one rule, and the rule is almost always BD16, N0, or the L2 pass order. Paste the logical-order string into the bidi rule explainer, read the level and type tables, and the wrong glyph names itself. More tools for text and encoding work live under elysiatools.com.