Skip to content

[web_paragraph] Fix multi-span BiDi span order and region direction - #194980

Merged
auto-submit[bot] merged 6 commits into
flutter:masterfrom
Rusino:webparagraph_multi_span_bidi
Oct 30, 2026
Merged

auto-submit[bot] merged 6 commits into
flutter:masterfrom
Rusino:webparagraph_multi_span_bidi

Conversation

@Rusino

@Rusino Rusino commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Within an RTL BidiRun, logical spans progress from right to left, so TextLayout.addLine must iterate paragraph.spans in reverse logical order to append visual blocks from left to right.

Additionally, TextSpan._getMetrics previously measured each span in isolation using the paragraph's default direction, causing leading or isolated neutral characters in a span following an RTL span to be measured as LTR (level = 0) instead of RTL (level = 1 per UAX #9 N1) and embedded LTR spans inside an RTL paragraph to be measured as RTL. WebParagraph._resolveSpans now keeps the builder-supplied _styleSpans private, splits text spans at paragraph-level bidiRegions boundaries, and exposes only the BiDi-resolved WebParagraph.spans with each sub-span's resolved region direction so extractTextClusters and Canvas2D metrics match paragraph-level BiDi runs.

Reviewer & AI Audit Context

  • Core Logic Hunks: +75 / -9 production lines in engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart and engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart (WebParagraph._resolveSpans(), TextLayout.extractBidiRuns(), TextLayout.addLine()) and +182 / -0 test lines in engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_bidi_test.dart.
  • C++ SkParagraph Parity Anchor: Matches OneLineShaper::iterateThroughShapingRegions (engine/src/flutter/third_party/skia/modules/skparagraph/src/OneLineShaper.cpp:556-578, 697), which intersects paragraph-level fBidiRegions with style Blocks so each shaped run has a single UAX Naming convention for ToolBar (ActionBar) #9 level, and TextLine::iterateThroughSingleRunByStyles (engine/src/flutter/third_party/skia/modules/skparagraph/src/TextLine.cpp:1034-1035), which traverses style blocks in reverse logical order (run->leftToRight() ? fBlockRange.start + index : fBlockRange.end - index - 1) within RTL runs.
  • Callers & Precondition Proofs:
    • Exposing only the BiDi-resolved WebParagraph.spans (while keeping builder-supplied _styleSpans private) guarantees that layout.dart, wrapper.dart, and painter.dart cannot accidentally read unsplit spans.
    • Within each UAX Naming convention for ToolBar (ActionBar) #9 BidiRegion sub-span, Canvas2D getTextClusters() emits clusters in monotonic logical offset order (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart:783, Ledger L007), and TextBlock / TextPainter whole-block and per-cluster rendering translate by spanShiftFromLineStart = shiftFromLineStart - shiftFromSpanStart (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:381, 1306, engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart:356-360), which handles RTL sub-spans measured in [-spanWidth, 0].
  • Cross-Phase Consumer Check (Placeholders / Backgrounds / Queries):
    • PlaceholderSpans are preserved without Canvas2D text measurement in _resolveSpans() and positioned in visual left-to-right order alongside TextBlocks in addLine().
    • TextPainter backgrounds/decorations (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart:214, 346), getBoxesForRange (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:587), getPositionForOffset (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:774), and getGlyphInfoAt (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:888) all consume line.visualBlocks and spanShiftFromLineStart, so visual left-to-right block ordering and sub-span direction propagate across painting, selection boxes, and hit-testing.

