[web] Separate text fragmenting from layout (flutter/engine#34085)

This commit is contained in:
Mouad Debbar 2022-10-18 16:58:35 -04:00 committed by GitHub
parent c2fed6dadf
commit 5ae7bb2623
24 changed files with 2562 additions and 1956 deletions

View File

@ -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

View File

@ -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';

View File

@ -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;

View File

@ -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;
}

View File

@ -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<ParagraphSpan> 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<RangeBox> 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<ui.FontFeature>? fontFeatures = style.fontFeatures;
if (fontFeatures != null && fontFeatures.isNotEmpty) {
_canDrawOnCanvas = false;
}
}
if (_canDrawOnCanvas) {
final List<ui.FontVariation>? 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<ui.FontFeature>? fontFeatures = style.fontFeatures;
if (fontFeatures != null && fontFeatures.isNotEmpty) {
_canDrawOnCanvas = false;
return;
}
final List<ui.FontVariation>? 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,

View File

@ -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<TextFragment> 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;
}
}

View File

@ -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<ParagraphSpan> paragraphSpans;
@override
List<LayoutFragment> fragment() {
final List<LayoutFragment> fragments = <LayoutFragment>[];
int fragmentStart = 0;
final Iterator<LineBreakFragment> lineBreakFragments = LineBreakFragmenter(text).fragment().iterator..moveNext();
final Iterator<BidiFragment> bidiFragments = BidiFragmenter(text).fragment().iterator..moveNext();
final Iterator<ParagraphSpan> 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<LayoutFragment?> split(int index) {
assert(start <= index);
assert(index <= end);
if (start == index) {
return <LayoutFragment?>[null, this];
}
if (end == index) {
return <LayoutFragment?>[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>[
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<LayoutFragment> split(int index) {
throw Exception('Cannot split an EllipsisFragment');
}
}

View File

@ -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<LineBreakFragment> 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<LineBreakFragment> _computeLineBreakFragments(String text) {
final List<LineBreakFragment> fragments = <LineBreakFragment>[];
// 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;
}
// Dont 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;
}

View File

@ -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<ParagraphLine> 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);
}
}

View File

@ -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<RangeBox> boxes;
/// The fragments that make up this line.
final List<LayoutFragment> 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<String>? fontFamilyFallback,
double? fontSize,
double? letterSpacing,
double? wordSpacing,
double? height,
ui.Locale? locale,
ui.Paint? background,
ui.Paint? foreground,
List<ui.Shadow>? shadows,
List<ui.FontFeature>? fontFeatures,
List<ui.FontVariation>? 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)) {

View File

@ -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<BidiFragment> 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<ui.TextDirection?> _textDirectionLookup = UnicodePro
null,
);
/// Represents a block of text with a certain [ui.TextDirection].
class DirectionalPosition {
const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly);
List<BidiFragment> _computeBidiFragments(String text) {
final List<BidiFragment> fragments = <BidiFragment>[];
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;
}

View File

@ -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,

View File

@ -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<void> 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<String?>((DomElement e) => e.text).toList(),

View File

@ -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);

View File

@ -28,6 +28,9 @@ Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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);
}

View File

@ -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<void> 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<void> testMain() async {
paragraph,
'<flt-paragraph style="${paragraphStyle()}">'
'<flt-span style="${spanStyle(top: 0, left: 0, width: 14*4)}">'
'Hell...'
'Hell'
'</flt-span>'
'<flt-span style="${spanStyle(top: 0, left: 14*4, width: 14*3)}">'
'...'
'</flt-span>'
'</flt-paragraph>',
ignorePositions: !isBlink,
@ -356,10 +383,10 @@ Future<void> testMain() async {
'<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
'Second'
'</flt-span>'
'<flt-span style="${spanStyle(top: 13, left: 78, width: 13*1, fontSize: 13)}">'
'<flt-span style="${spanStyle(top: 13, left: 13*6, width: 13*1, fontSize: 13)}">'
' '
'</flt-span>'
'<flt-span style="${spanStyle(top: 13, left: 91, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
'<flt-span style="${spanStyle(top: 13, left: 13*7, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
'ThirdLongLine'
'</flt-span>'
'</flt-paragraph>',
@ -377,8 +404,7 @@ Future<void> testMain() async {
'<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
'Second'
'</flt-span>'
// Trailing space.
'<flt-span style="${spanStyle(top: 13, left: 78, width: 0, fontSize: 13)}">'
'<flt-span style="${spanStyle(top: 13, left: 13*6, width: 0, fontSize: 13)}">'
' '
'</flt-span>'
'<flt-span style="${spanStyle(top: 26, left: 0, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'

View File

@ -370,7 +370,7 @@ Future<void> 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<void> 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<void> 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);
});
});

