Skip to content

Fix paragraph end handling in bidirectional text lines - #22553

Merged
MrJul merged 4 commits into
AvaloniaUI:mainfrom
Gillibald:fix/end-of-line-run-direction
Oct 21, 2026
Merged

MrJul merged 4 commits into
AvaloniaUI:mainfrom
Gillibald:fix/end-of-line-run-direction

Conversation

@Gillibald

@Gillibald Gillibald commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes two defects in how TextLineImpl treats the TextEndOfLine run of bidirectional lines: its direction and its visual position in right-to-left paragraphs.

What is the current behavior?

  • TextLineImpl.GetRunDirection returned the caller's direction for any run without glyphs. When a line ends in RTL text, the paragraph end sits visually right after the trailing RTL run, so GetLastDirectionalRunIndex pulled it into that span. GetTextRunBoundsRightToLeft walks the span from the visual right, consumed the paragraph end first, and measured every glyph after it one character off.
  • BidiReorderer forces TextEndOfLine to level 0 (Fixed an issue RTL wrapped text calculates an extra line for the control desired size #17209). That hid FinalizeLine and GetCharacterHitFromDistance reading the visual run array as if its last run were the logical end of the line. Level 0 contradicts the bidi rule that a paragraph separator takes the paragraph level (UAX # 9, L1), and puts the paragraph end of a right-to-left line at its right edge, where the line starts.

What is the updated/expected behavior with this PR?

  • IndexedTextRun carries the level each run was reordered with, and TextLineImpl takes a run's direction from it. The duplicated content-based direction helper in GetDistanceFromCharacterHit is gone.
  • TextEndOfLine takes the paragraph level again. FinalizeLine and GetCharacterHitFromDistance find it through the logical run order.
  • TextBlock and SelectableTextBlock hit testing matches TextLayout for text ending in Hebrew or Arabic, in both flow directions.
  • Wrapped right-to-left text still measures without an extra line.
  • Behavioral changes:
    • In a right-to-left paragraph, GetDistanceFromCharacterHit and GetTextBounds at the paragraph end now return the left edge of the line instead of the right edge, and the paragraph end is the first entry of TextLine.TextRuns.
    • A range that includes the paragraph end of a left-to-right paragraph ending in RTL text reports the paragraph end as its own empty left-to-right bounds at the line end, instead of merging it into the RTL span.
    • Inline objects take their direction from the level they were reordered with (the preceding run's level) instead of the paragraph direction.

Checklist

Breaking changes

None to the API; all changed types are internal. The paragraph end position and TextRuns order listed above change for right-to-left paragraphs.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #22530

🤖 Generated with Claude Code

…a paragraph end

A TextEndOfParagraph run must not change bounds, run indices or caret distances
of the text before it. Lines whose last text run is right-to-left and sits
visually next to the paragraph end report shifted bounds today.
TextLineImpl gave runs without glyphs whatever direction the caller asked about,
while BidiReorderer places TextEndOfLine at level 0. When text ends in RTL, the
paragraph end sits visually after the trailing RTL run, so it was pulled into
that directional span. The right-to-left walk consumes the span visually from
the right, so it consumed the paragraph end first and every glyph after it was
measured one character off: empty bounds for the first RTL character and a
full-range query one character short. Only text sources that end with
TextEndOfParagraph (TextBlock, SelectableTextBlock) are affected.

- Store the level each run was reordered with on IndexedTextRun.
- GetTextBounds and GetDistanceFromCharacterHit read direction from that level;
  the duplicated content-based direction helper is gone.
- A range that includes the paragraph end of an LTR paragraph ending in RTL
  text now reports the paragraph end as its own empty LTR bounds at the line
  end instead of merging it into the RTL span.
…graphs

The paragraph end belongs at the logical end of the paragraph: the right edge
of a left-to-right line and the left edge of a right-to-left line. Caret
distance and bounds put it at the right edge in both directions today.

- A wrapped TextLayout must get the same lines with and without a
  TextEndOfParagraph, so moving the paragraph end cannot bring back the
  extra line RTL wrapped text used to measure.
BidiReorderer forced TextEndOfLine to level 0 so it would always be the last
visual run. That hid TextLineImpl reading the visual run array as if its last
run were the logical end of the line: in a right-to-left paragraph the end
run is visually first, FinalizeLine missed the line break and wrapped text
measured an extra line. Level 0 broke the Unicode bidi rule that a paragraph
separator takes the paragraph level, and put the paragraph end of a
right-to-left line at its right edge, where the line starts.

- TextEndOfLine takes the paragraph level again.
- FinalizeLine and GetCharacterHitFromDistance find the end run through the
  logical run order, wherever it sits visually.
- Caret distance and bounds at the paragraph end of a right-to-left line now
  land on the left edge.
@Gillibald Gillibald added bug area-textprocessing backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown

NuGet packages

The packages for this pull request have been published (workflow run):

@MrJul
MrJul added this pull request to the merge queue Oct 21, 2026
Merged via the queue into AvaloniaUI:main with commit d3fcf74 Oct 21, 2026
13 checks passed
MrJul pushed a commit that referenced this pull request Nov 2, 2026
* Add failing test for hit testing a line that ends in RTL text before a paragraph end

A TextEndOfParagraph run must not change bounds, run indices or caret distances
of the text before it. Lines whose last text run is right-to-left and sits
visually next to the paragraph end report shifted bounds today.

* Derive a text line run's direction from its reordered bidi level

TextLineImpl gave runs without glyphs whatever direction the caller asked about,
while BidiReorderer places TextEndOfLine at level 0. When text ends in RTL, the
paragraph end sits visually after the trailing RTL run, so it was pulled into
that directional span. The right-to-left walk consumes the span visually from
the right, so it consumed the paragraph end first and every glyph after it was
measured one character off: empty bounds for the first RTL character and a
full-range query one character short. Only text sources that end with
TextEndOfParagraph (TextBlock, SelectableTextBlock) are affected.

- Store the level each run was reordered with on IndexedTextRun.
- GetTextBounds and GetDistanceFromCharacterHit read direction from that level;
  the duplicated content-based direction helper is gone.
- A range that includes the paragraph end of an LTR paragraph ending in RTL
  text now reports the paragraph end as its own empty LTR bounds at the line
  end instead of merging it into the RTL span.

* Add failing test for the paragraph end position in right-to-left paragraphs

The paragraph end belongs at the logical end of the paragraph: the right edge
of a left-to-right line and the left edge of a right-to-left line. Caret
distance and bounds put it at the right edge in both directions today.

- A wrapped TextLayout must get the same lines with and without a
  TextEndOfParagraph, so moving the paragraph end cannot bring back the
  extra line RTL wrapped text used to measure.

* Give the paragraph end the paragraph embedding level

BidiReorderer forced TextEndOfLine to level 0 so it would always be the last
visual run. That hid TextLineImpl reading the visual run array as if its last
run were the logical end of the line: in a right-to-left paragraph the end
run is visually first, FinalizeLine missed the line break and wrapped text
measured an extra line. Level 0 broke the Unicode bidi rule that a paragraph
separator takes the paragraph level, and put the paragraph end of a
right-to-left line at its right edge, where the line starts.

- TextEndOfLine takes the paragraph level again.
- FinalizeLine and GetCharacterHitFromDistance find the end run through the
  logical run order, wherever it sits visually.
- Caret distance and bounds at the paragraph end of a right-to-left line now
  land on the left edge.
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextBlock: HitTestTextRange returns empty bounds for right-to-left text at the end of the text

2 participants