Tests

  • engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_bidi_test.dart:
    • Paragraph BiDi order and layout within multi-span RTL bidi run — verifies left-to-right visual box ordering ("BiDi " -> "שלום" -> " & " -> "مرحبا بالعالم" -> " · Emoji ..."), GlyphInfo.writingDirection and cluster bounds, and getPositionForOffset hit-testing across a span that crosses an RTL->LTR boundary.
    • Paragraph BiDi isolated neutral span between RTL spans in LTR paragraph — verifies that an isolated neutral span (" + ") between two RTL spans resolves to RTL (level = 1) and is positioned visually between "שלום" and "مرحبا".
    • Paragraph BiDi multi-span order in RTL paragraph — verifies that two RTL spans in an RTL paragraph ("שלום " followed by bold "עולם") are ordered visually from right to left (second.right == first.left).
    • Embedded multi-span LTR run inside RTL paragraph — verifies that _resolveSpans overrides the RTL paragraph direction to TextDirection.ltr for an embedded multi-span LTR run ("Hello", " + ", "World").
    • Multi-span RTL run with inline placeholder — verifies that an inline PlaceholderSpan between two RTL text spans is preserved by _resolveSpans and positioned visually between them (second -> placeholder -> first).
  • Full WebParagraph corpus (--full, preferWebParagraph + forceTestFonts on 40c36fffd2ec):
    • Part 1 (engine/src/flutter/lib/web_ui): 17/17 engine/src/flutter/lib/web_ui/test/webparagraph suites (+214 ~5 -1, all 8 BiDi tests passing) + 62/62 engine/src/flutter/lib/web_ui/test/ui suites (+196 ~0 -44).
    • Part 2 (packages/flutter, 896/896 suites across 8/8 shards): +16478 ~1231 -812 (0 compile errors or unfinished shards).

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

Assisted-by: Gemini 4 Argon (Medium)

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Oct 21, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces paragraph-level UAX #9 BiDi regions to split text spans at region boundaries and handle RTL BiDi runs by reversing logical spans during iteration. It also adds corresponding tests for multi-span RTL runs and isolated neutral spans. The review feedback highlights that mutating paragraph.spans in-place is risky as it could be unmodifiable, and suggests refactoring TextLayout to use a lazy final _spans field for resolved spans instead.

@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team labels Oct 21, 2026
@Rusino Rusino self-assigned this Oct 21, 2026
@Rusino
Rusino force-pushed the webparagraph_multi_span_bidi branch from 1710216 to 4f9237d Compare October 21, 2026 19:45
Within an RTL BidiRun, logical spans progress from right to left, so
TextLayout.addLine must iterate spans in reverse logical order to append
visual blocks from left to right.

