feat(engine): lay out and draw right-to-left text - #536
Conversation
Text is stored in the order it is read and drawn in the order it appears on the page. For Latin those coincide, so the engine never had to tell them apart; for Hebrew and Arabic they do not. TextDirection is the authoring side of that distinction: LTR, RTL, or AUTO, which takes the direction from the first strong character. It is separate from TextAlign because alignment says where a line sits and direction says which way it runs — but they meet in one place, so a right-to-left paragraph aligns right unless the caller chose an alignment. The builder tracks that choice explicitly; a field defaulted to LEFT cannot tell "not chosen" from "chose LEFT", and either reading loses one of the two behaviours. BidiParagraphResolver wraps the Unicode Bidirectional Algorithm for the layout pipeline. It resolves the raw line, before control characters are stripped, because the explicit direction marks (U+200E, U+200F, U+061C) are category Cf and the sanitizer removes them — a resolver running afterwards would never see the instruction the author wrote. A line with no right-to-left character resolves to itself without constructing a Bidi at all, so existing documents take the path they always took. The resolver names its own base direction rather than the public enum, so the engine keeps knowing nothing about the document surface. ParagraphNode gains the direction as a trailing component with the previous canonical signature kept as a delegating constructor, the way verticalAlign and anchor were added. Nothing consumes the direction yet — wrapping and rendering follow.
A span is drawn with one show-text operation, so a line whose direction changes partway cannot be reordered by moving spans around — a single span holding "שלום Hello" draws in logical order however the renderer walks it. The plain-text path built exactly that: one span per line. A line that changes direction is now split at each change, and carries the permutation that describes how the spans are drawn. The spans themselves stay in logical order, because that is the order the text is read and the order the semantic backends need; only the permutation knows about the page. It is empty when the two orders coincide, so a left-to-right line takes the branch it always took and cannot drift. The bidirectional formatting characters now survive control-character sanitizing. They draw nothing and exist only to steer this algorithm, so removing them with the rest of category C deleted the author's instruction before anything could read it. They are dropped once read: from the span text, and at the glyph seam that both measures and draws — where substituting them with '?' would have put a visible mark on the page and given a zero-width character a width the wrapping had already committed to not having. The two copies of the fixed-width test measurement become one helper.
A PDF emits the characters of a string in the order it is handed them, so Hebrew stored in logical order came out backwards — every letter of every word, in a document that otherwise looked finished. A right-to-left run is now reversed as it is drawn, and only there: the span keeps its logical text for the semantic backends and for text extraction. Reversal is by grapheme cluster, because a letter and its vowel point are one thing on the page and reversing characters would put the mark before the letter it belongs to. Spans are walked in visual order. The cursor advances over that same sequence, so a run of text spans still leaves the pen exactly where the next one starts — the implicit text position stays correct and the left-to-right branch is untouched. Fixes the resolver reporting a single run at the paragraph's base level rather than the run's own. Direction of a paragraph and direction of a run are different things: Hebrew alone in a left-to-right paragraph is one run that still runs right to left, and Latin alone in a right-to-left paragraph is one run that does not. Both were being read as the paragraph's, which reversed the wrong lines — whole lines, silently. RtlGlyphOrderTest reads the X coordinate of every glyph out of the rendered page. A layout snapshot records node geometry and never sees a span, so it cannot tell a reordered line from a logical one; this can, exactly, with no pixel threshold.
A paragraph whose direction is taken from its text was resolving that direction but still starting at the left edge, so Hebrew set to AUTO read correctly and sat on the wrong side of the page. AUTO now aligns right when the first strong character runs right to left — the same rule the algorithm applies to the text itself, and one that Character answers directly. That matters here: document.dsl is canonical surface and may not reach into the engine, but reading a character's directionality needs nothing from it. The decision stays in the builder because that is the only place that knows whether the caller chose an alignment; the node carries a concrete value and cannot tell a default from a choice. An explicit align() still wins. Link rectangles are now placed by walking the spans in the order they are drawn. No span carries a link on the directional path yet, so this fixes nothing visible today — it stops the second traversal from disagreeing with the first once inline runs start producing reordered lines.
Where a line starts along x was written out three times: the PDF handler that draws the glyphs, the PDF backend that places the clickable rectangles over them, and the PPTX handler that anchors its text boxes. They agreed, and nothing made them keep agreeing. A drifted copy would not crash. It would put a link somewhere its text is not, or move a slide's words a few points from where the page had them — the kind of difference a reader notices and a suite does not. They now call one helper, in the payload package all three already depend on. Behaviour is unchanged, including a line wider than its box overflowing towards the edge it is aligned to rather than being clamped: clamping would disagree with the width the layout already committed to. The alignment that a right-to-left paragraph implies has one place to live now, before the remaining backends need it.
Rich text is how templates author a paragraph, and it was still laid out in logical order — so a CV or an invoice with Hebrew in it came out backwards while a plain paragraph of the same text came out right. Inline runs already arrive as one span per word, so unlike a plain paragraph nothing has to be split: each span is told which way it goes, and the line carries the order they are drawn in. Both halves matter. Marking the spans without reordering them reverses each word in place and leaves the words in the wrong order, which looks close enough to be missed. Direction is applied after the spans are built rather than inside the loop that builds them, so the wrapping, the chip coalescing and the trim seams are untouched. The line resolves over a probe string in which an inline graphic stands in as an object replacement character: a span's position in the probe then equals its position in the line, and an image inside right-to-left text takes part in the ordering as the neutral object it is instead of breaking it. A line with no right-to-left character returns an empty order and unmodified spans, so the path every existing rich-text document takes is the one it already took.
…ckend Markdown was the last wrap path laying text out in logical order. It splits a line into styled bodies rather than words, which is a different splitter producing the same shape of spans, so it takes the same pass the inline path does. All three paths now carry direction, and a path that quietly kept logical order next to two working ones is exactly the kind of gap nobody looks for. PPTX drew a plain line by appending its runs to one shared frame and letting PowerPoint flow them, which puts the words back in reading order and undoes the ordering the layout resolved. A right-to-left line now goes through the per-span absolute frames the backend already uses for chips and graphics, so each word is pinned where the page put it, and spans are walked in visual order as the PDF backend walks them. The text handed to PowerPoint stays logical and is deliberately not reversed. PowerPoint has its own bidirectional engine and reverses a run itself; doing it here as well would reverse it twice. That asymmetry with the PDF backend is the point — one draws characters in the order given, the other does not.
Word has its own bidirectional engine, so unlike a fixed-layout backend it reorders and shapes the text itself. The one thing it cannot work out is the paragraph's base direction: without w:bidi a line beginning with a neutral character, or one mixing scripts, is laid out as left-to-right text that happens to contain Hebrew. The document opens, every letter is there, and the line reads from the wrong end. A right-to-left paragraph now carries w:bidi. The text is handed over in logical order and deliberately not reversed — doing here what Word already does would undo it. That is the opposite of the PDF backend, and the tests say so out loud, because the asymmetry is the kind a later reader would "fix". w:rPr/w:rtl is not written per run: the paragraph's base direction is enough for Word to resolve each run itself, and a per-run flag would restate what it already derives. AUTO is not passed on. It was resolved into a concrete alignment when the node was built, and letting Word guess again could reach a different answer than the page did.
Describes what a reader gets and what they do not: direction as a separate choice from alignment, the bidirectional resolution that keeps embedded Latin and digits running forwards, why the formatting characters now survive sanitizing, and that Arabic still renders unjoined.
Pairs every row with the call that produced it: the same Hebrew set with no direction, with RTL, with AUTO, and with RTL plus an explicit align(LEFT) that overrides the right edge RTL would otherwise pick. The mixed row shows a Latin word and a year running forwards inside right-to-left text, and the last row shows Arabic, right-aligned and still unjoined. Registered in GenerateAllExamples and the examples README.
Covers the distinction the API rests on — alignment says where a line sits, direction says which way it runs — and the three things a reader hits next: that an explicit align() wins, that AUTO reads the first strong character, and that a mixed line resolves itself so runs need no splitting by hand. Names the bundled Hebrew and Arabic families with the version that carries them, says plainly that no bundled family covers both scripts, and that Arabic is still unjoined.
An A/B against this branch's base measured a real regression on documents with no right-to-left text at all: proposal-template +17.7% latency and -15% docs/s, long-token +11.6%, cv-template's layout stage +25.9%. The cost was per line and entirely wasted. Every laid-out line called the resolver, which copied the line into a char[] for java.text.Bidi.requiresBidi and then allocated a run record and a list to say "nothing to do". Lines are now screened before any of that. requiresBidi reads the string in place and stops at the first character that settles it, and the wrap paths ask that question before entering the resolver, so a line with nothing to reorder allocates nothing and takes the branch it took before direction existed. The scan stands in for a JDK method, so it is tested against it rather than against my reading of it — which caught two disagreements. A left-to-right mark alone does not require resolution (it has no right-to-left text to steer), and a surrogate pair has to be decoded and asked, because that range holds emoji as well as right-to-left scripts and treating every pair as bidirectional would send an emoji-bearing line down the slow path for nothing.
…base per paragraph Two wrong pages, both plausible enough to ship. A span is reversed — and drawn — as one unit, so it must be single-level. Tokenization splits on whitespace, but a direction change does not need a space: "ב-2026", the idiomatic Hebrew "in 2026", is one token whose digits run forwards inside a right-to-left word. Left as one span it was reversed whole, putting the year backwards — and only on the inline and markdown paths, so the same string authored through .text() rendered correctly while .rich() got it wrong. Spans are now split at each level boundary; sub-widths are re-measured, and advances are additive, so the line's geometry does not move. A chip stays whole (it is one rounded fill) and a right-to-left chip is now reversed at the chip seam, which previously ignored direction entirely. AUTO resolved its base per wrapped line, but UAX #9 fixes the base direction per paragraph — only the line-level reset is per line. Hebrew prose whose continuation line began with Latin flipped that line to a left-to-right base, arranging adjacent lines of one paragraph backwards relative to each other. The base is now resolved once, from the paragraph's full text, in the same place the builder derives AUTO's default alignment, so the page and the alignment cannot disagree. A page split preserves the direction on the fragment node it rebuilds. ParagraphLine now validates visualOrder as a permutation instead of trusting it: the payload is public API, and a hand-built order that was short or out of range would drop spans silently or fail deep inside a render handler. The proofs read glyph positions out of rendered pages: the digits of a mixed token stay forwards, and the Latin-initial second line of an AUTO paragraph keeps its sibling's base. The PPTX tests pin the other half of the contract — logical text in absolute frames, never the shared frame PowerPoint reflows.
BidiText existed for one property — reversal by grapheme cluster — and no test exercised it: swapping the BreakIterator for StringBuilder.reverse left the suite green, because the rest of it uses unpointed Hebrew, which naive reversal happens to get right. Pointed text and emoji now hold the claim, the iterator is pinned to Locale.ROOT, and reversal is asserted to be its own inverse. The sanitizer's three direction-mark operations and the glyph seam's drop are now covered the same way: the marks survive sanitizing, leave at the seam that both measures and draws, and a genuinely unencodable character still becomes a question mark — the exemption must not widen into swallowing text. The fast scan stands in for the JDK's requiresBidi, so its contract is stated against it: it may over-include (the Arabic comma, class CS, costs a harmless trip through the resolver) but can never miss. The JDK's own mask counts Arabic-Indic digits, which the tests document rather than assume. The bidirectional characters in the scan are now written as escapes: raw ones in source are the Trojan-Source shape, flagged by scanners and easy for a tool to mangle silently. The remaining new public surface carries its @SInCE. The changelog says out loud what extraction does with a right-to-left line — the content stream carries the visual order, so copy-paste yields reversed characters until ActualText is written; DOCX is unaffected. The examples README gains the section its catalogue row already pointed at.
The comment above isBidirectional said the ranges were written as escapes to avoid the Trojan-Source shape; the literals below it were the raw invisible characters themselves. A comment that misstates the line under it is worse than no comment, and these particular characters are the ones a diff cannot show. The ranges are now genuinely \u escapes, byte-identical in behaviour — the scan-parity test against java.text.Bidi pins that. Also drops two imports made redundant by their files' wildcard imports, and stamps the version marker on ParagraphNode's direction component the way its two sibling additions already carry it.
Every document the examples catalogue renders is either a committed preview or deliberately listed as unpublished — a render nobody decided about is what the drift guard exists to catch, and the new text-direction example was exactly that. The preview is committed and linked from its catalogue row and section, the way every other published example is.
Which way a paragraph runs was worked out twice from the same text. The builder scanned for the first character of strong directionality to decide which edge an unaligned paragraph sits at; the layout asked the bidirectional algorithm. Two readings of one rule, and they did not agree. They part company on isolates. UAX #9 rule P2 skips everything between an isolate initiator and its matching PDI when looking for the first strong character — that is what an isolate is for — and a plain scan reads straight into it. A paragraph opening with an isolated Hebrew quotation therefore aligned to the right edge and laid out from the left. This is the release that started carrying isolates through control sanitizing, so the disagreement was reachable by exactly the author who took the documentation's advice on steering a neutral stretch of text. ParagraphDirection is now the single answer, and it lives in the layout package because that is the sanctioned bridge from the canonical surface to the engine: the builder may call there, and only there does the call reach the resolver. Word is the third caller. AUTO used to reach the DOCX export unresolved, on the reasoning that it had already become a concrete alignment — but alignment is not direction, and what Word actually received was a paragraph with no base direction at all, left to infer one. That is the thing w:bidi exists to prevent, and it came out the other way round for a line opening with a digit or a parenthesis. The export is now marked from the same answer the page used, and the test that pinned the old behaviour asserts the new one in three cases instead.
|
Pushed It was read in two, and they disagreed. The builder scanned for the first character
So a paragraph opening with an isolated Hebrew quotation was aligned to the right edge
Word is the third caller. Tests: Full reactor gate green here and at the tip of the stack; #538 and #539 are rebased onto |
Stacked on #534 (which is stacked on #535) — review and merge in that order. This is the engine work of #140.
Why
The engine laid text out strictly left to right: tokens append in source order and the PDF handler advances one cursor from the line's left edge. Text is stored in logical order — the order it is read — and a page draws in visual order. For Latin the two coincide; for Hebrew and Arabic they do not, so every right-to-left line rendered reversed, in a document that otherwise looked finished.
What changed
ParagraphBuilder.direction(...)takesTextDirection.LTR,RTL, orAUTO(read off the paragraph's first strong character, resolved once per paragraph as UAX #9 P2–P3 requires). Direction is a separate choice fromTextAlign— alignment says where a line sits, direction says which way it runs — and they meet in one place: a right-to-left paragraph aligns right unless the caller chose an alignment, which the builder tracks explicitly because a field defaulted toLEFTcannot tell "not chosen" from "chose LEFT".Resolution lives in a new engine package,
com.demcha.compose.engine.text.bidi, wrappingjava.text.Bidi(no new dependency). All three wrap paths carry it: a plain line is split into one span per directional run; inline runs and markdown bodies, already one span per word, are split further wherever the level changes inside a token —"ב-2026", one whitespace token, would otherwise reverse its digits. Spans stay in logical order and each line carries avisualOrderpermutation, validated as one byParagraphLine.Each backend then does only what its medium requires:
showTextdraws characters in the order givenw:pPr/w:bidiThe bidirectional format characters (LRM, RLM, ALM, embeddings, isolates) now survive control-character sanitizing until the algorithm has read them, and are dropped at the glyph seam both measurement and rendering share — substituting them would have printed
?and given a zero-width character a width.A left-to-right line pays nothing: an allocation-free scan (checked against
java.text.Bidi.requiresBidiby test — it may over-include, it can never miss) bails out before any resolver work, and the LTR branch is the byte-identical code it always was.Verification
Full reactor gate green; no committed layout snapshot or visual baseline moved, which is the executable form of the LTR-identity claim.
The load-bearing proofs read glyph X-positions out of rendered pages (
RtlGlyphOrderTest) — a layout snapshot records node geometry and cannot see span order. They pin: Hebrew drawn right-to-left under both paragraph directions, Latin untouched, an embedded Latin word running forwards inside a reversed line, the digits of a mixed token staying forwards, and an AUTO paragraph whose Latin-initial second line keeps its sibling's base. ~70 new tests across core, render-pdf, render-pptx, render-docx and qa, including grapheme-cluster reversal held against a naiveStringBuilder.reverse(pointed Hebrew, emoji), the sanitizer's three direction-mark operations, and the PPTX shared-frame/absolute-frame split.Performance was measured, not argued: the first A/B against this branch's base caught a real regression (+17.7% proposal-template latency from a per-line
char[]copy and run-record allocation), which is why the scan above exists; the re-run after the fix and a third run after review fixes show every scenario inside the harness's declared noise band, with signs flipping between runs — noise, not effect.Known limits
ActualTextis written. DOCX is unaffected. Recorded in the changelog.