[web] Render RTL text correctly (flutter/engine#26811)

This commit is contained in:
Mouad Debbar 2021-06-29 15:30:32 -07:00 committed by GitHub
parent 8867a5c405
commit d760a28918
11 changed files with 1361 additions and 151 deletions

View File

@ -587,6 +587,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/measurement.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paint_service.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paragraph.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/ruler.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/text_direction.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_break_properties.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_breaker.dart

View File

@ -1,2 +1,2 @@
repository: https://github.com/flutter/goldens.git
revision: 8df047cbfb1edb34569f3170a26e718fef22ffa7
revision: 2b69a902e4c70b4e29044e66837e6b64aab1f473

View File

@ -165,6 +165,8 @@ export 'engine/text/canvas_paragraph.dart';
export 'engine/text/ruler.dart';
export 'engine/text/text_direction.dart';
export 'engine/text/unicode_range.dart';
export 'engine/text/word_break_properties.dart';

View File

@ -74,6 +74,8 @@ class CanvasParagraph implements EngineParagraph {
@override
bool isLaidOut = false;
bool get isRtl => paragraphStyle.effectiveTextDirection == ui.TextDirection.rtl;
ui.ParagraphConstraints? _lastUsedConstraints;
late final TextLayoutService _layoutService = TextLayoutService(this);
@ -168,7 +170,7 @@ class CanvasParagraph implements EngineParagraph {
// 2. Append all spans to the paragraph.
ParagraphSpan? span;
FlatTextSpan? span;
html.HtmlElement element = rootElement;
final List<EngineLineMetrics> lines = computeLineMetrics();
@ -180,21 +182,34 @@ class CanvasParagraph implements EngineParagraph {
}
final EngineLineMetrics line = lines[i];
for (final RangeBox box in line.boxes!) {
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 && box.span == span) {
buffer.write(box.toText());
continue;
}
if (buffer.isNotEmpty) {
domRenderer.appendText(element, buffer.toString());
buffer.clear();
}
if (box is SpanBox) {
if (box.span != span) {
span = box.span;
element = domRenderer.createElement('span') as html.HtmlElement;
applyTextStyleToElement(
element: element,
style: box.span.style,
isSpan: true
);
domRenderer.append(rootElement, element);
}
domRenderer.appendText(element, box.toText());
span = box.span;
element = domRenderer.createElement('span') as html.HtmlElement;
applyTextStyleToElement(
element: element,
style: box.span.style,
isSpan: true,
);
domRenderer.append(rootElement, element);
buffer.write(box.toText());
} else if (box is PlaceholderBox) {
span = box.placeholder;
span = null;
// If there's a line-end after this placeholder, we want the <BR> to
// be inserted in the root paragraph element.
element = rootElement;
@ -207,6 +222,11 @@ class CanvasParagraph implements EngineParagraph {
}
}
if (buffer.isNotEmpty) {
domRenderer.appendText(element, buffer.toString());
buffer.clear();
}
final String? ellipsis = line.ellipsis;
if (ellipsis != null) {
domRenderer.appendText(element, ellipsis);

View File

@ -13,6 +13,7 @@ import 'line_breaker.dart';
import 'measurement.dart';
import 'paragraph.dart';
import 'ruler.dart';
import 'text_direction.dart';
/// Performs layout on a [CanvasParagraph].
///
@ -137,9 +138,9 @@ class TextLayoutService {
spanIndex++;
} else if (span is FlatTextSpan) {
spanometer.currentSpan = span;
final LineBreakResult nextBreak = currentLine.findNextBreak(span.end);
final DirectionalPosition nextBreak = currentLine.findNextBreak();
final double additionalWidth =
currentLine.getAdditionalWidthTo(nextBreak);
currentLine.getAdditionalWidthTo(nextBreak.lineBreak);
if (currentLine.width + additionalWidth <= constraints.width) {
// TODO(mdebbar): Handle the case when `nextBreak` is just a span end
@ -160,8 +161,11 @@ class TextLayoutService {
// We've reached the line that requires an ellipsis to be appended
// to it.
currentLine.forceBreak(nextBreak,
allowEmpty: true, ellipsis: ellipsis);
currentLine.forceBreak(
nextBreak,
allowEmpty: true,
ellipsis: ellipsis,
);
lines.add(currentLine.build(ellipsis: ellipsis));
break;
} else if (currentLine.isEmpty) {
@ -226,7 +230,7 @@ class TextLayoutService {
spanIndex++;
} else if (span is FlatTextSpan) {
spanometer.currentSpan = span;
final LineBreakResult nextBreak = currentLine.findNextBreak(span.end);
final DirectionalPosition nextBreak = currentLine.findNextBreak();
// For the purpose of max intrinsic width, we don't care if the line
// fits within the constraints or not. So we always extend it.
@ -348,23 +352,89 @@ class TextLayoutService {
}
}
/// Represents a box inside [span] with the range of [start] to [end].
/// Represents a box inside a paragraph span with the range of [start] to [end].
///
/// The box's coordinates are all relative to the line it belongs to. For
/// example, [left] is the distance from the left edge of the line to the left
/// edge of the box.
///
/// This is what the various measurements/coordinates look like for a box in an
/// LTR paragraph:
///
/// *------------------------lineWidth------------------*
/// *--width--*
///
/// ---BOX---
///
/// *---startOffset---*
/// *------left-------*
/// *--------endOffset----------*
/// *----------right------------*
///
///
/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
/// the line starts from the right. Here's what they look like:
///
/// *------------------------lineWidth------------------*
/// *--width--*
///
/// ---BOX---
///
/// *------startOffset------*
/// *------left-------*
/// *-----------endOffset-------------*
/// *----------right------------*
///
abstract class RangeBox {
LineBreakResult get start;
LineBreakResult get end;
RangeBox(
this.start,
this.end,
this.width,
this.paragraphDirection,
this.boxDirection,
);
final LineBreakResult start;
final LineBreakResult end;
/// The distance from the beginning of the line to the beginning of the box.
late final double startOffset;
/// The distance from the beginning of the line to the end of the box.
double get endOffset => startOffset + width;
/// The distance from the left edge of the line to the left edge of the box.
double get left;
double get left => paragraphDirection == ui.TextDirection.ltr
? startOffset
: lineWidth - endOffset;
/// The distance from the left edge of the line to the right edge of the box.
double get right;
double get right => paragraphDirection == ui.TextDirection.ltr
? endOffset
: lineWidth - startOffset;
/// The direction in which text inside this box flows.
ui.TextDirection get direction;
/// The distance from the left edge of the box to the right edge of the box.
final double width;
/// The width of the line that this box belongs to.
late final double lineWidth;
/// The text direction of the paragraph that this box belongs to.
final ui.TextDirection paragraphDirection;
/// Indicates how this box flows among other boxes.
///
/// Example: In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is shown
/// visually in the following order:
///
/// +-------------------------------+
/// | ABC | 123 | drow_werbeh | DEF |
/// +-------------------------------+
/// box direction: LTR RTL RTL LTR
/// ----> <---- <------------ ---->
///
/// (In the above example, we are ignoring whitespace to simplify).
final ui.TextDirection boxDirection;
/// Returns a [ui.TextBox] representing this range box in the given [line].
///
@ -385,27 +455,13 @@ class PlaceholderBox extends RangeBox {
PlaceholderBox(
this.placeholder, {
required LineBreakResult index,
required this.left,
required this.direction,
}) : start = index, end = index;
required ui.TextDirection paragraphDirection,
required ui.TextDirection boxDirection,
}) : super(index, index, placeholder.width, paragraphDirection, boxDirection);
final PlaceholderSpan placeholder;
@override
final LineBreakResult start;
@override
final LineBreakResult end;
@override
final double left;
@override
double get right => left + placeholder.width;
@override
final ui.TextDirection direction;
ui.TextBox toTextBox(EngineLineMetrics line) {
final double left = line.left + this.left;
final double right = line.left + this.right;
@ -444,7 +500,7 @@ class PlaceholderBox extends RangeBox {
top,
right,
top + placeholder.height,
direction,
paragraphDirection,
);
}
@ -463,27 +519,52 @@ class PlaceholderBox extends RangeBox {
class SpanBox extends RangeBox {
SpanBox(
Spanometer spanometer, {
required this.start,
required this.end,
required this.left,
required this.direction,
}) : this.spanometer = spanometer,
required LineBreakResult start,
required LineBreakResult end,
required double width,
required ui.TextDirection paragraphDirection,
required ui.TextDirection boxDirection,
required this.contentDirection,
required this.isSpaceOnly,
}) : this.spanometer = spanometer,
span = spanometer.currentSpan,
height = spanometer.height,
baseline = spanometer.ascent,
width = spanometer.measureIncludingSpace(start, end);
super(start, end, width, paragraphDirection, boxDirection);
final Spanometer spanometer;
final FlatTextSpan span;
final LineBreakResult start;
final LineBreakResult end;
@override
final double left;
/// The direction of the text inside this box.
///
/// To illustrate the difference between [boxDirection] and [contentDirection]
/// here's an example:
///
/// In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is rendered as
/// follows:
///
/// ----> <---- <------------ ---->
/// box direction: LTR RTL RTL LTR
/// +-------------------------------+
/// | ABC | 123 | drow_werbeh | DEF |
/// +-------------------------------+
/// content direction: LTR LTR RTL LTR
/// ----> ----> <------------ ---->
///
/// Notice the box containing "123" flows in the RTL direction (because it
/// comes after an RTL box), while the content of the box flows in the LTR
/// direction (i.e. the text is shown as "123" not "321").
final ui.TextDirection contentDirection;
/// The distance from the left edge to the right edge of the box.
final double width;
/// Whether this box is made of only white space.
final bool isSpaceOnly;
/// Whether the contents of this box flow in the left-to-right direction.
bool get isContentLtr => contentDirection == ui.TextDirection.ltr;
/// Whether the contents of this box flow in the right-to-left direction.
bool get isContentRtl => !isContentLtr;
/// The distance from the top edge to the bottom edge of the box.
final double height;
@ -492,12 +573,6 @@ class SpanBox extends RangeBox {
/// the box.
final double baseline;
@override
final ui.TextDirection direction;
@override
double get right => left + width;
/// Whether this box's range overlaps with the range from [startIndex] to
/// [endIndex].
bool overlapsWith(int startIndex, int endIndex) {
@ -515,6 +590,7 @@ class SpanBox extends RangeBox {
///
/// The coordinates of the resulting [ui.TextBox] are relative to the
/// paragraph, not to the line.
@override
ui.TextBox toTextBox(EngineLineMetrics line) {
return intersect(line, start.index, end.index);
}
@ -526,41 +602,111 @@ class SpanBox extends RangeBox {
/// paragraph, not to the line.
ui.TextBox intersect(EngineLineMetrics line, int start, int end) {
final double top = line.baseline - baseline;
final double left, right;
final double before;
if (start <= this.start.index) {
left = this.left;
before = 0.0;
} else {
spanometer.currentSpan = span;
left = this.left + spanometer._measure(this.start.index, start);
before = spanometer._measure(this.start.index, start);
}
final double after;
if (end >= this.end.indexWithoutTrailingNewlines) {
right = this.right;
after = 0.0;
} else {
spanometer.currentSpan = span;
right = this.right -
spanometer._measure(end, this.end.indexWithoutTrailingNewlines);
after = spanometer._measure(end, this.end.indexWithoutTrailingNewlines);
}
final double left, right;
if (isContentLtr) {
// Example: let's say the text is "Loremipsum" and we want to get the box
// for "rem". In this case, `before` is the width of "Lo", and `after`
// is the width of "ipsum".
//
// Here's how the measurements/coordinates look like:
//
// before after
// |----| |----------|
// +---------------------+
// | L o r e m i p s u m |
// +---------------------+
// this.left ^ ^ this.right
left = this.left + before;
right = this.right - after;
} else {
// Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
// right to left). Say we want to get the box for "brew". The `before` is
// the width of "He", and `after` is the width of "_text".
//
// after before
// |----------| |----|
// +-----------------------+
// | t x e t _ w e r b e H |
// +-----------------------+
// this.left ^ ^ this.right
//
// Notice how `before` and `after` are reversed in the RTL example. That's
// because the text flows from right to left.
left = this.left + after;
right = this.right - before;
}
// The [RangeBox]'s left and right edges are relative to the line. In order
// to make them relative to the paragraph, we need to add the left edge of
// the line.
return ui.TextBox.fromLTRBD(
left + line.left,
line.left + left,
top,
right + line.left,
line.left + right,
top + height,
direction,
contentDirection,
);
}
/// Transforms the [x] coordinate to be relative to this box and matches the
/// flow of content.
///
/// In LTR paragraphs, the [startOffset] and [endOffset] of an RTL box
/// indicate the visual beginning and end of the box. But the text inside the
/// box flows in the opposite direction (from [endOffset] to [startOffset]).
///
/// The X (input) is relative to the line, and always from left-to-right
/// independent of paragraph and content direction.
///
/// Here's how it looks for a box with LTR content:
///
/// *------------------------lineWidth------------------*
/// *---------------X (input)
///
/// --content-direction-->
///
/// *---X' (output)
/// *---left----*
/// *---------------right----------------*
///
///
/// And here's how it looks for a box with RTL content:
///
/// *------------------------lineWidth------------------*
/// *----------------X (input)
///
/// <--content-direction--
///
/// (output) X'------------------*
/// *---left----*
/// *---------------right----------------*
///
double _makeXRelativeToContent(double x) {
return isContentRtl ? right - x : x - left;
}
@override
ui.TextPosition getPositionForX(double x) {
spanometer.currentSpan = span;
// Make `x` relative to this box.
x -= left;
x = _makeXRelativeToContent(x);
final int startIndex = start.index;
final int endIndex = end.indexWithoutTrailingNewlines;
@ -730,10 +876,7 @@ class LineBuilder {
/// The horizontal offset necessary for the line to be correctly aligned.
double get alignOffset {
final double emptySpace = maxWidth - width;
final ui.TextDirection textDirection =
paragraph.paragraphStyle.effectiveTextDirection;
final ui.TextAlign textAlign =
paragraph.paragraphStyle.effectiveTextAlign;
final ui.TextAlign textAlign = paragraph.paragraphStyle.effectiveTextAlign;
switch (textAlign) {
case ui.TextAlign.center:
@ -741,9 +884,9 @@ class LineBuilder {
case ui.TextAlign.right:
return emptySpace;
case ui.TextAlign.start:
return textDirection == ui.TextDirection.rtl ? emptySpace : 0.0;
return _paragraphDirection == ui.TextDirection.rtl ? emptySpace : 0.0;
case ui.TextAlign.end:
return textDirection == ui.TextDirection.rtl ? 0.0 : emptySpace;
return _paragraphDirection == ui.TextDirection.rtl ? 0.0 : emptySpace;
default:
return 0.0;
}
@ -767,12 +910,37 @@ class LineBuilder {
return (_boxes.last is PlaceholderBox);
}
ui.TextDirection get _paragraphDirection =>
paragraph.paragraphStyle.effectiveTextDirection;
late ui.TextDirection _currentBoxDirection = _paragraphDirection;
late ui.TextDirection _currentContentDirection = _paragraphDirection;
bool _shouldCreateBoxBeforeExtendingTo(DirectionalPosition newEnd) {
// When the direction changes, we need to make sure to put them in separate
// boxes.
return newEnd.isSpaceOnly || _currentBoxDirection != newEnd.textDirection || _currentContentDirection != newEnd.textDirection;
}
/// Extends the line by setting a [newEnd].
void extendTo(LineBreakResult newEnd) {
void extendTo(DirectionalPosition newEnd) {
ascent = math.max(ascent, spanometer.ascent);
descent = math.max(descent, spanometer.descent);
_addSegment(_createSegment(newEnd));
// When the direction changes, we need to make sure to put them in separate
// boxes.
if (_shouldCreateBoxBeforeExtendingTo(newEnd)) {
createBox();
}
_currentBoxDirection = newEnd.textDirection ?? _currentBoxDirection;
_currentContentDirection = newEnd.textDirection ?? ui.TextDirection.ltr;
_addSegment(_createSegment(newEnd.lineBreak));
if (newEnd.isSpaceOnly) {
// Whitespace sequences go in their own boxes.
createBox(isSpaceOnly: true);
}
}
/// Extends the line to the end of the paragraph.
@ -854,10 +1022,11 @@ class LineBuilder {
// Add the placeholder box.
_boxes.add(PlaceholderBox(
placeholder,
index: _boxStart,
left: _boxLeft,
direction: paragraph.paragraphStyle.effectiveTextDirection,
index: _currentBoxStart,
paragraphDirection: _paragraphDirection,
boxDirection: _currentBoxDirection,
));
_currentBoxStartOffset = widthIncludingSpace;
}
/// Creates a new segment to be appended to the end of this line.
@ -944,7 +1113,7 @@ class LineBuilder {
/// 2. The line doesn't have any line break opportunities and has to be
/// force-broken.
void forceBreak(
LineBreakResult nextBreak, {
DirectionalPosition nextBreak, {
required bool allowEmpty,
String? ellipsis,
}) {
@ -952,7 +1121,7 @@ class LineBuilder {
final double availableWidth = maxWidth - widthIncludingSpace;
final int breakingPoint = spanometer.forceBreak(
end.index,
nextBreak.indexWithoutTrailingSpaces,
nextBreak.lineBreak.indexWithoutTrailingSpaces,
availableWidth: availableWidth,
allowEmpty: allowEmpty,
);
@ -961,15 +1130,13 @@ class LineBuilder {
// 1. Next break is only one character away, with zero or many spaces. AND
// 2. There isn't enough width to fit the single character. AND
// 3. `allowEmpty` is false.
if (breakingPoint == nextBreak.indexWithoutTrailingSpaces) {
if (breakingPoint == nextBreak.lineBreak.indexWithoutTrailingSpaces) {
// In this case, we just extend to `nextBreak` instead of creating a new
// artificial break. It's safe (and better) to do so, because we don't
// want the trailing white space to go to the next line.
extendTo(nextBreak);
} else {
extendTo(
LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited),
);
extendTo(nextBreak.copyWithIndex(breakingPoint));
}
return;
}
@ -986,7 +1153,7 @@ class LineBuilder {
final double availableWidth = maxWidth - ellipsisWidth;
// First, we create the new segment until `nextBreak`.
LineSegment segmentToBreak = _createSegment(nextBreak);
LineSegment segmentToBreak = _createSegment(nextBreak.lineBreak);
// Then, we keep popping until we find the segment that has to be broken.
// After the loop ends, two things are correct:
@ -1005,17 +1172,18 @@ class LineBuilder {
availableWidth: availableWidthForSegment,
allowEmpty: allowEmpty,
);
extendTo(
LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited));
// There's a possibility that the end of line has moved backwards, so we
// need to remove some boxes in that case.
while (_boxes.length > 0 && _boxes.last.end.index > breakingPoint) {
_boxes.removeLast();
}
_currentBoxStartOffset = widthIncludingSpace;
extendTo(nextBreak.copyWithIndex(breakingPoint));
}
LineBreakResult get _boxStart {
LineBreakResult get _currentBoxStart {
if (_boxes.isEmpty) {
return start;
}
@ -1023,25 +1191,22 @@ class LineBuilder {
return _boxes.last.end;
}
double get _boxLeft {
if (_boxes.isEmpty) {
return 0.0;
}
return _boxes.last.right;
}
double _currentBoxStartOffset = 0.0;
ui.TextDirection get direction =>
paragraph.paragraphStyle.effectiveTextDirection;
double get _currentBoxWidth => widthIncludingSpace - _currentBoxStartOffset;
/// Cuts a new box in the line.
///
/// If this is the first box in the line, it'll start at the beginning of the
/// line. Else, it'll start at the end of the last box.
///
/// A box should be cut whenever the end of line is reached, or when switching
/// from one span to another.
void createBox() {
final LineBreakResult boxStart = _boxStart;
/// A box should be cut whenever the end of line is reached, when switching
/// from one span to another, or when switching text direction.
///
/// [isSpaceOnly] indicates that the box contains nothing but whitespace
/// characters.
void createBox({bool isSpaceOnly = false}) {
final LineBreakResult boxStart = _currentBoxStart;
final LineBreakResult boxEnd = end;
// Avoid creating empty boxes. This could happen when the end of a span
// coincides with the end of a line. In this case, `createBox` is called twice.
@ -1053,15 +1218,21 @@ class LineBuilder {
spanometer,
start: boxStart,
end: boxEnd,
left: _boxLeft,
direction: paragraph.paragraphStyle.effectiveTextDirection,
width: _currentBoxWidth,
paragraphDirection: _paragraphDirection,
boxDirection: _currentBoxDirection,
contentDirection: _currentContentDirection,
isSpaceOnly: isSpaceOnly,
));
_currentBoxStartOffset = widthIncludingSpace;
}
/// Builds the [EngineLineMetrics] instance that represents this line.
EngineLineMetrics build({String? ellipsis}) {
// At the end of each line, we cut the last box of the line.
createBox();
_positionBoxes();
final double ellipsisWidth =
ellipsis == null ? 0.0 : spanometer.measureText(ellipsis);
@ -1091,9 +1262,112 @@ class LineBuilder {
);
}
/// Positions the boxes and takes into account their directions, and the
/// paragraph's direction.
void _positionBoxes() {
final List<RangeBox> boxes = _boxes;
int i = 0;
double cumulativeWidth = 0.0;
while (i < boxes.length) {
final RangeBox box = boxes[i];
if (box.boxDirection == _paragraphDirection) {
// The box is in the same direction as the paragraph.
box.startOffset = cumulativeWidth;
box.lineWidth = width;
cumulativeWidth += box.width;
i++;
continue;
}
// At this point, we found a box that has the opposite direction to the
// paragraph. This could be a sequence of one or more boxes.
//
// These boxes should flow in the opposite direction. So we need to
// position them in reverse order.
//
// If the last box in the sequence is a space-only box (contains only
// whitespace characters), it should be excluded from the sequence.
//
// Example: an LTR paragraph with the contents:
//
// "ABC rtl1 rtl2 rtl3 XYZ"
// ^ ^ ^ ^
// SP1 SP2 SP3 SP4
//
//
// box direction: LTR RTL LTR
// |------>|<-----------------------|------>
// +----------------------------------------+
// | ABC | | rtl3 | | rtl2 | | rtl1 | | XYZ |
// +----------------------------------------+
// ^ ^ ^ ^
// SP1 SP3 SP2 SP4
//
// Notice how SP2 and SP3 are flowing in the RTL direction because of the
// surrounding RTL words. SP4 is also preceded by an RTL word, but it marks
// the end of the RTL sequence, so it goes back to flowing in the paragraph
// direction (LTR).
final int first = i;
int lastNonSpaceBox = first;
i++;
while (i < boxes.length && boxes[i].boxDirection != _paragraphDirection) {
final RangeBox box = boxes[i];
if (box is SpanBox && box.isSpaceOnly) {
// Do nothing.
} else {
lastNonSpaceBox = i;
}
i++;
}
final int last = lastNonSpaceBox;
i = lastNonSpaceBox + 1;
// The range (first:last) is the entire sequence of boxes that have the
// opposite direction to the paragraph.
final double sequenceWidth =
_positionBoxesInReverse(boxes, first, last, startOffset: cumulativeWidth);
cumulativeWidth += sequenceWidth;
}
}
/// Positions a sequence of boxes in the direction opposite to the paragraph
/// text direction.
///
/// This is needed when a right-to-left sequence appears in the middle of a
/// left-to-right paragraph, or vice versa.
///
/// Returns the total width of all the positioned boxes in the sequence.
///
/// [first] and [last] are expected to be inclusive.
double _positionBoxesInReverse(
List<RangeBox> boxes,
int first,
int last, {
required double startOffset,
}) {
double cumulativeWidth = 0.0;
for (int i = last; i >= first; i--) {
// Update the visual position of each box.
final RangeBox box = boxes[i];
assert(box.boxDirection != _paragraphDirection);
box.startOffset = startOffset + cumulativeWidth;
box.lineWidth = width;
cumulativeWidth += box.width;
}
return cumulativeWidth;
}
/// Finds the next line break after the end of this line.
LineBreakResult findNextBreak(int maxEnd) {
return nextLineBreak(paragraph.toPlainText(), end.index, maxEnd: maxEnd);
DirectionalPosition findNextBreak() {
final String text = paragraph.toPlainText();
final int maxEnd = spanometer.currentSpan.end;
final LineBreakResult result = nextLineBreak(text, end.index, maxEnd: maxEnd);
// The current end of the line is the beginning of the next block.
return getDirectionalBlockEnd(text, end, result);
}
/// Creates a new [LineBuilder] to build the next line in the paragraph.

View File

@ -0,0 +1,118 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:ui/ui.dart' as ui;
import 'line_breaker.dart';
import 'unicode_range.dart';
// This data was taken from the source code of the Closure library:
//
// - https://github.com/google/closure-library/blob/9d24a6c1809a671c2e54c328897ebeae15a6d172/closure/goog/i18n/bidi.js#L203-L234
final UnicodePropertyLookup<ui.TextDirection?> _textDirectionLookup = UnicodePropertyLookup<ui.TextDirection?>(
<UnicodeRange<ui.TextDirection>>[
// LTR
UnicodeRange<ui.TextDirection>(kChar_A, kChar_Z, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(kChar_a, kChar_z, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0x00C0, 0x00D6, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0x00D8, 0x00F6, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0x00F8, 0x02B8, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0x0300, 0x0590, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0x0591, 0x06EF, ui.TextDirection.rtl),
UnicodeRange<ui.TextDirection>(0x06FA, 0x08FF, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0x0900, 0x1FFF, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0x200E, 0x200E, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0x200F, 0x200F, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0x2C00, 0xD801, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0xD802, 0xD803, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0xD804, 0xD839, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0xD83A, 0xD83B, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0xD83C, 0xDBFF, ui.TextDirection.ltr),
UnicodeRange<ui.TextDirection>(0xF900, 0xFB1C, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0xFB1D, 0xFDFF, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0xFE00, 0xFE6F, ui.TextDirection.ltr),
// RTL
UnicodeRange<ui.TextDirection>(0xFE70, 0xFEFC, ui.TextDirection.rtl),
// LTR
UnicodeRange<ui.TextDirection>(0xFEFD, 0xFFFF, ui.TextDirection.ltr),
],
null,
);
/// Represents a block of text with a certain [ui.TextDirection].
class DirectionalPosition {
const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly);
final LineBreakResult lineBreak;
final ui.TextDirection? textDirection;
final bool isSpaceOnly;
LineBreakType get type => lineBreak.type;
/// Creates a copy of this [DirectionalPosition] with a different [index].
///
/// The type of the returned [DirectionalPosition] is set to
/// [LineBreakType.prohibited].
DirectionalPosition copyWithIndex(int index) {
return DirectionalPosition(
LineBreakResult.sameIndex(index, LineBreakType.prohibited),
textDirection,
isSpaceOnly,
);
}
}
/// Finds the end of the directional block of text that starts at [start] up
/// until [end].
///
/// If the block goes beyond [end], the part after [end] is ignored.
DirectionalPosition getDirectionalBlockEnd(
String text,
LineBreakResult start,
LineBreakResult end,
) {
if (start.index == end.index) {
return DirectionalPosition(end, null, false);
}
// Check if we are in a space-only block.
if (start.index == end.indexWithoutTrailingSpaces) {
return DirectionalPosition(end, null, true);
}
ui.TextDirection? blockDirection = _textDirectionLookup.find(text, start.index);
int i = start.index + 1;
while (i < end.indexWithoutTrailingSpaces) {
final ui.TextDirection? direction = _textDirectionLookup.find(text, i);
if (direction != blockDirection) {
// Reached the next block.
break;
}
i++;
}
if (i == end.indexWithoutTrailingNewlines) {
// If all that remains before [end] is new lines, let's include them in the
// block.
return DirectionalPosition(end, blockDirection, false);
}
return DirectionalPosition(
LineBreakResult.sameIndex(i, LineBreakType.prohibited),
blockDirection,
false,
);
}

View File

@ -2,13 +2,13 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
const int _kChar_0 = 48;
const int _kChar_9 = 57;
const int _kChar_A = 65;
const int _kChar_Z = 90;
const int _kChar_a = 97;
const int _kChar_z = 122;
const int _kCharBang = 33;
const int kChar_0 = 48;
const int kChar_9 = 57;
const int kChar_A = 65;
const int kChar_Z = 90;
const int kChar_a = 97;
const int kChar_z = 122;
const int kCharBang = 33;
enum _ComparisonResult {
inside,
@ -210,7 +210,7 @@ List<UnicodeRange<P>> _unpackProperties<P>(
i += 4;
int rangeEnd;
if (packedData.codeUnitAt(i) == _kCharBang) {
if (packedData.codeUnitAt(i) == kCharBang) {
rangeEnd = rangeStart;
i++;
} else {
@ -231,15 +231,15 @@ int _getEnumIndexFromPackedValue(int charCode) {
// This has to stay in sync with [EnumValue.serialized] in
// `tool/unicode_sync_script.dart`.
assert((charCode >= _kChar_A && charCode <= _kChar_Z) ||
(charCode >= _kChar_a && charCode <= _kChar_z));
assert((charCode >= kChar_A && charCode <= kChar_Z) ||
(charCode >= kChar_a && charCode <= kChar_z));
// Uppercase letters were assigned to the first 26 enum values.
if (charCode <= _kChar_Z) {
return charCode - _kChar_A;
if (charCode <= kChar_Z) {
return charCode - kChar_A;
}
// Lowercase letters were assigned to enum values above 26.
return 26 + charCode - _kChar_a;
return 26 + charCode - kChar_a;
}
int _consumeInt(String packedData, int index) {
@ -261,12 +261,12 @@ int _consumeInt(String packedData, int index) {
/// Does the same thing as [int.parse(str, 36)] but takes only a single
/// character as a [charCode] integer.
int _getIntFromCharCode(int charCode) {
assert((charCode >= _kChar_0 && charCode <= _kChar_9) ||
(charCode >= _kChar_a && charCode <= _kChar_z));
assert((charCode >= kChar_0 && charCode <= kChar_9) ||
(charCode >= kChar_a && charCode <= kChar_z));
if (charCode <= _kChar_9) {
return charCode - _kChar_0;
if (charCode <= kChar_9) {
return charCode - kChar_0;
}
// "a" starts from 10 and remaining letters go up from there.
return charCode - _kChar_a + 10;
return charCode - kChar_a + 10;
}

View File

@ -0,0 +1,582 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/ui.dart' hide window;
import 'package:ui/src/engine.dart';
import '../scuba.dart';
import 'helper.dart';
typedef CanvasTest = FutureOr<void> Function(EngineCanvas canvas);
const String _rtlWord1 = 'واحد';
const String _rtlWord2 = 'اثنان';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() async {
setUpStableTestFonts();
void paintBasicBidiStartingWithLtr(
EngineCanvas canvas,
Rect bounds,
double y,
TextDirection textDirection,
TextAlign textAlign,
) {
// The text starts with a left-to-right word.
const String text = 'One 12 $_rtlWord1 $_rtlWord2 34 two 56';
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textDirection: textDirection,
textAlign: textAlign,
);
final CanvasParagraph paragraph = plain(
paragraphStyle,
text,
textStyle: EngineTextStyle.only(color: blue),
);
final double maxWidth = bounds.width - 10;
paragraph.layout(constrain(maxWidth));
canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5));
}
test('basic bidi starting with ltr', () {
const Rect bounds = Rect.fromLTWH(0, 0, 340, 600);
final canvas = BitmapCanvas(bounds, RenderStrategy());
const double height = 40;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBasicBidiStartingWithLtr(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBasicBidiStartingWithLtr(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_ltr');
});
test('basic bidi starting with ltr (DOM)', () {
const Rect bounds = Rect.fromLTWH(0, 0, 340, 600);
final canvas = DomCanvas(domRenderer.createElement('flt-picture'));
const double height = 40;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBasicBidiStartingWithLtr(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBasicBidiStartingWithLtr(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBasicBidiStartingWithLtr(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBasicBidiStartingWithLtr(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_ltr_dom');
});
void paintBasicBidiStartingWithRtl(
EngineCanvas canvas,
Rect bounds,
double y,
TextDirection textDirection,
TextAlign textAlign,
) {
// The text starts with a right-to-left word.
const String text = '$_rtlWord1 12 one 34 $_rtlWord2 56 two';
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textDirection: textDirection,
textAlign: textAlign,
);
final CanvasParagraph paragraph = plain(
paragraphStyle,
text,
textStyle: EngineTextStyle.only(color: blue),
);
final double maxWidth = bounds.width - 10;
paragraph.layout(constrain(maxWidth));
canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5));
}
test('basic bidi starting with rtl', () {
const Rect bounds = Rect.fromLTWH(0, 0, 340, 600);
final canvas = BitmapCanvas(bounds, RenderStrategy());
const double height = 40;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBasicBidiStartingWithRtl(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBasicBidiStartingWithRtl(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_rtl');
});
test('basic bidi starting with rtl (DOM)', () {
const Rect bounds = Rect.fromLTWH(0, 0, 340, 600);
final canvas = DomCanvas(domRenderer.createElement('flt-picture'));
const double height = 40;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 320, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBasicBidiStartingWithRtl(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBasicBidiStartingWithRtl(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(0, ltrBox.height + 10);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBasicBidiStartingWithRtl(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBasicBidiStartingWithRtl(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_start_rtl_dom');
});
void paintMultilineBidi(
EngineCanvas canvas,
Rect bounds,
double y,
TextDirection textDirection,
TextAlign textAlign,
) {
// '''
// Lorem 12 $_rtlWord1
// $_rtlWord2 34 ipsum
// dolor 56
// '''
const String text = 'Lorem 12 $_rtlWord1 $_rtlWord2 34 ipsum dolor 56';
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textDirection: textDirection,
textAlign: textAlign,
);
final CanvasParagraph paragraph = plain(
paragraphStyle,
text,
textStyle: EngineTextStyle.only(color: blue),
);
final double maxWidth = bounds.width - 10;
paragraph.layout(constrain(maxWidth));
canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5));
}
test('multiline bidi', () {
final Rect bounds = Rect.fromLTWH(0, 0, 400, 500);
final canvas = BitmapCanvas(bounds, RenderStrategy());
const double height = 95;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintMultilineBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintMultilineBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintMultilineBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintMultilineBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintMultilineBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintMultilineBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintMultilineBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintMultilineBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintMultilineBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintMultilineBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multiline');
});
test('multiline bidi (DOM)', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 500);
final canvas = DomCanvas(domRenderer.createElement('flt-picture'));
const double height = 95;
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintMultilineBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintMultilineBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintMultilineBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintMultilineBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintMultilineBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintMultilineBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintMultilineBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintMultilineBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintMultilineBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintMultilineBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multiline_dom');
});
void paintMultSpanBidi(
EngineCanvas canvas,
Rect bounds,
double y,
TextDirection textDirection,
TextAlign textAlign,
) {
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textDirection: textDirection,
textAlign: textAlign,
);
// '''
// Lorem 12 $_rtlWord1
// $_rtlWord2 34 ipsum
// dolor 56
// '''
final CanvasParagraph paragraph = rich(paragraphStyle, (builder) {
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('Lorem ');
builder.pushStyle(EngineTextStyle.only(color: green));
builder.addText('12 ');
builder.pushStyle(EngineTextStyle.only(color: red));
builder.addText('$_rtlWord1 ');
builder.pushStyle(EngineTextStyle.only(color: black));
builder.addText('$_rtlWord2 ');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('34 ipsum ');
builder.pushStyle(EngineTextStyle.only(color: green));
builder.addText('dolor 56 ');
});
final double maxWidth = bounds.width - 10;
paragraph.layout(constrain(maxWidth));
canvas.drawParagraph(paragraph, Offset(bounds.left + 5, bounds.top + y + 5));
}
test('multi span bidi', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 900);
final canvas = BitmapCanvas(bounds, RenderStrategy());
const double height = 95;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintMultSpanBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintMultSpanBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintMultSpanBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintMultSpanBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintMultSpanBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintMultSpanBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintMultSpanBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintMultSpanBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintMultSpanBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintMultSpanBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multispan');
});
test('multi span bidi (DOM)', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 900);
final canvas = DomCanvas(domRenderer.createElement('flt-picture'));
const double height = 95;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintMultSpanBidi(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintMultSpanBidi(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintMultSpanBidi(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintMultSpanBidi(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintMultSpanBidi(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintMultSpanBidi(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintMultSpanBidi(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintMultSpanBidi(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintMultSpanBidi(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintMultSpanBidi(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_multispan_dom');
});
void paintBidiWithSelection(
EngineCanvas canvas,
Rect bounds,
double y,
TextDirection textDirection,
TextAlign textAlign,
) {
// '''
// Lorem 12 $_rtlWord1
// $_rtlWord2 34 ipsum
// dolor 56
// '''
const String text = 'Lorem 12 $_rtlWord1 $_rtlWord2 34 ipsum dolor 56';
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textDirection: textDirection,
textAlign: textAlign,
);
final CanvasParagraph paragraph = plain(
paragraphStyle,
text,
textStyle: EngineTextStyle.only(color: blue),
);
final double maxWidth = bounds.width - 10;
paragraph.layout(constrain(maxWidth));
final Offset offset = Offset(bounds.left + 5, bounds.top + y + 5);
// Range for "em 12 " and the first character of `_rtlWord1`.
paintBoxes(canvas, offset, paragraph.getBoxesForRange(3, 10), lightBlue);
// Range for the second half of `_rtlWord1` and all of `_rtlWord2` and " 3".
paintBoxes(canvas, offset, paragraph.getBoxesForRange(11, 21), lightPurple);
// Range for "psum dolo".
paintBoxes(canvas, offset, paragraph.getBoxesForRange(24, 33), green);
canvas.drawParagraph(paragraph, offset);
}
test('bidi with selection', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 500);
final canvas = BitmapCanvas(bounds, RenderStrategy());
const double height = 95;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBidiWithSelection(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBidiWithSelection(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBidiWithSelection(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBidiWithSelection(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBidiWithSelection(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBidiWithSelection(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBidiWithSelection(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBidiWithSelection(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBidiWithSelection(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBidiWithSelection(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_selection');
});
test('bidi with selection (DOM)', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 500);
final canvas = DomCanvas(domRenderer.createElement('flt-picture'));
const double height = 95;
// Border for ltr paragraphs.
final Rect ltrBox = Rect.fromLTWH(0, 0, 150, 5 * height).inflate(5).translate(10, 10);
canvas.drawRect(
ltrBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// LTR with different text align values:
paintBidiWithSelection(canvas, ltrBox, 0 * height, TextDirection.ltr, TextAlign.left);
paintBidiWithSelection(canvas, ltrBox, 1 * height, TextDirection.ltr, TextAlign.right);
paintBidiWithSelection(canvas, ltrBox, 2 * height, TextDirection.ltr, TextAlign.center);
paintBidiWithSelection(canvas, ltrBox, 3 * height, TextDirection.ltr, TextAlign.start);
paintBidiWithSelection(canvas, ltrBox, 4 * height, TextDirection.ltr, TextAlign.end);
// Border for rtl paragraphs.
final Rect rtlBox = ltrBox.translate(ltrBox.width + 10, 0);
canvas.drawRect(
rtlBox,
SurfacePaintData()
..color = black
..style = PaintingStyle.stroke,
);
// RTL with different text align values:
paintBidiWithSelection(canvas, rtlBox, 0 * height, TextDirection.rtl, TextAlign.left);
paintBidiWithSelection(canvas, rtlBox, 1 * height, TextDirection.rtl, TextAlign.right);
paintBidiWithSelection(canvas, rtlBox, 2 * height, TextDirection.rtl, TextAlign.center);
paintBidiWithSelection(canvas, rtlBox, 3 * height, TextDirection.rtl, TextAlign.start);
paintBidiWithSelection(canvas, rtlBox, 4 * height, TextDirection.rtl, TextAlign.end);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_bidi_selection_dom');
});
}
void paintBoxes(EngineCanvas canvas, Offset offset, List<TextBox> boxes, Color color) {
for (final TextBox box in boxes) {
final Rect rect = box.toRect().shift(offset);
canvas.drawRect(rect, SurfacePaintData()..color = color);
}
}

View File

@ -11,14 +11,30 @@ import 'package:web_engine_tester/golden_tester.dart';
const Color white = Color(0xFFFFFFFF);
const Color black = Color(0xFF000000);
const Color red = Color(0xFFFF0000);
const Color lightGreen = Color(0xFFDCEDC8);
const Color green = Color(0xFF00FF00);
const Color lightBlue = Color(0xFFB3E5FC);
const Color blue = Color(0xFF0000FF);
const Color yellow = Color(0xFFFFEB3B);
const Color lightPurple = Color(0xFFE1BEE7);
ParagraphConstraints constrain(double width) {
return ParagraphConstraints(width: width);
}
CanvasParagraph plain(
EngineParagraphStyle style,
String text, {
EngineTextStyle? textStyle,
}) {
final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style);
if (textStyle != null) {
builder.pushStyle(textStyle);
}
builder.addText(text);
return builder.build();
}
CanvasParagraph rich(
EngineParagraphStyle style,
void Function(CanvasParagraphBuilder) callback,

View File

@ -84,7 +84,10 @@ void testMain() async {
// "Lorem "
paragraph.getBoxesForRange(0, 6),
<ui.TextBox>[
box(0, 0, 60, 10),
// "Lorem"
box(0, 0, 50, 10),
// " "
box(50, 0, 60, 10),
],
);
@ -101,7 +104,10 @@ void testMain() async {
// "um "
paragraph.getBoxesForRange(9, 12),
<ui.TextBox>[
box(90, 0, 120, 10),
// "um"
box(90, 0, 110, 10),
// " "
box(110, 0, 120, 10),
],
);
@ -111,7 +117,11 @@ void testMain() async {
// "rem ipsum"
paragraph.getBoxesForRange(2, 11),
<ui.TextBox>[
box(20, 0, 60, 10),
// "rem"
box(20, 0, 50, 10),
// " "
box(50, 0, 60, 10),
// "ipsum"
box(60, 0, 110, 10),
],
);
@ -119,11 +129,18 @@ void testMain() async {
// Across all spans "Lorem ", "ipsum ", ".".
expect(
// "Lorem ipsum."
// "Lorem ipsum ."
paragraph.getBoxesForRange(0, 13),
<ui.TextBox>[
box(0, 0, 60, 10),
box(60, 0, 120, 10),
// "Lorem"
box(0, 0, 50, 10),
// " "
box(50, 0, 60, 10),
// "ipsum"
box(60, 0, 110, 10),
// " "
box(110, 0, 120, 10),
// "."
box(120, 0, 130, 10),
],
);
@ -155,7 +172,10 @@ void testMain() async {
// "Lorem "
paragraph.getBoxesForRange(0, 6),
<ui.TextBox>[
box(0, 0, 60, 10),
// "Lorem"
box(0, 0, 50, 10),
// " "
box(50, 0, 60, 10),
],
);
@ -165,7 +185,10 @@ void testMain() async {
// "psum "
paragraph.getBoxesForRange(7, 12),
<ui.TextBox>[
box(10, 10, 60, 20),
// "psum"
box(10, 10, 50, 20),
// " "
box(50, 10, 60, 20),
],
);
@ -177,9 +200,20 @@ void testMain() async {
// "dolor s"
paragraph.getBoxesForRange(3, 19),
<ui.TextBox>[
box(30, 0, 60, 10),
box(0, 10, 60, 20),
box(0, 20, 70, 30),
// "em"
box(30, 0, 50, 10),
// " "
box(50, 0, 60, 10),
// "ipsum"
box(0, 10, 50, 20),
// " "
box(50, 10, 60, 20),
// "dolor"
box(0, 20, 50, 30),
// " "
box(50, 20, 60, 30),
// "s"
box(60, 20, 70, 30),
],
);
});
@ -213,7 +247,10 @@ void testMain() async {
// "Lorem "
paragraph.getBoxesForRange(0, 6),
<ui.TextBox>[
box(0, 0, 60, 10),
// "Lorem"
box(0, 0, 50, 10),
// " "
box(50, 0, 60, 10),
],
);
@ -223,7 +260,10 @@ void testMain() async {
// "psum "
paragraph.getBoxesForRange(7, 12),
<ui.TextBox>[
box(10, 10, 60, 20),
// "psum"
box(10, 10, 50, 20),
// " "
box(50, 10, 60, 20),
],
);
@ -233,7 +273,11 @@ void testMain() async {
// "lor sit"
paragraph.getBoxesForRange(14, 21),
<ui.TextBox>[
box(20, 20, 60, 30),
// "lor"
box(20, 20, 50, 30),
// " "
box(50, 20, 60, 30),
// "sit"
box(60, 20, 90, 30),
],
);
@ -246,9 +290,19 @@ void testMain() async {
// "dolor s"
paragraph.getBoxesForRange(3, 19),
<ui.TextBox>[
box(30, 0, 60, 10),
box(0, 10, 60, 20),
box(0, 20, 60, 30),
// "em"
box(30, 0, 50, 10),
// " "
box(50, 0, 60, 10),
// "ipsum"
box(0, 10, 50, 20),
// " "
box(50, 10, 60, 20),
// "dolor"
box(0, 20, 50, 30),
// " "
box(50, 20, 60, 30),
// "s"
box(60, 20, 70, 30),
],
);
@ -287,8 +341,15 @@ void testMain() async {
// "em ipsum dol"
paragraph.getBoxesForRange(3, 15),
<ui.TextBox>[
box(60, 16, 120, 36),
box(120, 0, 360, 40),
// "em"
box(60, 16, 100, 36),
// " "
box(100, 16, 120, 36),
// "ipsum"
box(120, 0, 320, 40),
// " "
box(320, 0, 360, 40),
// "dol"
box(360, 24, 390, 34),
],
);
@ -298,9 +359,19 @@ void testMain() async {
// "sit amet"
paragraph.getBoxesForRange(8, 26),
<ui.TextBox>[
box(200, 0, 360, 40),
box(360, 24, 420, 34),
box(0, 40, 120, 70),
// "sum"
box(200, 0, 320, 40),
// " "
box(320, 0, 360, 40),
// "dolor"
box(360, 24, 410, 34),
// " "
box(410, 24, 420, 34),
// "sit"
box(0, 40, 90, 70),
// " "
box(90, 40, 120, 70),
// "amet"
box(120, 48, 200, 68),
],
);

View File

@ -0,0 +1,126 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart';
// Two RTL strings, 5 characters each, to match the length of "$rtl1" and "$rtl2".
const String rtl1 = 'واحدة';
const String rtl2 = 'ثنتان';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() async {
group('$getDirectionalBlockEnd', () {
test('basic cases', () {
const String text = 'Lorem 12 $rtl1 ipsum34';
const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
const LineBreakResult end = LineBreakResult.sameIndex(text.length, LineBreakType.endOfText);
const LineBreakResult loremMiddle = LineBreakResult.sameIndex(3, LineBreakType.prohibited);
const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
const LineBreakResult twelveStart = LineBreakResult(6, 6, 5, LineBreakType.opportunity);
const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
const LineBreakResult rtl1Start = LineBreakResult(9, 9, 8, LineBreakType.opportunity);
const LineBreakResult rtl1End = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
const LineBreakResult ipsumStart = LineBreakResult(17, 17, 15, LineBreakType.opportunity);
const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(22, LineBreakType.prohibited);
DirectionalPosition blockEnd;
blockEnd = getDirectionalBlockEnd(text, start, end);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, loremEnd);
blockEnd = getDirectionalBlockEnd(text, start, loremMiddle);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, loremMiddle);
blockEnd = getDirectionalBlockEnd(text, loremMiddle, loremEnd);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, loremEnd);
blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
expect(blockEnd.isSpaceOnly, isTrue);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, twelveStart);
blockEnd = getDirectionalBlockEnd(text, twelveStart, rtl1Start);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, twelveEnd);
blockEnd = getDirectionalBlockEnd(text, rtl1Start, end);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.rtl);
expect(blockEnd.lineBreak, rtl1End);
blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, ipsumEnd);
blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, end);
});
test('handles new lines', () {
const String text = 'Lorem\n12\nipsum \n';
const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
const LineBreakResult end = LineBreakResult(
text.length,
text.length - 1,
text.length - 3,
LineBreakType.mandatory,
);
const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
const LineBreakResult twelveStart = LineBreakResult(6, 5, 5, LineBreakType.mandatory);
const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
const LineBreakResult ipsumStart = LineBreakResult(9, 8, 8, LineBreakType.mandatory);
const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
DirectionalPosition blockEnd;
blockEnd = getDirectionalBlockEnd(text, start, twelveStart);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, twelveStart);
blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
expect(blockEnd.isSpaceOnly, isTrue);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, twelveStart);
blockEnd = getDirectionalBlockEnd(text, twelveStart, ipsumStart);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, ipsumStart);
blockEnd = getDirectionalBlockEnd(text, twelveEnd, ipsumStart);
expect(blockEnd.isSpaceOnly, isTrue);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, ipsumStart);
blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
expect(blockEnd.isSpaceOnly, isFalse);
expect(blockEnd.textDirection, TextDirection.ltr);
expect(blockEnd.lineBreak, ipsumEnd);
blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
expect(blockEnd.isSpaceOnly, isTrue);
expect(blockEnd.textDirection, isNull);
expect(blockEnd.lineBreak, end);
});
});
}