Additionally, TextSpan._getMetrics previously measured each span in
isolation using the paragraph's default direction, causing leading or
isolated neutral characters in a span following an RTL span to be
measured as LTR (level 0) instead of RTL (level 1 per UAX flutter#9 N1). Split
text spans at paragraph-level BiDi region boundaries in
extractTextClusters and assign each sub-span its resolved region
direction so Canvas2D metrics match paragraph-level BiDi runs.

Assisted-by: Gemini 4 Argon (Medium)
…plitting

Store spans split at paragraph-level BiDi region boundaries in a lazy
_spans field on TextLayout instead of mutating paragraph.spans in-place.

Assisted-by: Gemini 4 Argon (Medium)
…._resolveSpans

Explain why each condition and cursor in _resolveSpans and extractTextClusters preserves UAX flutter#9 paragraph-level BiDi direction across sub-spans and placeholders.

Assisted-by: Gemini 4 Argon (Medium)
@Rusino
Rusino force-pushed the webparagraph_multi_span_bidi branch from 4f9237d to 51ee534 Compare October 22, 2026 20:42
@Rusino
Rusino requested a review from mdebbar October 23, 2026 00:28
@dbebawy dbebawy mentioned this pull request Oct 26, 2026
10 of 11 tasks
@Rusino
Rusino force-pushed the webparagraph_multi_span_bidi branch from 92b5bfe to 40c36ff Compare October 27, 2026 21:35
@mdebbar mdebbar added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 30, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into flutter:master with commit 8a72c88 Oct 30, 2026
23 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 30, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Oct 30, 2026
flutter/flutter@c091098...5e805bf

2026-10-30 [email protected] Fix/shared thread merger expired lease (flutter/flutter#194200)
2026-10-30 [email protected] [FGP] Report the real error when applying kotlin-android fails (flutter/flutter#195080)
2026-10-30 [email protected] [Impeller] Minify GLSL shaders (flutter/flutter#195240)
2026-10-30 [email protected] Roll Packages from 0b72e92 to 3742a4e (12 revisions) (flutter/flutter#195686)
2026-10-30 [email protected] Roll Dart SDK from 52c908c00267 to fe609902984b (2 revisions) (flutter/flutter#195679)
2026-10-30 [email protected] Roll Skia from 0789f2ab93b8 to c0c46ee9f938 (1 revision) (flutter/flutter#195671)
2026-10-30 [email protected] Roll Fuchsia Linux SDK from SORPfa9tgFn83M1CR... to x2SqGbVUkOm4d6vk_... (flutter/flutter#195670)
2026-10-30 [email protected] Roll Skia from 4470b8abaad6 to 0789f2ab93b8 (2 revisions) (flutter/flutter#195665)
2026-10-30 [email protected] Roll Dart SDK from 89f7a35480d6 to 52c908c00267 (1 revision) (flutter/flutter#195659)
2026-10-30 [email protected] Roll Skia from 9f4e3117465e to 4470b8abaad6 (4 revisions) (flutter/flutter#195658)
2026-10-30 [email protected] Roll Skia from ec13cd547e98 to 9f4e3117465e (1 revision) (flutter/flutter#195650)
2026-10-30 [email protected] [web] Keep semantics editing state while the text input connection is open (flutter/flutter#195141)
2026-10-30 [email protected] [web_paragraph] Fix multi-span BiDi span order and region direction (flutter/flutter#194980)
2026-10-30 [email protected] [web] Unskip web tests that pass on CanvasKit and Skwasm: accessibility_inspector_test, color_filter_test (flutter/flutter#195541)
2026-10-30 [email protected] Roll Skia from 4ecd22a4a1d0 to ec13cd547e98 (10 revisions) (flutter/flutter#195647)

If this roll has caused a breakage, revert this CL and set the roller
to dry run mode using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC [email protected],[email protected] on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
gmackall pushed a commit to gmackall/flutter that referenced this pull request Nov 3, 2026
…lutter#194980)

Within an RTL `BidiRun`, logical spans progress from right to left, so
`TextLayout.addLine` must iterate `paragraph.spans` in reverse logical
order to append visual blocks from left to right.

Additionally, `TextSpan._getMetrics` previously measured each span in
isolation using the paragraph's default direction, causing leading or
isolated neutral characters in a span following an RTL span to be
measured as LTR (`level = 0`) instead of RTL (`level = 1` per UAX flutter#9 N1)
and embedded LTR spans inside an RTL paragraph to be measured as RTL.
`WebParagraph._resolveSpans` now keeps the builder-supplied
`_styleSpans` private, splits text spans at paragraph-level
`bidiRegions` boundaries, and exposes only the BiDi-resolved
`WebParagraph.spans` with each sub-span's resolved region direction so
`extractTextClusters` and `Canvas2D` metrics match paragraph-level BiDi
runs.

### Reviewer & AI Audit Context
- **Core Logic Hunks**: `+75 / -9` production lines in
`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart`
and
`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart`
(`WebParagraph._resolveSpans()`, `TextLayout.extractBidiRuns()`,
`TextLayout.addLine()`) and `+182 / -0` test lines in
`engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_bidi_test.dart`.
- **C++ `SkParagraph` Parity Anchor**: Matches
`OneLineShaper::iterateThroughShapingRegions`
(`engine/src/flutter/third_party/skia/modules/skparagraph/src/OneLineShaper.cpp:556-578,
697`), which intersects paragraph-level `fBidiRegions` with style
`Block`s so each shaped run has a single UAX flutter#9 level, and
`TextLine::iterateThroughSingleRunByStyles`
(`engine/src/flutter/third_party/skia/modules/skparagraph/src/TextLine.cpp:1034-1035`),
which traverses style blocks in reverse logical order
(`run->leftToRight() ? fBlockRange.start + index : fBlockRange.end -
index - 1`) within RTL runs.
- **Callers & Precondition Proofs**:
- Exposing only the BiDi-resolved `WebParagraph.spans` (while keeping
builder-supplied `_styleSpans` private) guarantees that `layout.dart`,
`wrapper.dart`, and `painter.dart` cannot accidentally read unsplit
spans.
- Within each UAX flutter#9 `BidiRegion` sub-span, `Canvas2D`
`getTextClusters()` emits clusters in monotonic logical offset order
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart:783`,
Ledger `L007`), and `TextBlock` / `TextPainter` whole-block and
per-cluster rendering translate by `spanShiftFromLineStart =
shiftFromLineStart - shiftFromSpanStart`
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:381,
1306`,
`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart:356-360`),
which handles RTL sub-spans measured in `[-spanWidth, 0]`.
- **Cross-Phase Consumer Check (`Placeholders` / `Backgrounds` /
`Queries`)**:
- `PlaceholderSpan`s are preserved without `Canvas2D` text measurement
in `_resolveSpans()` and positioned in visual left-to-right order
alongside `TextBlock`s in `addLine()`.
- `TextPainter` backgrounds/decorations
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart:214,
346`), `getBoxesForRange`
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:587`),
`getPositionForOffset`
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:774`),
and `getGlyphInfoAt`
(`engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:888`)
all consume `line.visualBlocks` and `spanShiftFromLineStart`, so visual
left-to-right block ordering and sub-span direction propagate across
painting, selection boxes, and hit-testing.

### Tests
-
`engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_bidi_test.dart`:
- `Paragraph BiDi order and layout within multi-span RTL bidi run` —
verifies left-to-right visual box ordering (`"BiDi "` -> `"שלום"` -> `"
& "` -> `"مرحبا بالعالم"` -> `" · Emoji ..."`),
`GlyphInfo.writingDirection` and cluster bounds, and
`getPositionForOffset` hit-testing across a span that crosses an
RTL->LTR boundary.
- `Paragraph BiDi isolated neutral span between RTL spans in LTR
paragraph` — verifies that an isolated neutral span (`" + "`) between
two RTL spans resolves to RTL (`level = 1`) and is positioned visually
between `"שלום"` and `"مرحبا"`.
- `Paragraph BiDi multi-span order in RTL paragraph` — verifies that two
RTL spans in an RTL paragraph (`"שלום "` followed by bold `"עולם"`) are
ordered visually from right to left (`second.right == first.left`).
- `Embedded multi-span LTR run inside RTL paragraph` — verifies that
`_resolveSpans` overrides the RTL paragraph direction to
`TextDirection.ltr` for an embedded multi-span LTR run (`"Hello"`, `" +
"`, `"World"`).
- `Multi-span RTL run with inline placeholder` — verifies that an inline
`PlaceholderSpan` between two RTL text spans is preserved by
`_resolveSpans` and positioned visually between them (`second` ->
placeholder -> `first`).
- **Full WebParagraph corpus (`--full`, `preferWebParagraph` +
`forceTestFonts` on `40c36fffd2ec`)**:
- Part 1 (`engine/src/flutter/lib/web_ui`): `17/17`
`engine/src/flutter/lib/web_ui/test/webparagraph` suites (`+214 ~5 -1`,
all 8 BiDi tests passing) + `62/62`
`engine/src/flutter/lib/web_ui/test/ui` suites (`+196 ~0 -44`).
- Part 2 (`packages/flutter`, `896/896` suites across `8/8` shards):
`+16478 ~1231 -812` (`0` compile errors or unfinished shards).

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

Assisted-by: Gemini 4 Argon (Medium)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants