Repository navigation
[web_paragraph] Fix multi-span BiDi span order and region direction - #194980
Merged
auto-submit[bot] merged 6 commits intoOct 30, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
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.
Rusino
force-pushed
the
webparagraph_multi_span_bidi
branch
from
October 21, 2026 19:45
1710216 to
4f9237d
Compare
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
force-pushed
the
webparagraph_multi_span_bidi
branch
from
October 22, 2026 20:42
4f9237d to
51ee534
Compare
10 of 11 tasks
mdebbar
reviewed
Oct 27, 2026
Rusino
force-pushed
the
webparagraph_multi_span_bidi
branch
from
October 27, 2026 21:35
92b5bfe to
40c36ff
Compare
mdebbar
approved these changes
Oct 30, 2026
9 of 11 tasks
This was referenced 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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Within an RTL
BidiRun, logical spans progress from right to left, soTextLayout.addLinemust iterateparagraph.spansin reverse logical order to append visual blocks from left to right.Additionally,
TextSpan._getMetricspreviously 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 = 1per UAX #9 N1) and embedded LTR spans inside an RTL paragraph to be measured as RTL.WebParagraph._resolveSpansnow keeps the builder-supplied_styleSpansprivate, splits text spans at paragraph-levelbidiRegionsboundaries, and exposes only the BiDi-resolvedWebParagraph.spanswith each sub-span's resolved region direction soextractTextClustersandCanvas2Dmetrics match paragraph-level BiDi runs.Reviewer & AI Audit Context
+75 / -9production lines inengine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dartandengine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart(WebParagraph._resolveSpans(),TextLayout.extractBidiRuns(),TextLayout.addLine()) and+182 / -0test lines inengine/src/flutter/lib/web_ui/test/webparagraph/paragraph_bidi_test.dart.SkParagraphParity Anchor: MatchesOneLineShaper::iterateThroughShapingRegions(engine/src/flutter/third_party/skia/modules/skparagraph/src/OneLineShaper.cpp:556-578, 697), which intersects paragraph-levelfBidiRegionswith styleBlocks so each shaped run has a single UAX Naming convention for ToolBar (ActionBar) #9 level, andTextLine::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.WebParagraph.spans(while keeping builder-supplied_styleSpansprivate) guarantees thatlayout.dart,wrapper.dart, andpainter.dartcannot accidentally read unsplit spans.BidiRegionsub-span,Canvas2DgetTextClusters()emits clusters in monotonic logical offset order (engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart:783, LedgerL007), andTextBlock/TextPainterwhole-block and per-cluster rendering translate byspanShiftFromLineStart = 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].Placeholders/Backgrounds/Queries):PlaceholderSpans are preserved withoutCanvas2Dtext measurement in_resolveSpans()and positioned in visual left-to-right order alongsideTextBlocks inaddLine().TextPainterbackgrounds/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), andgetGlyphInfoAt(engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:888) all consumeline.visualBlocksandspanShiftFromLineStart, 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.writingDirectionand cluster bounds, andgetPositionForOffsethit-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_resolveSpansoverrides the RTL paragraph direction toTextDirection.ltrfor an embedded multi-span LTR run ("Hello"," + ","World").Multi-span RTL run with inline placeholder— verifies that an inlinePlaceholderSpanbetween two RTL text spans is preserved by_resolveSpansand positioned visually between them (second-> placeholder ->first).--full,preferWebParagraph+forceTestFontson40c36fffd2ec):engine/src/flutter/lib/web_ui):17/17engine/src/flutter/lib/web_ui/test/webparagraphsuites (+214 ~5 -1, all 8 BiDi tests passing) +62/62engine/src/flutter/lib/web_ui/test/uisuites (+196 ~0 -44).packages/flutter,896/896suites across8/8shards):+16478 ~1231 -812(0compile errors or unfinished shards).Pre-launch Checklist
///).Assisted-by: Gemini 4 Argon (Medium)