From 5ae7bb26235ebe7dd15ed8a2aeb5519063945880 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 18 Oct 2022 16:58:35 -0400 Subject: [PATCH] [web] Separate text fragmenting from layout (flutter/engine#34085) --- .../ci/licenses_golden/licenses_flutter | 2 + .../flutter/lib/web_ui/lib/src/engine.dart | 2 + .../lib/web_ui/lib/src/engine/dom.dart | 2 + .../lib/src/engine/html/bitmap_canvas.dart | 6 +- .../lib/src/engine/text/canvas_paragraph.dart | 156 +- .../lib/src/engine/text/fragmenter.dart | 34 + .../src/engine/text/layout_fragmenter.dart | 628 +++++++ .../lib/src/engine/text/layout_service.dart | 1631 +++++------------ .../lib/src/engine/text/line_breaker.dart | 441 ++--- .../lib/src/engine/text/paint_service.dart | 134 +- .../web_ui/lib/src/engine/text/paragraph.dart | 128 +- .../lib/src/engine/text/text_direction.dart | 184 +- .../lib/src/engine/text/unicode_range.dart | 4 +- .../test/html/bitmap_canvas_golden_test.dart | 6 + .../web_ui/test/html/paragraph/helper.dart | 16 + .../html/paragraph/overflow_golden_test.dart | 11 +- .../text/canvas_paragraph_builder_test.dart | 36 +- .../test/text/canvas_paragraph_test.dart | 41 +- .../test/text/layout_fragmenter_test.dart | 293 +++ .../test/text/layout_service_helper.dart | 19 +- .../test/text/layout_service_plain_test.dart | 26 +- .../test/text/layout_service_rich_test.dart | 10 +- .../web_ui/test/text/line_breaker_test.dart | 448 +++-- .../web_ui/test/text/text_direction_test.dart | 260 ++- 24 files changed, 2562 insertions(+), 1956 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart create mode 100644 engine/src/flutter/lib/web_ui/test/text/layout_fragmenter_test.dart diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index 3b4555d66fe..627096d0d20 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -1926,6 +1926,8 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/svg.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/test_embedding.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/font_collection.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_service.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_break_properties.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart 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 54464dd4dba..f83e43ce524 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -148,6 +148,8 @@ export 'engine/svg.dart'; export 'engine/test_embedding.dart'; export 'engine/text/canvas_paragraph.dart'; export 'engine/text/font_collection.dart'; +export 'engine/text/fragmenter.dart'; +export 'engine/text/layout_fragmenter.dart'; export 'engine/text/layout_service.dart'; export 'engine/text/line_break_properties.dart'; export 'engine/text/line_breaker.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart index 3ef7e820f91..ee754996ccd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart @@ -634,6 +634,8 @@ extension DomCanvasRenderingContext2DExtension on DomCanvasRenderingContext2D { external set fillStyle(Object? style); external String get font; external set font(String value); + external String get direction; + external set direction(String value); external set lineWidth(num? value); external set strokeStyle(Object? value); external Object? get strokeStyle; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart index e0c70ebb703..b2b93abbb67 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart @@ -913,9 +913,11 @@ class BitmapCanvas extends EngineCanvas { _cachedLastCssFont = null; } - void setCssFont(String cssFont) { + void setCssFont(String cssFont, ui.TextDirection textDirection) { + final DomCanvasRenderingContext2D ctx = _canvasPool.context; + ctx.direction = textDirection == ui.TextDirection.ltr ? 'ltr' : 'rtl'; + if (cssFont != _cachedLastCssFont) { - final DomCanvasRenderingContext2D ctx = _canvasPool.context; ctx.font = cssFont; _cachedLastCssFont = cssFont; } 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 b0de4f86115..59cfc57d0c0 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 @@ -9,6 +9,7 @@ import '../embedder.dart'; import '../html/bitmap_canvas.dart'; import '../profiler.dart'; import '../util.dart'; +import 'layout_fragmenter.dart'; import 'layout_service.dart'; import 'paint_service.dart'; import 'paragraph.dart'; @@ -16,6 +17,8 @@ import 'word_breaker.dart'; const ui.Color _defaultTextColor = ui.Color(0xFFFF0000); +final String _placeholderChar = String.fromCharCode(0xFFFC); + /// A paragraph made up of a flat list of text spans and placeholders. /// /// [CanvasParagraph] doesn't use a DOM element to represent the structure of @@ -32,7 +35,7 @@ class CanvasParagraph implements ui.Paragraph { required this.plainText, required this.placeholderCount, required this.canDrawOnCanvas, - }); + }) : assert(spans.isNotEmpty); /// The flat list of spans that make up this paragraph. final List spans; @@ -168,38 +171,28 @@ class CanvasParagraph implements ui.Paragraph { // 2. Append all spans to the paragraph. - DomHTMLElement? lastSpanElement; for (int i = 0; i < lines.length; i++) { final ParagraphLine line = lines[i]; - 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) { - lastSpanElement = domDocument.createElement('flt-span') as - DomHTMLElement; - applyTextStyleToElement( - element: lastSpanElement, - style: box.span.style, - isSpan: true, - ); - _positionSpanElement(lastSpanElement, line, box); - lastSpanElement.appendText(box.toText()); - rootElement.append(lastSpanElement); - buffer.write(box.toText()); - } else if (box is PlaceholderBox) { - lastSpanElement = null; - } else { - throw UnimplementedError('Unknown box type: ${box.runtimeType}'); + for (final LayoutFragment fragment in line.fragments) { + if (fragment.isPlaceholder) { + continue; } - } - final String? ellipsis = line.ellipsis; - if (ellipsis != null) { - (lastSpanElement ?? rootElement).appendText(ellipsis); + final String text = fragment.getText(this); + if (text.isEmpty) { + continue; + } + + final DomHTMLElement spanElement = domDocument.createElement('flt-span') as DomHTMLElement; + applyTextStyleToElement( + element: spanElement, + style: fragment.style, + isSpan: true, + ); + _positionSpanElement(spanElement, line, fragment); + + spanElement.appendText(text); + rootElement.append(spanElement); } } @@ -283,8 +276,8 @@ class CanvasParagraph implements ui.Paragraph { } } -void _positionSpanElement(DomElement element, ParagraphLine line, RangeBox box) { - final ui.Rect boxRect = box.toTextBox(line, forPainting: true).toRect(); +void _positionSpanElement(DomElement element, ParagraphLine line, LayoutFragment fragment) { + final ui.Rect boxRect = fragment.toPaintingTextBox().toRect(); element.style ..position = 'absolute' ..top = '${boxRect.top}px' @@ -304,6 +297,9 @@ abstract class ParagraphSpan { /// The index of the end of the range of text represented by this span. int get end; + + /// The resolved style of the span. + EngineTextStyle get style; } /// Represent a span of text in the paragraph. @@ -323,7 +319,7 @@ class FlatTextSpan implements ParagraphSpan { required this.end, }); - /// The resolved style of the span. + @override final EngineTextStyle style; @override @@ -341,14 +337,24 @@ class FlatTextSpan implements ParagraphSpan { class PlaceholderSpan extends ParagraphPlaceholder implements ParagraphSpan { PlaceholderSpan( - int index, - super.width, - super.height, - super.alignment, { - required super.baselineOffset, - required super.baseline, - }) : start = index, - end = index; + this.style, + this.start, + this.end, + double width, + double height, + ui.PlaceholderAlignment alignment, { + required double baselineOffset, + required ui.TextBaseline baseline, + }) : super( + width, + height, + alignment, + baselineOffset: baselineOffset, + baseline: baseline, + ); + + @override + final EngineTextStyle style; @override final int start; @@ -624,10 +630,19 @@ class CanvasParagraphBuilder implements ui.ParagraphBuilder { alignment == ui.PlaceholderAlignment.belowBaseline || alignment == ui.PlaceholderAlignment.baseline) || baseline != null); + final int start = _plainTextBuffer.length; + _plainTextBuffer.write(_placeholderChar); + final int end = _plainTextBuffer.length; + + final EngineTextStyle style = _currentStyleNode.resolveStyle(); + _updateCanDrawOnCanvas(style); + _placeholderCount++; _placeholderScales.add(scale); _spans.add(PlaceholderSpan( - _plainTextBuffer.length, + style, + start, + end, width * scale, height * scale, alignment, @@ -652,37 +667,54 @@ class CanvasParagraphBuilder implements ui.ParagraphBuilder { @override void addText(String text) { - final EngineTextStyle style = _currentStyleNode.resolveStyle(); final int start = _plainTextBuffer.length; _plainTextBuffer.write(text); final int end = _plainTextBuffer.length; - if (_canDrawOnCanvas) { - final ui.TextDecoration? decoration = style.decoration; - if (decoration != null && decoration != ui.TextDecoration.none) { - _canDrawOnCanvas = false; - } - } - - if (_canDrawOnCanvas) { - final List? fontFeatures = style.fontFeatures; - if (fontFeatures != null && fontFeatures.isNotEmpty) { - _canDrawOnCanvas = false; - } - } - - if (_canDrawOnCanvas) { - final List? fontVariations = style.fontVariations; - if (fontVariations != null && fontVariations.isNotEmpty) { - _canDrawOnCanvas = false; - } - } + final EngineTextStyle style = _currentStyleNode.resolveStyle(); + _updateCanDrawOnCanvas(style); _spans.add(FlatTextSpan(style: style, start: start, end: end)); } + void _updateCanDrawOnCanvas(EngineTextStyle style) { + if (!_canDrawOnCanvas) { + return; + } + + final ui.TextDecoration? decoration = style.decoration; + if (decoration != null && decoration != ui.TextDecoration.none) { + _canDrawOnCanvas = false; + return; + } + + final List? fontFeatures = style.fontFeatures; + if (fontFeatures != null && fontFeatures.isNotEmpty) { + _canDrawOnCanvas = false; + return; + } + + final List? fontVariations = style.fontVariations; + if (fontVariations != null && fontVariations.isNotEmpty) { + _canDrawOnCanvas = false; + return; + } + } + @override CanvasParagraph build() { + if (_spans.isEmpty) { + // In case `addText` and `addPlaceholder` were never called. + // + // We want the paragraph to always have a non-empty list of spans to match + // the expectations of the [LayoutFragmenter]. + _spans.add(FlatTextSpan( + style: _rootStyleNode.resolveStyle(), + start: 0, + end: 0, + )); + } + return CanvasParagraph( _spans, paragraphStyle: _paragraphStyle, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart new file mode 100644 index 00000000000..9fdd82e167c --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart @@ -0,0 +1,34 @@ +// 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. + +/// Splits [text] into a list of [TextFragment]s. +/// +/// Various subclasses can perform the fragmenting based on their own criteria. +/// +/// See: +/// +/// - [LineBreakFragmenter]: Fragments text based on line break opportunities. +/// - [BidiFragmenter]: Fragments text based on directionality. +abstract class TextFragmenter { + const TextFragmenter(this.text); + + /// The text to be fragmented. + final String text; + + /// Performs the fragmenting of [text] and returns a list of [TextFragment]s. + List fragment(); +} + +/// Represents a fragment produced by [TextFragmenter]. +abstract class TextFragment { + const TextFragment(this.start, this.end); + + final int start; + final int end; + + /// Whether this fragment's range overlaps with the range from [start] to [end]. + bool overlapsWith(int start, int end) { + return start < this.end && this.start < end; + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart new file mode 100644 index 00000000000..0c48c0f5eeb --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart @@ -0,0 +1,628 @@ +// 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:math' as math; + +import 'package:ui/ui.dart' as ui; + +import '../util.dart'; +import 'canvas_paragraph.dart'; +import 'fragmenter.dart'; +import 'layout_service.dart'; +import 'line_breaker.dart'; +import 'paragraph.dart'; +import 'text_direction.dart'; + +/// Splits [text] into fragments that are ready to be laid out by +/// [TextLayoutService]. +/// +/// This fragmenter takes into account line breaks, directionality and styles. +class LayoutFragmenter extends TextFragmenter { + const LayoutFragmenter(super.text, this.paragraphSpans); + + final List paragraphSpans; + + @override + List fragment() { + final List fragments = []; + + int fragmentStart = 0; + + final Iterator lineBreakFragments = LineBreakFragmenter(text).fragment().iterator..moveNext(); + final Iterator bidiFragments = BidiFragmenter(text).fragment().iterator..moveNext(); + final Iterator spans = paragraphSpans.iterator..moveNext(); + + LineBreakFragment currentLineBreakFragment = lineBreakFragments.current; + BidiFragment currentBidiFragment = bidiFragments.current; + ParagraphSpan currentSpan = spans.current; + + while (true) { + final int fragmentEnd = math.min( + currentLineBreakFragment.end, + math.min( + currentBidiFragment.end, + currentSpan.end, + ), + ); + + final int distanceFromLineBreak = currentLineBreakFragment.end - fragmentEnd; + + final LineBreakType lineBreakType = distanceFromLineBreak == 0 + ? currentLineBreakFragment.type + : LineBreakType.prohibited; + + final int trailingNewlines = currentLineBreakFragment.trailingNewlines - distanceFromLineBreak; + final int trailingSpaces = currentLineBreakFragment.trailingSpaces - distanceFromLineBreak; + + final int fragmentLength = fragmentEnd - fragmentStart; + fragments.add(LayoutFragment( + fragmentStart, + fragmentEnd, + lineBreakType, + currentBidiFragment.textDirection, + currentBidiFragment.fragmentFlow, + currentSpan, + trailingNewlines: clampInt(trailingNewlines, 0, fragmentLength), + trailingSpaces: clampInt(trailingSpaces, 0, fragmentLength), + )); + + fragmentStart = fragmentEnd; + + bool moved = false; + if (currentLineBreakFragment.end == fragmentEnd) { + if (lineBreakFragments.moveNext()) { + moved = true; + currentLineBreakFragment = lineBreakFragments.current; + } + } + if (currentBidiFragment.end == fragmentEnd) { + if (bidiFragments.moveNext()) { + moved = true; + currentBidiFragment = bidiFragments.current; + } + } + if (currentSpan.end == fragmentEnd) { + if (spans.moveNext()) { + moved = true; + currentSpan = spans.current; + } + } + + // Once we reached the end of all fragments, exit the loop. + if (!moved) { + break; + } + } + + return fragments; + } +} + +abstract class _CombinedFragment extends TextFragment { + _CombinedFragment( + super.start, + super.end, + this.type, + this._textDirection, + this.fragmentFlow, + this.span, { + required this.trailingNewlines, + required this.trailingSpaces, + }) : assert(trailingNewlines >= 0), + assert(trailingSpaces >= trailingNewlines); + + final LineBreakType type; + + ui.TextDirection? get textDirection => _textDirection; + ui.TextDirection? _textDirection; + + final FragmentFlow fragmentFlow; + + final ParagraphSpan span; + + final int trailingNewlines; + + final int trailingSpaces; + + @override + int get hashCode => Object.hash( + start, + end, + type, + textDirection, + fragmentFlow, + span, + trailingNewlines, + trailingSpaces, + ); + + @override + bool operator ==(Object other) { + return other is LayoutFragment && + other.start == start && + other.end == end && + other.type == type && + other.textDirection == textDirection && + other.fragmentFlow == fragmentFlow && + other.span == span && + other.trailingNewlines == trailingNewlines && + other.trailingSpaces == trailingSpaces; + } +} + +class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentPosition, _FragmentBox { + LayoutFragment( + super.start, + super.end, + super.type, + super.textDirection, + super.fragmentFlow, + super.span, { + required super.trailingNewlines, + required super.trailingSpaces, + }); + + int get length => end - start; + bool get isSpaceOnly => length == trailingSpaces; + bool get isPlaceholder => span is PlaceholderSpan; + bool get isBreak => type != LineBreakType.prohibited; + bool get isHardBreak => type == LineBreakType.mandatory || type == LineBreakType.endOfText; + EngineTextStyle get style => span.style; + + /// Returns the substring from [paragraph] that corresponds to this fragment, + /// excluding new line characters. + String getText(CanvasParagraph paragraph) { + return paragraph.plainText.substring(start, end - trailingNewlines); + } + + /// Splits this fragment into two fragments with the split point being the + /// given [index]. + // TODO(mdebbar): If we ever get multiple return values in Dart, we should use it! + // See: https://github.com/dart-lang/language/issues/68 + List split(int index) { + assert(start <= index); + assert(index <= end); + + if (start == index) { + return [null, this]; + } + + if (end == index) { + return [this, null]; + } + + // The length of the second fragment after the split. + final int secondLength = end - index; + + // Trailing spaces/new lines go to the second fragment. Any left over goes + // to the first fragment. + final int secondTrailingNewlines = math.min(trailingNewlines, secondLength); + final int secondTrailingSpaces = math.min(trailingSpaces, secondLength); + + return [ + LayoutFragment( + start, + index, + LineBreakType.prohibited, + textDirection, + fragmentFlow, + span, + trailingNewlines: trailingNewlines - secondTrailingNewlines, + trailingSpaces: trailingSpaces - secondTrailingSpaces, + ), + LayoutFragment( + index, + end, + type, + textDirection, + fragmentFlow, + span, + trailingNewlines: secondTrailingNewlines, + trailingSpaces: secondTrailingSpaces, + ), + ]; + } + + @override + String toString() { + return '$LayoutFragment($start, $end, $type, $textDirection)'; + } +} + +mixin _FragmentMetrics on _CombinedFragment { + late Spanometer _spanometer; + + /// The rise from the baseline as calculated from the font and style for this text. + double get ascent => _ascent; + late double _ascent; + + /// The drop from the baseline as calculated from the font and style for this text. + double get descent => _descent; + late double _descent; + + /// The width of the measured text, not including trailing spaces. + double get widthExcludingTrailingSpaces => _widthExcludingTrailingSpaces; + late double _widthExcludingTrailingSpaces; + + /// The width of the measured text, including any trailing spaces. + double get widthIncludingTrailingSpaces => _widthIncludingTrailingSpaces + _extraWidthForJustification; + late double _widthIncludingTrailingSpaces; + + double _extraWidthForJustification = 0.0; + + /// The total height as calculated from the font and style for this text. + double get height => ascent + descent; + + double get widthOfTrailingSpaces => widthIncludingTrailingSpaces - widthExcludingTrailingSpaces; + + /// Set measurement values for the fragment. + void setMetrics(Spanometer spanometer, { + required double ascent, + required double descent, + required double widthExcludingTrailingSpaces, + required double widthIncludingTrailingSpaces, + }) { + _spanometer = spanometer; + _ascent = ascent; + _descent = descent; + _widthExcludingTrailingSpaces = widthExcludingTrailingSpaces; + _widthIncludingTrailingSpaces = widthIncludingTrailingSpaces; + } +} + +/// Encapsulates positioning of the fragment relative to the line. +/// +/// The 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 fragment. +/// +/// This is what the various measurements/coordinates look like for a fragment +/// in an LTR paragraph: +/// +/// *------------------------line.width-----------------* +/// *---width----* +/// ┌─────────────────┬────────────┬────────────────────┐ +/// │ │--FRAGMENT--│ │ +/// └─────────────────┴────────────┴────────────────────┘ +/// *---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: +/// +/// *------------------------line.width-----------------* +/// *---width----* +/// ┌─────────────────┬────────────┬────────────────────┐ +/// │ │--FRAGMENT--│ │ +/// └─────────────────┴────────────┴────────────────────┘ +/// *----startOffset-----* +/// *------left-------* +/// *-----------endOffset-------------* +/// *----------right---------------* +/// +mixin _FragmentPosition on _CombinedFragment, _FragmentMetrics { + /// The distance from the beginning of the line to the beginning of the fragment. + double get startOffset => _startOffset; + late double _startOffset; + + /// The width of the line that contains this fragment. + late ParagraphLine line; + + /// The distance from the beginning of the line to the end of the fragment. + double get endOffset => startOffset + widthIncludingTrailingSpaces; + + /// The distance from the left edge of the line to the left edge of the fragment. + double get left => line.textDirection == ui.TextDirection.ltr + ? startOffset + : line.width - endOffset; + + /// The distance from the left edge of the line to the right edge of the fragment. + double get right => line.textDirection == ui.TextDirection.ltr + ? endOffset + : line.width - startOffset; + + /// Set the horizontal position of this fragment relative to the [line] that + /// contains it. + void setPosition({ + required double startOffset, + required ui.TextDirection textDirection, + }) { + _startOffset = startOffset; + _textDirection ??= textDirection; + } + + /// Adjust the width of this fragment for paragraph justification. + void justifyTo({required double paragraphWidth}) { + // Only justify this fragment if it's not a trailing space in the line. + if (end > line.endIndex - line.trailingSpaces) { + // Don't justify fragments that are part of trailing spaces of the line. + return; + } + + if (trailingSpaces == 0) { + // If this fragment has no spaces, there's nothing to justify. + return; + } + + final double justificationTotal = paragraphWidth - line.width; + final double justificationPerSpace = justificationTotal / line.nonTrailingSpaces; + _extraWidthForJustification = justificationPerSpace * trailingSpaces; + } +} + +/// Encapsulates calculations related to the bounding box of the fragment +/// relative to the paragraph. +mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition { + double get top => line.baseline - ascent; + double get bottom => line.baseline + descent; + + late final ui.TextBox _textBoxIncludingTrailingSpaces = ui.TextBox.fromLTRBD( + line.left + left, + top, + line.left + right, + bottom, + textDirection!, + ); + + /// Whether or not the trailing spaces of this fragment are part of trailing + /// spaces of the line containing the fragment. + bool get _isPartOfTrailingSpacesInLine => end > line.endIndex - line.trailingSpaces; + + /// Returns a [ui.TextBox] for the purpose of painting this fragment. + /// + /// The coordinates of the resulting [ui.TextBox] are relative to the + /// paragraph, not to the line. + /// + /// Trailing spaces in each line aren't painted on the screen, so they are + /// excluded from the resulting text box. + ui.TextBox toPaintingTextBox() { + if (_isPartOfTrailingSpacesInLine) { + // For painting, we exclude the width of trailing spaces from the box. + return textDirection! == ui.TextDirection.ltr + ? ui.TextBox.fromLTRBD( + line.left + left, + top, + line.left + right - widthOfTrailingSpaces, + bottom, + textDirection!, + ) + : ui.TextBox.fromLTRBD( + line.left + left + widthOfTrailingSpaces, + top, + line.left + right, + bottom, + textDirection!, + ); + } + return _textBoxIncludingTrailingSpaces; + } + + /// Returns a [ui.TextBox] representing this fragment. + /// + /// The coordinates of the resulting [ui.TextBox] are relative to the + /// paragraph, not to the line. + /// + /// As opposed to [toPaintingTextBox], the resulting text box from this method + /// includes trailing spaces of the fragment. + ui.TextBox toTextBox({ + int? start, + int? end, + }) { + start ??= this.start; + end ??= this.end; + + if (start <= this.start && end >= this.end - trailingNewlines) { + return _textBoxIncludingTrailingSpaces; + } + return _intersect(start, end); + } + + /// Performs the intersection of this fragment with the range given by [start] and + /// [end] indices, and returns a [ui.TextBox] representing that intersection. + /// + /// The coordinates of the resulting [ui.TextBox] are relative to the + /// paragraph, not to the line. + ui.TextBox _intersect(int start, int end) { + // `_intersect` should only be called when there's an actual intersection. + assert(start > this.start || end < this.end); + + final double before; + if (start <= this.start) { + before = 0.0; + } else { + _spanometer.currentSpan = span; + before = _spanometer.measureRange(this.start, start); + } + + final double after; + if (end >= this.end - trailingNewlines) { + after = 0.0; + } else { + _spanometer.currentSpan = span; + after = _spanometer.measureRange(end, this.end - trailingNewlines); + } + + final double left, right; + if (textDirection! == ui.TextDirection.ltr) { + // 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 fragment'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( + line.left + left, + top, + line.left + right, + bottom, + textDirection!, + ); + } + + /// Returns the text position within this fragment's range that's closest to + /// the given [x] offset. + /// + /// The [x] offset is expected to be relative to the left edge of the fragment. + ui.TextPosition getPositionForX(double x) { + x = _makeXDirectionAgnostic(x); + + final int startIndex = start; + final int endIndex = end - trailingNewlines; + + // Check some special cases to return the result quicker. + + final int length = endIndex - startIndex; + if (length == 0) { + return ui.TextPosition(offset: startIndex); + } + if (length == 1) { + // Find out if `x` is closer to `startIndex` or `endIndex`. + final double distanceFromStart = x; + final double distanceFromEnd = widthIncludingTrailingSpaces - x; + return distanceFromStart < distanceFromEnd + ? ui.TextPosition(offset: startIndex) + : ui.TextPosition(offset: endIndex, affinity: ui.TextAffinity.upstream,); + } + + _spanometer.currentSpan = span; + // The resulting `cutoff` is the index of the character where the `x` offset + // falls. We should return the text position of either `cutoff` or + // `cutoff + 1` depending on which one `x` is closer to. + // + // offset x + // ↓ + // "A B C D E F" + // ↑ + // cutoff + final int cutoff = _spanometer.forceBreak( + startIndex, + endIndex, + availableWidth: x, + allowEmpty: true, + ); + + if (cutoff == endIndex) { + return ui.TextPosition( + offset: cutoff, + affinity: ui.TextAffinity.upstream, + ); + } + + final double lowWidth = _spanometer.measureRange(startIndex, cutoff); + final double highWidth = _spanometer.measureRange(startIndex, cutoff + 1); + + // See if `x` is closer to `cutoff` or `cutoff + 1`. + if (x - lowWidth < highWidth - x) { + // The offset is closer to cutoff. + return ui.TextPosition(offset: cutoff); + } else { + // The offset is closer to cutoff + 1. + return ui.TextPosition( + offset: cutoff + 1, + affinity: ui.TextAffinity.upstream, + ); + } + } + + /// Transforms the [x] coordinate to be direction-agnostic. + /// + /// The X (input) is relative to the [left] edge of the fragment, and this + /// method returns an X' (output) that's relative to beginning of the text. + /// + /// Here's how it looks for a fragment with LTR content: + /// + /// *------------------------line width------------------* + /// *-----X (input) + /// ┌───────────┬────────────────────────┬───────────────┐ + /// │ │ ---text-direction----> │ │ + /// └───────────┴────────────────────────┴───────────────┘ + /// *-----X' (output) + /// *---left----* + /// *---------------right----------------* + /// + /// + /// And here's how it looks for a fragment with RTL content: + /// + /// *------------------------line width------------------* + /// *-----X (input) + /// ┌───────────┬────────────────────────┬───────────────┐ + /// │ │ <---text-direction---- │ │ + /// └───────────┴────────────────────────┴───────────────┘ + /// (output) X'-----------------* + /// *---left----* + /// *---------------right----------------* + /// + double _makeXDirectionAgnostic(double x) { + if (textDirection == ui.TextDirection.rtl) { + return widthIncludingTrailingSpaces - x; + } + return x; + } +} + +class EllipsisFragment extends LayoutFragment { + EllipsisFragment( + int index, + ParagraphSpan span, + ) : super( + index, + index, + LineBreakType.endOfText, + null, + // The ellipsis is always at the end of the line, so it can't be + // sandwiched. This means it'll always follow the paragraph direction. + FragmentFlow.sandwich, + span, + trailingNewlines: 0, + trailingSpaces: 0, + ); + + @override + bool get isSpaceOnly => false; + + @override + bool get isPlaceholder => false; + + @override + String getText(CanvasParagraph paragraph) { + return paragraph.paragraphStyle.ellipsis!; + } + + @override + List split(int index) { + throw Exception('Cannot split an EllipsisFragment'); + } +} 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 a5b91bd016c..b3bdb624ade 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 @@ -9,6 +9,7 @@ import 'package:ui/ui.dart' as ui; import '../dom.dart'; import 'canvas_paragraph.dart'; +import 'layout_fragmenter.dart'; import 'line_breaker.dart'; import 'measurement.dart'; import 'paragraph.dart'; @@ -23,7 +24,8 @@ class TextLayoutService { final CanvasParagraph paragraph; - final DomCanvasRenderingContext2D context = createDomCanvasElement().context2D; + final DomCanvasRenderingContext2D context = + createDomCanvasElement().context2D; // *** Results of layout *** // @@ -51,13 +53,10 @@ class TextLayoutService { ui.Rect get paintBounds => _paintBounds; ui.Rect _paintBounds = ui.Rect.zero; - // *** Convenient shortcuts used during layout *** // + late final Spanometer spanometer = Spanometer(paragraph, context); - int? get maxLines => paragraph.paragraphStyle.maxLines; - bool get unlimitedLines => maxLines == null; - - String? get ellipsis => paragraph.paragraphStyle.ellipsis; - bool get hasEllipsis => ellipsis != null; + late final LayoutFragmenter layoutFragmenter = + LayoutFragmenter(paragraph.plainText, paragraph.spans); /// Performs the layout on a paragraph given the [constraints]. /// @@ -74,8 +73,6 @@ class TextLayoutService { /// 2. Enough lines have been computed to satisfy [maxLines]. /// 3. An ellipsis is appended because of an overflow. void performLayout(ui.ParagraphConstraints constraints) { - final int spanCount = paragraph.spans.length; - // Reset results from previous layout. width = constraints.width; height = 0.0; @@ -85,129 +82,51 @@ class TextLayoutService { didExceedMaxLines = false; lines.clear(); - if (spanCount == 0) { - return; - } - - final Spanometer spanometer = Spanometer(paragraph, context); - - int spanIndex = 0; LineBuilder currentLine = LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); - // The only way to exit this while loop is by hitting one of the `break;` - // statements (e.g. when we reach `endOfText`, when ellipsis has been - // appended). - while (true) { - // ************************** // - // *** HANDLE END OF TEXT *** // - // ************************** // + final List fragments = + layoutFragmenter.fragment()..forEach(spanometer.measureFragment); - // All spans have been consumed. - final bool reachedEnd = spanIndex == spanCount; - if (reachedEnd) { - // In some cases, we need to extend the line to the end of text and - // build it: - // - // 1. Line is not empty. This could happen when the last span is a - // placeholder. - // - // 2. We haven't reached `LineBreakType.endOfText` yet. This could - // happen when the last character is a new line. - if (currentLine.isNotEmpty || currentLine.end.type != LineBreakType.endOfText) { - currentLine.extendToEndOfText(); + outerLoop: + for (int i = 0; i < fragments.length; i++) { + final LayoutFragment fragment = fragments[i]; + + currentLine.addFragment(fragment); + + while (currentLine.isOverflowing) { + if (currentLine.canHaveEllipsis) { + currentLine.insertEllipsis(); lines.add(currentLine.build()); + didExceedMaxLines = true; + break outerLoop; } - break; - } - // ********************************* // - // *** THE MAIN MEASUREMENT PART *** // - // ********************************* // - - ParagraphSpan span = paragraph.spans[spanIndex]; - - if (span is PlaceholderSpan) { - if (currentLine.widthIncludingSpace + span.width <= constraints.width) { - // The placeholder fits on the current line. - currentLine.addPlaceholder(span); + if (currentLine.isBreakable) { + currentLine.revertToLastBreakOpportunity(); } else { - // The placeholder can't fit on the current line. - if (currentLine.isNotEmpty) { - lines.add(currentLine.build()); - currentLine = currentLine.nextLine(); - } - currentLine.addPlaceholder(span); - } - spanIndex++; - } else if (span is FlatTextSpan) { - spanometer.currentSpan = span; - final DirectionalPosition nextBreak = currentLine.findNextBreak(); - final double additionalWidth = - currentLine.getAdditionalWidthTo(nextBreak.lineBreak); - - if (currentLine.width + additionalWidth <= constraints.width) { - // The line can extend to `nextBreak` without overflowing. - currentLine.extendTo(nextBreak); - if (nextBreak.type == LineBreakType.mandatory) { - lines.add(currentLine.build()); - currentLine = currentLine.nextLine(); - } - } else { - // The chunk of text can't fit into the current line. - final bool isLastLine = - (hasEllipsis && unlimitedLines) || lines.length + 1 == maxLines; - - if (isLastLine && hasEllipsis) { - // We've reached the line that requires an ellipsis to be appended - // to it. - - currentLine.forceBreak( - nextBreak, - allowEmpty: true, - ellipsis: ellipsis, - ); - lines.add(currentLine.build(ellipsis: ellipsis)); - break; - } else if (currentLine.isNotBreakable) { - // The entire line is unbreakable, which means we are dealing - // with a single block of text that doesn't fit in a single line. - // We need to force-break it without adding an ellipsis. - - currentLine.forceBreak(nextBreak, allowEmpty: false); - lines.add(currentLine.build()); - currentLine = currentLine.nextLine(); - } else { - // Normal line break. - currentLine.revertToLastBreakOpportunity(); - // If a revert had occurred in the line, we need to revert the span - // index accordingly. - // - // If no revert occurred, then `revertedToSpan` will be equal to - // `span` and the following while loop won't do anything. - final ParagraphSpan revertedToSpan = currentLine.lastSegment.span; - while (span != revertedToSpan) { - span = paragraph.spans[--spanIndex]; - } - lines.add(currentLine.build()); - currentLine = currentLine.nextLine(); - } + // The line can't be legally broken, so the last fragment (that caused + // the line to overflow) needs to be force-broken. + currentLine.forceBreakLastFragment(); } - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end) { - currentLine.createBox(); - ++spanIndex; - } - } else { - throw UnimplementedError('Unknown span type: ${span.runtimeType}'); + i += currentLine.appendZeroWidthFragments(fragments, startFrom: i + 1); + lines.add(currentLine.build()); + currentLine = currentLine.nextLine(); } - if (lines.length == maxLines) { - break; + if (currentLine.isHardBreak) { + lines.add(currentLine.build()); + currentLine = currentLine.nextLine(); } } + final int? maxLines = paragraph.paragraphStyle.maxLines; + if (maxLines != null && lines.length > maxLines) { + didExceedMaxLines = true; + lines.removeRange(maxLines, lines.length); + } + // ***************************************************************** // // *** PARAGRAPH BASELINE & HEIGHT & LONGEST LINE & PAINT BOUNDS *** // // ***************************************************************** // @@ -241,69 +160,58 @@ class TextLayoutService { height, ); - // ********************** // - // *** POSITION BOXES *** // - // ********************** // + // **************************** // + // *** FRAGMENT POSITIONING *** // + // **************************** // + // We have to perform justification alignment first so that we can position + // fragments correctly later. if (lines.isNotEmpty) { - final ParagraphLine lastLine = lines.last; - final bool shouldJustifyParagraph = - width.isFinite && - paragraph.paragraphStyle.textAlign == ui.TextAlign.justify; + final bool shouldJustifyParagraph = width.isFinite && + paragraph.paragraphStyle.textAlign == ui.TextAlign.justify; - for (final ParagraphLine line in lines) { + if (shouldJustifyParagraph) { // Don't apply justification to the last line. - final bool shouldJustifyLine = shouldJustifyParagraph && line != lastLine; - _positionLineBoxes(line, withJustification: shouldJustifyLine); + for (int i = 0; i < lines.length - 1; i++) { + for (final LayoutFragment fragment in lines[i].fragments) { + fragment.justifyTo(paragraphWidth: width); + } + } } } + lines.forEach(_positionLineFragments); + // ******************************** // // *** MAX/MIN INTRINSIC WIDTHS *** // // ******************************** // - spanIndex = 0; - currentLine = - LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); + // TODO(mdebbar): Handle maxLines https://github.com/flutter/flutter/issues/91254 - while (spanIndex < spanCount) { - final ParagraphSpan span = paragraph.spans[spanIndex]; - bool breakToNextLine = false; - - if (span is PlaceholderSpan) { - currentLine.addPlaceholder(span); - spanIndex++; - } else if (span is FlatTextSpan) { - spanometer.currentSpan = span; - 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. - currentLine.extendTo(nextBreak); - if (nextBreak.type == LineBreakType.mandatory) { - // We don't want to break the line now because we want to update - // min/max intrinsic widths below first. - breakToNextLine = true; - } - - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end) { - spanIndex++; - } - } - - final double widthOfLastSegment = currentLine.lastSegment.width; - if (minIntrinsicWidth < widthOfLastSegment) { - minIntrinsicWidth = widthOfLastSegment; - } + double runningMinIntrinsicWidth = 0; + double runningMaxIntrinsicWidth = 0; + for (final LayoutFragment fragment in fragments) { + runningMinIntrinsicWidth += fragment.widthExcludingTrailingSpaces; // Max intrinsic width includes the width of trailing spaces. - if (maxIntrinsicWidth < currentLine.widthIncludingSpace) { - maxIntrinsicWidth = currentLine.widthIncludingSpace; - } + runningMaxIntrinsicWidth += fragment.widthIncludingTrailingSpaces; - if (breakToNextLine) { - currentLine = currentLine.nextLine(); + switch (fragment.type) { + case LineBreakType.prohibited: + break; + + case LineBreakType.opportunity: + minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth); + runningMinIntrinsicWidth = 0; + break; + + case LineBreakType.mandatory: + case LineBreakType.endOfText: + minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth); + maxIntrinsicWidth = math.max(maxIntrinsicWidth, runningMaxIntrinsicWidth); + runningMinIntrinsicWidth = 0; + runningMaxIntrinsicWidth = 0; + break; } } } @@ -311,143 +219,130 @@ class TextLayoutService { ui.TextDirection get _paragraphDirection => paragraph.paragraphStyle.effectiveTextDirection; - /// Positions the boxes in the given [line] and takes into account their - /// directions, the paragraph's direction, and alignment justification. - void _positionLineBoxes(ParagraphLine line, { - required bool withJustification, - }) { - final List boxes = line.boxes; - final double justifyPerSpaceBox = withJustification - ? _calculateJustifyPerSpaceBox(line) - : 0.0; + /// Positions the fragments taking into account their directions and the + /// paragraph's direction. + void _positionLineFragments(ParagraphLine line) { + ui.TextDirection previousDirection = _paragraphDirection; - 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 = line.width; - if (box is SpanBox && box.isSpaceOnly && !box.isTrailingSpace) { - box._width += justifyPerSpaceBox; + double startOffset = 0.0; + int? sandwichStart; + int sequenceStart = 0; + + for (int i = 0; i <= line.fragments.length; i++) { + if (i < line.fragments.length) { + final LayoutFragment fragment = line.fragments[i]; + + if (fragment.fragmentFlow == FragmentFlow.previous) { + sandwichStart = null; + continue; + } + if (fragment.fragmentFlow == FragmentFlow.sandwich) { + sandwichStart ??= i; + continue; } - 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). + assert(fragment.fragmentFlow == FragmentFlow.ltr || + fragment.fragmentFlow == FragmentFlow.rtl); - 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; + final ui.TextDirection currentDirection = + fragment.fragmentFlow == FragmentFlow.ltr + ? ui.TextDirection.ltr + : ui.TextDirection.rtl; + + if (currentDirection == previousDirection) { + sandwichStart = null; + continue; } - 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 = _positionLineBoxesInReverse( - line, - first, - last, - startOffset: cumulativeWidth, - justifyPerSpaceBox: justifyPerSpaceBox, - ); - cumulativeWidth += sequenceWidth; + // We've reached a fragment that'll flip the text direction. Let's + // position the sequence that we've been traversing. + + if (sandwichStart == null) { + // Position fragments in range [sequenceStart:i) + startOffset += _positionFragmentRange( + line: line, + start: sequenceStart, + end: i, + direction: previousDirection, + startOffset: startOffset, + ); + } else { + // Position fragments in range [sequenceStart:sandwichStart) + startOffset += _positionFragmentRange( + line: line, + start: sequenceStart, + end: sandwichStart, + direction: previousDirection, + startOffset: startOffset, + ); + // Position fragments in range [sandwichStart:i) + startOffset += _positionFragmentRange( + line: line, + start: sandwichStart, + end: i, + direction: _paragraphDirection, + startOffset: startOffset, + ); + } + + sequenceStart = i; + sandwichStart = null; + + if (i < line.fragments.length){ + previousDirection = line.fragments[i].textDirection!; + } } } - /// 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 _positionLineBoxesInReverse( - ParagraphLine line, - int first, - int last, { + double _positionFragmentRange({ + required ParagraphLine line, + required int start, + required int end, + required ui.TextDirection direction, required double startOffset, - required double justifyPerSpaceBox, }) { - final List boxes = line.boxes; - 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 = line.width; - if (box is SpanBox && box.isSpaceOnly && !box.isTrailingSpace) { - box._width += justifyPerSpaceBox; - } + assert(start <= end); - cumulativeWidth += box.width; + double cumulativeWidth = 0.0; + + // The bodies of the two for loops below must remain identical. The only + // difference is the looping direction. One goes from start to end, while + // the other goes from end to start. + + if (direction == _paragraphDirection) { + for (int i = start; i < end; i++) { + cumulativeWidth += + _positionOneFragment(line, i, startOffset + cumulativeWidth, direction); + } + } else { + for (int i = end - 1; i >= start; i--) { + cumulativeWidth += + _positionOneFragment(line, i, startOffset + cumulativeWidth, direction); + } } + return cumulativeWidth; } - /// Calculates for the given [line], the amount of extra width that needs to be - /// added to each space box in order to align the line with the rest of the - /// paragraph. - double _calculateJustifyPerSpaceBox(ParagraphLine line) { - final double justifyTotal = width - line.width; - - final int spaceBoxesToJustify = line.nonTrailingSpaceBoxCount; - if (spaceBoxesToJustify > 0) { - return justifyTotal / spaceBoxesToJustify; - } - - return 0.0; + double _positionOneFragment( + ParagraphLine line, + int i, + double startOffset, + ui.TextDirection direction, + ) { + final LayoutFragment fragment = line.fragments[i]; + fragment.setPosition(startOffset: startOffset, textDirection: direction); + return fragment.widthIncludingTrailingSpaces; } List getBoxesForPlaceholders() { final List boxes = []; for (final ParagraphLine line in lines) { - for (final RangeBox box in line.boxes) { - if (box is PlaceholderBox) { - boxes.add(box.toTextBox(line, forPainting: false)); + for (final LayoutFragment fragment in line.fragments) { + if (fragment.isPlaceholder) { + boxes.add(fragment.toTextBox()); } } } @@ -475,9 +370,9 @@ class TextLayoutService { for (final ParagraphLine line in lines) { if (line.overlapsWith(start, end)) { - for (final RangeBox box in line.boxes) { - if (box is SpanBox && box.overlapsWith(start, end)) { - boxes.add(box.intersect(line, start, end, forPainting: false)); + for (final LayoutFragment fragment in line.fragments) { + if (!fragment.isPlaceholder && fragment.overlapsWith(start, end)) { + boxes.add(fragment.toTextBox(start: start, end: end)); } } } @@ -501,15 +396,15 @@ class TextLayoutService { // [offset] is to the right of the line. if (offset.dx >= line.left + line.widthWithTrailingSpaces) { return ui.TextPosition( - offset: line.endIndexWithoutNewlines, + offset: line.endIndex - line.trailingNewlines, affinity: ui.TextAffinity.upstream, ); } final double dx = offset.dx - line.left; - for (final RangeBox box in line.boxes) { - if (box.left <= dx && dx <= box.right) { - return box.getPositionForX(dx); + for (final LayoutFragment fragment in line.fragments) { + if (fragment.left <= dx && dx <= fragment.right) { + return fragment.getPositionForX(dx - fragment.left); } } // Is this ever reachable? @@ -530,496 +425,32 @@ class TextLayoutService { } } -/// 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 { - RangeBox( - this.start, - this.end, - 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 => 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 => paragraphDirection == ui.TextDirection.ltr - ? endOffset - : lineWidth - startOffset; - - /// The distance from the left edge of the box to the right edge of the box. - double get 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]. - /// - /// The coordinates of the resulting [ui.TextBox] are relative to the - /// paragraph, not to the line. - /// - /// The [forPainting] parameter specifies whether the text box is wanted for - /// painting purposes or not. The difference is observed in the handling of - /// trailing spaces. Trailing spaces aren't painted on the screen, but their - /// dimensions are still useful for other cases like highlighting selection. - ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}); - - /// Returns the text position within this box's range that's closest to the - /// given [x] offset. - /// - /// The [x] offset is expected to be relative to the left edge of the line, - /// just like the coordinates of this box. - ui.TextPosition getPositionForX(double x); -} - -/// Represents a box for a [PlaceholderSpan]. -class PlaceholderBox extends RangeBox { - PlaceholderBox( - this.placeholder, { - required LineBreakResult index, - required ui.TextDirection paragraphDirection, - required ui.TextDirection boxDirection, - }) : super(index, index, paragraphDirection, boxDirection); - - final PlaceholderSpan placeholder; - - @override - double get width => placeholder.width; - - @override - ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) { - final double left = line.left + this.left; - final double right = line.left + this.right; - - final double lineTop = line.baseline - line.ascent; - - final double top; - switch (placeholder.alignment) { - case ui.PlaceholderAlignment.top: - top = lineTop; - break; - - case ui.PlaceholderAlignment.middle: - top = lineTop + (line.height - placeholder.height) / 2; - break; - - case ui.PlaceholderAlignment.bottom: - top = lineTop + line.height - placeholder.height; - break; - - case ui.PlaceholderAlignment.aboveBaseline: - top = line.baseline - placeholder.height; - break; - - case ui.PlaceholderAlignment.belowBaseline: - top = line.baseline; - break; - - case ui.PlaceholderAlignment.baseline: - top = line.baseline - placeholder.baselineOffset; - break; - } - - return ui.TextBox.fromLTRBD( - left, - top, - right, - top + placeholder.height, - paragraphDirection, - ); - } - - @override - ui.TextPosition getPositionForX(double x) { - // See if `x` is closer to the left edge or the right edge of the box. - final bool closerToLeft = x - left < right - x; - return ui.TextPosition( - offset: start.index, - affinity: closerToLeft ? ui.TextAffinity.upstream : ui.TextAffinity.downstream, - ); - } -} - -/// Represents a box in a [FlatTextSpan]. -class SpanBox extends RangeBox { - SpanBox( - this.spanometer, { - required LineBreakResult start, - required LineBreakResult end, - required double width, - required ui.TextDirection paragraphDirection, - required ui.TextDirection boxDirection, - required this.contentDirection, - required this.isSpaceOnly, - }) : span = spanometer.currentSpan, - height = spanometer.height, - baseline = spanometer.ascent, - _width = width, - super(start, end, paragraphDirection, boxDirection); - - - final Spanometer spanometer; - final FlatTextSpan span; - - /// 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; - - /// Whether this box is made of only white space. - final bool isSpaceOnly; - - /// Whether this box is a trailing space box at the end of a line. - bool get isTrailingSpace => _isTrailingSpace; - bool _isTrailingSpace = false; - - /// This is made mutable so it can be updated later in the layout process for - /// the purpose of aligning the lines of a paragraph with [ui.TextAlign.justify]. - double _width; - - @override - double get width => _width; - - /// 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; - - /// The distance from the top edge of the box to the alphabetic baseline of - /// the box. - final double baseline; - - /// Whether this box's range overlaps with the range from [startIndex] to - /// [endIndex]. - bool overlapsWith(int startIndex, int endIndex) { - return startIndex < end.index && start.index < endIndex; - } - - /// Returns the substring of the paragraph that's represented by this box. - /// - /// Trailing newlines are omitted, if any. - String toText() { - return spanometer.paragraph.toPlainText().substring(start.index, end.indexWithoutTrailingNewlines); - } - - @override - ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) { - return intersect(line, start.index, end.index, forPainting: forPainting); - } - - /// Performs the intersection of this box with the range given by [start] and - /// [end] indices, and returns a [ui.TextBox] representing that intersection. - /// - /// The coordinates of the resulting [ui.TextBox] are relative to the - /// paragraph, not to the line. - ui.TextBox intersect(ParagraphLine line, int start, int end, {required bool forPainting}) { - final double top = line.baseline - baseline; - - final double before; - if (start <= this.start.index) { - before = 0.0; - } else { - spanometer.currentSpan = span; - before = spanometer._measure(this.start.index, start); - } - - final double after; - if (end >= this.end.indexWithoutTrailingNewlines) { - after = 0.0; - } else { - spanometer.currentSpan = span; - after = spanometer._measure(end, this.end.indexWithoutTrailingNewlines); - } - - 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; - } - - // When painting a paragraph, trailing spaces should have a zero width. - final bool isZeroWidth = forPainting && isTrailingSpace; - if (isZeroWidth) { - // Collapse the box to the left or to the right depending on the paragraph - // direction. - if (paragraphDirection == ui.TextDirection.ltr) { - right = left; - } else { - left = right; - } - } - - // 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( - line.left + left, - top, - line.left + right, - top + height, - 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; - - x = _makeXRelativeToContent(x); - - final int startIndex = start.index; - final int endIndex = end.indexWithoutTrailingNewlines; - // The resulting `cutoff` is the index of the character where the `x` offset - // falls. We should return the text position of either `cutoff` or - // `cutoff + 1` depending on which one `x` is closer to. - // - // offset x - // ↓ - // "A B C D E F" - // ↑ - // cutoff - final int cutoff = spanometer.forceBreak( - startIndex, - endIndex, - availableWidth: x, - allowEmpty: true, - ); - - if (cutoff == endIndex) { - return ui.TextPosition( - offset: cutoff, - affinity: ui.TextAffinity.upstream, - ); - } - - final double lowWidth = spanometer._measure(startIndex, cutoff); - final double highWidth = spanometer._measure(startIndex, cutoff + 1); - - // See if `x` is closer to `cutoff` or `cutoff + 1`. - if (x - lowWidth < highWidth - x) { - // The offset is closer to cutoff. - return ui.TextPosition( - offset: cutoff, - ); - } else { - // The offset is closer to cutoff + 1. - return ui.TextPosition( - offset: cutoff + 1, - affinity: ui.TextAffinity.upstream, - ); - } - } -} - -/// Represents a segment in a line of a paragraph. -/// -/// For example, this line: "Lorem ipsum dolor sit" is broken up into the -/// following segments: -/// -/// - "Lorem " -/// - "ipsum " -/// - "dolor " -/// - "sit" -class LineSegment { - LineSegment({ - required this.span, - required this.start, - required this.end, - required this.width, - required this.widthIncludingSpace, - }); - - /// The span that this segment belongs to. - final ParagraphSpan span; - - /// The index of the beginning of the segment in the paragraph. - final LineBreakResult start; - - /// The index of the end of the segment in the paragraph. - final LineBreakResult end; - - /// The width of the segment excluding any trailing white space. - final double width; - - /// The width of the segment including any trailing white space. - final double widthIncludingSpace; - - /// The width of the trailing white space in the segment. - double get widthOfTrailingSpace => widthIncludingSpace - width; - - /// Whether this segment is made of only white space. - /// - /// We rely on the [width] to determine this because relying on incides - /// doesn't work well for placeholders (they are zero-length strings). - bool get isSpaceOnly => width == 0; -} - /// Builds instances of [ParagraphLine] for the given [paragraph]. /// /// Usage of this class starts by calling [LineBuilder.first] to start building /// the first line of the paragraph. /// -/// Then new line breaks can be found by calling [LineBuilder.findNextBreak]. +/// Then fragments can be added by calling [addFragment]. /// -/// The line can be extended one or more times before it's built by calling -/// [LineBuilder.build] which generates the [ParagraphLine] instance. +/// After adding a fragment, one can use [isOverflowing] to determine whether +/// the added fragment caused the line to overflow or not. /// -/// To start building the next line, simply call [LineBuilder.nextLine] which -/// creates a new [LineBuilder] that can be extended and built and so on. +/// Once the line is complete, it can be built by calling [build] to generate +/// a [ParagraphLine] instance. +/// +/// To start building the next line, simply call [nextLine] to get a new +/// [LineBuilder] for the next line. class LineBuilder { LineBuilder._( this.paragraph, this.spanometer, { required this.maxWidth, - required this.start, required this.lineNumber, required this.accumulatedHeight, - }) : _end = start; + required List fragments, + }) : _fragments = fragments { + _recalculateMetrics(); + } /// Creates a [LineBuilder] for the first line in a paragraph. factory LineBuilder.first( @@ -1032,41 +463,47 @@ class LineBuilder { spanometer, maxWidth: maxWidth, lineNumber: 0, - start: const LineBreakResult.sameIndex(0, LineBreakType.prohibited), accumulatedHeight: 0.0, + fragments: [], ); } - final List _segments = []; - final List _boxes = []; + final List _fragments; + List? _fragmentsForNextLine; + + int get startIndex { + assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty); + + return isNotEmpty + ? _fragments.first.start + : _fragmentsForNextLine!.first.start; + } + + int get endIndex { + assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty); + + return isNotEmpty + ? _fragments.last.end + : _fragmentsForNextLine!.first.start; + } final double maxWidth; final CanvasParagraph paragraph; final Spanometer spanometer; - final LineBreakResult start; final int lineNumber; /// The accumulated height of all preceding lines, excluding the current line. final double accumulatedHeight; - /// The index of the end of the line so far. - LineBreakResult get end => _end; - LineBreakResult _end; - set end(LineBreakResult value) { - if (value.type != LineBreakType.prohibited) { - isBreakable = true; - } - _end = value; - } - /// The width of the line so far, excluding trailing white space. double width = 0.0; /// The width of the line so far, including trailing white space. double widthIncludingSpace = 0.0; - /// The width of trailing white space in the line. - double get widthOfTrailingSpace => widthIncludingSpace - width; + double get _widthExcludingLastFragment => _fragments.length > 1 + ? widthIncludingSpace - _fragments.last.widthIncludingTrailingSpaces + : 0; /// The distance from the top of the line to the alphabetic baseline. double ascent = 0.0; @@ -1077,22 +514,31 @@ class LineBuilder { /// The height of the line so far. double get height => ascent + descent; - /// The last segment in this line. - LineSegment get lastSegment => _segments.last; + int _lastBreakableFragment = -1; + int _breakCount = 0; - /// Returns true if there is at least one break opportunity in the line. - bool isBreakable = false; + /// Whether this line can be legally broken into more than one line. + bool get isBreakable { + if (_fragments.isEmpty) { + return false; + } + if (_fragments.last.isBreak) { + // We need one more break other than the last one. + return _breakCount > 1; + } + return _breakCount > 0; + } - /// Returns true if there's no break opportunity in the line. + /// Returns true if the line can't be legally broken any further. bool get isNotBreakable => !isBreakable; - /// Whether the end of this line is a prohibited break. - bool get isEndProhibited => end.type == LineBreakType.prohibited; + int _spaceCount = 0; + int _trailingSpaces = 0; - int _spaceBoxCount = 0; + bool get isEmpty => _fragments.isEmpty; + bool get isNotEmpty => _fragments.isNotEmpty; - bool get isEmpty => _segments.isEmpty; - bool get isNotEmpty => _segments.isNotEmpty; + bool get isHardBreak => _fragments.isNotEmpty && _fragments.last.isHardBreak; /// The horizontal offset necessary for the line to be correctly aligned. double get alignOffset { @@ -1113,81 +559,72 @@ class LineBuilder { } } - /// Measures the width of text between the end of this line and [newEnd]. - double getAdditionalWidthTo(LineBreakResult newEnd) { - // If the extension is all made of space characters, it shouldn't add - // anything to the width. - if (end.index == newEnd.indexWithoutTrailingSpaces) { - return 0.0; - } + bool get isOverflowing => width > maxWidth; - return widthOfTrailingSpace + spanometer.measure(end, newEnd); - } - - bool get _isLastBoxAPlaceholder { - if (_boxes.isEmpty) { + bool get canHaveEllipsis { + if (paragraph.paragraphStyle.ellipsis == null) { return false; } - return _boxes.last is PlaceholderBox; + + final int? maxLines = paragraph.paragraphStyle.maxLines; + return (maxLines == null) || (maxLines == lineNumber + 1); + } + + bool get _canAppendEmptyFragments { + if (isHardBreak) { + // Can't append more fragments to this line if it has a hard break. + return false; + } + + if (_fragmentsForNextLine?.isNotEmpty ?? false) { + // If we already have fragments prepared for the next line, then we can't + // append more fragments to this line. + return false; + } + + return true; } ui.TextDirection get _paragraphDirection => paragraph.paragraphStyle.effectiveTextDirection; - late ui.TextDirection _currentBoxDirection = _paragraphDirection; + void addFragment(LayoutFragment fragment) { + _updateMetrics(fragment); - late ui.TextDirection _currentContentDirection = _paragraphDirection; + if (fragment.isBreak) { + _lastBreakableFragment = _fragments.length; + } - 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; + _fragments.add(fragment); } - /// Extends the line by setting a [newEnd]. - void extendTo(DirectionalPosition newEnd) { - ascent = math.max(ascent, spanometer.ascent); - descent = math.max(descent, spanometer.descent); + /// Updates the [LineBuilder]'s metrics to take into account the new [fragment]. + void _updateMetrics(LayoutFragment fragment) { + _spaceCount += fragment.trailingSpaces; - // 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. - void extendToEndOfText() { - if (end.type == LineBreakType.endOfText) { - return; - } - - final LineBreakResult endOfText = LineBreakResult.sameIndex( - paragraph.toPlainText().length, - LineBreakType.endOfText, - ); - - // The spanometer may not be ready in some cases. E.g. when the paragraph - // is made up of only placeholders and no text. - if (spanometer.isReady) { - ascent = math.max(ascent, spanometer.ascent); - descent = math.max(descent, spanometer.descent); - _addSegment(_createSegment(endOfText)); + if (fragment.isSpaceOnly) { + _trailingSpaces += fragment.trailingSpaces; } else { - end = endOfText; + _trailingSpaces = fragment.trailingSpaces; + width = widthIncludingSpace + fragment.widthExcludingTrailingSpaces; } + widthIncludingSpace += fragment.widthIncludingTrailingSpaces; + + if (fragment.isPlaceholder) { + _adjustPlaceholderAscentDescent(fragment); + } + + if (fragment.isBreak) { + _breakCount++; + } + + ascent = math.max(ascent, fragment.ascent); + descent = math.max(descent, fragment.descent); } - void addPlaceholder(PlaceholderSpan placeholder) { - // Increase the line's height to fit the placeholder, if necessary. + void _adjustPlaceholderAscentDescent(LayoutFragment fragment) { + final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan; + final double ascent, descent; switch (placeholder.alignment) { case ui.PlaceholderAlignment.top: @@ -1229,330 +666,198 @@ class LineBuilder { break; } - this.ascent = math.max(this.ascent, ascent); - this.descent = math.max(this.descent, descent); - - _addSegment(LineSegment( - span: placeholder, - start: end, - end: end, - width: placeholder.width, - widthIncludingSpace: placeholder.width, - )); - - // Add the placeholder box. - _boxes.add(PlaceholderBox( - placeholder, - index: _currentBoxStart, - paragraphDirection: _paragraphDirection, - boxDirection: _currentBoxDirection, - )); - _currentBoxStartOffset = widthIncludingSpace; - // Breaking is always allowed after a placeholder. - isBreakable = true; - } - - /// Creates a new segment to be appended to the end of this line. - LineSegment _createSegment(LineBreakResult segmentEnd) { - // The segment starts at the end of the line. - final LineBreakResult segmentStart = end; - return LineSegment( - span: spanometer.currentSpan, - start: segmentStart, - end: segmentEnd, - width: spanometer.measure(segmentStart, segmentEnd), - widthIncludingSpace: - spanometer.measureIncludingSpace(segmentStart, segmentEnd), + // Update the metrics of the fragment to reflect the calculated ascent and + // descent. + fragment.setMetrics(spanometer, + ascent: ascent, + descent: descent, + widthExcludingTrailingSpaces: fragment.widthExcludingTrailingSpaces, + widthIncludingTrailingSpaces: fragment.widthIncludingTrailingSpaces, ); } - /// Adds a segment to this line. - /// - /// It adjusts the width properties to accommodate the new segment. It also - /// sets the line end to the end of the segment. - void _addSegment(LineSegment segment) { - _segments.add(segment); + void _recalculateMetrics() { + width = 0; + widthIncludingSpace = 0; + ascent = 0; + descent = 0; + _spaceCount = 0; + _trailingSpaces = 0; + _breakCount = 0; + _lastBreakableFragment = -1; - // Adding a space-only segment has no effect on `width` because it doesn't - // include trailing white space. - if (!segment.isSpaceOnly) { - // Add the width of previous trailing space. - width += widthOfTrailingSpace + segment.width; - } - widthIncludingSpace += segment.widthIncludingSpace; - end = segment.end; - } - - /// Removes the latest [LineSegment] added by [_addSegment]. - /// - /// It re-adjusts the width properties and the end of the line. - LineSegment _popSegment() { - final LineSegment poppedSegment = _segments.removeLast(); - - if (_segments.isEmpty) { - width = 0.0; - widthIncludingSpace = 0.0; - end = start; - } else { - widthIncludingSpace -= poppedSegment.widthIncludingSpace; - end = lastSegment.end; - - // Now, let's figure out what to do with `width`. - - // Popping a space-only segment has no effect on `width`. - if (!poppedSegment.isSpaceOnly) { - // First, we subtract the width of the popped segment. - width -= poppedSegment.width; - - // Second, we subtract all trailing spaces from `width`. There could be - // multiple trailing segments that are space-only. - double widthOfTrailingSpace = 0.0; - int i = _segments.length - 1; - while (i >= 0 && _segments[i].isSpaceOnly) { - // Since the segment is space-only, `widthIncludingSpace` contains - // the width of the space and nothing else. - widthOfTrailingSpace += _segments[i].widthIncludingSpace; - i--; - } - if (i >= 0) { - // Having `i >= 0` means in the above loop we stopped at a - // non-space-only segment. We should also subtract its trailing spaces. - widthOfTrailingSpace += _segments[i].widthOfTrailingSpace; - } - width -= widthOfTrailingSpace; + for (int i = 0; i < _fragments.length; i++) { + _updateMetrics(_fragments[i]); + if (_fragments[i].isBreak) { + _lastBreakableFragment = i; } } - - // Now let's fixes boxes if they need fixing. - // - // If we popped a segment of an already created box, we should pop the box - // too. - if (_currentBoxStart.index > poppedSegment.start.index) { - final RangeBox poppedBox = _boxes.removeLast(); - _currentBoxStartOffset -= poppedBox.width; - if (poppedBox is SpanBox && poppedBox.isSpaceOnly) { - _spaceBoxCount--; - } - } - - return poppedSegment; } - /// Force-breaks the line in order to fit in [maxWidth] while trying to extend - /// to [nextBreak]. - /// - /// This should only be called when there isn't enough width to extend to - /// [nextBreak], and either of the following is true: - /// - /// 1. An ellipsis is being appended to this line, OR - /// 2. The line doesn't have any line break opportunities and has to be - /// force-broken. - void forceBreak( - DirectionalPosition nextBreak, { - required bool allowEmpty, - String? ellipsis, - }) { - if (ellipsis == null) { - final double availableWidth = maxWidth - widthIncludingSpace; - final int breakingPoint = spanometer.forceBreak( - end.index, - nextBreak.lineBreak.indexWithoutTrailingSpaces, - availableWidth: availableWidth, - allowEmpty: allowEmpty, - ); + void forceBreakLastFragment({ double? availableWidth, bool allowEmptyLine = false }) { + assert(isNotEmpty); - // This condition can be true in the following case: - // 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.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(nextBreak.copyWithIndex(breakingPoint)); + availableWidth ??= maxWidth; + assert(widthIncludingSpace > availableWidth); + + _fragmentsForNextLine ??= []; + + // When the line has fragments other than the last one, we can always allow + // the last fragment to be empty (i.e. completely removed from the line). + final bool hasOtherFragments = _fragments.length > 1; + final bool allowLastFragmentToBeEmpty = hasOtherFragments || allowEmptyLine; + + final LayoutFragment lastFragment = _fragments.last; + + if (lastFragment.isPlaceholder) { + // Placeholder can't be force-broken. Either keep all of it in the line or + // move it to the next line. + if (allowLastFragmentToBeEmpty) { + _fragmentsForNextLine!.insert(0, _fragments.removeLast()); + _recalculateMetrics(); } return; } - // For example: "foo bar baz". Let's say all characters have the same width, and - // the constraint width can only fit 9 characters "foo bar b". So if the - // paragraph has an ellipsis, we can't just remove the last segment "baz" - // and replace it with "..." because that would overflow. - // - // We need to keep popping segments until we are able to fit the "..." - // without overflowing. In this example, that would be: "foo ba..." + spanometer.currentSpan = lastFragment.span; + final double lineWidthWithoutLastFragment = widthIncludingSpace - lastFragment.widthIncludingTrailingSpaces; + final double availableWidthForFragment = availableWidth - lineWidthWithoutLastFragment; + final int forceBreakEnd = lastFragment.end - lastFragment.trailingNewlines; - final double ellipsisWidth = spanometer.measureText(ellipsis); - final double availableWidth = maxWidth - ellipsisWidth; - - // First, we create the new segment until `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: - // 1. All remaining segments in `_segments` can fit within constraints. - // 2. Adding `segmentToBreak` causes the line to overflow. - while (_segments.isNotEmpty && widthIncludingSpace > availableWidth) { - segmentToBreak = _popSegment(); - } - - spanometer.currentSpan = segmentToBreak.span as FlatTextSpan; - final double availableWidthForSegment = - availableWidth - widthIncludingSpace; final int breakingPoint = spanometer.forceBreak( - segmentToBreak.start.index, - segmentToBreak.end.index, - availableWidth: availableWidthForSegment, - allowEmpty: allowEmpty, + lastFragment.start, + forceBreakEnd, + availableWidth: availableWidthForFragment, + allowEmpty: allowLastFragmentToBeEmpty, ); - // There's a possibility that the end of line has moved backwards, so we - // need to remove some boxes in that case. - while (_boxes.isNotEmpty && _boxes.last.end.index > breakingPoint) { - _boxes.removeLast(); + if (breakingPoint == forceBreakEnd) { + // The entire fragment remained intact. Let's keep everything as is. + return; } - _currentBoxStartOffset = widthIncludingSpace; - extendTo(nextBreak.copyWithIndex(breakingPoint)); + _fragments.removeLast(); + _recalculateMetrics(); + + final List split = lastFragment.split(breakingPoint); + + final LayoutFragment? first = split.first; + if (first != null) { + spanometer.measureFragment(first); + addFragment(first); + } + + final LayoutFragment? second = split.last; + if (second != null) { + spanometer.measureFragment(second); + _fragmentsForNextLine!.insert(0, second); + } + } + + void insertEllipsis() { + assert(canHaveEllipsis); + assert(isOverflowing); + + final String ellipsisText = paragraph.paragraphStyle.ellipsis!; + + _fragmentsForNextLine = []; + + spanometer.currentSpan = _fragments.last.span; + double ellipsisWidth = spanometer.measureText(ellipsisText); + double availableWidth = math.max(0, maxWidth - ellipsisWidth); + + while (_widthExcludingLastFragment > availableWidth) { + _fragmentsForNextLine!.insert(0, _fragments.removeLast()); + _recalculateMetrics(); + + spanometer.currentSpan = _fragments.last.span; + ellipsisWidth = spanometer.measureText(ellipsisText); + availableWidth = maxWidth - ellipsisWidth; + } + + final LayoutFragment lastFragment = _fragments.last; + forceBreakLastFragment(availableWidth: availableWidth, allowEmptyLine: true); + + final EllipsisFragment ellipsisFragment = EllipsisFragment( + endIndex, + lastFragment.span, + ); + ellipsisFragment.setMetrics(spanometer, + ascent: lastFragment.ascent, + descent: lastFragment.descent, + widthExcludingTrailingSpaces: ellipsisWidth, + widthIncludingTrailingSpaces: ellipsisWidth, + ); + addFragment(ellipsisFragment); } - /// Looks for the last break opportunity in the line and reverts the line to - /// that point. - /// - /// If the line already ends with a break opportunity, this method does - /// nothing. void revertToLastBreakOpportunity() { assert(isBreakable); - while (isEndProhibited) { - _popSegment(); + + // The last fragment in the line may or may not be breakable. Regardless, + // it needs to be removed. + // + // We need to find the latest breakable fragment in the line (other than the + // last fragment). Such breakable fragment is guaranteed to be found because + // the line `isBreakable`. + + // Start from the end and skip the last fragment. + int i = _fragments.length - 2; + while (!_fragments[i].isBreak) { + i--; } - // Make sure the line is not empty and still breakable after popping a few - // segments. - assert(isNotEmpty); - assert(isBreakable); + + _fragmentsForNextLine = _fragments.getRange(i + 1, _fragments.length).toList(); + _fragments.removeRange(i + 1, _fragments.length); + _recalculateMetrics(); } - LineBreakResult get _currentBoxStart { - if (_boxes.isEmpty) { - return start; - } - // The end of the last box is the start of the new box. - return _boxes.last.end; - } - - double _currentBoxStartOffset = 0.0; - - double get _currentBoxWidth => widthIncludingSpace - _currentBoxStartOffset; - - /// Cuts a new box in the line. + /// Appends as many zero-width fragments as this line allows. /// - /// 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, 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. - if (boxStart.index == boxEnd.index) { - return; + /// Returns the number of fragments that were appended. + int appendZeroWidthFragments(List fragments, {required int startFrom}) { + int i = startFrom; + while (_canAppendEmptyFragments && + i < fragments.length && + fragments[i].widthExcludingTrailingSpaces == 0) { + addFragment(fragments[i]); + i++; } - - _boxes.add(SpanBox( - spanometer, - start: boxStart, - end: boxEnd, - width: _currentBoxWidth, - paragraphDirection: _paragraphDirection, - boxDirection: _currentBoxDirection, - contentDirection: _currentContentDirection, - isSpaceOnly: isSpaceOnly, - )); - - if (isSpaceOnly) { - _spaceBoxCount++; - } - - _currentBoxStartOffset = widthIncludingSpace; + return i - startFrom; } /// Builds the [ParagraphLine] instance that represents this line. - ParagraphLine build({String? ellipsis}) { - // At the end of each line, we cut the last box of the line. - createBox(); - - final double ellipsisWidth = - ellipsis == null ? 0.0 : spanometer.measureText(ellipsis); - - final int endIndexWithoutNewlines = math.max(start.index, end.indexWithoutTrailingNewlines); - final bool hardBreak; - if (end.type != LineBreakType.endOfText && _isLastBoxAPlaceholder) { - hardBreak = false; - } else { - hardBreak = end.isHard; + ParagraphLine build() { + if (_fragmentsForNextLine == null) { + _fragmentsForNextLine = _fragments.getRange(_lastBreakableFragment + 1, _fragments.length).toList(); + _fragments.removeRange(_lastBreakableFragment + 1, _fragments.length); } - _processTrailingSpaces(); - - return ParagraphLine( + final int trailingNewlines = isEmpty ? 0 : _fragments.last.trailingNewlines; + final ParagraphLine line = ParagraphLine( lineNumber: lineNumber, - ellipsis: ellipsis, - startIndex: start.index, - endIndex: end.index, - endIndexWithoutNewlines: endIndexWithoutNewlines, - hardBreak: hardBreak, - width: width + ellipsisWidth, - widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth, + startIndex: startIndex, + endIndex: endIndex, + trailingNewlines: trailingNewlines, + trailingSpaces: _trailingSpaces, + spaceCount: _spaceCount, + hardBreak: isHardBreak, + width: width, + widthWithTrailingSpaces: widthIncludingSpace, left: alignOffset, height: height, baseline: accumulatedHeight + ascent, ascent: ascent, descent: descent, - boxes: _boxes, - spaceBoxCount: _spaceBoxCount, - trailingSpaceBoxCount: _trailingSpaceBoxCount, + fragments: _fragments, + textDirection: _paragraphDirection, ); - } - int _trailingSpaceBoxCount = 0; - - void _processTrailingSpaces() { - _trailingSpaceBoxCount = 0; - for (int i = _boxes.length - 1; i >= 0; i--) { - final RangeBox box = _boxes[i]; - final bool isSpaceBox = box is SpanBox && box.isSpaceOnly; - if (!isSpaceBox) { - // We traversed all trailing space boxes. - break; - } - - box._isTrailingSpace = true; - _trailingSpaceBoxCount++; + for (final LayoutFragment fragment in _fragments) { + fragment.line = line; } - } - LineBreakResult? _cachedNextBreak; - - /// Finds the next line break after the end of this line. - DirectionalPosition findNextBreak() { - LineBreakResult? nextBreak = _cachedNextBreak; - final String text = paragraph.toPlainText(); - // Don't recompute the `nextBreak` until the line has reached the previously - // computed `nextBreak`. - if (nextBreak == null || end.index >= nextBreak.index) { - final int maxEnd = spanometer.currentSpan.end; - nextBreak = nextLineBreak(text, end.index, maxEnd: maxEnd); - _cachedNextBreak = nextBreak; - } - // The current end of the line is the beginning of the next block. - return getDirectionalBlockEnd(text, end, nextBreak); + return line; } /// Creates a new [LineBuilder] to build the next line in the paragraph. @@ -1561,9 +866,9 @@ class LineBuilder { paragraph, spanometer, maxWidth: maxWidth, - start: end, lineNumber: lineNumber + 1, accumulatedHeight: accumulatedHeight + height, + fragments: _fragmentsForNextLine ?? [], ); } } @@ -1604,10 +909,10 @@ class Spanometer { double? get letterSpacing => currentSpan.style.letterSpacing; TextHeightRuler? _currentRuler; - FlatTextSpan? _currentSpan; + ParagraphSpan? _currentSpan; - FlatTextSpan get currentSpan => _currentSpan!; - set currentSpan(FlatTextSpan? span) { + ParagraphSpan get currentSpan => _currentSpan!; + set currentSpan(ParagraphSpan? span) { if (span == _currentSpan) { return; } @@ -1649,24 +954,44 @@ class Spanometer { /// The line height of the current span. double get height => _currentRuler!.height; - /// Measures the width of text between two line breaks. - /// - /// Doesn't include the width of any trailing white space. - double measure(LineBreakResult start, LineBreakResult end) { - return _measure(start.index, end.indexWithoutTrailingSpaces); - } - - /// Measures the width of text between two line breaks. - /// - /// Includes the width of trailing white space, if any. - double measureIncludingSpace(LineBreakResult start, LineBreakResult end) { - return _measure(start.index, end.indexWithoutTrailingNewlines); - } - double measureText(String text) { return measureSubstring(context, text, 0, text.length); } + double measureRange(int start, int end) { + assert(_currentSpan != null); + + // Make sure the range is within the current span. + assert(start >= currentSpan.start && start <= currentSpan.end); + assert(end >= currentSpan.start && end <= currentSpan.end); + + return _measure(start, end); + } + + void measureFragment(LayoutFragment fragment) { + if (fragment.isPlaceholder) { + final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan; + // The ascent/descent values of the placeholder fragment will be finalized + // later when the line is built. + fragment.setMetrics(this, + ascent: placeholder.height, + descent: 0, + widthExcludingTrailingSpaces: placeholder.width, + widthIncludingTrailingSpaces: placeholder.width, + ); + } else { + currentSpan = fragment.span; + final double widthExcludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingSpaces); + final double widthIncludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingNewlines); + fragment.setMetrics(this, + ascent: ascent, + descent: descent, + widthExcludingTrailingSpaces: widthExcludingTrailingSpaces, + widthIncludingTrailingSpaces: widthIncludingTrailingSpaces, + ); + } + } + /// In a continuous, unbreakable block of text from [start] to [end], finds /// the point where text should be broken to fit in the given [availableWidth]. /// @@ -1687,11 +1012,9 @@ class Spanometer { }) { assert(_currentSpan != null); - final FlatTextSpan span = currentSpan; - // Make sure the range is within the current span. - assert(start >= span.start && start <= span.end); - assert(end >= span.start && end <= span.end); + assert(start >= currentSpan.start && start <= currentSpan.end); + assert(end >= currentSpan.start && end <= currentSpan.end); if (availableWidth <= 0.0) { return allowEmpty ? start : start + 1; @@ -1699,7 +1022,7 @@ class Spanometer { int low = start; int high = end; - do { + while (high - low > 1) { final int mid = (low + high) ~/ 2; final double width = _measure(start, mid); if (width < availableWidth) { @@ -1709,7 +1032,7 @@ class Spanometer { } else { low = high = mid; } - } while (high - low > 1); + } if (low == start && !allowEmpty) { low++; @@ -1719,11 +1042,9 @@ class Spanometer { double _measure(int start, int end) { assert(_currentSpan != null); - final FlatTextSpan span = currentSpan; - // Make sure the range is within the current span. - assert(start >= span.start && start <= span.end); - assert(end >= span.start && end <= span.end); + assert(start >= currentSpan.start && start <= currentSpan.end); + assert(end >= currentSpan.start && end <= currentSpan.end); final String text = paragraph.toPlainText(); return measureSubstring( diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart index 9068980e958..fa2adb85618 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart @@ -2,9 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; - -import '../util.dart'; +import 'fragmenter.dart'; import 'line_break_properties.dart'; import 'unicode_range.dart'; @@ -26,96 +24,42 @@ enum LineBreakType { endOfText, } -/// Acts as a tuple that encapsulates information about a line break. -/// -/// It contains multiple indices that are helpful when it comes to measuring the -/// width of a line of text. -/// -/// [indexWithoutTrailingSpaces] <= [indexWithoutTrailingNewlines] <= [index] -/// -/// Example: for the string "foo \nbar " here are the indices: -/// ``` -/// f o o \n b a r -/// ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ -/// 0 1 2 3 4 5 6 7 8 9 -/// ``` -/// It contains two line breaks: -/// ``` -/// // The first line break: -/// LineBreakResult(5, 4, 3, LineBreakType.mandatory) -/// -/// // Second line break: -/// LineBreakResult(9, 9, 8, LineBreakType.mandatory) -/// ``` -class LineBreakResult { - const LineBreakResult( - this.index, - this.indexWithoutTrailingNewlines, - this.indexWithoutTrailingSpaces, - this.type, - ): assert(indexWithoutTrailingSpaces <= indexWithoutTrailingNewlines), - assert(indexWithoutTrailingNewlines <= index); - - /// Creates a [LineBreakResult] where all indices are the same (i.e. there are - /// no trailing spaces or new lines). - const LineBreakResult.sameIndex(this.index, this.type) - : indexWithoutTrailingNewlines = index, - indexWithoutTrailingSpaces = index; - - /// The true index at which the line break should occur, including all spaces - /// and new lines. - final int index; - - /// The index of the line break excluding any trailing new lines. - final int indexWithoutTrailingNewlines; - - /// The index of the line break excluding any trailing spaces. - final int indexWithoutTrailingSpaces; - - /// The type of line break is useful to determine the behavior in text - /// measurement. - /// - /// For example, a mandatory line break always causes a line break regardless - /// of width constraints. But a line break opportunity requires further checks - /// to decide whether to take the line break or not. - final LineBreakType type; - - bool get isHard => - type == LineBreakType.mandatory || type == LineBreakType.endOfText; +/// Splits [text] into fragments based on line breaks. +class LineBreakFragmenter extends TextFragmenter { + const LineBreakFragmenter(super.text); @override - int get hashCode => Object.hash( - index, - indexWithoutTrailingNewlines, - indexWithoutTrailingSpaces, - type, - ); + List fragment() { + return _computeLineBreakFragments(text); + } +} + +class LineBreakFragment extends TextFragment { + const LineBreakFragment(super.start, super.end, this.type, { + required this.trailingNewlines, + required this.trailingSpaces, + }); + + final LineBreakType type; + final int trailingNewlines; + final int trailingSpaces; + + @override + int get hashCode => Object.hash(start, end, type, trailingNewlines, trailingSpaces); @override bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other.runtimeType != runtimeType) { - return false; - } - return other is LineBreakResult && - other.index == index && - other.indexWithoutTrailingNewlines == indexWithoutTrailingNewlines && - other.indexWithoutTrailingSpaces == indexWithoutTrailingSpaces && - other.type == type; + return other is LineBreakFragment && + other.start == start && + other.end == end && + other.type == type && + other.trailingNewlines == trailingNewlines && + other.trailingSpaces == trailingSpaces; } @override String toString() { - if (assertionsEnabled) { - return 'LineBreakResult(index: $index, ' - 'without new lines: $indexWithoutTrailingNewlines, ' - 'without spaces: $indexWithoutTrailingSpaces, ' - 'type: $type)'; - } else { - return super.toString(); - } + return 'LineBreakFragment($start, $end, $type)'; } } @@ -151,6 +95,10 @@ bool _hasEastAsianWidthFWH(int charCode) { (charCode >= 0xFE17 && charCode <= 0xFF62); } +bool _isSurrogatePair(int? codePoint) { + return codePoint != null && codePoint > 0xFFFF; +} + /// Finds the next line break in the given [text] starting from [index]. /// /// We think about indices as pointing between characters, and they go all the @@ -163,100 +111,105 @@ bool _hasEastAsianWidthFWH(int charCode) { /// 0 1 2 3 4 5 6 7 /// ``` /// -/// This way the indices work well with [String.substring()]. +/// This way the indices work well with [String.substring]. /// /// Useful resources: /// /// * https://www.unicode.org/reports/tr14/tr14-45.html#Algorithm /// * https://www.unicode.org/Public/11.0.0/ucd/LineBreak.txt -LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) { - final LineBreakResult unsafeResult = _unsafeNextLineBreak(text, index, maxEnd: maxEnd); - if (maxEnd != null && unsafeResult.index > maxEnd) { - return LineBreakResult( - maxEnd, - math.min(maxEnd, unsafeResult.indexWithoutTrailingNewlines), - math.min(maxEnd, unsafeResult.indexWithoutTrailingSpaces), - LineBreakType.prohibited, - ); - } - return unsafeResult; -} - -LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { - int? codePoint = getCodePoint(text, index); - LineCharProperty curr = lineLookup.findForChar(codePoint); - - LineCharProperty? prev1; +List _computeLineBreakFragments(String text) { + final List fragments = []; // Keeps track of the character two positions behind. LineCharProperty? prev2; + LineCharProperty? prev1; - // When there's a sequence of spaces or combining marks, this variable - // contains the base property i.e. the property of the character before the - // sequence. - LineCharProperty? baseOfSpaceSequence; + int? codePoint = getCodePoint(text, 0); + LineCharProperty? curr = lineLookup.findForChar(codePoint); - /// The index of the last character that wasn't a space. - int lastNonSpaceIndex = index; + // When there's a sequence of spaces, this variable contains the base property + // i.e. the property of the character preceding the sequence. + LineCharProperty baseOfSpaceSequence = LineCharProperty.WJ; - /// The index of the last character that wasn't a new line. - int lastNonNewlineIndex = index; + // When there's a sequence of combining marks, this variable contains the base + // property i.e. the property of the character preceding the sequence. + LineCharProperty baseOfCombiningMarks = LineCharProperty.AL; - // When the text/line starts with SP, we should treat the beginning of text/line - // as if it were a WJ (word joiner). - if (curr == LineCharProperty.SP) { - baseOfSpaceSequence = LineCharProperty.WJ; + int index = 0; + int trailingNewlines = 0; + int trailingSpaces = 0; + + int fragmentStart = 0; + + void setBreak(LineBreakType type, int debugRuleNumber) { + final int fragmentEnd = + type == LineBreakType.endOfText ? text.length : index; + assert(fragmentEnd >= fragmentStart); + + if (prev1 == LineCharProperty.SP) { + trailingSpaces++; + } else if (_isHardBreak(prev1) || prev1 == LineCharProperty.CR) { + trailingNewlines++; + trailingSpaces++; + } + + if (type == LineBreakType.prohibited) { + // Don't create a fragment. + return; + } + + fragments.add(LineBreakFragment( + fragmentStart, + fragmentEnd, + type, + trailingNewlines: trailingNewlines, + trailingSpaces: trailingSpaces, + )); + + fragmentStart = index; + + // Reset trailing spaces/newlines counter after a new fragment. + trailingNewlines = 0; + trailingSpaces = 0; + + prev1 = prev2 = null; } - bool isCurrZWJ = curr == LineCharProperty.ZWJ; + // Never break at the start of text. + // LB2: sot × + setBreak(LineBreakType.prohibited, 2); - // LB10: Treat any remaining combining mark or ZWJ as AL. - // This catches the case where a CM is the first character on the line. - if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) { - curr = LineCharProperty.AL; - } + // Never break at the start of text. + // LB2: sot × + // + // Skip index 0 because a line break can't exist at the start of text. + index++; int regionalIndicatorCount = 0; - // Always break at the end of text. - // LB3: ! eot - while (index < text.length) { - if (maxEnd != null && index > maxEnd) { - return LineBreakResult( - maxEnd, - math.min(maxEnd, lastNonNewlineIndex), - math.min(maxEnd, lastNonSpaceIndex), - LineBreakType.prohibited, - ); - } - - // Keep count of the RI (regional indicator) sequence. - if (curr == LineCharProperty.RI) { - regionalIndicatorCount++; - } else { - regionalIndicatorCount = 0; - } - - if (codePoint != null && codePoint > 0xFFFF) { - // Advance `index` one extra step when handling a surrogate pair in the - // string. - index++; - } - index++; + // We need to go until `text.length` in order to handle the case where the + // paragraph ends with a hard break. In this case, there will be an empty line + // at the end. + for (; index <= text.length; index++) { prev2 = prev1; prev1 = curr; - final bool isPrevZWJ = isCurrZWJ; - - // Reset the base when we are past the space sequence. - if (prev1 != LineCharProperty.SP) { - baseOfSpaceSequence = null; + if (_isSurrogatePair(codePoint)) { + // Can't break in the middle of a surrogate pair. + setBreak(LineBreakType.prohibited, -1); + // Advance `index` one extra step to skip the tail of the surrogate pair. + index++; } codePoint = getCodePoint(text, index); curr = lineLookup.findForChar(codePoint); - isCurrZWJ = curr == LineCharProperty.ZWJ; + // Keep count of the RI (regional indicator) sequence. + if (prev1 == LineCharProperty.RI) { + regionalIndicatorCount++; + } else { + regionalIndicatorCount = 0; + } // Always break after hard line breaks. // LB4: BK ! @@ -265,69 +218,44 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // LB5: LF ! // NL ! if (_isHardBreak(prev1)) { - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.mandatory, - ); + setBreak(LineBreakType.mandatory, 5); + continue; } if (prev1 == LineCharProperty.CR) { if (curr == LineCharProperty.LF) { // LB5: CR × LF - continue; + setBreak(LineBreakType.prohibited, 5); } else { // LB5: CR ! - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.mandatory, - ); + setBreak(LineBreakType.mandatory, 5); } - } - - // At this point, we know for sure the prev character wasn't a new line. - lastNonNewlineIndex = index; - if (prev1 != LineCharProperty.SP) { - lastNonSpaceIndex = index; + continue; } // Do not break before hard line breaks. // LB6: × ( BK | CR | LF | NL ) if (_isHardBreak(curr) || curr == LineCharProperty.CR) { + setBreak(LineBreakType.prohibited, 6); continue; } - // Always break at the end of text. - // LB3: ! eot if (index >= text.length) { - return LineBreakResult( - text.length, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.endOfText, - ); + break; + } + + // Establish the base for the space sequence. + if (prev1 != LineCharProperty.SP) { + // When the text/line starts with SP, we should treat the beginning of text/line + // as if it were a WJ (word joiner). + baseOfSpaceSequence = prev1 ?? LineCharProperty.WJ; } // Do not break before spaces or zero width space. // LB7: × SP - if (curr == LineCharProperty.SP) { - // When we encounter SP, we preserve the property of the previous - // character so we can later apply the indirect breaking rules. - if (prev1 == LineCharProperty.SP) { - // If we are in the middle of a space sequence, a base should've - // already been set. - assert(baseOfSpaceSequence != null); - } else { - // We are at the beginning of a space sequence, establish the base. - baseOfSpaceSequence = prev1; - } - continue; - } - // LB7: × ZW - if (curr == LineCharProperty.ZW) { + // × ZW + if (curr == LineCharProperty.SP || curr == LineCharProperty.ZW) { + setBreak(LineBreakType.prohibited, 7); continue; } @@ -336,53 +264,61 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // LB8: ZW SP* ÷ if (prev1 == LineCharProperty.ZW || baseOfSpaceSequence == LineCharProperty.ZW) { - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.opportunity, - ); + setBreak(LineBreakType.opportunity, 8); + continue; + } + + // Do not break after a zero width joiner. + // LB8a: ZWJ × + if (prev1 == LineCharProperty.ZWJ) { + setBreak(LineBreakType.prohibited, 8); + continue; + } + + // Establish the base for the sequences of combining marks. + if (prev1 != LineCharProperty.CM && prev1 != LineCharProperty.ZWJ) { + baseOfCombiningMarks = prev1 ?? LineCharProperty.AL; } // Do not break a combining character sequence; treat it as if it has the // line breaking class of the base character in all of the following rules. // Treat ZWJ as if it were CM. - // LB9: Treat X (CM | ZWJ)* as if it were X - // where X is any line break class except BK, NL, LF, CR, SP, or ZW. if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) { - // Other properties: BK, NL, LF, CR, ZW would've already generated a line - // break, so we won't find them in `prev`. - if (prev1 == LineCharProperty.SP) { + if (baseOfCombiningMarks == LineCharProperty.SP) { // LB10: Treat any remaining combining mark or ZWJ as AL. curr = LineCharProperty.AL; } else { - if (prev1 == LineCharProperty.RI) { + // LB9: Treat X (CM | ZWJ)* as if it were X + // where X is any line break class except BK, NL, LF, CR, SP, or ZW. + curr = baseOfCombiningMarks; + if (curr == LineCharProperty.RI) { // Prevent the previous RI from being double-counted. regionalIndicatorCount--; } - // Preserve the property of the previous character to treat the sequence - // as if it were X. - curr = prev1; + setBreak(LineBreakType.prohibited, 9); continue; } } - - // Do not break after a zero width joiner. - // LB8a: ZWJ × - if (isPrevZWJ) { - continue; + // In certain situations (e.g. CM immediately following a hard break), we + // need to also check if the previous character was CM/ZWJ. That's because + // hard breaks caused the previous iteration to short-circuit, which leads + // to `baseOfCombiningMarks` not being updated properly. + if (prev1 == LineCharProperty.CM || prev1 == LineCharProperty.ZWJ) { + prev1 = baseOfCombiningMarks; } // Do not break before or after Word joiner and related characters. // LB11: × WJ // WJ × if (curr == LineCharProperty.WJ || prev1 == LineCharProperty.WJ) { + setBreak(LineBreakType.prohibited, 11); continue; } // Do not break after NBSP and related characters. // LB12: GL × if (prev1 == LineCharProperty.GL) { + setBreak(LineBreakType.prohibited, 12); continue; } @@ -393,6 +329,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { prev1 == LineCharProperty.BA || prev1 == LineCharProperty.HY) && curr == LineCharProperty.GL) { + setBreak(LineBreakType.prohibited, 12); continue; } @@ -412,6 +349,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { curr == LineCharProperty.EX || curr == LineCharProperty.IS || curr == LineCharProperty.SY)) { + setBreak(LineBreakType.prohibited, 13); continue; } @@ -421,6 +359,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // The above is a quote from unicode.org. In our implementation, we did the // following modification: Allow breaks when there are spaces. if (prev1 == LineCharProperty.OP) { + setBreak(LineBreakType.prohibited, 14); continue; } @@ -430,6 +369,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // The above is a quote from unicode.org. In our implementation, we did the // following modification: Allow breaks when there are spaces. if (prev1 == LineCharProperty.QU && curr == LineCharProperty.OP) { + setBreak(LineBreakType.prohibited, 15); continue; } @@ -441,6 +381,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { prev1 == LineCharProperty.CP || baseOfSpaceSequence == LineCharProperty.CP) && curr == LineCharProperty.NS) { + setBreak(LineBreakType.prohibited, 16); continue; } @@ -449,37 +390,34 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { if ((prev1 == LineCharProperty.B2 || baseOfSpaceSequence == LineCharProperty.B2) && curr == LineCharProperty.B2) { + setBreak(LineBreakType.prohibited, 17); continue; } // Break after spaces. // LB18: SP ÷ if (prev1 == LineCharProperty.SP) { - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.opportunity, - ); + setBreak(LineBreakType.opportunity, 18); + continue; } // Do not break before or after quotation marks, such as ‘”’. // LB19: × QU // QU × if (prev1 == LineCharProperty.QU || curr == LineCharProperty.QU) { + setBreak(LineBreakType.prohibited, 19); continue; } // Break before and after unresolved CB. // LB20: ÷ CB // CB ÷ + // + // In flutter web, we use this as an object-replacement character for + // placeholders. if (prev1 == LineCharProperty.CB || curr == LineCharProperty.CB) { - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.opportunity, - ); + setBreak(LineBreakType.opportunity, 20); + continue; } // Do not break before hyphen-minus, other hyphens, fixed-width spaces, @@ -492,6 +430,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { curr == LineCharProperty.HY || curr == LineCharProperty.NS || prev1 == LineCharProperty.BB) { + setBreak(LineBreakType.prohibited, 21); continue; } @@ -499,18 +438,21 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // LB21a: HL (HY | BA) × if (prev2 == LineCharProperty.HL && (prev1 == LineCharProperty.HY || prev1 == LineCharProperty.BA)) { + setBreak(LineBreakType.prohibited, 21); continue; } // Don’t break between Solidus and Hebrew letters. // LB21b: SY × HL if (prev1 == LineCharProperty.SY && curr == LineCharProperty.HL) { + setBreak(LineBreakType.prohibited, 21); continue; } // Do not break before ellipses. // LB22: × IN if (curr == LineCharProperty.IN) { + setBreak(LineBreakType.prohibited, 22); continue; } @@ -519,6 +461,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // NU × (AL | HL) if ((_isALorHL(prev1) && curr == LineCharProperty.NU) || (prev1 == LineCharProperty.NU && _isALorHL(curr))) { + setBreak(LineBreakType.prohibited, 23); continue; } @@ -529,6 +472,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { (curr == LineCharProperty.ID || curr == LineCharProperty.EB || curr == LineCharProperty.EM)) { + setBreak(LineBreakType.prohibited, 23); continue; } // LB23a: (ID | EB | EM) × PO @@ -536,6 +480,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { prev1 == LineCharProperty.EB || prev1 == LineCharProperty.EM) && curr == LineCharProperty.PO) { + setBreak(LineBreakType.prohibited, 23); continue; } @@ -544,11 +489,13 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // LB24: (PR | PO) × (AL | HL) if ((prev1 == LineCharProperty.PR || prev1 == LineCharProperty.PO) && _isALorHL(curr)) { + setBreak(LineBreakType.prohibited, 24); continue; } // LB24: (AL | HL) × (PR | PO) if (_isALorHL(prev1) && (curr == LineCharProperty.PR || curr == LineCharProperty.PO)) { + setBreak(LineBreakType.prohibited, 24); continue; } @@ -558,11 +505,13 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { prev1 == LineCharProperty.CP || prev1 == LineCharProperty.NU) && (curr == LineCharProperty.PO || curr == LineCharProperty.PR)) { + setBreak(LineBreakType.prohibited, 25); continue; } // LB25: (PO | PR) × OP if ((prev1 == LineCharProperty.PO || prev1 == LineCharProperty.PR) && curr == LineCharProperty.OP) { + setBreak(LineBreakType.prohibited, 25); continue; } // LB25: (PO | PR | HY | IS | NU | SY) × NU @@ -573,6 +522,7 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { prev1 == LineCharProperty.NU || prev1 == LineCharProperty.SY) && curr == LineCharProperty.NU) { + setBreak(LineBreakType.prohibited, 25); continue; } @@ -583,38 +533,45 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { curr == LineCharProperty.JV || curr == LineCharProperty.H2 || curr == LineCharProperty.H3)) { + setBreak(LineBreakType.prohibited, 26); continue; } // LB26: (JV | H2) × (JV | JT) if ((prev1 == LineCharProperty.JV || prev1 == LineCharProperty.H2) && (curr == LineCharProperty.JV || curr == LineCharProperty.JT)) { + setBreak(LineBreakType.prohibited, 26); continue; } // LB26: (JT | H3) × JT if ((prev1 == LineCharProperty.JT || prev1 == LineCharProperty.H3) && curr == LineCharProperty.JT) { + setBreak(LineBreakType.prohibited, 26); continue; } // Treat a Korean Syllable Block the same as ID. // LB27: (JL | JV | JT | H2 | H3) × PO if (_isKoreanSyllable(prev1) && curr == LineCharProperty.PO) { + setBreak(LineBreakType.prohibited, 27); continue; } // LB27: PR × (JL | JV | JT | H2 | H3) if (prev1 == LineCharProperty.PR && _isKoreanSyllable(curr)) { + setBreak(LineBreakType.prohibited, 27); continue; } // Do not break between alphabetics. // LB28: (AL | HL) × (AL | HL) if (_isALorHL(prev1) && _isALorHL(curr)) { + setBreak(LineBreakType.prohibited, 28); continue; } // Do not break between numeric punctuation and alphabetics (“e.g.”). // LB29: IS × (AL | HL) if (prev1 == LineCharProperty.IS && _isALorHL(curr)) { + setBreak(LineBreakType.prohibited, 29); continue; } @@ -627,12 +584,14 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { if ((_isALorHL(prev1) || prev1 == LineCharProperty.NU) && curr == LineCharProperty.OP && !_hasEastAsianWidthFWH(text.codeUnitAt(index))) { + setBreak(LineBreakType.prohibited, 30); continue; } // LB30: CP × (AL | HL | NU) if (prev1 == LineCharProperty.CP && !_hasEastAsianWidthFWH(text.codeUnitAt(index - 1)) && (_isALorHL(curr) || curr == LineCharProperty.NU)) { + setBreak(LineBreakType.prohibited, 30); continue; } @@ -642,37 +601,29 @@ LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) { // [^RI] (RI RI)* RI × RI if (curr == LineCharProperty.RI) { if (regionalIndicatorCount.isOdd) { - continue; + setBreak(LineBreakType.prohibited, 30); } else { - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.opportunity, - ); + setBreak(LineBreakType.opportunity, 30); } + continue; } // Do not break between an emoji base and an emoji modifier. // LB30b: EB × EM if (prev1 == LineCharProperty.EB && curr == LineCharProperty.EM) { + setBreak(LineBreakType.prohibited, 30); continue; } // Break everywhere else. // LB31: ALL ÷ // ÷ ALL - return LineBreakResult( - index, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.opportunity, - ); + setBreak(LineBreakType.opportunity, 31); } - return LineBreakResult( - text.length, - lastNonNewlineIndex, - lastNonSpaceIndex, - LineBreakType.endOfText, - ); + + // Always break at the end of text. + // LB3: ! eot + setBreak(LineBreakType.endOfText, 3); + + return fragments; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paint_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paint_service.dart index 1183a66a6b6..857d55a2d53 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paint_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paint_service.dart @@ -8,7 +8,7 @@ import '../dom.dart'; import '../html/bitmap_canvas.dart'; import '../html/painting.dart'; import 'canvas_paragraph.dart'; -import 'layout_service.dart'; +import 'layout_fragmenter.dart'; import 'paragraph.dart'; /// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas]. @@ -18,31 +18,15 @@ class TextPaintService { final CanvasParagraph paragraph; void paint(BitmapCanvas canvas, ui.Offset offset) { - // Loop through all the lines, for each line, loop through all the boxes and - // paint them. The boxes have enough information so they can be painted + // Loop through all the lines, for each line, loop through all fragments and + // paint them. The fragment objects have enough information to be painted // individually. final List lines = paragraph.lines; - if (lines.isEmpty) { - return; - } - for (final ParagraphLine line in lines) { - if (line.boxes.isEmpty) { - continue; - } - - final RangeBox lastBox = line.boxes.last; - - for (final RangeBox box in line.boxes) { - final bool isTrailingSpaceBox = - box == lastBox && box is SpanBox && box.isSpaceOnly; - - // Don't paint background for the trailing space in the line. - if (!isTrailingSpaceBox) { - _paintBackground(canvas, offset, line, box); - } - _paintText(canvas, offset, line, box); + for (final LayoutFragment fragment in line.fragments) { + _paintBackground(canvas, offset, fragment); + _paintText(canvas, offset, line, fragment); } } } @@ -50,17 +34,17 @@ class TextPaintService { void _paintBackground( BitmapCanvas canvas, ui.Offset offset, - ParagraphLine line, - RangeBox box, + LayoutFragment fragment, ) { - if (box is SpanBox) { - final FlatTextSpan span = box.span; - + final ParagraphSpan span = fragment.span; + if (span is FlatTextSpan) { // Paint the background of the box, if the span has a background. final SurfacePaint? background = span.style.background as SurfacePaint?; if (background != null) { - final ui.Rect rect = box.toTextBox(line, forPainting: true).toRect().shift(offset); - canvas.drawRect(rect, background.paintData); + final ui.Rect rect = fragment.toPaintingTextBox().toRect(); + if (!rect.isEmpty) { + canvas.drawRect(rect.shift(offset), background.paintData); + } } } } @@ -69,63 +53,63 @@ class TextPaintService { BitmapCanvas canvas, ui.Offset offset, ParagraphLine line, - RangeBox box, + LayoutFragment fragment, ) { // There's no text to paint in placeholder spans. - if (box is SpanBox) { - final FlatTextSpan span = box.span; - - _applySpanStyleToCanvas(span, canvas); - final double x = offset.dx + line.left + box.left; - final double y = offset.dy + line.baseline; - - // Don't paint the text for space-only boxes. This is just an - // optimization, it doesn't have any effect on the output. - if (!box.isSpaceOnly) { - final String text = paragraph.toPlainText().substring( - box.start.index, - box.end.indexWithoutTrailingNewlines, - ); - final double? letterSpacing = span.style.letterSpacing; - if (letterSpacing == null || letterSpacing == 0.0) { - canvas.drawText(text, x, y, - style: span.style.foreground?.style, shadows: span.style.shadows); - } else { - // TODO(mdebbar): Implement letter-spacing on canvas more efficiently: - // https://github.com/flutter/flutter/issues/51234 - double charX = x; - final int len = text.length; - for (int i = 0; i < len; i++) { - final String char = text[i]; - canvas.drawText(char, charX.roundToDouble(), y, - style: span.style.foreground?.style, - shadows: span.style.shadows); - charX += letterSpacing + canvas.measureText(char).width!; - } - } - } - - // Paint the ellipsis using the same span styles. - final String? ellipsis = line.ellipsis; - if (ellipsis != null && box == line.boxes.last) { - final double x = offset.dx + line.left + box.right; - canvas.drawText(ellipsis, x, y, style: span.style.foreground?.style); - } - - canvas.tearDownPaint(); + if (fragment.isPlaceholder) { + return; } + + // Don't paint the text for space-only boxes. This is just an + // optimization, it doesn't have any effect on the output. + if (fragment.isSpaceOnly) { + return; + } + + _prepareCanvasForFragment(canvas, fragment); + final double fragmentX = fragment.textDirection! == ui.TextDirection.ltr + ? fragment.left + : fragment.right; + + final double x = offset.dx + line.left + fragmentX; + final double y = offset.dy + line.baseline; + + final EngineTextStyle style = fragment.style; + + final String text = fragment.getText(paragraph); + final double? letterSpacing = style.letterSpacing; + if (letterSpacing == null || letterSpacing == 0.0) { + canvas.drawText(text, x, y, + style: style.foreground?.style, shadows: style.shadows); + } else { + // TODO(mdebbar): Implement letter-spacing on canvas more efficiently: + // https://github.com/flutter/flutter/issues/51234 + double charX = x; + final int len = text.length; + for (int i = 0; i < len; i++) { + final String char = text[i]; + canvas.drawText(char, charX.roundToDouble(), y, + style: style.foreground?.style, + shadows: style.shadows); + charX += letterSpacing + canvas.measureText(char).width!; + } + } + + canvas.tearDownPaint(); } - void _applySpanStyleToCanvas(FlatTextSpan span, BitmapCanvas canvas) { + void _prepareCanvasForFragment(BitmapCanvas canvas, LayoutFragment fragment) { + final EngineTextStyle style = fragment.style; + final SurfacePaint? paint; - final ui.Paint? foreground = span.style.foreground; + final ui.Paint? foreground = style.foreground; if (foreground != null) { paint = foreground as SurfacePaint; } else { - paint = (ui.Paint()..color = span.style.color!) as SurfacePaint; + paint = (ui.Paint()..color = style.color!) as SurfacePaint; } - canvas.setCssFont(span.style.cssFontString); + canvas.setCssFont(style.cssFontString, fragment.textDirection!); canvas.setUpPaint(paint.paintData, null); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart index 7011038f710..ef0d55961d0 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart @@ -10,7 +10,8 @@ import '../browser_detection.dart'; import '../dom.dart'; import '../embedder.dart'; import '../util.dart'; -import 'layout_service.dart'; +import 'canvas_paragraph.dart'; +import 'layout_fragmenter.dart'; import 'ruler.dart'; class EngineLineMetrics implements ui.LineMetrics { @@ -114,16 +115,17 @@ class ParagraphLine { required double left, required double baseline, required int lineNumber, - required this.ellipsis, required this.startIndex, required this.endIndex, - required this.endIndexWithoutNewlines, + required this.trailingNewlines, + required this.trailingSpaces, + required this.spaceCount, required this.widthWithTrailingSpaces, - required this.boxes, - required this.spaceBoxCount, - required this.trailingSpaceBoxCount, + required this.fragments, + required this.textDirection, this.displayText, - }) : lineMetrics = EngineLineMetrics( + }) : assert(trailingNewlines <= endIndex - startIndex), + lineMetrics = EngineLineMetrics( hardBreak: hardBreak, ascent: ascent, descent: descent, @@ -138,12 +140,6 @@ class ParagraphLine { /// Metrics for this line of the paragraph. final EngineLineMetrics lineMetrics; - /// The string to be displayed as an overflow indicator. - /// - /// When the value is non-null, it means this line is overflowing and the - /// [ellipsis] needs to be displayed at the end of it. - final String? ellipsis; - /// The index (inclusive) in the text where this line begins. final int startIndex; @@ -153,9 +149,14 @@ class ParagraphLine { /// the text and doesn't stop at the overflow cutoff. final int endIndex; - /// The index (exclusive) in the text where this line ends, ignoring newline - /// characters. - final int endIndexWithoutNewlines; + /// The number of new line characters at the end of the line. + final int trailingNewlines; + + /// The number of spaces at the end of the line. + final int trailingSpaces; + + /// The number of space characters in the entire line. + final int spaceCount; /// The full width of the line including all trailing space but not new lines. /// @@ -169,21 +170,17 @@ class ParagraphLine { /// spaces so [widthWithTrailingSpaces] is more suitable. final double widthWithTrailingSpaces; - /// The list of boxes representing the entire line, possibly across multiple - /// spans. - final List boxes; + /// The fragments that make up this line. + final List fragments; - /// The number of boxes that are space-only. - final int spaceBoxCount; - - /// The number of trailing boxes that are space-only. - final int trailingSpaceBoxCount; + /// The text direction of this line, which is the same as the paragraph's. + final ui.TextDirection textDirection; /// The text to be rendered on the screen representing this line. final String? displayText; - /// The number of space-only boxes excluding trailing spaces. - int get nonTrailingSpaceBoxCount => spaceBoxCount - trailingSpaceBoxCount; + /// The number of space characters in the line excluding trailing spaces. + int get nonTrailingSpaces => spaceCount - trailingSpaces; // Convenient getters for line metrics properties. @@ -201,17 +198,25 @@ class ParagraphLine { return startIndex < this.endIndex && this.startIndex < endIndex; } + String getText(CanvasParagraph paragraph) { + final StringBuffer buffer = StringBuffer(); + for (final LayoutFragment fragment in fragments) { + buffer.write(fragment.getText(paragraph)); + } + return buffer.toString(); + } + @override int get hashCode => Object.hash( lineMetrics, - ellipsis, startIndex, endIndex, - endIndexWithoutNewlines, + trailingNewlines, + trailingSpaces, + spaceCount, widthWithTrailingSpaces, - boxes, - spaceBoxCount, - trailingSpaceBoxCount, + fragments, + textDirection, displayText, ); @@ -225,16 +230,21 @@ class ParagraphLine { } return other is ParagraphLine && other.lineMetrics == lineMetrics && - other.ellipsis == ellipsis && other.startIndex == startIndex && other.endIndex == endIndex && - other.endIndexWithoutNewlines == endIndexWithoutNewlines && + other.trailingNewlines == trailingNewlines && + other.trailingSpaces == trailingSpaces && + other.spaceCount == spaceCount && other.widthWithTrailingSpaces == widthWithTrailingSpaces && - other.boxes == boxes && - other.spaceBoxCount == spaceBoxCount && - other.trailingSpaceBoxCount == trailingSpaceBoxCount && + other.fragments == fragments && + other.textDirection == textDirection && other.displayText == displayText; } + + @override + String toString() { + return '$ParagraphLine($startIndex, $endIndex, $lineMetrics)'; + } } /// The web implementation of [ui.ParagraphStyle]. @@ -496,6 +506,52 @@ class EngineTextStyle implements ui.TextStyle { ); } + EngineTextStyle copyWith({ + ui.Color? color, + ui.TextDecoration? decoration, + ui.Color? decorationColor, + ui.TextDecorationStyle? decorationStyle, + double? decorationThickness, + ui.FontWeight? fontWeight, + ui.FontStyle? fontStyle, + ui.TextBaseline? textBaseline, + String? fontFamily, + List? fontFamilyFallback, + double? fontSize, + double? letterSpacing, + double? wordSpacing, + double? height, + ui.Locale? locale, + ui.Paint? background, + ui.Paint? foreground, + List? shadows, + List? fontFeatures, + List? fontVariations, + }) { + return EngineTextStyle( + color: color ?? this.color, + decoration: decoration ?? this.decoration, + decorationColor: decorationColor ?? this.decorationColor, + decorationStyle: decorationStyle ?? this.decorationStyle, + decorationThickness: decorationThickness ?? this.decorationThickness, + fontWeight: fontWeight ?? this.fontWeight, + fontStyle: fontStyle ?? this.fontStyle, + textBaseline: textBaseline ?? this.textBaseline, + fontFamily: fontFamily ?? this.fontFamily, + fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback, + fontSize: fontSize ?? this.fontSize, + letterSpacing: letterSpacing ?? this.letterSpacing, + wordSpacing: wordSpacing ?? this.wordSpacing, + height: height ?? this.height, + locale: locale ?? this.locale, + background: background ?? this.background, + foreground: foreground ?? this.foreground, + shadows: shadows ?? this.shadows, + fontFeatures: fontFeatures ?? this.fontFeatures, + fontVariations: fontVariations ?? this.fontVariations, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) { 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 index 782b0b6681c..5fe0f335882 100644 --- 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 @@ -4,9 +4,63 @@ import 'package:ui/ui.dart' as ui; -import 'line_breaker.dart'; +import 'fragmenter.dart'; import 'unicode_range.dart'; +enum FragmentFlow { + /// The fragment flows from left to right regardless of its surroundings. + ltr, + /// The fragment flows from right to left regardless of its surroundings. + rtl, + /// The fragment flows the same as the previous fragment. + /// + /// If it's the first fragment in a line, then it flows the same as the + /// paragraph direction. + /// + /// E.g. digits. + previous, + /// If the previous and next fragments flow in the same direction, then this + /// fragment flows in that same direction. Otherwise, it flows the same as the + /// paragraph direction. + /// + /// E.g. spaces, symbols. + sandwich, +} + +/// Splits [text] into fragments based on directionality. +class BidiFragmenter extends TextFragmenter { + const BidiFragmenter(super.text); + + @override + List fragment() { + return _computeBidiFragments(text); + } +} + +class BidiFragment extends TextFragment { + const BidiFragment(super.start, super.end, this.textDirection, this.fragmentFlow); + + final ui.TextDirection? textDirection; + final FragmentFlow fragmentFlow; + + @override + int get hashCode => Object.hash(start, end, textDirection, fragmentFlow); + + @override + bool operator ==(Object other) { + return other is BidiFragment && + other.start == start && + other.end == end && + other.textDirection == textDirection && + other.fragmentFlow == fragmentFlow; + } + + @override + String toString() { + return 'BidiFragment($start, $end, $textDirection)'; + } +} + // 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 @@ -50,69 +104,83 @@ final UnicodePropertyLookup _textDirectionLookup = UnicodePro null, ); -/// Represents a block of text with a certain [ui.TextDirection]. -class DirectionalPosition { - const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly); +List _computeBidiFragments(String text) { + final List fragments = []; - 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); + if (text.isEmpty) { + fragments.add(const BidiFragment(0, 0, null, FragmentFlow.previous)); + return fragments; } - // Check if we are in a space-only block. - if (start.index == end.indexWithoutTrailingSpaces) { - return DirectionalPosition(end, null, true); - } + int fragmentStart = 0; + ui.TextDirection? textDirection = _getTextDirection(text, 0); + FragmentFlow fragmentFlow = _getFragmentFlow(text, 0); - final ui.TextDirection? blockDirection = _textDirectionLookup.find(text, start.index); - int i = start.index + 1; + for (int i = 1; i < text.length; i++) { + final ui.TextDirection? charTextDirection = _getTextDirection(text, i); - while (i < end.indexWithoutTrailingSpaces) { - final ui.TextDirection? direction = _textDirectionLookup.find(text, i); - if (direction != blockDirection) { - // Reached the next block. - break; + if (charTextDirection != textDirection) { + // We've reached the end of a text direction fragment. + fragments.add(BidiFragment(fragmentStart, i, textDirection, fragmentFlow)); + fragmentStart = i; + textDirection = charTextDirection; + + fragmentFlow = _getFragmentFlow(text, i); + } else { + // This code handles the case of a sequence of digits followed by a sequence + // of LTR characters with no space in between. + if (fragmentFlow == FragmentFlow.previous) { + fragmentFlow = _getFragmentFlow(text, i); + } } - 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, - ); + fragments.add(BidiFragment(fragmentStart, text.length, textDirection, fragmentFlow)); + return fragments; +} + +ui.TextDirection? _getTextDirection(String text, int i) { + final int codePoint = getCodePoint(text, i)!; + if (_isDigit(codePoint) || _isMashriqiDigit(codePoint)) { + // A sequence of regular digits or Mashriqi digits always goes from left to + // regardless of their fragment flow direction. + return ui.TextDirection.ltr; + } + + final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint); + if (textDirection != null) { + return textDirection; + } + + return null; +} + +FragmentFlow _getFragmentFlow(String text, int i) { + final int codePoint = getCodePoint(text, i)!; + if (_isDigit(codePoint)) { + return FragmentFlow.previous; + } + if (_isMashriqiDigit(codePoint)) { + return FragmentFlow.rtl; + } + + final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint); + switch (textDirection) { + case ui.TextDirection.ltr: + return FragmentFlow.ltr; + + case ui.TextDirection.rtl: + return FragmentFlow.rtl; + + case null: + return FragmentFlow.sandwich; + } +} + +bool _isDigit(int codePoint) { + return codePoint >= kChar_0 && codePoint <= kChar_9; +} + +bool _isMashriqiDigit(int codePoint) { + return codePoint >= kMashriqi_0 && codePoint <= kMashriqi_9; } 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 81d14edc402..0e280cc6ae8 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 @@ -3,12 +3,14 @@ // found in the LICENSE file. const int kChar_0 = 48; -const int kChar_9 = 57; +const int kChar_9 = kChar_0 + 9; 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 kMashriqi_0 = 0x660; +const int kMashriqi_9 = kMashriqi_0 + 9; enum _ComparisonResult { inside, diff --git a/engine/src/flutter/lib/web_ui/test/html/bitmap_canvas_golden_test.dart b/engine/src/flutter/lib/web_ui/test/html/bitmap_canvas_golden_test.dart index 211bf448783..656bd3025d4 100644 --- a/engine/src/flutter/lib/web_ui/test/html/bitmap_canvas_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/html/bitmap_canvas_golden_test.dart @@ -10,6 +10,7 @@ import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; import 'package:web_engine_tester/golden_tester.dart'; +import 'paragraph/helper.dart'; import 'screenshot.dart'; void main() { @@ -225,8 +226,13 @@ Future testMain() async { ..lineTo(-r, 0) ..close()).shift(const Offset(250, 250)); + final SurfacePaintData borderPaint = SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke; + canvas.drawPath(path, pathPaint); canvas.drawParagraph(paragraph, const Offset(180, 50)); + canvas.drawRect(Rect.fromLTWH(180, 50, paragraph.width, paragraph.height), borderPaint); expect( canvas.rootElement.querySelectorAll('flt-paragraph').map((DomElement e) => e.text).toList(), diff --git a/engine/src/flutter/lib/web_ui/test/html/paragraph/helper.dart b/engine/src/flutter/lib/web_ui/test/html/paragraph/helper.dart index 05f85e63ea4..fde2dd6d916 100644 --- a/engine/src/flutter/lib/web_ui/test/html/paragraph/helper.dart +++ b/engine/src/flutter/lib/web_ui/test/html/paragraph/helper.dart @@ -6,6 +6,22 @@ import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; import 'package:web_engine_tester/golden_tester.dart'; +const LineBreakType prohibited = LineBreakType.prohibited; +const LineBreakType opportunity = LineBreakType.opportunity; +const LineBreakType mandatory = LineBreakType.mandatory; +const LineBreakType endOfText = LineBreakType.endOfText; + +const TextDirection ltr = TextDirection.ltr; +const TextDirection rtl = TextDirection.rtl; + +const FragmentFlow ffLtr = FragmentFlow.ltr; +const FragmentFlow ffRtl = FragmentFlow.rtl; +const FragmentFlow ffPrevious = FragmentFlow.previous; +const FragmentFlow ffSandwich = FragmentFlow.sandwich; + +const String rtlWord1 = 'واحدة'; +const String rtlWord2 = 'ثنتان'; + const Color white = Color(0xFFFFFFFF); const Color black = Color(0xFF000000); const Color red = Color(0xFFFF0000); diff --git a/engine/src/flutter/lib/web_ui/test/html/paragraph/overflow_golden_test.dart b/engine/src/flutter/lib/web_ui/test/html/paragraph/overflow_golden_test.dart index ff39d66e3f7..9f1e8bc7bef 100644 --- a/engine/src/flutter/lib/web_ui/test/html/paragraph/overflow_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/html/paragraph/overflow_golden_test.dart @@ -28,6 +28,9 @@ Future testMain() async { const double fontSize = 22.0; const double width = 126.0; const double padding = 20.0; + final SurfacePaintData borderPaint = SurfacePaintData() + ..color = black + ..style = PaintingStyle.stroke; paragraph = rich( EngineParagraphStyle(fontFamily: 'Roboto', fontSize: fontSize, ellipsis: '...'), @@ -39,6 +42,7 @@ Future testMain() async { }, )..layout(constrain(width)); canvas.drawParagraph(paragraph, offset); + canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint); offset = offset.translate(0, paragraph.height + padding); paragraph = rich( @@ -53,6 +57,7 @@ Future testMain() async { }, )..layout(constrain(width)); canvas.drawParagraph(paragraph, offset); + canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint); offset = offset.translate(0, paragraph.height + padding); paragraph = rich( @@ -83,6 +88,7 @@ Future testMain() async { }, )..layout(constrain(width)); canvas.drawParagraph(paragraph, offset); + canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint); offset = offset.translate(0, paragraph.height + padding); paragraph = rich( @@ -91,9 +97,9 @@ Future testMain() async { builder.pushStyle(EngineTextStyle.only(color: blue)); builder.addText('Lorem'); builder.pushStyle(EngineTextStyle.only(color: green)); - builder.addText('ipsum'); + builder.addText('ipsu'); builder.pushStyle(EngineTextStyle.only(color: red)); - builder.addText('dolor'); + builder.addText('mdolor'); builder.pushStyle(EngineTextStyle.only(color: black)); builder.addText('sit'); builder.pushStyle(EngineTextStyle.only(color: blue)); @@ -103,6 +109,7 @@ Future testMain() async { }, )..layout(constrain(width)); canvas.drawParagraph(paragraph, offset); + canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint); offset = offset.translate(0, paragraph.height + padding); } diff --git a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart index b898f5a9562..2a7040fddfd 100644 --- a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart @@ -7,6 +7,8 @@ import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; +import '../html/paragraph/helper.dart'; + bool get isIosSafari => browserEngine == BrowserEngine.webkit && operatingSystem == OperatingSystem.iOs; @@ -35,6 +37,28 @@ void main() { Future testMain() async { await initializeTestFlutterViewEmbedder(); + test('empty paragraph', () { + final CanvasParagraph paragraph1 = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) {}, + ); + expect(paragraph1.plainText, ''); + expect(paragraph1.spans, hasLength(1)); + expect(paragraph1.spans.single.start, 0); + expect(paragraph1.spans.single.end, 0); + + final CanvasParagraph paragraph2 = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addText(''); + }, + ); + expect(paragraph2.plainText, ''); + expect(paragraph2.spans, hasLength(1)); + expect(paragraph2.spans.single.start, 0); + expect(paragraph2.spans.single.end, 0); + }); + test('Builds a text-only canvas paragraph', () { final EngineParagraphStyle style = EngineParagraphStyle(fontSize: 13.0); final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); @@ -142,7 +166,10 @@ Future testMain() async { paragraph, '' '' - 'Hell...' + 'Hell' + '' + '' + '...' '' '', ignorePositions: !isBlink, @@ -356,10 +383,10 @@ Future testMain() async { '' 'Second' '' - '' + '' ' ' '' - '' + '' 'ThirdLongLine' '' '', @@ -377,8 +404,7 @@ Future testMain() async { '' 'Second' '' - // Trailing space. - '' + '' ' ' '' '' 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 6a7caaaf01b..c661016758f 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 @@ -370,7 +370,7 @@ Future testMain() async { ); }); - test('pops boxes when segments are popped', () { + test('reverts to last line break opportunity', () { final CanvasParagraph paragraph = rich(ahemStyle, (ui.ParagraphBuilder builder) { // Lines: // "AAA " @@ -383,6 +383,10 @@ Future testMain() async { builder.addText('DD'); }); + String getTextForFragment(LayoutFragment fragment) { + return paragraph.plainText.substring(fragment.start, fragment.end); + } + // The layout algorithm will keep appending segments to the line builder // until it reaches: "AAA B_". At that point, it'll try to add the "C" but // realizes there isn't enough width in the line. Since the line already @@ -398,29 +402,32 @@ Future testMain() async { final ParagraphLine firstLine = paragraph.lines[0]; final ParagraphLine secondLine = paragraph.lines[1]; - // There should be no "B" in the first line's boxes. - expect(firstLine.boxes, hasLength(2)); + // There should be no "B" in the first line's fragments. + expect(firstLine.fragments, hasLength(2)); - expect((firstLine.boxes[0] as SpanBox).toText(), 'AAA'); - expect((firstLine.boxes[0] as SpanBox).left, 0.0); + expect(getTextForFragment(firstLine.fragments[0]), 'AAA'); + expect(firstLine.fragments[0].left, 0.0); - expect((firstLine.boxes[1] as SpanBox).toText(), ' '); - expect((firstLine.boxes[1] as SpanBox).left, 30.0); + expect(getTextForFragment(firstLine.fragments[1]), ' '); + expect(firstLine.fragments[1].left, 30.0); - // Make sure the second line isn't missing any boxes. - expect(secondLine.boxes, hasLength(4)); + // Make sure the second line isn't missing any fragments. + expect(secondLine.fragments, hasLength(5)); - expect((secondLine.boxes[0] as SpanBox).toText(), 'B'); - expect((secondLine.boxes[0] as SpanBox).left, 0.0); + expect(getTextForFragment(secondLine.fragments[0]), 'B'); + expect(secondLine.fragments[0].left, 0.0); - expect((secondLine.boxes[1] as SpanBox).toText(), '_C'); - expect((secondLine.boxes[1] as SpanBox).left, 10.0); + expect(getTextForFragment(secondLine.fragments[1]), '_'); + expect(secondLine.fragments[1].left, 10.0); - expect((secondLine.boxes[2] as SpanBox).toText(), ' '); - expect((secondLine.boxes[2] as SpanBox).left, 30.0); + expect(getTextForFragment(secondLine.fragments[2]), 'C'); + expect(secondLine.fragments[2].left, 20.0); - expect((secondLine.boxes[3] as SpanBox).toText(), 'DD'); - expect((secondLine.boxes[3] as SpanBox).left, 40.0); + expect(getTextForFragment(secondLine.fragments[3]), ' '); + expect(secondLine.fragments[3].left, 30.0); + + expect(getTextForFragment(secondLine.fragments[4]), 'DD'); + expect(secondLine.fragments[4].left, 40.0); }); }); diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_fragmenter_test.dart b/engine/src/flutter/lib/web_ui/test/text/layout_fragmenter_test.dart new file mode 100644 index 00000000000..0c315ce01cd --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/text/layout_fragmenter_test.dart @@ -0,0 +1,293 @@ +// 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'; + +import '../html/paragraph/helper.dart'; + +final EngineTextStyle defaultStyle = EngineTextStyle.only( + color: const Color(0xFFFF0000), + fontFamily: FlutterViewEmbedder.defaultFontFamily, + fontSize: FlutterViewEmbedder.defaultFontSize, +); +final EngineTextStyle style1 = defaultStyle.copyWith(fontSize: 20); +final EngineTextStyle style2 = defaultStyle.copyWith(color: blue); +final EngineTextStyle style3 = defaultStyle.copyWith(fontFamily: 'Roboto'); + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + group('$LayoutFragmenter', () { + test('empty paragraph', () { + final CanvasParagraph paragraph1 = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) {}, + ); + expect(split(paragraph1), <_Fragment>[ + _Fragment('', endOfText, null, ffPrevious, defaultStyle), + ]); + + final CanvasParagraph paragraph2 = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addText(''); + }, + ); + expect(split(paragraph2), <_Fragment>[ + _Fragment('', endOfText, null, ffPrevious, defaultStyle), + ]); + + final CanvasParagraph paragraph3 = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.pushStyle(style1); + builder.addText(''); + }, + ); + expect(split(paragraph3), <_Fragment>[ + _Fragment('', endOfText, null, ffPrevious, style1), + ]); + }); + + test('single span', () { + final CanvasParagraph paragraph = + plain(EngineParagraphStyle(), 'Lorem 12 $rtlWord1 ipsum34'); + expect(split(paragraph), <_Fragment>[ + _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle), + _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1), + _Fragment('12', prohibited, ltr, ffPrevious, defaultStyle), + _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1), + _Fragment(rtlWord1, prohibited, rtl, ffRtl, defaultStyle), + _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 3), + _Fragment('ipsum34', endOfText, ltr, ffLtr, defaultStyle), + ]); + }); + + test('multi span', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.pushStyle(style1); + builder.addText('Lorem'); + builder.pop(); + builder.pushStyle(style2); + builder.addText(' ipsum 12 '); + builder.pop(); + builder.pushStyle(style3); + builder.addText(' $rtlWord1 foo.'); + builder.pop(); + }, + ); + + expect(split(paragraph), <_Fragment>[ + _Fragment('Lorem', prohibited, ltr, ffLtr, style1), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1), + _Fragment('ipsum', prohibited, ltr, ffLtr, style2), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1), + _Fragment('12', prohibited, ltr, ffPrevious, style2), + _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1), + _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1), + _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3), + _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1), + _Fragment('foo', prohibited, ltr, ffLtr, style3), + _Fragment('.', endOfText, null, ffSandwich, style3), + ]); + }); + + test('new lines', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.pushStyle(style1); + builder.addText('Lor\nem \n'); + builder.pop(); + builder.pushStyle(style2); + builder.addText(' \n ipsum 12 '); + builder.pop(); + builder.pushStyle(style3); + builder.addText(' $rtlWord1 fo'); + builder.pop(); + builder.pushStyle(style1); + builder.addText('o.'); + builder.pop(); + }, + ); + + expect(split(paragraph), <_Fragment>[ + _Fragment('Lor', prohibited, ltr, ffLtr, style1), + _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1), + _Fragment('em', prohibited, ltr, ffLtr, style1), + _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2), + _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2), + _Fragment('ipsum', prohibited, ltr, ffLtr, style2), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1), + _Fragment('12', prohibited, ltr, ffPrevious, style2), + _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1), + _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1), + _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3), + _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1), + _Fragment('fo', prohibited, ltr, ffLtr, style3), + _Fragment('o', prohibited, ltr, ffLtr, style1), + _Fragment('.', endOfText, null, ffSandwich, style1), + ]); + }); + + test('last line is empty', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.pushStyle(style1); + builder.addText('Lorem \n'); + builder.pop(); + builder.pushStyle(style2); + builder.addText(' \n ipsum \n'); + builder.pop(); + }, + ); + + expect(split(paragraph), <_Fragment>[ + _Fragment('Lorem', prohibited, ltr, ffLtr, style1), + _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2), + _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2), + _Fragment('ipsum', prohibited, ltr, ffLtr, style2), + _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2), + _Fragment('', endOfText, null, ffSandwich, style2), + ]); + }); + + test('space-only spans', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addText('Lorem '); + builder.pushStyle(style1); + builder.addText(' '); + builder.pop(); + builder.pushStyle(style2); + builder.addText(' '); + builder.pop(); + builder.addText('ipsum'); + }, + ); + + expect(split(paragraph), <_Fragment>[ + _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle), + _Fragment(' ', prohibited, null, ffSandwich, defaultStyle, sp: 1), + _Fragment(' ', prohibited, null, ffSandwich, style1, sp: 3), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2), + _Fragment('ipsum', endOfText, ltr, ffLtr, defaultStyle), + ]); + }); + + test('placeholders', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.pushStyle(style1); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('Lorem'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('ipsum\n'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.pop(); + builder.pushStyle(style2); + builder.addText('$rtlWord1 '); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('\nsit'); + builder.pop(); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + }, + ); + + final String placeholderChar = String.fromCharCode(0xFFFC); + + expect(split(paragraph), <_Fragment>[ + _Fragment(placeholderChar, opportunity, ltr, ffLtr, null), + _Fragment('Lorem', opportunity, ltr, ffLtr, style1), + _Fragment(placeholderChar, opportunity, ltr, ffLtr, null), + _Fragment('ipsum', prohibited, ltr, ffLtr, style1), + _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1), + _Fragment(placeholderChar, opportunity, ltr, ffLtr, null), + _Fragment(rtlWord1, prohibited, rtl, ffRtl, style2), + _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1), + _Fragment(placeholderChar, prohibited, ltr, ffLtr, null), + _Fragment('\n', mandatory, null, ffSandwich, style2, nl: 1, sp: 1), + _Fragment('sit', opportunity, ltr, ffLtr, style2), + _Fragment(placeholderChar, endOfText, ltr, ffLtr, null), + ]); + }); + }); +} + +/// Holds information about how a fragment. +class _Fragment { + _Fragment(this.text, this.type, this.textDirection, this.fragmentFlow, this.style, { + this.nl = 0, + this.sp = 0, + }); + + factory _Fragment._fromLayoutFragment(String text, LayoutFragment layoutFragment) { + final ParagraphSpan span = layoutFragment.span; + return _Fragment( + text.substring(layoutFragment.start, layoutFragment.end), + layoutFragment.type, + layoutFragment.textDirection, + layoutFragment.fragmentFlow, + span is FlatTextSpan ? span.style : null, + nl: layoutFragment.trailingNewlines, + sp: layoutFragment.trailingSpaces, + ); + } + + final String text; + final LineBreakType type; + final TextDirection? textDirection; + final FragmentFlow fragmentFlow; + final EngineTextStyle? style; + + /// The number of trailing new line characters. + final int nl; + + /// The number of trailing spaces. + final int sp; + + @override + int get hashCode => Object.hash(text, type, textDirection, fragmentFlow, style, nl, sp); + + @override + bool operator ==(Object other) { + return other is _Fragment && + other.text == text && + other.type == type && + other.textDirection == textDirection && + other.fragmentFlow == fragmentFlow && + other.style == style && + other.nl == nl && + other.sp == sp; + } + + @override + String toString() { + return '"$text" ($type, $textDirection, $fragmentFlow, nl: $nl, sp: $sp)'; + } +} + +List<_Fragment> split(CanvasParagraph paragraph) { + return <_Fragment>[ + for (final LayoutFragment layoutFragment + in computeLayoutFragments(paragraph)) + _Fragment._fromLayoutFragment(paragraph.plainText, layoutFragment) + ]; +} + +List computeLayoutFragments(CanvasParagraph paragraph) { + return LayoutFragmenter(paragraph.plainText, paragraph.spans).fragment(); +} diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_service_helper.dart b/engine/src/flutter/lib/web_ui/test/text/layout_service_helper.dart index 9fef792b034..e516d199ec7 100644 --- a/engine/src/flutter/lib/web_ui/test/text/layout_service_helper.dart +++ b/engine/src/flutter/lib/web_ui/test/text/layout_service_helper.dart @@ -10,7 +10,6 @@ TestLine l( String? displayText, int? startIndex, int? endIndex, { - int? endIndexWithoutNewlines, bool? hardBreak, double? height, double? width, @@ -22,7 +21,6 @@ TestLine l( displayText: displayText, startIndex: startIndex, endIndex: endIndex, - endIndexWithoutNewlines: endIndexWithoutNewlines, hardBreak: hardBreak, height: height, width: width, @@ -33,7 +31,6 @@ TestLine l( } void expectLines(CanvasParagraph paragraph, List expectedLines) { - final String text = paragraph.toPlainText(); final List lines = paragraph.lines; expect(lines, hasLength(expectedLines.length)); for (int i = 0; i < lines.length; i++) { @@ -46,11 +43,7 @@ void expectLines(CanvasParagraph paragraph, List expectedLines) { reason: 'line #$i had the wrong `lineNumber`. Expected: $i. Actual: ${line.lineNumber}', ); if (expectedLine.displayText != null) { - String displayText = - text.substring(line.startIndex, line.endIndexWithoutNewlines); - if (line.ellipsis != null) { - displayText += line.ellipsis!; - } + final String displayText = line.getText(paragraph); expect( displayText, expectedLine.displayText, @@ -74,14 +67,6 @@ void expectLines(CanvasParagraph paragraph, List expectedLines) { 'line #$i had a different `endIndex` value: "${line.endIndex}" vs. "${expectedLine.endIndex}"', ); } - if (expectedLine.endIndexWithoutNewlines != null) { - expect( - line.endIndexWithoutNewlines, - expectedLine.endIndexWithoutNewlines, - reason: - 'line #$i had a different `endIndexWithoutNewlines` value: "${line.endIndexWithoutNewlines}" vs. "${expectedLine.endIndexWithoutNewlines}"', - ); - } if (expectedLine.hardBreak != null) { expect( line.hardBreak, @@ -130,7 +115,6 @@ class TestLine { this.displayText, this.startIndex, this.endIndex, - this.endIndexWithoutNewlines, this.hardBreak, this.height, this.width, @@ -142,7 +126,6 @@ class TestLine { final String? displayText; final int? startIndex; final int? endIndex; - final int? endIndexWithoutNewlines; final bool? hardBreak; final double? height; final double? width; diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart index 9cdb9a5b676..777c80c11e4 100644 --- a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart @@ -46,8 +46,10 @@ Future testMain() async { expect(paragraph.maxIntrinsicWidth, 0); expect(paragraph.minIntrinsicWidth, 0); - expect(paragraph.height, 0); - expect(paragraph.computeLineMetrics(), isEmpty); + expect(paragraph.height, 10); + expectLines(paragraph, [ + l('', 0, 0, width: 0.0, height: 10.0, baseline: 8.0), + ]); }); test('preserves whitespace when measuring', () { @@ -422,7 +424,7 @@ Future testMain() async { expect(longText.maxIntrinsicWidth, 480); expect(longText.height, 10); expectLines(longText, [ - l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0), + l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0), ]); // The short prefix should make the text break into two lines, but the @@ -436,7 +438,7 @@ Future testMain() async { expect(longTextShortPrefix.height, 20); expectLines(longTextShortPrefix, [ l('AAA', 0, 4, hardBreak: true, width: 30.0, left: 0.0), - l('AA...', 4, 6, hardBreak: false, width: 50.0, left: 0.0), + l('AA...', 4, 6, hardBreak: true, width: 50.0, left: 0.0), ]); // Constraints only enough to fit "AA" with the ellipsis, but not the @@ -447,7 +449,7 @@ Future testMain() async { expect(trailingSpace.maxIntrinsicWidth, 60); expect(trailingSpace.height, 10); expectLines(trailingSpace, [ - l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0), + l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0), ]); // Tiny constraints. @@ -457,7 +459,7 @@ Future testMain() async { expect(paragraph.maxIntrinsicWidth, 40); expect(paragraph.height, 10); expectLines(paragraph, [ - l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0), + l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0), ]); // Tinier constraints (not enough for the ellipsis). @@ -471,7 +473,7 @@ Future testMain() async { // l('.', 0, 0, hardBreak: false, width: 10.0, left: 0.0), // ]); expectLines(paragraph, [ - l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0), + l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0), ]); }); @@ -550,14 +552,14 @@ Future testMain() async { paragraph = plain(onelineStyle, 'abcd efg')..layout(constrain(60.0)); expect(paragraph.height, 10); expectLines(paragraph, [ - l('abc...', 0, 3, hardBreak: false, width: 60.0, left: 0.0), + l('abc...', 0, 3, hardBreak: true, width: 60.0, left: 0.0), ]); // Another simple overflow case. paragraph = plain(onelineStyle, 'a bcde fgh')..layout(constrain(60.0)); expect(paragraph.height, 10); expectLines(paragraph, [ - l('a b...', 0, 3, hardBreak: false, width: 60.0, left: 0.0), + l('a b...', 0, 3, hardBreak: true, width: 60.0, left: 0.0), ]); // The ellipsis is supposed to go on the second line, but because the @@ -574,7 +576,7 @@ Future testMain() async { expect(paragraph.height, 20); expectLines(paragraph, [ l('abcd ', 0, 5, hardBreak: false, width: 40.0, left: 0.0), - l('efg...', 5, 8, hardBreak: false, width: 60.0, left: 0.0), + l('efg...', 5, 8, hardBreak: true, width: 60.0, left: 0.0), ]); // Even if the second line can be broken, we don't break it, we just @@ -584,7 +586,7 @@ Future testMain() async { expect(paragraph.height, 20); expectLines(paragraph, [ l('abcde ', 0, 6, hardBreak: false, width: 50.0, left: 0.0), - l('f g...', 6, 9, hardBreak: false, width: 60.0, left: 0.0), + l('f g...', 6, 9, hardBreak: true, width: 60.0, left: 0.0), ]); // First line overflows but second line doesn't. @@ -601,7 +603,7 @@ Future testMain() async { expect(paragraph.height, 20); expectLines(paragraph, [ l('abcdef', 0, 6, hardBreak: false, width: 60.0, left: 0.0), - l('g h...', 6, 9, hardBreak: false, width: 60.0, left: 0.0), + l('g h...', 6, 9, hardBreak: true, width: 60.0, left: 0.0), ]); }); diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_service_rich_test.dart b/engine/src/flutter/lib/web_ui/test/text/layout_service_rich_test.dart index 28d50bc784d..f1d38160c43 100644 --- a/engine/src/flutter/lib/web_ui/test/text/layout_service_rich_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/layout_service_rich_test.dart @@ -11,6 +11,8 @@ import 'package:ui/ui.dart' as ui; import '../html/paragraph/helper.dart'; import 'layout_service_helper.dart'; +final String placeholderChar = String.fromCharCode(0xFFFC); + const ui.Color white = ui.Color(0xFFFFFFFF); const ui.Color black = ui.Color(0xFF000000); const ui.Color red = ui.Color(0xFFFF0000); @@ -166,7 +168,7 @@ Future testMain() async { expect(paragraph.minIntrinsicWidth, 300.0); expect(paragraph.height, 50.0); expectLines(paragraph, [ - l('', 0, 0, hardBreak: true, width: 300.0, left: 100.0), + l(placeholderChar, 0, 1, hardBreak: true, width: 300.0, left: 100.0), ]); }); @@ -185,7 +187,7 @@ Future testMain() async { expect(paragraph.minIntrinsicWidth, 300.0); expect(paragraph.height, 50.0); expectLines(paragraph, [ - l('abcd', 0, 4, hardBreak: true, width: 340.0, left: 30.0), + l('abcd$placeholderChar', 0, 5, hardBreak: true, width: 340.0, left: 30.0), ]); }); @@ -207,8 +209,8 @@ Future testMain() async { expect(paragraph.height, 10.0 + 40.0 + 10.0); expectLines(paragraph, [ l('Lorem', 0, 6, hardBreak: true, width: 50.0, height: 10.0, left: 125.0), - l('', 6, 6, hardBreak: false, width: 300.0, height: 40.0, left: 0.0), - l('ipsum', 6, 11, hardBreak: true, width: 50.0, height: 10.0, left: 125.0), + l(placeholderChar, 6, 7, hardBreak: false, width: 300.0, height: 40.0, left: 0.0), + l('ipsum', 7, 12, hardBreak: true, width: 50.0, height: 10.0, left: 125.0), ]); }); diff --git a/engine/src/flutter/lib/web_ui/test/text/line_breaker_test.dart b/engine/src/flutter/lib/web_ui/test/text/line_breaker_test.dart index 3b218552b82..b536bd4c7b5 100644 --- a/engine/src/flutter/lib/web_ui/test/text/line_breaker_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/line_breaker_test.dart @@ -6,53 +6,53 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import '../html/paragraph/helper.dart'; import 'line_breaker_test_helper.dart'; import 'line_breaker_test_raw_data.dart'; +final String placeholderChar = String.fromCharCode(0xFFFC); + void main() { internalBootstrapBrowserTest(() => testMain); } void testMain() { - group('nextLineBreak', () { - test('Does not go beyond the ends of a string', () { - expect(split('foo'), [ - Line('foo', LineBreakType.endOfText), + group('$LineBreakFragmenter', () { + test('empty string', () { + expect(split(''), [ + Line('', endOfText), ]); - - final LineBreakResult result = nextLineBreak('foo', 'foo'.length); - expect(result.index, 'foo'.length); - expect(result.type, LineBreakType.endOfText); }); test('whitespace', () { expect(split('foo bar'), [ - Line('foo ', LineBreakType.opportunity), - Line('bar', LineBreakType.endOfText), + Line('foo ', opportunity, sp: 1), + Line('bar', endOfText), ]); expect(split(' foo bar '), [ - Line(' ', LineBreakType.opportunity), - Line('foo ', LineBreakType.opportunity), - Line('bar ', LineBreakType.endOfText), + Line(' ', opportunity, sp: 2), + Line('foo ', opportunity, sp: 4), + Line('bar ', endOfText, sp: 2), ]); }); test('single-letter lines', () { expect(split('foo a bar'), [ - Line('foo ', LineBreakType.opportunity), - Line('a ', LineBreakType.opportunity), - Line('bar', LineBreakType.endOfText), + Line('foo ', opportunity, sp: 1), + Line('a ', opportunity, sp: 1), + Line('bar', endOfText), ]); expect(split('a b c'), [ - Line('a ', LineBreakType.opportunity), - Line('b ', LineBreakType.opportunity), - Line('c', LineBreakType.endOfText), + Line('a ', opportunity, sp: 1), + Line('b ', opportunity, sp: 1), + Line('c', endOfText), ]); expect(split(' a b '), [ - Line(' ', LineBreakType.opportunity), - Line('a ', LineBreakType.opportunity), - Line('b ', LineBreakType.endOfText), + Line(' ', opportunity, sp: 1), + Line('a ', opportunity, sp: 1), + Line('b ', endOfText, sp: 1), ]); }); @@ -60,242 +60,344 @@ void testMain() { final String bk = String.fromCharCode(0x000B); // Can't have a line break between CR×LF. expect(split('foo\r\nbar'), [ - Line('foo\r\n', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\r\n', mandatory, nl: 2, sp: 2), + Line('bar', endOfText), ]); // Any other new line is considered a line break on its own. expect(split('foo\n\nbar'), [ - Line('foo\n', LineBreakType.mandatory), - Line('\n', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\n', mandatory, nl: 1, sp: 1), + Line('\n', mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo\r\rbar'), [ - Line('foo\r', LineBreakType.mandatory), - Line('\r', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\r', mandatory, nl: 1, sp: 1), + Line('\r', mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo$bk${bk}bar'), [ - Line('foo$bk', LineBreakType.mandatory), - Line(bk, LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo$bk', mandatory, nl: 1, sp: 1), + Line(bk, mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo\n\rbar'), [ - Line('foo\n', LineBreakType.mandatory), - Line('\r', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\n', mandatory, nl: 1, sp: 1), + Line('\r', mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo$bk\rbar'), [ - Line('foo$bk', LineBreakType.mandatory), - Line('\r', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo$bk', mandatory, nl: 1, sp: 1), + Line('\r', mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo\r${bk}bar'), [ - Line('foo\r', LineBreakType.mandatory), - Line(bk, LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\r', mandatory, nl: 1, sp: 1), + Line(bk, mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo$bk\nbar'), [ - Line('foo$bk', LineBreakType.mandatory), - Line('\n', LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo$bk', mandatory, nl: 1, sp: 1), + Line('\n', mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); expect(split('foo\n${bk}bar'), [ - Line('foo\n', LineBreakType.mandatory), - Line(bk, LineBreakType.mandatory), - Line('bar', LineBreakType.endOfText), + Line('foo\n', mandatory, nl: 1, sp: 1), + Line(bk, mandatory, nl: 1, sp: 1), + Line('bar', endOfText), ]); // New lines at the beginning and end. expect(split('foo\n'), [ - Line('foo\n', LineBreakType.mandatory), - Line('', LineBreakType.endOfText), + Line('foo\n', mandatory, nl: 1, sp: 1), + Line('', endOfText), ]); expect(split('foo\r'), [ - Line('foo\r', LineBreakType.mandatory), - Line('', LineBreakType.endOfText), + Line('foo\r', mandatory, nl: 1, sp: 1), + Line('', endOfText), ]); expect(split('foo$bk'), [ - Line('foo$bk', LineBreakType.mandatory), - Line('', LineBreakType.endOfText), + Line('foo$bk', mandatory, nl: 1, sp: 1), + Line('', endOfText), ]); expect(split('\nfoo'), [ - Line('\n', LineBreakType.mandatory), - Line('foo', LineBreakType.endOfText), + Line('\n', mandatory, nl: 1, sp: 1), + Line('foo', endOfText), ]); expect(split('\rfoo'), [ - Line('\r', LineBreakType.mandatory), - Line('foo', LineBreakType.endOfText), + Line('\r', mandatory, nl: 1, sp: 1), + Line('foo', endOfText), ]); expect(split('${bk}foo'), [ - Line(bk, LineBreakType.mandatory), - Line('foo', LineBreakType.endOfText), + Line(bk, mandatory, nl: 1, sp: 1), + Line('foo', endOfText), ]); // Whitespace with new lines. expect(split('foo \n'), [ - Line('foo \n', LineBreakType.mandatory), - Line('', LineBreakType.endOfText), + Line('foo \n', mandatory, nl: 1, sp: 3), + Line('', endOfText), ]); expect(split('foo \n '), [ - Line('foo \n', LineBreakType.mandatory), - Line(' ', LineBreakType.endOfText), + Line('foo \n', mandatory, nl: 1, sp: 3), + Line(' ', endOfText, sp: 3), ]); expect(split('foo \n bar'), [ - Line('foo \n', LineBreakType.mandatory), - Line(' ', LineBreakType.opportunity), - Line('bar', LineBreakType.endOfText), + Line('foo \n', mandatory, nl: 1, sp: 3), + Line(' ', opportunity, sp: 3), + Line('bar', endOfText), ]); expect(split('\n foo'), [ - Line('\n', LineBreakType.mandatory), - Line(' ', LineBreakType.opportunity), - Line('foo', LineBreakType.endOfText), + Line('\n', mandatory, nl: 1, sp: 1), + Line(' ', opportunity, sp: 2), + Line('foo', endOfText), ]); expect(split(' \n foo'), [ - Line(' \n', LineBreakType.mandatory), - Line(' ', LineBreakType.opportunity), - Line('foo', LineBreakType.endOfText), + Line(' \n', mandatory, nl: 1, sp: 4), + Line(' ', opportunity, sp: 2), + Line('foo', endOfText), ]); }); test('trailing spaces and new lines', () { - expect( - findBreaks('foo bar '), - const [ - LineBreakResult(4, 4, 3, LineBreakType.opportunity), - LineBreakResult(9, 9, 7, LineBreakType.endOfText), + expect(split('foo bar '), [ + Line('foo ', opportunity, sp: 1), + Line('bar ', endOfText, sp: 2), ], ); - expect( - findBreaks('foo \nbar\nbaz \n'), - const [ - LineBreakResult(6, 5, 3, LineBreakType.mandatory), - LineBreakResult(10, 9, 9, LineBreakType.mandatory), - LineBreakResult(17, 16, 13, LineBreakType.mandatory), - LineBreakResult(17, 17, 17, LineBreakType.endOfText), + expect(split('foo \nbar\nbaz \n'), [ + Line('foo \n', mandatory, nl: 1, sp: 3), + Line('bar\n', mandatory, nl: 1, sp: 1), + Line('baz \n', mandatory, nl: 1, sp: 4), + Line('', endOfText), ], ); }); test('leading spaces', () { - expect( - findBreaks(' foo'), - const [ - LineBreakResult(1, 1, 0, LineBreakType.opportunity), - LineBreakResult(4, 4, 4, LineBreakType.endOfText), + expect(split(' foo'), [ + Line(' ', opportunity, sp: 1), + Line('foo', endOfText), ], ); - expect( - findBreaks(' foo'), - const [ - LineBreakResult(3, 3, 0, LineBreakType.opportunity), - LineBreakResult(6, 6, 6, LineBreakType.endOfText), + expect(split(' foo'), [ + Line(' ', opportunity, sp: 3), + Line('foo', endOfText), ], ); - expect( - findBreaks(' foo bar'), - const [ - LineBreakResult(2, 2, 0, LineBreakType.opportunity), - LineBreakResult(8, 8, 5, LineBreakType.opportunity), - LineBreakResult(11, 11, 11, LineBreakType.endOfText), + expect(split(' foo bar'), [ + Line(' ', opportunity, sp: 2), + Line('foo ', opportunity, sp: 3), + Line('bar', endOfText), ], ); - expect( - findBreaks(' \n foo'), - const [ - LineBreakResult(3, 2, 0, LineBreakType.mandatory), - LineBreakResult(6, 6, 3, LineBreakType.opportunity), - LineBreakResult(9, 9, 9, LineBreakType.endOfText), + expect(split(' \n foo'), [ + Line(' \n', mandatory, nl: 1, sp: 3), + Line(' ', opportunity, sp: 3), + Line('foo', endOfText), ], ); }); test('whitespace before the last character', () { - const String text = 'Lorem sit .'; - const LineBreakResult expectedResult = - LineBreakResult(10, 10, 9, LineBreakType.opportunity); + expect(split('Lorem sit .'), [ + Line('Lorem ', opportunity, sp: 1), + Line('sit ', opportunity, sp: 1), + Line('.', endOfText), + ], + ); + }); - LineBreakResult result; + test('placeholders', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('Lorem'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('ipsum\n'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('dolor'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('\nsit'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + }, + ); - result = nextLineBreak(text, 6); - expect(result, expectedResult); + final String placeholderChar = String.fromCharCode(0xFFFC); - result = nextLineBreak(text, 9); - expect(result, expectedResult); + expect(splitParagraph(paragraph), [ + Line(placeholderChar, opportunity), + Line('Lorem', opportunity), + Line(placeholderChar, opportunity), + Line('ipsum\n', mandatory, nl: 1, sp: 1), + Line(placeholderChar, opportunity), + Line('dolor', opportunity), + Line('$placeholderChar\n', mandatory, nl: 1, sp: 1), + Line('sit', opportunity), + Line(placeholderChar, endOfText), + ]); + }); - result = nextLineBreak(text, 9, maxEnd: 10); - expect(result, expectedResult); + test('single placeholder', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + }, + ); + + final String placeholderChar = String.fromCharCode(0xFFFC); + + expect(splitParagraph(paragraph), [ + Line(placeholderChar, endOfText), + ]); + }); + + test('placeholders surrounded by spaces and new lines', () { + final CanvasParagraph paragraph = rich( + EngineParagraphStyle(), + (CanvasParagraphBuilder builder) { + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText(' Lorem '); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText(' \nipsum \n'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + builder.addText('\n'); + builder.addPlaceholder(100, 100, PlaceholderAlignment.top); + }, + ); + + expect(splitParagraph(paragraph), [ + Line('$placeholderChar ', opportunity, sp: 2), + Line('Lorem ', opportunity, sp: 2), + Line('$placeholderChar \n', mandatory, nl: 1, sp: 3), + Line('ipsum \n', mandatory, nl: 1, sp: 2), + Line('$placeholderChar\n', mandatory, nl: 1, sp: 1), + Line(placeholderChar, endOfText), + ], + ); + }); + + test('surrogates', () { + expect(split('A\u{1F600}'), [ + Line('A', opportunity), + Line('\u{1F600}', endOfText), + ], + ); + + expect(split('\u{1F600}A'), [ + Line('\u{1F600}', opportunity), + Line('A', endOfText), + ], + ); + + expect(split('\u{1F600}\u{1F600}'), [ + Line('\u{1F600}', opportunity), + Line('\u{1F600}', endOfText), + ], + ); + + expect(split('A \u{1F600} \u{1F600}'), [ + Line('A ', opportunity, sp: 1), + Line('\u{1F600} ', opportunity, sp: 1), + Line('\u{1F600}', endOfText), + ], + ); }); test('comprehensive test', () { - final List testCollection = parseRawTestData(rawLineBreakTestData); + final List testCollection = + parseRawTestData(rawLineBreakTestData); for (int t = 0; t < testCollection.length; t++) { final TestCase testCase = testCollection[t]; - final String text = testCase.toText(); - int lastLineBreak = 0; + final String text = testCase.toText(); + final List fragments = LineBreakFragmenter(text).fragment(); + + // `f` is the index in the `fragments` list. + int f = 0; + LineBreakFragment currentFragment = fragments[f]; + int surrogateCount = 0; // `s` is the index in the `testCase.signs` list. - for (int s = 0; s < testCase.signs.length; s++) { + for (int s = 0; s < testCase.signs.length - 1; s++) { // `i` is the index in the `text`. final int i = s + surrogateCount; - if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) { - surrogateCount++; - } - final Sign sign = testCase.signs[s]; - final LineBreakResult result = nextLineBreak(text, lastLineBreak); + if (sign.isBreakOpportunity) { - // The line break should've been found at index `i`. expect( - result.index, + currentFragment.end, i, reason: 'Failed at test case number $t:\n' '$testCase\n' '"$text"\n' - '\nExpected line break at {$lastLineBreak - $i} but found line break at {$lastLineBreak - ${result.index}}.', + '\nExpected fragment to end at {$i} but ended at {${currentFragment.end}}.', ); - - // Since this is a line break, passing a `maxEnd` that's greater - // should return the same line break. - final LineBreakResult maxEndResult = - nextLineBreak(text, lastLineBreak, maxEnd: i + 1); - expect(maxEndResult.index, i); - expect(maxEndResult.type, isNot(LineBreakType.prohibited)); - - lastLineBreak = i; + currentFragment = fragments[++f]; } else { - // This isn't a line break opportunity so the line break should be - // somewhere after index `i`. expect( - result.index, + currentFragment.end, greaterThan(i), reason: 'Failed at test case number $t:\n' '$testCase\n' '"$text"\n' - '\nUnexpected line break found at {$lastLineBreak - ${result.index}}.', + '\nFragment ended in early at {${currentFragment.end}}.', ); + } - // Since this isn't a line break, passing it as a `maxEnd` should - // return `maxEnd` as a prohibited line break type. - final LineBreakResult maxEndResult = - nextLineBreak(text, lastLineBreak, maxEnd: i); - expect(maxEndResult.index, i); - expect(maxEndResult.type, LineBreakType.prohibited); + if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) { + surrogateCount++; } } + + // Now let's look at the last sign, which requires different handling. + + // The last line break is an endOfText (or a hard break followed by + // endOfText if the last character is a hard line break). + if (currentFragment.type == mandatory) { + // When last character is a hard line break, there should be an + // extra fragment to represent the empty line at the end. + expect( + fragments, + hasLength(f + 2), + reason: 'Failed at test case number $t:\n' + '$testCase\n' + '"$text"\n' + "\nExpected an extra fragment for endOfText but there wasn't one.", + ); + + currentFragment = fragments[++f]; + } + + expect( + currentFragment.type, + endOfText, + reason: 'Failed at test case number $t:\n' + '$testCase\n' + '"$text"\n\n' + 'Expected an endOfText fragment but found: $currentFragment', + ); + expect( + currentFragment.end, + text.length, + reason: 'Failed at test case number $t:\n' + '$testCase\n' + '"$text"\n\n' + 'Expected an endOfText fragment ending at {${text.length}} but found: $currentFragment', + ); } }); }); @@ -303,17 +405,32 @@ void testMain() { /// Holds information about how a line was split from a string. class Line { - Line(this.text, this.breakType); + Line(this.text, this.breakType, {this.nl = 0, this.sp = 0}); + + factory Line.fromLineBreakFragment(String text, LineBreakFragment fragment) { + return Line( + text.substring(fragment.start, fragment.end), + fragment.type, + nl: fragment.trailingNewlines, + sp: fragment.trailingSpaces, + ); + } final String text; final LineBreakType breakType; + final int nl; + final int sp; @override - int get hashCode => Object.hash(text, breakType); + int get hashCode => Object.hash(text, breakType, nl, sp); @override bool operator ==(Object other) { - return other is Line && other.text == text && other.breakType == breakType; + return other is Line && + other.text == text && + other.breakType == breakType && + other.nl == nl && + other.sp == sp; } String get escapedText { @@ -329,29 +446,20 @@ class Line { @override String toString() { - return '"$escapedText" ($breakType)'; + return '"$escapedText" ($breakType, nl: $nl, sp: $sp)'; } } List split(String text) { - final List lines = []; - - int lastIndex = 0; - for (final LineBreakResult brk in findBreaks(text)) { - lines.add(Line(text.substring(lastIndex, brk.index), brk.type)); - lastIndex = brk.index; - } - return lines; + return [ + for (final LineBreakFragment fragment in LineBreakFragmenter(text).fragment()) + Line.fromLineBreakFragment(text, fragment) + ]; } -List findBreaks(String text) { - final List breaks = []; - - LineBreakResult brk = nextLineBreak(text, 0); - breaks.add(brk); - while (brk.type != LineBreakType.endOfText) { - brk = nextLineBreak(text, brk.index); - breaks.add(brk); - } - return breaks; +List splitParagraph(CanvasParagraph paragraph) { + return [ + for (final LineBreakFragment fragment in LineBreakFragmenter(paragraph.plainText).fragment()) + Line.fromLineBreakFragment(paragraph.toPlainText(), fragment) + ]; } 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 index 61b7b44e4df..3c39220c015 100644 --- 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 @@ -2,125 +2,197 @@ // 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 = 'ثنتان'; +import '../html/paragraph/helper.dart'; void main() { internalBootstrapBrowserTest(() => testMain); } Future testMain() async { - group('$getDirectionalBlockEnd', () { + group('$BidiFragmenter', () { + test('empty string', () { + expect(split(''), <_Bidi>[ + _Bidi('', null, ffPrevious), + ]); + }); 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); + expect(split('Lorem 11 $rtlWord1 22 ipsum'), <_Bidi>[ + _Bidi('Lorem', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('11', ltr, ffPrevious), + _Bidi(' ', null, ffSandwich), + _Bidi(rtlWord1, rtl, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('22', ltr, ffPrevious), + _Bidi(' ', null, ffSandwich), + _Bidi('ipsum', ltr, ffLtr), + ]); + }); - DirectionalPosition blockEnd; + test('text and digits', () { + expect(split('Lorem11 ${rtlWord1}22 33ipsum44dolor ${rtlWord2}55$rtlWord1'), <_Bidi>[ + _Bidi('Lorem11', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi(rtlWord1, rtl, ffRtl), + _Bidi('22', ltr, ffPrevious), + _Bidi(' ', null, ffSandwich), + _Bidi('33ipsum44dolor', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi(rtlWord2, rtl, ffRtl), + _Bidi('55', ltr, ffPrevious), + _Bidi(rtlWord1, rtl, ffRtl), + ]); + }); - blockEnd = getDirectionalBlockEnd(text, start, end); - expect(blockEnd.isSpaceOnly, isFalse); - expect(blockEnd.textDirection, TextDirection.ltr); - expect(blockEnd.lineBreak, loremEnd); + test('Mashriqi digits', () { + expect(split('foo ١١ ٢٢ bar'), <_Bidi>[ + _Bidi('foo', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('١١', ltr, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('٢٢', ltr, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('bar', ltr, ffLtr), + ]); - blockEnd = getDirectionalBlockEnd(text, start, loremMiddle); - expect(blockEnd.isSpaceOnly, isFalse); - expect(blockEnd.textDirection, TextDirection.ltr); - expect(blockEnd.lineBreak, loremMiddle); + expect(split('$rtlWord1 ١١ ٢٢ $rtlWord2'), <_Bidi>[ + _Bidi(rtlWord1, rtl, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('١١', ltr, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('٢٢', ltr, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi(rtlWord2, rtl, ffRtl), + ]); + }); - blockEnd = getDirectionalBlockEnd(text, loremMiddle, loremEnd); - expect(blockEnd.isSpaceOnly, isFalse); - expect(blockEnd.textDirection, TextDirection.ltr); - expect(blockEnd.lineBreak, loremEnd); + test('spaces', () { + expect(split(' '), <_Bidi>[ + _Bidi(' ', null, ffSandwich), + ]); + }); - blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart); - expect(blockEnd.isSpaceOnly, isTrue); - expect(blockEnd.textDirection, isNull); - expect(blockEnd.lineBreak, twelveStart); + test('symbols', () { + expect(split('Calculate 2.2 + 4.5 and write the result'), <_Bidi>[ + _Bidi('Calculate', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('2', ltr, ffPrevious), + _Bidi('.', null, ffSandwich), + _Bidi('2', ltr, ffPrevious), + _Bidi(' + ', null, ffSandwich), + _Bidi('4', ltr, ffPrevious), + _Bidi('.', null, ffSandwich), + _Bidi('5', ltr, ffPrevious), + _Bidi(' ', null, ffSandwich), + _Bidi('and', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('write', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('the', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('result', ltr, ffLtr), + ]); - blockEnd = getDirectionalBlockEnd(text, twelveStart, rtl1Start); - expect(blockEnd.isSpaceOnly, isFalse); - expect(blockEnd.textDirection, isNull); - expect(blockEnd.lineBreak, twelveEnd); + expect(split('Calculate $rtlWord1 2.2 + 4.5 and write the result'), <_Bidi>[ + _Bidi('Calculate', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi(rtlWord1, rtl, ffRtl), + _Bidi(' ', null, ffSandwich), + _Bidi('2', ltr, ffPrevious), + _Bidi('.', null, ffSandwich), + _Bidi('2', ltr, ffPrevious), + _Bidi(' + ', null, ffSandwich), + _Bidi('4', ltr, ffPrevious), + _Bidi('.', null, ffSandwich), + _Bidi('5', ltr, ffPrevious), + _Bidi(' ', null, ffSandwich), + _Bidi('and', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('write', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('the', ltr, ffLtr), + _Bidi(' ', null, ffSandwich), + _Bidi('result', ltr, ffLtr), + ]); - 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); + expect(split('12 + 24 = 36'), <_Bidi>[ + _Bidi('12', ltr, ffPrevious), + _Bidi(' + ', null, ffSandwich), + _Bidi('24', ltr, ffPrevious), + _Bidi(' = ', null, ffSandwich), + _Bidi('36', ltr, ffPrevious), + ]); }); 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); + expect(split('Lorem\n12\nipsum \n'), <_Bidi>[ + _Bidi('Lorem', ltr, ffLtr), + _Bidi('\n', null, ffSandwich), + _Bidi('12', ltr, ffPrevious), + _Bidi('\n', null, ffSandwich), + _Bidi('ipsum', ltr, ffLtr), + _Bidi(' \n', null, ffSandwich), + ]); - DirectionalPosition blockEnd; + expect(split('$rtlWord1\n $rtlWord2 \n'), <_Bidi>[ + _Bidi(rtlWord1, rtl, ffRtl), + _Bidi('\n ', null, ffSandwich), + _Bidi(rtlWord2, rtl, ffRtl), + _Bidi(' \n', null, ffSandwich), + ]); + }); - 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); + test('surrogates', () { + expect(split('A\u{1F600}'), <_Bidi>[ + _Bidi('A', ltr, ffLtr), + _Bidi('\u{1F600}', null, ffSandwich), + ]); }); }); } + +/// Holds information about how a bidi region was split from a string. +class _Bidi { + _Bidi(this.text, this.textDirection, this.fragmentFlow); + + factory _Bidi.fromBidiFragment(String text, BidiFragment bidiFragment) { + return _Bidi( + text.substring(bidiFragment.start, bidiFragment.end), + bidiFragment.textDirection, + bidiFragment.fragmentFlow, + ); + } + + final String text; + final TextDirection? textDirection; + final FragmentFlow fragmentFlow; + + @override + int get hashCode => Object.hash(text, textDirection); + + @override + bool operator ==(Object other) { + return other is _Bidi && + other.text == text && + other.textDirection == textDirection && + other.fragmentFlow == fragmentFlow; + } + + @override + String toString() { + return '"$text" ($textDirection | $fragmentFlow)'; + } +} + +List<_Bidi> split(String text) { + return <_Bidi>[ + for (final BidiFragment bidiFragment in BidiFragmenter(text).fragment()) + _Bidi.fromBidiFragment(text, bidiFragment) + ]; +}