View File

@ -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<void> 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<LayoutFragment> computeLayoutFragments(CanvasParagraph paragraph) {
return LayoutFragmenter(paragraph.plainText, paragraph.spans).fragment();
}

View File

@ -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<TestLine> expectedLines) {
final String text = paragraph.toPlainText();
final List<ParagraphLine> 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<TestLine> 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<TestLine> 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;

View File

@ -46,8 +46,10 @@ Future<void> testMain() async {
expect(paragraph.maxIntrinsicWidth, 0);
expect(paragraph.minIntrinsicWidth, 0);
expect(paragraph.height, 0);
expect(paragraph.computeLineMetrics(), isEmpty);
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
l('', 0, 0, width: 0.0, height: 10.0, baseline: 8.0),
]);
});
test('preserves whitespace when measuring', () {
@ -422,7 +424,7 @@ Future<void> testMain() async {
expect(longText.maxIntrinsicWidth, 480);
expect(longText.height, 10);
expectLines(longText, <TestLine>[
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<void> testMain() async {
expect(longTextShortPrefix.height, 20);
expectLines(longTextShortPrefix, <TestLine>[
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<void> testMain() async {
expect(trailingSpace.maxIntrinsicWidth, 60);
expect(trailingSpace.height, 10);
expectLines(trailingSpace, <TestLine>[
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<void> testMain() async {
expect(paragraph.maxIntrinsicWidth, 40);
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
// l('.', 0, 0, hardBreak: false, width: 10.0, left: 0.0),
// ]);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
paragraph = plain(onelineStyle, 'abcd efg')..layout(constrain(60.0));
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
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, <TestLine>[
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<void> testMain() async {
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
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),
]);
});

View File

@ -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<void> testMain() async {
expect(paragraph.minIntrinsicWidth, 300.0);
expect(paragraph.height, 50.0);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
expect(paragraph.minIntrinsicWidth, 300.0);
expect(paragraph.height, 50.0);
expectLines(paragraph, <TestLine>[
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<void> testMain() async {
expect(paragraph.height, 10.0 + 40.0 + 10.0);
expectLines(paragraph, <TestLine>[
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),
]);
});

View File

@ -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>[
Line('foo', LineBreakType.endOfText),
group('$LineBreakFragmenter', () {
test('empty string', () {
expect(split(''), <Line>[
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>[
Line('foo ', LineBreakType.opportunity),
Line('bar', LineBreakType.endOfText),
Line('foo ', opportunity, sp: 1),
Line('bar', endOfText),
]);
expect(split(' foo bar '), <Line>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
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>[
Line('foo\n', LineBreakType.mandatory),
Line('', LineBreakType.endOfText),
Line('foo\n', mandatory, nl: 1, sp: 1),
Line('', endOfText),
]);
expect(split('foo\r'), <Line>[
Line('foo\r', LineBreakType.mandatory),
Line('', LineBreakType.endOfText),
Line('foo\r', mandatory, nl: 1, sp: 1),
Line('', endOfText),
]);
expect(split('foo$bk'), <Line>[
Line('foo$bk', LineBreakType.mandatory),
Line('', LineBreakType.endOfText),
Line('foo$bk', mandatory, nl: 1, sp: 1),
Line('', endOfText),
]);
expect(split('\nfoo'), <Line>[
Line('\n', LineBreakType.mandatory),
Line('foo', LineBreakType.endOfText),
Line('\n', mandatory, nl: 1, sp: 1),
Line('foo', endOfText),
]);
expect(split('\rfoo'), <Line>[
Line('\r', LineBreakType.mandatory),
Line('foo', LineBreakType.endOfText),
Line('\r', mandatory, nl: 1, sp: 1),
Line('foo', endOfText),
]);
expect(split('${bk}foo'), <Line>[
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>[
Line('foo \n', LineBreakType.mandatory),
Line('', LineBreakType.endOfText),
Line('foo \n', mandatory, nl: 1, sp: 3),
Line('', endOfText),
]);
expect(split('foo \n '), <Line>[
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>[
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>[
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>[
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>[
LineBreakResult(4, 4, 3, LineBreakType.opportunity),
LineBreakResult(9, 9, 7, LineBreakType.endOfText),
expect(split('foo bar '), <Line>[
Line('foo ', opportunity, sp: 1),
Line('bar ', endOfText, sp: 2),
],
);
expect(
findBreaks('foo \nbar\nbaz \n'),
const <LineBreakResult>[
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>[
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>[
LineBreakResult(1, 1, 0, LineBreakType.opportunity),
LineBreakResult(4, 4, 4, LineBreakType.endOfText),
expect(split(' foo'), <Line>[
Line(' ', opportunity, sp: 1),
Line('foo', endOfText),
],
);
expect(
findBreaks(' foo'),
const <LineBreakResult>[
LineBreakResult(3, 3, 0, LineBreakType.opportunity),
LineBreakResult(6, 6, 6, LineBreakType.endOfText),
expect(split(' foo'), <Line>[
Line(' ', opportunity, sp: 3),
Line('foo', endOfText),
],
);
expect(
findBreaks(' foo bar'),
const <LineBreakResult>[
LineBreakResult(2, 2, 0, LineBreakType.opportunity),
LineBreakResult(8, 8, 5, LineBreakType.opportunity),
LineBreakResult(11, 11, 11, LineBreakType.endOfText),
expect(split(' foo bar'), <Line>[
Line(' ', opportunity, sp: 2),
Line('foo ', opportunity, sp: 3),
Line('bar', endOfText),
],
);
expect(
findBreaks(' \n foo'),
const <LineBreakResult>[
LineBreakResult(3, 2, 0, LineBreakType.mandatory),
LineBreakResult(6, 6, 3, LineBreakType.opportunity),
LineBreakResult(9, 9, 9, LineBreakType.endOfText),
expect(split(' \n foo'), <Line>[
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>[
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>[
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>[
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>[
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>[
Line('A', opportunity),
Line('\u{1F600}', endOfText),
],
);
expect(split('\u{1F600}A'), <Line>[
Line('\u{1F600}', opportunity),
Line('A', endOfText),
],
);
expect(split('\u{1F600}\u{1F600}'), <Line>[
Line('\u{1F600}', opportunity),
Line('\u{1F600}', endOfText),
],
);
expect(split('A \u{1F600} \u{1F600}'), <Line>[
Line('A ', opportunity, sp: 1),
Line('\u{1F600} ', opportunity, sp: 1),
Line('\u{1F600}', endOfText),
],
);
});
test('comprehensive test', () {
final List<TestCase> testCollection = parseRawTestData(rawLineBreakTestData);
final List<TestCase> 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<LineBreakFragment> 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<Line> split(String text) {
final List<Line> lines = <Line>[];
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 <Line>[
for (final LineBreakFragment fragment in LineBreakFragmenter(text).fragment())
Line.fromLineBreakFragment(text, fragment)
];
}
List<LineBreakResult> findBreaks(String text) {
final List<LineBreakResult> breaks = <LineBreakResult>[];
LineBreakResult brk = nextLineBreak(text, 0);
breaks.add(brk);
while (brk.type != LineBreakType.endOfText) {
brk = nextLineBreak(text, brk.index);
breaks.add(brk);
}
return breaks;
List<Line> splitParagraph(CanvasParagraph paragraph) {
return <Line>[
for (final LineBreakFragment fragment in LineBreakFragmenter(paragraph.plainText).fragment())
Line.fromLineBreakFragment(paragraph.toPlainText(), fragment)
];
}

View File

@ -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<void> 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)
];
}