From d760a2891831a5bea9e77e9d0dec4cd0e86be574 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 29 Jun 2021 15:30:32 -0700 Subject: [PATCH] [web] Render RTL text correctly (flutter/engine#26811) --- .../ci/licenses_golden/licenses_flutter | 1 + .../flutter/lib/web_ui/dev/goldens_lock.yaml | 2 +- .../flutter/lib/web_ui/lib/src/engine.dart | 2 + .../lib/src/engine/text/canvas_paragraph.dart | 48 +- .../lib/src/engine/text/layout_service.dart | 466 +++++++++++--- .../lib/src/engine/text/text_direction.dart | 118 ++++ .../lib/src/engine/text/unicode_range.dart | 36 +- .../engine/canvas_paragraph/bidi_test.dart | 582 ++++++++++++++++++ .../engine/canvas_paragraph/helper.dart | 16 + .../test/text/canvas_paragraph_test.dart | 115 +++- .../web_ui/test/text/text_direction_test.dart | 126 ++++ 11 files changed, 1361 insertions(+), 151 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/text/text_direction.dart create mode 100644 engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/bidi_test.dart create mode 100644 engine/src/flutter/lib/web_ui/test/text/text_direction_test.dart diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index b6275cbb348..bfdd3e2ceb9 100755 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -587,6 +587,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/measurement.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paint_service.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paragraph.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/ruler.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/text_direction.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_break_properties.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_breaker.dart diff --git a/engine/src/flutter/lib/web_ui/dev/goldens_lock.yaml b/engine/src/flutter/lib/web_ui/dev/goldens_lock.yaml index 9793398899b..b29b283e97b 100644 --- a/engine/src/flutter/lib/web_ui/dev/goldens_lock.yaml +++ b/engine/src/flutter/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: 8df047cbfb1edb34569f3170a26e718fef22ffa7 +revision: 2b69a902e4c70b4e29044e66837e6b64aab1f473 diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 2d447a14fd8..04462c17530 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -165,6 +165,8 @@ export 'engine/text/canvas_paragraph.dart'; export 'engine/text/ruler.dart'; +export 'engine/text/text_direction.dart'; + export 'engine/text/unicode_range.dart'; export 'engine/text/word_break_properties.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart index 83ae56ed662..feab7962a03 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -74,6 +74,8 @@ class CanvasParagraph implements EngineParagraph { @override bool isLaidOut = false; + bool get isRtl => paragraphStyle.effectiveTextDirection == ui.TextDirection.rtl; + ui.ParagraphConstraints? _lastUsedConstraints; late final TextLayoutService _layoutService = TextLayoutService(this); @@ -168,7 +170,7 @@ class CanvasParagraph implements EngineParagraph { // 2. Append all spans to the paragraph. - ParagraphSpan? span; + FlatTextSpan? span; html.HtmlElement element = rootElement; final List lines = computeLineMetrics(); @@ -180,21 +182,34 @@ class CanvasParagraph implements EngineParagraph { } final EngineLineMetrics line = lines[i]; - for (final RangeBox box in line.boxes!) { + final List boxes = line.boxes!; + final StringBuffer buffer = StringBuffer(); + + int j = 0; + while (j < boxes.length) { + final RangeBox box = boxes[j++]; + if (box is SpanBox && box.span == span) { + buffer.write(box.toText()); + continue; + } + + if (buffer.isNotEmpty) { + domRenderer.appendText(element, buffer.toString()); + buffer.clear(); + } + if (box is SpanBox) { - if (box.span != span) { - span = box.span; - element = domRenderer.createElement('span') as html.HtmlElement; - applyTextStyleToElement( - element: element, - style: box.span.style, - isSpan: true - ); - domRenderer.append(rootElement, element); - } - domRenderer.appendText(element, box.toText()); + span = box.span; + element = domRenderer.createElement('span') as html.HtmlElement; + applyTextStyleToElement( + element: element, + style: box.span.style, + isSpan: true, + ); + domRenderer.append(rootElement, element); + buffer.write(box.toText()); } else if (box is PlaceholderBox) { - span = box.placeholder; + span = null; // If there's a line-end after this placeholder, we want the
to // be inserted in the root paragraph element. element = rootElement; @@ -207,6 +222,11 @@ class CanvasParagraph implements EngineParagraph { } } + if (buffer.isNotEmpty) { + domRenderer.appendText(element, buffer.toString()); + buffer.clear(); + } + final String? ellipsis = line.ellipsis; if (ellipsis != null) { domRenderer.appendText(element, ellipsis); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart index 4e404a553a3..7c880d146e9 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -13,6 +13,7 @@ import 'line_breaker.dart'; import 'measurement.dart'; import 'paragraph.dart'; import 'ruler.dart'; +import 'text_direction.dart'; /// Performs layout on a [CanvasParagraph]. /// @@ -137,9 +138,9 @@ class TextLayoutService { spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; - final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); + final DirectionalPosition nextBreak = currentLine.findNextBreak(); final double additionalWidth = - currentLine.getAdditionalWidthTo(nextBreak); + currentLine.getAdditionalWidthTo(nextBreak.lineBreak); if (currentLine.width + additionalWidth <= constraints.width) { // TODO(mdebbar): Handle the case when `nextBreak` is just a span end @@ -160,8 +161,11 @@ class TextLayoutService { // We've reached the line that requires an ellipsis to be appended // to it. - currentLine.forceBreak(nextBreak, - allowEmpty: true, ellipsis: ellipsis); + currentLine.forceBreak( + nextBreak, + allowEmpty: true, + ellipsis: ellipsis, + ); lines.add(currentLine.build(ellipsis: ellipsis)); break; } else if (currentLine.isEmpty) { @@ -226,7 +230,7 @@ class TextLayoutService { spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; - final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); + final DirectionalPosition nextBreak = currentLine.findNextBreak(); // For the purpose of max intrinsic width, we don't care if the line // fits within the constraints or not. So we always extend it. @@ -348,23 +352,89 @@ class TextLayoutService { } } -/// Represents a box inside [span] with the range of [start] to [end]. +/// Represents a box inside a paragraph span with the range of [start] to [end]. /// /// The box's coordinates are all relative to the line it belongs to. For /// example, [left] is the distance from the left edge of the line to the left /// edge of the box. +/// +/// This is what the various measurements/coordinates look like for a box in an +/// LTR paragraph: +/// +/// *------------------------lineWidth------------------* +/// *--width--* +/// ┌─────────────────┬─────────┬───────────────────────┐ +/// │ │---BOX---│ │ +/// └─────────────────┴─────────┴───────────────────────┘ +/// *---startOffset---* +/// *------left-------* +/// *--------endOffset----------* +/// *----------right------------* +/// +/// +/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because +/// the line starts from the right. Here's what they look like: +/// +/// *------------------------lineWidth------------------* +/// *--width--* +/// ┌─────────────────┬─────────┬───────────────────────┐ +/// │ │---BOX---│ │ +/// └─────────────────┴─────────┴───────────────────────┘ +/// *------startOffset------* +/// *------left-------* +/// *-----------endOffset-------------* +/// *----------right------------* +/// abstract class RangeBox { - LineBreakResult get start; - LineBreakResult get end; + RangeBox( + this.start, + this.end, + this.width, + this.paragraphDirection, + this.boxDirection, + ); + + final LineBreakResult start; + final LineBreakResult end; + + /// The distance from the beginning of the line to the beginning of the box. + late final double startOffset; + + /// The distance from the beginning of the line to the end of the box. + double get endOffset => startOffset + width; /// The distance from the left edge of the line to the left edge of the box. - double get left; + double get left => paragraphDirection == ui.TextDirection.ltr + ? startOffset + : lineWidth - endOffset; /// The distance from the left edge of the line to the right edge of the box. - double get right; + double get right => paragraphDirection == ui.TextDirection.ltr + ? endOffset + : lineWidth - startOffset; - /// The direction in which text inside this box flows. - ui.TextDirection get direction; + /// The distance from the left edge of the box to the right edge of the box. + final double width; + + /// The width of the line that this box belongs to. + late final double lineWidth; + + /// The text direction of the paragraph that this box belongs to. + final ui.TextDirection paragraphDirection; + + /// Indicates how this box flows among other boxes. + /// + /// Example: In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is shown + /// visually in the following order: + /// + /// +-------------------------------+ + /// | ABC | 123 | drow_werbeh | DEF | + /// +-------------------------------+ + /// box direction: LTR RTL RTL LTR + /// ----> <---- <------------ ----> + /// + /// (In the above example, we are ignoring whitespace to simplify). + final ui.TextDirection boxDirection; /// Returns a [ui.TextBox] representing this range box in the given [line]. /// @@ -385,27 +455,13 @@ class PlaceholderBox extends RangeBox { PlaceholderBox( this.placeholder, { required LineBreakResult index, - required this.left, - required this.direction, - }) : start = index, end = index; + required ui.TextDirection paragraphDirection, + required ui.TextDirection boxDirection, + }) : super(index, index, placeholder.width, paragraphDirection, boxDirection); final PlaceholderSpan placeholder; @override - final LineBreakResult start; - - @override - final LineBreakResult end; - - @override - final double left; - - @override - double get right => left + placeholder.width; - - @override - final ui.TextDirection direction; - ui.TextBox toTextBox(EngineLineMetrics line) { final double left = line.left + this.left; final double right = line.left + this.right; @@ -444,7 +500,7 @@ class PlaceholderBox extends RangeBox { top, right, top + placeholder.height, - direction, + paragraphDirection, ); } @@ -463,27 +519,52 @@ class PlaceholderBox extends RangeBox { class SpanBox extends RangeBox { SpanBox( Spanometer spanometer, { - required this.start, - required this.end, - required this.left, - required this.direction, - }) : this.spanometer = spanometer, + required LineBreakResult start, + required LineBreakResult end, + required double width, + required ui.TextDirection paragraphDirection, + required ui.TextDirection boxDirection, + required this.contentDirection, + required this.isSpaceOnly, + }) : this.spanometer = spanometer, span = spanometer.currentSpan, height = spanometer.height, baseline = spanometer.ascent, - width = spanometer.measureIncludingSpace(start, end); + super(start, end, width, paragraphDirection, boxDirection); final Spanometer spanometer; final FlatTextSpan span; - final LineBreakResult start; - final LineBreakResult end; - @override - final double left; + /// The direction of the text inside this box. + /// + /// To illustrate the difference between [boxDirection] and [contentDirection] + /// here's an example: + /// + /// In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is rendered as + /// follows: + /// + /// ----> <---- <------------ ----> + /// box direction: LTR RTL RTL LTR + /// +-------------------------------+ + /// | ABC | 123 | drow_werbeh | DEF | + /// +-------------------------------+ + /// content direction: LTR LTR RTL LTR + /// ----> ----> <------------ ----> + /// + /// Notice the box containing "123" flows in the RTL direction (because it + /// comes after an RTL box), while the content of the box flows in the LTR + /// direction (i.e. the text is shown as "123" not "321"). + final ui.TextDirection contentDirection; - /// The distance from the left edge to the right edge of the box. - final double width; + /// Whether this box is made of only white space. + final bool isSpaceOnly; + + /// Whether the contents of this box flow in the left-to-right direction. + bool get isContentLtr => contentDirection == ui.TextDirection.ltr; + + /// Whether the contents of this box flow in the right-to-left direction. + bool get isContentRtl => !isContentLtr; /// The distance from the top edge to the bottom edge of the box. final double height; @@ -492,12 +573,6 @@ class SpanBox extends RangeBox { /// the box. final double baseline; - @override - final ui.TextDirection direction; - - @override - double get right => left + width; - /// Whether this box's range overlaps with the range from [startIndex] to /// [endIndex]. bool overlapsWith(int startIndex, int endIndex) { @@ -515,6 +590,7 @@ class SpanBox extends RangeBox { /// /// The coordinates of the resulting [ui.TextBox] are relative to the /// paragraph, not to the line. + @override ui.TextBox toTextBox(EngineLineMetrics line) { return intersect(line, start.index, end.index); } @@ -526,41 +602,111 @@ class SpanBox extends RangeBox { /// paragraph, not to the line. ui.TextBox intersect(EngineLineMetrics line, int start, int end) { final double top = line.baseline - baseline; - final double left, right; + final double before; if (start <= this.start.index) { - left = this.left; + before = 0.0; } else { spanometer.currentSpan = span; - left = this.left + spanometer._measure(this.start.index, start); + before = spanometer._measure(this.start.index, start); } + final double after; if (end >= this.end.indexWithoutTrailingNewlines) { - right = this.right; + after = 0.0; } else { spanometer.currentSpan = span; - right = this.right - - spanometer._measure(end, this.end.indexWithoutTrailingNewlines); + after = spanometer._measure(end, this.end.indexWithoutTrailingNewlines); + } + + final double left, right; + if (isContentLtr) { + // Example: let's say the text is "Loremipsum" and we want to get the box + // for "rem". In this case, `before` is the width of "Lo", and `after` + // is the width of "ipsum". + // + // Here's how the measurements/coordinates look like: + // + // before after + // |----| |----------| + // +---------------------+ + // | L o r e m i p s u m | + // +---------------------+ + // this.left ^ ^ this.right + left = this.left + before; + right = this.right - after; + } else { + // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from + // right to left). Say we want to get the box for "brew". The `before` is + // the width of "He", and `after` is the width of "_text". + // + // after before + // |----------| |----| + // +-----------------------+ + // | t x e t _ w e r b e H | + // +-----------------------+ + // this.left ^ ^ this.right + // + // Notice how `before` and `after` are reversed in the RTL example. That's + // because the text flows from right to left. + left = this.left + after; + right = this.right - before; } // The [RangeBox]'s left and right edges are relative to the line. In order // to make them relative to the paragraph, we need to add the left edge of // the line. return ui.TextBox.fromLTRBD( - left + line.left, + line.left + left, top, - right + line.left, + line.left + right, top + height, - direction, + contentDirection, ); } + /// Transforms the [x] coordinate to be relative to this box and matches the + /// flow of content. + /// + /// In LTR paragraphs, the [startOffset] and [endOffset] of an RTL box + /// indicate the visual beginning and end of the box. But the text inside the + /// box flows in the opposite direction (from [endOffset] to [startOffset]). + /// + /// The X (input) is relative to the line, and always from left-to-right + /// independent of paragraph and content direction. + /// + /// Here's how it looks for a box with LTR content: + /// + /// *------------------------lineWidth------------------* + /// *---------------X (input) + /// ┌───────────┬────────────────────────┬───────────────┐ + /// │ │ --content-direction--> │ │ + /// └───────────┴────────────────────────┴───────────────┘ + /// *---X' (output) + /// *---left----* + /// *---------------right----------------* + /// + /// + /// And here's how it looks for a box with RTL content: + /// + /// *------------------------lineWidth------------------* + /// *----------------X (input) + /// ┌───────────┬────────────────────────┬───────────────┐ + /// │ │ <--content-direction-- │ │ + /// └───────────┴────────────────────────┴───────────────┘ + /// (output) X'------------------* + /// *---left----* + /// *---------------right----------------* + /// + double _makeXRelativeToContent(double x) { + return isContentRtl ? right - x : x - left; + } + @override ui.TextPosition getPositionForX(double x) { spanometer.currentSpan = span; - // Make `x` relative to this box. - x -= left; + x = _makeXRelativeToContent(x); final int startIndex = start.index; final int endIndex = end.indexWithoutTrailingNewlines; @@ -730,10 +876,7 @@ class LineBuilder { /// The horizontal offset necessary for the line to be correctly aligned. double get alignOffset { final double emptySpace = maxWidth - width; - final ui.TextDirection textDirection = - paragraph.paragraphStyle.effectiveTextDirection; - final ui.TextAlign textAlign = - paragraph.paragraphStyle.effectiveTextAlign; + final ui.TextAlign textAlign = paragraph.paragraphStyle.effectiveTextAlign; switch (textAlign) { case ui.TextAlign.center: @@ -741,9 +884,9 @@ class LineBuilder { case ui.TextAlign.right: return emptySpace; case ui.TextAlign.start: - return textDirection == ui.TextDirection.rtl ? emptySpace : 0.0; + return _paragraphDirection == ui.TextDirection.rtl ? emptySpace : 0.0; case ui.TextAlign.end: - return textDirection == ui.TextDirection.rtl ? 0.0 : emptySpace; + return _paragraphDirection == ui.TextDirection.rtl ? 0.0 : emptySpace; default: return 0.0; } @@ -767,12 +910,37 @@ class LineBuilder { return (_boxes.last is PlaceholderBox); } + ui.TextDirection get _paragraphDirection => + paragraph.paragraphStyle.effectiveTextDirection; + + late ui.TextDirection _currentBoxDirection = _paragraphDirection; + + late ui.TextDirection _currentContentDirection = _paragraphDirection; + + bool _shouldCreateBoxBeforeExtendingTo(DirectionalPosition newEnd) { + // When the direction changes, we need to make sure to put them in separate + // boxes. + return newEnd.isSpaceOnly || _currentBoxDirection != newEnd.textDirection || _currentContentDirection != newEnd.textDirection; + } + /// Extends the line by setting a [newEnd]. - void extendTo(LineBreakResult newEnd) { + void extendTo(DirectionalPosition newEnd) { ascent = math.max(ascent, spanometer.ascent); descent = math.max(descent, spanometer.descent); - _addSegment(_createSegment(newEnd)); + // When the direction changes, we need to make sure to put them in separate + // boxes. + if (_shouldCreateBoxBeforeExtendingTo(newEnd)) { + createBox(); + } + _currentBoxDirection = newEnd.textDirection ?? _currentBoxDirection; + _currentContentDirection = newEnd.textDirection ?? ui.TextDirection.ltr; + + _addSegment(_createSegment(newEnd.lineBreak)); + if (newEnd.isSpaceOnly) { + // Whitespace sequences go in their own boxes. + createBox(isSpaceOnly: true); + } } /// Extends the line to the end of the paragraph. @@ -854,10 +1022,11 @@ class LineBuilder { // Add the placeholder box. _boxes.add(PlaceholderBox( placeholder, - index: _boxStart, - left: _boxLeft, - direction: paragraph.paragraphStyle.effectiveTextDirection, + index: _currentBoxStart, + paragraphDirection: _paragraphDirection, + boxDirection: _currentBoxDirection, )); + _currentBoxStartOffset = widthIncludingSpace; } /// Creates a new segment to be appended to the end of this line. @@ -944,7 +1113,7 @@ class LineBuilder { /// 2. The line doesn't have any line break opportunities and has to be /// force-broken. void forceBreak( - LineBreakResult nextBreak, { + DirectionalPosition nextBreak, { required bool allowEmpty, String? ellipsis, }) { @@ -952,7 +1121,7 @@ class LineBuilder { final double availableWidth = maxWidth - widthIncludingSpace; final int breakingPoint = spanometer.forceBreak( end.index, - nextBreak.indexWithoutTrailingSpaces, + nextBreak.lineBreak.indexWithoutTrailingSpaces, availableWidth: availableWidth, allowEmpty: allowEmpty, ); @@ -961,15 +1130,13 @@ class LineBuilder { // 1. Next break is only one character away, with zero or many spaces. AND // 2. There isn't enough width to fit the single character. AND // 3. `allowEmpty` is false. - if (breakingPoint == nextBreak.indexWithoutTrailingSpaces) { + if (breakingPoint == nextBreak.lineBreak.indexWithoutTrailingSpaces) { // In this case, we just extend to `nextBreak` instead of creating a new // artificial break. It's safe (and better) to do so, because we don't // want the trailing white space to go to the next line. extendTo(nextBreak); } else { - extendTo( - LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited), - ); + extendTo(nextBreak.copyWithIndex(breakingPoint)); } return; } @@ -986,7 +1153,7 @@ class LineBuilder { final double availableWidth = maxWidth - ellipsisWidth; // First, we create the new segment until `nextBreak`. - LineSegment segmentToBreak = _createSegment(nextBreak); + LineSegment segmentToBreak = _createSegment(nextBreak.lineBreak); // Then, we keep popping until we find the segment that has to be broken. // After the loop ends, two things are correct: @@ -1005,17 +1172,18 @@ class LineBuilder { availableWidth: availableWidthForSegment, allowEmpty: allowEmpty, ); - extendTo( - LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited)); // There's a possibility that the end of line has moved backwards, so we // need to remove some boxes in that case. while (_boxes.length > 0 && _boxes.last.end.index > breakingPoint) { _boxes.removeLast(); } + _currentBoxStartOffset = widthIncludingSpace; + + extendTo(nextBreak.copyWithIndex(breakingPoint)); } - LineBreakResult get _boxStart { + LineBreakResult get _currentBoxStart { if (_boxes.isEmpty) { return start; } @@ -1023,25 +1191,22 @@ class LineBuilder { return _boxes.last.end; } - double get _boxLeft { - if (_boxes.isEmpty) { - return 0.0; - } - return _boxes.last.right; - } + double _currentBoxStartOffset = 0.0; - ui.TextDirection get direction => - paragraph.paragraphStyle.effectiveTextDirection; + double get _currentBoxWidth => widthIncludingSpace - _currentBoxStartOffset; /// Cuts a new box in the line. /// /// If this is the first box in the line, it'll start at the beginning of the /// line. Else, it'll start at the end of the last box. /// - /// A box should be cut whenever the end of line is reached, or when switching - /// from one span to another. - void createBox() { - final LineBreakResult boxStart = _boxStart; + /// A box should be cut whenever the end of line is reached, when switching + /// from one span to another, or when switching text direction. + /// + /// [isSpaceOnly] indicates that the box contains nothing but whitespace + /// characters. + void createBox({bool isSpaceOnly = false}) { + final LineBreakResult boxStart = _currentBoxStart; final LineBreakResult boxEnd = end; // Avoid creating empty boxes. This could happen when the end of a span // coincides with the end of a line. In this case, `createBox` is called twice. @@ -1053,15 +1218,21 @@ class LineBuilder { spanometer, start: boxStart, end: boxEnd, - left: _boxLeft, - direction: paragraph.paragraphStyle.effectiveTextDirection, + width: _currentBoxWidth, + paragraphDirection: _paragraphDirection, + boxDirection: _currentBoxDirection, + contentDirection: _currentContentDirection, + isSpaceOnly: isSpaceOnly, )); + + _currentBoxStartOffset = widthIncludingSpace; } /// Builds the [EngineLineMetrics] instance that represents this line. EngineLineMetrics build({String? ellipsis}) { // At the end of each line, we cut the last box of the line. createBox(); + _positionBoxes(); final double ellipsisWidth = ellipsis == null ? 0.0 : spanometer.measureText(ellipsis); @@ -1091,9 +1262,112 @@ class LineBuilder { ); } + /// Positions the boxes and takes into account their directions, and the + /// paragraph's direction. + void _positionBoxes() { + final List boxes = _boxes; + + int i = 0; + double cumulativeWidth = 0.0; + while (i < boxes.length) { + final RangeBox box = boxes[i]; + if (box.boxDirection == _paragraphDirection) { + // The box is in the same direction as the paragraph. + box.startOffset = cumulativeWidth; + box.lineWidth = width; + + cumulativeWidth += box.width; + i++; + continue; + } + + // At this point, we found a box that has the opposite direction to the + // paragraph. This could be a sequence of one or more boxes. + // + // These boxes should flow in the opposite direction. So we need to + // position them in reverse order. + // + // If the last box in the sequence is a space-only box (contains only + // whitespace characters), it should be excluded from the sequence. + // + // Example: an LTR paragraph with the contents: + // + // "ABC rtl1 rtl2 rtl3 XYZ" + // ^ ^ ^ ^ + // SP1 SP2 SP3 SP4 + // + // + // box direction: LTR RTL LTR + // |------>|<-----------------------|------> + // +----------------------------------------+ + // | ABC | | rtl3 | | rtl2 | | rtl1 | | XYZ | + // +----------------------------------------+ + // ^ ^ ^ ^ + // SP1 SP3 SP2 SP4 + // + // Notice how SP2 and SP3 are flowing in the RTL direction because of the + // surrounding RTL words. SP4 is also preceded by an RTL word, but it marks + // the end of the RTL sequence, so it goes back to flowing in the paragraph + // direction (LTR). + + final int first = i; + int lastNonSpaceBox = first; + i++; + while (i < boxes.length && boxes[i].boxDirection != _paragraphDirection) { + final RangeBox box = boxes[i]; + if (box is SpanBox && box.isSpaceOnly) { + // Do nothing. + } else { + lastNonSpaceBox = i; + } + i++; + } + final int last = lastNonSpaceBox; + i = lastNonSpaceBox + 1; + + // The range (first:last) is the entire sequence of boxes that have the + // opposite direction to the paragraph. + final double sequenceWidth = + _positionBoxesInReverse(boxes, first, last, startOffset: cumulativeWidth); + cumulativeWidth += sequenceWidth; + } + } + + /// Positions a sequence of boxes in the direction opposite to the paragraph + /// text direction. + /// + /// This is needed when a right-to-left sequence appears in the middle of a + /// left-to-right paragraph, or vice versa. + /// + /// Returns the total width of all the positioned boxes in the sequence. + /// + /// [first] and [last] are expected to be inclusive. + double _positionBoxesInReverse( + List boxes, + int first, + int last, { + required double startOffset, + }) { + double cumulativeWidth = 0.0; + for (int i = last; i >= first; i--) { + // Update the visual position of each box. + final RangeBox box = boxes[i]; + assert(box.boxDirection != _paragraphDirection); + box.startOffset = startOffset + cumulativeWidth; + box.lineWidth = width; + + cumulativeWidth += box.width; + } + return cumulativeWidth; + } + /// Finds the next line break after the end of this line. - LineBreakResult findNextBreak(int maxEnd) { - return nextLineBreak(paragraph.toPlainText(), end.index, maxEnd: maxEnd); + DirectionalPosition findNextBreak() { + final String text = paragraph.toPlainText(); + final int maxEnd = spanometer.currentSpan.end; + final LineBreakResult result = nextLineBreak(text, end.index, maxEnd: maxEnd); + // The current end of the line is the beginning of the next block. + return getDirectionalBlockEnd(text, end, result); } /// Creates a new [LineBuilder] to build the next line in the paragraph. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/text_direction.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/text_direction.dart new file mode 100644 index 00000000000..56e6f0459bb --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/text_direction.dart @@ -0,0 +1,118 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:ui/ui.dart' as ui; + +import 'line_breaker.dart'; +import 'unicode_range.dart'; + +// This data was taken from the source code of the Closure library: +// +// - https://github.com/google/closure-library/blob/9d24a6c1809a671c2e54c328897ebeae15a6d172/closure/goog/i18n/bidi.js#L203-L234 +final UnicodePropertyLookup _textDirectionLookup = UnicodePropertyLookup( + >[ + // LTR + UnicodeRange(kChar_A, kChar_Z, ui.TextDirection.ltr), + UnicodeRange(kChar_a, kChar_z, ui.TextDirection.ltr), + UnicodeRange(0x00C0, 0x00D6, ui.TextDirection.ltr), + UnicodeRange(0x00D8, 0x00F6, ui.TextDirection.ltr), + UnicodeRange(0x00F8, 0x02B8, ui.TextDirection.ltr), + UnicodeRange(0x0300, 0x0590, ui.TextDirection.ltr), + // RTL + UnicodeRange(0x0591, 0x06EF, ui.TextDirection.rtl), + UnicodeRange(0x06FA, 0x08FF, ui.TextDirection.rtl), + // LTR + UnicodeRange(0x0900, 0x1FFF, ui.TextDirection.ltr), + UnicodeRange(0x200E, 0x200E, ui.TextDirection.ltr), + // RTL + UnicodeRange(0x200F, 0x200F, ui.TextDirection.rtl), + // LTR + UnicodeRange(0x2C00, 0xD801, ui.TextDirection.ltr), + // RTL + UnicodeRange(0xD802, 0xD803, ui.TextDirection.rtl), + // LTR + UnicodeRange(0xD804, 0xD839, ui.TextDirection.ltr), + // RTL + UnicodeRange(0xD83A, 0xD83B, ui.TextDirection.rtl), + // LTR + UnicodeRange(0xD83C, 0xDBFF, ui.TextDirection.ltr), + UnicodeRange(0xF900, 0xFB1C, ui.TextDirection.ltr), + // RTL + UnicodeRange(0xFB1D, 0xFDFF, ui.TextDirection.rtl), + // LTR + UnicodeRange(0xFE00, 0xFE6F, ui.TextDirection.ltr), + // RTL + UnicodeRange(0xFE70, 0xFEFC, ui.TextDirection.rtl), + // LTR + UnicodeRange(0xFEFD, 0xFFFF, ui.TextDirection.ltr), + ], + null, +); + +/// Represents a block of text with a certain [ui.TextDirection]. +class DirectionalPosition { + const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly); + + final LineBreakResult lineBreak; + + final ui.TextDirection? textDirection; + + final bool isSpaceOnly; + + LineBreakType get type => lineBreak.type; + + /// Creates a copy of this [DirectionalPosition] with a different [index]. + /// + /// The type of the returned [DirectionalPosition] is set to + /// [LineBreakType.prohibited]. + DirectionalPosition copyWithIndex(int index) { + return DirectionalPosition( + LineBreakResult.sameIndex(index, LineBreakType.prohibited), + textDirection, + isSpaceOnly, + ); + } +} + +/// Finds the end of the directional block of text that starts at [start] up +/// until [end]. +/// +/// If the block goes beyond [end], the part after [end] is ignored. +DirectionalPosition getDirectionalBlockEnd( + String text, + LineBreakResult start, + LineBreakResult end, +) { + if (start.index == end.index) { + return DirectionalPosition(end, null, false); + } + + // Check if we are in a space-only block. + if (start.index == end.indexWithoutTrailingSpaces) { + return DirectionalPosition(end, null, true); + } + + ui.TextDirection? blockDirection = _textDirectionLookup.find(text, start.index); + int i = start.index + 1; + + while (i < end.indexWithoutTrailingSpaces) { + final ui.TextDirection? direction = _textDirectionLookup.find(text, i); + if (direction != blockDirection) { + // Reached the next block. + break; + } + i++; + } + + if (i == end.indexWithoutTrailingNewlines) { + // If all that remains before [end] is new lines, let's include them in the + // block. + return DirectionalPosition(end, blockDirection, false); + } + return DirectionalPosition( + LineBreakResult.sameIndex(i, LineBreakType.prohibited), + blockDirection, + false, + ); +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart index 4c96d6043ef..90ed51fb171 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart @@ -2,13 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -const int _kChar_0 = 48; -const int _kChar_9 = 57; -const int _kChar_A = 65; -const int _kChar_Z = 90; -const int _kChar_a = 97; -const int _kChar_z = 122; -const int _kCharBang = 33; +const int kChar_0 = 48; +const int kChar_9 = 57; +const int kChar_A = 65; +const int kChar_Z = 90; +const int kChar_a = 97; +const int kChar_z = 122; +const int kCharBang = 33; enum _ComparisonResult { inside, @@ -210,7 +210,7 @@ List> _unpackProperties

( i += 4; int rangeEnd; - if (packedData.codeUnitAt(i) == _kCharBang) { + if (packedData.codeUnitAt(i) == kCharBang) { rangeEnd = rangeStart; i++; } else { @@ -231,15 +231,15 @@ int _getEnumIndexFromPackedValue(int charCode) { // This has to stay in sync with [EnumValue.serialized] in // `tool/unicode_sync_script.dart`. - assert((charCode >= _kChar_A && charCode <= _kChar_Z) || - (charCode >= _kChar_a && charCode <= _kChar_z)); + assert((charCode >= kChar_A && charCode <= kChar_Z) || + (charCode >= kChar_a && charCode <= kChar_z)); // Uppercase letters were assigned to the first 26 enum values. - if (charCode <= _kChar_Z) { - return charCode - _kChar_A; + if (charCode <= kChar_Z) { + return charCode - kChar_A; } // Lowercase letters were assigned to enum values above 26. - return 26 + charCode - _kChar_a; + return 26 + charCode - kChar_a; } int _consumeInt(String packedData, int index) { @@ -261,12 +261,12 @@ int _consumeInt(String packedData, int index) { /// Does the same thing as [int.parse(str, 36)] but takes only a single /// character as a [charCode] integer. int _getIntFromCharCode(int charCode) { - assert((charCode >= _kChar_0 && charCode <= _kChar_9) || - (charCode >= _kChar_a && charCode <= _kChar_z)); + assert((charCode >= kChar_0 && charCode <= kChar_9) || + (charCode >= kChar_a && charCode <= kChar_z)); - if (charCode <= _kChar_9) { - return charCode - _kChar_0; + if (charCode <= kChar_9) { + return charCode - kChar_0; } // "a" starts from 10 and remaining letters go up from there. - return charCode - _kChar_a + 10; + return charCode - kChar_a + 10; } diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/bidi_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/bidi_test.dart new file mode 100644 index 00000000000..048c4759c1d --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/bidi_test.dart @@ -0,0 +1,582 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/ui.dart' hide window; +import 'package:ui/src/engine.dart'; + +import '../scuba.dart'; +import 'helper.dart'; + +typedef CanvasTest = FutureOr Function(EngineCanvas canvas); + +const String _rtlWord1 = 'واحد'; +const String _rtlWord2 = 'اثنان'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() async { + setUpStableTestFonts(); + + void paintBasicBidiStartingWithLtr( + EngineCanvas canvas, + Rect bounds, + double y, + TextDirection textDirection, + TextAlign textAlign, + ) { + // The text starts with a left-to-right word. + const String text = 'One 12 $_rtlWord1 $_rtlWord2 34 two 56'; + + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'Roboto', + fontSize: 20.0, + textDirection: textDirection, + textAlign: textAlign, + ); + final CanvasParagraph paragraph = plain( + paragraphStyle, + text, + textStyle: EngineTextStyle.only(color: blue), + ); + final double maxWidth = bounds.width - 10; + paragraph.layout(constrain(maxWidth)); + canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5)); + } + + test('basic bidi starting with ltr', () { + const Rect bounds = Rect.fromLTWH(0, 0, 340, 600); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const double height = 40; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBasicBidiStartingWithLtr(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBasicBidiStartingWithLtr(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_ltr'); + }); + + test('basic bidi starting with ltr (DOM)', () { + const Rect bounds = Rect.fromLTWH(0, 0, 340, 600); + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const double height = 40; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBasicBidiStartingWithLtr(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBasicBidiStartingWithLtr(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBasicBidiStartingWithLtr(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBasicBidiStartingWithLtr(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_ltr_dom'); + }); + + void paintBasicBidiStartingWithRtl( + EngineCanvas canvas, + Rect bounds, + double y, + TextDirection textDirection, + TextAlign textAlign, + ) { + // The text starts with a right-to-left word. + const String text = '$_rtlWord1 12 one 34 $_rtlWord2 56 two'; + + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'Roboto', + fontSize: 20.0, + textDirection: textDirection, + textAlign: textAlign, + ); + final CanvasParagraph paragraph = plain( + paragraphStyle, + text, + textStyle: EngineTextStyle.only(color: blue), + ); + final double maxWidth = bounds.width - 10; + paragraph.layout(constrain(maxWidth)); + canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5)); + } + + test('basic bidi starting with rtl', () { + const Rect bounds = Rect.fromLTWH(0, 0, 340, 600); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const double height = 40; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBasicBidiStartingWithRtl(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBasicBidiStartingWithRtl(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_rtl'); + }); + + test('basic bidi starting with rtl (DOM)', () { + const Rect bounds = Rect.fromLTWH(0, 0, 340, 600); + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const double height = 40; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBasicBidiStartingWithRtl(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBasicBidiStartingWithRtl(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBasicBidiStartingWithRtl(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBasicBidiStartingWithRtl(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_rtl_dom'); + }); + + void paintMultilineBidi( + EngineCanvas canvas, + Rect bounds, + double y, + TextDirection textDirection, + TextAlign textAlign, + ) { + // ''' + // Lorem 12 $_rtlWord1 + // $_rtlWord2 34 ipsum + // dolor 56 + // ''' + const String text = 'Lorem 12 $_rtlWord1 $_rtlWord2 34 ipsum dolor 56'; + + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'Roboto', + fontSize: 20.0, + textDirection: textDirection, + textAlign: textAlign, + ); + final CanvasParagraph paragraph = plain( + paragraphStyle, + text, + textStyle: EngineTextStyle.only(color: blue), + ); + final double maxWidth = bounds.width - 10; + paragraph.layout(constrain(maxWidth)); + canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5)); + } + + test('multiline bidi', () { + final Rect bounds = Rect.fromLTWH(0, 0, 400, 500); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const double height = 95; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintMultilineBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintMultilineBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintMultilineBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintMultilineBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintMultilineBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintMultilineBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintMultilineBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintMultilineBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintMultilineBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintMultilineBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multiline'); + }); + + test('multiline bidi (DOM)', () { + const Rect bounds = Rect.fromLTWH(0, 0, 400, 500); + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const double height = 95; + + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintMultilineBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintMultilineBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintMultilineBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintMultilineBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintMultilineBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintMultilineBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintMultilineBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintMultilineBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintMultilineBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintMultilineBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multiline_dom'); + }); + + void paintMultSpanBidi( + EngineCanvas canvas, + Rect bounds, + double y, + TextDirection textDirection, + TextAlign textAlign, + ) { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'Roboto', + fontSize: 20.0, + textDirection: textDirection, + textAlign: textAlign, + ); + // ''' + // Lorem 12 $_rtlWord1 + // $_rtlWord2 34 ipsum + // dolor 56 + // ''' + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('12 '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('$_rtlWord1 '); + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText('$_rtlWord2 '); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('34 ipsum '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('dolor 56 '); + }); + final double maxWidth = bounds.width - 10; + paragraph.layout(constrain(maxWidth)); + canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5)); + } + + test('multi span bidi', () { + const Rect bounds = Rect.fromLTWH(0, 0, 400, 900); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const double height = 95; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintMultSpanBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintMultSpanBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintMultSpanBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintMultSpanBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintMultSpanBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintMultSpanBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintMultSpanBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintMultSpanBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintMultSpanBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintMultSpanBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multispan'); + }); + + test('multi span bidi (DOM)', () { + const Rect bounds = Rect.fromLTWH(0, 0, 400, 900); + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const double height = 95; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintMultSpanBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintMultSpanBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintMultSpanBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintMultSpanBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintMultSpanBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintMultSpanBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintMultSpanBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintMultSpanBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintMultSpanBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintMultSpanBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multispan_dom'); + }); + + void paintBidiWithSelection( + EngineCanvas canvas, + Rect bounds, + double y, + TextDirection textDirection, + TextAlign textAlign, + ) { + // ''' + // Lorem 12 $_rtlWord1 + // $_rtlWord2 34 ipsum + // dolor 56 + // ''' + const String text = 'Lorem 12 $_rtlWord1 $_rtlWord2 34 ipsum dolor 56'; + + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'Roboto', + fontSize: 20.0, + textDirection: textDirection, + textAlign: textAlign, + ); + final CanvasParagraph paragraph = plain( + paragraphStyle, + text, + textStyle: EngineTextStyle.only(color: blue), + ); + + final double maxWidth = bounds.width - 10; + paragraph.layout(constrain(maxWidth)); + + final Offset offset = Offset(bounds.left + 5, bounds.top + y + 5); + + // Range for "em 12 " and the first character of `_rtlWord1`. + paintBoxes(canvas, offset, paragraph.getBoxesForRange(3, 10), lightBlue); + // Range for the second half of `_rtlWord1` and all of `_rtlWord2` and " 3". + paintBoxes(canvas, offset, paragraph.getBoxesForRange(11, 21), lightPurple); + // Range for "psum dolo". + paintBoxes(canvas, offset, paragraph.getBoxesForRange(24, 33), green); + + canvas.drawParagraph(paragraph, offset); + } + + test('bidi with selection', () { + const Rect bounds = Rect.fromLTWH(0, 0, 400, 500); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const double height = 95; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBidiWithSelection(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBidiWithSelection(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBidiWithSelection(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBidiWithSelection(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBidiWithSelection(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBidiWithSelection(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBidiWithSelection(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBidiWithSelection(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBidiWithSelection(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBidiWithSelection(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_selection'); + }); + + test('bidi with selection (DOM)', () { + const Rect bounds = Rect.fromLTWH(0, 0, 400, 500); + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const double height = 95; + + // Border for ltr paragraphs. + final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10); + canvas.drawRect( + ltrBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // LTR with different text align values: + paintBidiWithSelection(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left); + paintBidiWithSelection(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right); + paintBidiWithSelection(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center); + paintBidiWithSelection(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start); + paintBidiWithSelection(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end); + + // Border for rtl paragraphs. + final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0); + canvas.drawRect( + rtlBox, + SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke, + ); + // RTL with different text align values: + paintBidiWithSelection(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left); + paintBidiWithSelection(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right); + paintBidiWithSelection(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center); + paintBidiWithSelection(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start); + paintBidiWithSelection(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_selection_dom'); + }); +} + +void paintBoxes(EngineCanvas canvas, Offset offset, List boxes, Color color) { + for (final TextBox box in boxes) { + final Rect rect = box.toRect().shift(offset); + canvas.drawRect(rect, SurfacePaintData()..color = color); + } +} diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart index d828ca12b35..7c913d3bab9 100644 --- a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart @@ -11,14 +11,30 @@ import 'package:web_engine_tester/golden_tester.dart'; const Color white = Color(0xFFFFFFFF); const Color black = Color(0xFF000000); const Color red = Color(0xFFFF0000); +const Color lightGreen = Color(0xFFDCEDC8); const Color green = Color(0xFF00FF00); +const Color lightBlue = Color(0xFFB3E5FC); const Color blue = Color(0xFF0000FF); const Color yellow = Color(0xFFFFEB3B); +const Color lightPurple = Color(0xFFE1BEE7); ParagraphConstraints constrain(double width) { return ParagraphConstraints(width: width); } +CanvasParagraph plain( + EngineParagraphStyle style, + String text, { + EngineTextStyle? textStyle, +}) { + final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); + if (textStyle != null) { + builder.pushStyle(textStyle); + } + builder.addText(text); + return builder.build(); +} + CanvasParagraph rich( EngineParagraphStyle style, void Function(CanvasParagraphBuilder) callback, diff --git a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_test.dart index f7fbfc88ecd..1dd9df7c8f0 100644 --- a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_test.dart @@ -84,7 +84,10 @@ void testMain() async { // "Lorem " paragraph.getBoxesForRange(0, 6), [ - box(0, 0, 60, 10), + // "Lorem" + box(0, 0, 50, 10), + // " " + box(50, 0, 60, 10), ], ); @@ -101,7 +104,10 @@ void testMain() async { // "um " paragraph.getBoxesForRange(9, 12), [ - box(90, 0, 120, 10), + // "um" + box(90, 0, 110, 10), + // " " + box(110, 0, 120, 10), ], ); @@ -111,7 +117,11 @@ void testMain() async { // "rem ipsum" paragraph.getBoxesForRange(2, 11), [ - box(20, 0, 60, 10), + // "rem" + box(20, 0, 50, 10), + // " " + box(50, 0, 60, 10), + // "ipsum" box(60, 0, 110, 10), ], ); @@ -119,11 +129,18 @@ void testMain() async { // Across all spans "Lorem ", "ipsum ", ".". expect( - // "Lorem ipsum." + // "Lorem ipsum ." paragraph.getBoxesForRange(0, 13), [ - box(0, 0, 60, 10), - box(60, 0, 120, 10), + // "Lorem" + box(0, 0, 50, 10), + // " " + box(50, 0, 60, 10), + // "ipsum" + box(60, 0, 110, 10), + // " " + box(110, 0, 120, 10), + // "." box(120, 0, 130, 10), ], ); @@ -155,7 +172,10 @@ void testMain() async { // "Lorem " paragraph.getBoxesForRange(0, 6), [ - box(0, 0, 60, 10), + // "Lorem" + box(0, 0, 50, 10), + // " " + box(50, 0, 60, 10), ], ); @@ -165,7 +185,10 @@ void testMain() async { // "psum " paragraph.getBoxesForRange(7, 12), [ - box(10, 10, 60, 20), + // "psum" + box(10, 10, 50, 20), + // " " + box(50, 10, 60, 20), ], ); @@ -177,9 +200,20 @@ void testMain() async { // "dolor s" paragraph.getBoxesForRange(3, 19), [ - box(30, 0, 60, 10), - box(0, 10, 60, 20), - box(0, 20, 70, 30), + // "em" + box(30, 0, 50, 10), + // " " + box(50, 0, 60, 10), + // "ipsum" + box(0, 10, 50, 20), + // " " + box(50, 10, 60, 20), + // "dolor" + box(0, 20, 50, 30), + // " " + box(50, 20, 60, 30), + // "s" + box(60, 20, 70, 30), ], ); }); @@ -213,7 +247,10 @@ void testMain() async { // "Lorem " paragraph.getBoxesForRange(0, 6), [ - box(0, 0, 60, 10), + // "Lorem" + box(0, 0, 50, 10), + // " " + box(50, 0, 60, 10), ], ); @@ -223,7 +260,10 @@ void testMain() async { // "psum " paragraph.getBoxesForRange(7, 12), [ - box(10, 10, 60, 20), + // "psum" + box(10, 10, 50, 20), + // " " + box(50, 10, 60, 20), ], ); @@ -233,7 +273,11 @@ void testMain() async { // "lor sit" paragraph.getBoxesForRange(14, 21), [ - box(20, 20, 60, 30), + // "lor" + box(20, 20, 50, 30), + // " " + box(50, 20, 60, 30), + // "sit" box(60, 20, 90, 30), ], ); @@ -246,9 +290,19 @@ void testMain() async { // "dolor s" paragraph.getBoxesForRange(3, 19), [ - box(30, 0, 60, 10), - box(0, 10, 60, 20), - box(0, 20, 60, 30), + // "em" + box(30, 0, 50, 10), + // " " + box(50, 0, 60, 10), + // "ipsum" + box(0, 10, 50, 20), + // " " + box(50, 10, 60, 20), + // "dolor" + box(0, 20, 50, 30), + // " " + box(50, 20, 60, 30), + // "s" box(60, 20, 70, 30), ], ); @@ -287,8 +341,15 @@ void testMain() async { // "em ipsum dol" paragraph.getBoxesForRange(3, 15), [ - box(60, 16, 120, 36), - box(120, 0, 360, 40), + // "em" + box(60, 16, 100, 36), + // " " + box(100, 16, 120, 36), + // "ipsum" + box(120, 0, 320, 40), + // " " + box(320, 0, 360, 40), + // "dol" box(360, 24, 390, 34), ], ); @@ -298,9 +359,19 @@ void testMain() async { // "sit amet" paragraph.getBoxesForRange(8, 26), [ - box(200, 0, 360, 40), - box(360, 24, 420, 34), - box(0, 40, 120, 70), + // "sum" + box(200, 0, 320, 40), + // " " + box(320, 0, 360, 40), + // "dolor" + box(360, 24, 410, 34), + // " " + box(410, 24, 420, 34), + // "sit" + box(0, 40, 90, 70), + // " " + box(90, 40, 120, 70), + // "amet" box(120, 48, 200, 68), ], ); diff --git a/engine/src/flutter/lib/web_ui/test/text/text_direction_test.dart b/engine/src/flutter/lib/web_ui/test/text/text_direction_test.dart new file mode 100644 index 00000000000..3176706a638 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/text/text_direction_test.dart @@ -0,0 +1,126 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; + +// Two RTL strings, 5 characters each, to match the length of "$rtl1" and "$rtl2". +const String rtl1 = 'واحدة'; +const String rtl2 = 'ثنتان'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() async { + group('$getDirectionalBlockEnd', () { + + test('basic cases', () { + const String text = 'Lorem 12 $rtl1 ipsum34'; + const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited); + const LineBreakResult end = LineBreakResult.sameIndex(text.length, LineBreakType.endOfText); + const LineBreakResult loremMiddle = LineBreakResult.sameIndex(3, LineBreakType.prohibited); + const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited); + const LineBreakResult twelveStart = LineBreakResult(6, 6, 5, LineBreakType.opportunity); + const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited); + const LineBreakResult rtl1Start = LineBreakResult(9, 9, 8, LineBreakType.opportunity); + const LineBreakResult rtl1End = LineBreakResult.sameIndex(14, LineBreakType.prohibited); + const LineBreakResult ipsumStart = LineBreakResult(17, 17, 15, LineBreakType.opportunity); + const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(22, LineBreakType.prohibited); + + DirectionalPosition blockEnd; + + blockEnd = getDirectionalBlockEnd(text, start, end); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, loremEnd); + + blockEnd = getDirectionalBlockEnd(text, start, loremMiddle); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, loremMiddle); + + blockEnd = getDirectionalBlockEnd(text, loremMiddle, loremEnd); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, loremEnd); + + blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart); + expect(blockEnd.isSpaceOnly, isTrue); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, twelveStart); + + blockEnd = getDirectionalBlockEnd(text, twelveStart, rtl1Start); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, twelveEnd); + + blockEnd = getDirectionalBlockEnd(text, rtl1Start, end); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.rtl); + expect(blockEnd.lineBreak, rtl1End); + + blockEnd = getDirectionalBlockEnd(text, ipsumStart, end); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, ipsumEnd); + + blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, end); + }); + + test('handles new lines', () { + const String text = 'Lorem\n12\nipsum \n'; + const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited); + const LineBreakResult end = LineBreakResult( + text.length, + text.length - 1, + text.length - 3, + LineBreakType.mandatory, + ); + const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited); + const LineBreakResult twelveStart = LineBreakResult(6, 5, 5, LineBreakType.mandatory); + const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited); + const LineBreakResult ipsumStart = LineBreakResult(9, 8, 8, LineBreakType.mandatory); + const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(14, LineBreakType.prohibited); + + DirectionalPosition blockEnd; + + blockEnd = getDirectionalBlockEnd(text, start, twelveStart); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, twelveStart); + + blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart); + expect(blockEnd.isSpaceOnly, isTrue); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, twelveStart); + + blockEnd = getDirectionalBlockEnd(text, twelveStart, ipsumStart); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, ipsumStart); + + blockEnd = getDirectionalBlockEnd(text, twelveEnd, ipsumStart); + expect(blockEnd.isSpaceOnly, isTrue); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, ipsumStart); + + blockEnd = getDirectionalBlockEnd(text, ipsumStart, end); + expect(blockEnd.isSpaceOnly, isFalse); + expect(blockEnd.textDirection, TextDirection.ltr); + expect(blockEnd.lineBreak, ipsumEnd); + + blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end); + expect(blockEnd.isSpaceOnly, isTrue); + expect(blockEnd.textDirection, isNull); + expect(blockEnd.lineBreak, end); + }); + }); +}