Skip to content

feat(engine): lay out and draw right-to-left text - #536

Open
DemchaAV wants to merge 17 commits into
feat/rtl-bidi-supportfrom
feat/rtl-bidi-reordering
Open

feat(engine): lay out and draw right-to-left text#536
DemchaAV wants to merge 17 commits into
feat/rtl-bidi-supportfrom
feat/rtl-bidi-reordering

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

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(...) takes TextDirection.LTR, RTL, or AUTO (read off the paragraph's first strong character, resolved once per paragraph as UAX #9 P2–P3 requires). Direction is a separate choice from TextAlign — 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 to LEFT cannot tell "not chosen" from "chose LEFT".

Resolution lives in a new engine package, com.demcha.compose.engine.text.bidi, wrapping java.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 a visualOrder permutation, validated as one by ParagraphLine.

Each backend then does only what its medium requires:

Backend Span order Reverses text Why
PDF visual yes, by grapheme cluster showText draws characters in the order given
PPTX visual no PowerPoint's own bidi engine reverses; absolute per-span frames stop it reflowing the word order
DOCX logical no Word lays out itself and only needs w:pPr/w:bidi

The 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.requiresBidi by 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 naive StringBuilder.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

  • Arabic renders in unjoined letter forms; contextual shaping via the presentation forms Amiri carries is the next step on Arabic, Hebrew, other Unicode support #140.
  • Plain-text extraction and copy-paste from a PDF read the content stream, which carries the visual order — a Hebrew line pastes reversed until ActualText is written. DOCX is unaffected. Recorded in the changelog.
  • A run is drawn in one font and no bundled family covers both Arabic and Hebrew; mixing them in one run needs a user-registered font.
  • A highlight chip is one unsplittable fill, so a chip whose text changes direction inside itself keeps logical order within the chip.

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.
@DemchaAV

Copy link
Copy Markdown
Owner Author

Pushed 20c7bac1: the direction a paragraph runs in is now read in one place.

It was read in two, and they disagreed. 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. They agree on ordinary text and 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, which is what an isolate is
for, and a plain scan reads straight into it:

text builder layout
שלום RTL RTL
2026 שלום RTL RTL
LRI שלום PDI hello RTL LTR
RLI hello PDI שלום LTR RTL

So a paragraph opening with an isolated Hebrew quotation was 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 that single answer. It lives in document.layout 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 BidiParagraphResolver. No cycle: nothing in
document.layout imports document.dsl.

Word is the third caller. AUTO reached 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 what w:bidi exists to prevent, and it comes out the other way round for a
line opening with a digit or a parenthesis. The export is marked from the same answer the
page used.

Tests: ParagraphDirectionTest covers explicit directions, first-strong with neutrals,
the two isolate cases above, and — the one that matters — that the alignment a paragraph
is built with matches the direction it is laid out with, across all five inputs. I
checked it fails against the old scan before keeping it; it does, naming
<LRI>שלום<PDI> hello. The DOCX test that pinned "AUTO is not passed on" now asserts the
opposite in three cases, including the neutral-prefix one.

Full reactor gate green here and at the tip of the stack; #538 and #539 are rebased onto
this and pushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant