[web] Implement TextAlign.justify (flutter/engine#29158)

This commit is contained in:
Mouad Debbar 2021-10-19 22:11:59 +00:00 committed by GitHub
parent cd18e95d73
commit dedd0625e3
7 changed files with 299 additions and 73 deletions

View File

@ -1,2 +1,2 @@
repository: https://github.com/flutter/goldens.git
revision: 9c36f57f1a673a7ab444f4f20df16601dde15335
revision: 8e169f3ca5cf4af283a2ff4cfd98a564fb173adf

View File

@ -185,7 +185,7 @@ class CanvasParagraph implements EngineParagraph {
}
final EngineLineMetrics line = lines[i];
final List<RangeBox> boxes = line.boxes!;
final List<RangeBox> boxes = line.boxes;
final StringBuffer buffer = StringBuffer();
int j = 0;

View File

@ -273,7 +273,7 @@ class TextLayoutService {
List<ui.TextBox> getBoxesForPlaceholders() {
final List<ui.TextBox> boxes = <ui.TextBox>[];
for (final EngineLineMetrics line in lines) {
for (final RangeBox box in line.boxes!) {
for (final RangeBox box in line.boxes) {
if (box is PlaceholderBox) {
boxes.add(box.toTextBox(line));
}
@ -303,7 +303,7 @@ class TextLayoutService {
for (final EngineLineMetrics line in lines) {
if (line.overlapsWith(start, end)) {
for (final RangeBox box in line.boxes!) {
for (final RangeBox box in line.boxes) {
if (box is SpanBox && box.overlapsWith(start, end)) {
boxes.add(box.intersect(line, start, end));
}
@ -336,7 +336,7 @@ class TextLayoutService {
}
final double dx = offset.dx - line.left;
for (final RangeBox box in line.boxes!) {
for (final RangeBox box in line.boxes) {
if (box.left <= dx && dx <= box.right) {
return box.getPositionForX(dx);
}
@ -892,6 +892,8 @@ class LineBuilder {
/// Whether the end of this line is a prohibited break.
bool get isEndProhibited => end.type == LineBreakType.prohibited;
int _spaceBoxCount = 0;
bool get isEmpty => _segments.isEmpty;
bool get isNotEmpty => _segments.isNotEmpty;
@ -1131,6 +1133,9 @@ class LineBuilder {
if (_currentBoxStart.index > poppedSegment.start.index) {
final RangeBox poppedBox = _boxes.removeLast();
_currentBoxStartOffset -= poppedBox.width;
if (poppedBox is SpanBox && poppedBox.isSpaceOnly) {
_spaceBoxCount--;
}
}
return poppedSegment;
@ -1274,6 +1279,10 @@ class LineBuilder {
isSpaceOnly: isSpaceOnly,
));
if (isSpaceOnly) {
_spaceBoxCount++;
}
_currentBoxStartOffset = widthIncludingSpace;
}
@ -1308,6 +1317,7 @@ class LineBuilder {
ascent: ascent,
descent: descent,
boxes: _boxes,
spaceBoxCount: _spaceBoxCount,
);
}

View File

@ -22,60 +22,105 @@ class TextPaintService {
// individually.
final List<EngineLineMetrics> lines = paragraph.computeLineMetrics();
if (lines.isEmpty) {
return;
}
final EngineLineMetrics lastLine = lines.last;
for (final EngineLineMetrics line in lines) {
for (final RangeBox box in line.boxes!) {
_paintBox(canvas, offset, line, box);
if (line.boxes.isEmpty) {
continue;
}
final RangeBox lastBox = line.boxes.last;
final double justifyPerSpaceBox =
_calculateJustifyPerSpaceBox(paragraph, line, lastLine, lastBox);
ui.Offset justifiedOffset = offset;
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, justifiedOffset, line, box, justifyPerSpaceBox);
}
_paintText(canvas, justifiedOffset, line, box);
if (box is SpanBox && box.isSpaceOnly && justifyPerSpaceBox != 0.0) {
justifiedOffset = justifiedOffset.translate(justifyPerSpaceBox, 0.0);
}
}
}
}
void _paintBox(
void _paintBackground(
BitmapCanvas canvas,
ui.Offset offset,
EngineLineMetrics line,
RangeBox box,
double justifyPerSpaceBox,
) {
// Placeholder spans don't need any painting. Their boxes should remain
// empty so that their underlying widgets do their own painting.
if (box is SpanBox) {
final FlatTextSpan span = box.span;
// Paint the background of the box, if the span has a background.
final SurfacePaint? background = span.style.background as SurfacePaint?;
if (background != null) {
canvas.drawRect(
box.toTextBox(line).toRect().shift(offset),
background.paintData,
);
ui.Rect rect = box.toTextBox(line).toRect().shift(offset);
if (box.isSpaceOnly) {
rect = ui.Rect.fromPoints(
rect.topLeft,
rect.bottomRight.translate(justifyPerSpaceBox, 0.0),
);
}
canvas.drawRect(rect, background.paintData);
}
}
}
void _paintText(
BitmapCanvas canvas,
ui.Offset offset,
EngineLineMetrics line,
RangeBox box,
) {
// There's no text to paint in placeholder spans.
if (box is SpanBox) {
final FlatTextSpan span = box.span;
// Paint the actual text.
_applySpanStyleToCanvas(span, canvas);
final double x = offset.dx + line.left + box.left;
final double y = offset.dy + line.baseline;
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.fillText(text, x, y, 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.fillText(char, charX.roundToDouble(), y,
shadows: span.style.shadows);
charX += letterSpacing + canvas.measureText(char).width!;
// 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.fillText(text, x, y, 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.fillText(char, charX.roundToDouble(), y,
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) {
if (ellipsis != null && box == line.boxes.last) {
final double x = offset.dx + line.left + box.right;
canvas.fillText(ellipsis, x, y);
}
@ -97,3 +142,31 @@ class TextPaintService {
canvas.setUpPaint(paint.paintData, null);
}
}
/// Calculates for the given [line], the amount of extra width that needs to be
/// added to each space box in order to align the line with the rest of the
/// paragraph.
double _calculateJustifyPerSpaceBox(
CanvasParagraph paragraph,
EngineLineMetrics line,
EngineLineMetrics lastLine,
RangeBox lastBox,
) {
// Don't apply any justification on the last line.
if (line != lastLine &&
paragraph.width.isFinite &&
paragraph.paragraphStyle.textAlign == ui.TextAlign.justify) {
final double justifyTotal = paragraph.width - line.width;
int spaceBoxesToJustify = line.spaceBoxCount;
// If the last box is a space box, we can't use it to justify text.
if (lastBox is SpanBox && lastBox.isSpaceOnly) {
spaceBoxesToJustify--;
}
if (spaceBoxesToJustify > 0) {
return justifyTotal / spaceBoxesToJustify;
}
}
return 0.0;
}

View File

@ -31,33 +31,8 @@ class EngineLineMetrics implements ui.LineMetrics {
endIndex = -1,
endIndexWithoutNewlines = -1,
widthWithTrailingSpaces = width,
boxes = null;
EngineLineMetrics.withText(
String this.displayText, {
required this.startIndex,
required this.endIndex,
required this.endIndexWithoutNewlines,
required this.hardBreak,
required this.width,
required this.widthWithTrailingSpaces,
required this.left,
required this.lineNumber,
}) : assert(displayText != null), // ignore: unnecessary_null_comparison,
assert(startIndex != null), // ignore: unnecessary_null_comparison
assert(endIndex != null), // ignore: unnecessary_null_comparison
assert(endIndexWithoutNewlines != null), // ignore: unnecessary_null_comparison
assert(hardBreak != null), // ignore: unnecessary_null_comparison
assert(width != null), // ignore: unnecessary_null_comparison
assert(left != null), // ignore: unnecessary_null_comparison
assert(lineNumber != null && lineNumber >= 0), // ignore: unnecessary_null_comparison
ellipsis = null,
ascent = double.infinity,
descent = double.infinity,
unscaledAscent = double.infinity,
height = double.infinity,
baseline = double.infinity,
boxes = null;
boxes = <RangeBox>[],
spaceBoxCount = 0;
EngineLineMetrics.rich(
this.lineNumber, {
@ -74,6 +49,7 @@ class EngineLineMetrics implements ui.LineMetrics {
required this.ascent,
required this.descent,
required this.boxes,
required this.spaceBoxCount,
}) : displayText = null,
unscaledAscent = double.infinity;
@ -101,7 +77,10 @@ class EngineLineMetrics implements ui.LineMetrics {
/// The list of boxes representing the entire line, possibly across multiple
/// spans.
final List<RangeBox>? boxes;
final List<RangeBox> boxes;
/// The number of boxes that are space-only.
final int spaceBoxCount;
@override
final bool hardBreak;

View File

@ -0,0 +1,164 @@
// 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/src/engine.dart';
import 'package:ui/ui.dart' hide window;
import 'helper.dart';
import 'text_scuba.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
Future<void> testMain() async {
setUpStableTestFonts();
test('TextAlign.justify with multiple spans', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 400);
final BitmapCanvas canvas = BitmapCanvas(bounds, RenderStrategy());
void build(CanvasParagraphBuilder builder) {
builder.pushStyle(EngineTextStyle.only(color: black));
builder.addText('Lorem ipsum dolor sit ');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('amet, consectetur ');
builder.pushStyle(EngineTextStyle.only(color: green));
builder.addText('adipiscing elit, sed do eiusmod tempor incididunt ut ');
builder.pushStyle(EngineTextStyle.only(color: red));
builder
.addText('labore et dolore magna aliqua. Ut enim ad minim veniam, ');
builder.pushStyle(EngineTextStyle.only(color: lightPurple));
builder.addText('quis nostrud exercitation ullamco ');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('laboris nisi ut aliquip ex ea commodo consequat.');
}
final CanvasParagraph paragraph = rich(
EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textAlign: TextAlign.justify,
),
build,
);
paragraph.layout(constrain(250.0));
canvas.drawParagraph(paragraph, Offset.zero);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_justify');
});
test('TextAlign.justify with single space and empty line', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 400);
final BitmapCanvas canvas = BitmapCanvas(bounds, RenderStrategy());
void build(CanvasParagraphBuilder builder) {
builder.pushStyle(bg(yellow));
builder.pushStyle(EngineTextStyle.only(color: black));
builder.addText('Loremipsumdolorsit');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('amet, consectetur\n\n');
builder.pushStyle(EngineTextStyle.only(color: lightPurple));
builder.addText('adipiscing elit, sed do eiusmod tempor incididunt ut ');
builder.pushStyle(EngineTextStyle.only(color: red));
builder.addText('labore et dolore magna aliqua.');
}
final CanvasParagraph paragraph = rich(
EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textAlign: TextAlign.justify,
),
build,
);
paragraph.layout(constrain(250.0));
canvas.drawParagraph(paragraph, Offset.zero);
return takeScreenshot(
canvas, bounds, 'canvas_paragraph_justify_empty_line');
});
test('TextAlign.justify with ellipsis', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 300);
final BitmapCanvas canvas = BitmapCanvas(bounds, RenderStrategy());
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textAlign: TextAlign.justify,
maxLines: 4,
ellipsis: '...',
);
final CanvasParagraph paragraph = rich(
paragraphStyle,
(CanvasParagraphBuilder builder) {
builder.pushStyle(EngineTextStyle.only(color: black));
builder.addText('Lorem ipsum dolor sit ');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('amet, consectetur ');
builder.pushStyle(EngineTextStyle.only(color: green));
builder
.addText('adipiscing elit, sed do eiusmod tempor incididunt ut ');
builder.pushStyle(EngineTextStyle.only(color: red));
builder.addText(
'labore et dolore magna aliqua. Ut enim ad minim veniam, ');
builder.pushStyle(EngineTextStyle.only(color: lightPurple));
builder.addText('quis nostrud exercitation ullamco ');
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('laboris nisi ut aliquip ex ea commodo consequat.');
},
);
paragraph.layout(constrain(250));
canvas.drawParagraph(paragraph, Offset.zero);
return takeScreenshot(canvas, bounds, 'canvas_paragraph_justify_ellipsis');
});
test('TextAlign.justify with background', () {
const Rect bounds = Rect.fromLTWH(0, 0, 400, 400);
final BitmapCanvas canvas = BitmapCanvas(bounds, RenderStrategy());
final EngineParagraphStyle paragraphStyle = EngineParagraphStyle(
fontFamily: 'Roboto',
fontSize: 20.0,
textAlign: TextAlign.justify,
);
final CanvasParagraph paragraph = rich(
paragraphStyle,
(CanvasParagraphBuilder builder) {
builder.pushStyle(EngineTextStyle.only(color: black));
builder.pushStyle(bg(blue));
builder.addText('Lorem ipsum dolor sit ');
builder.pushStyle(bg(black));
builder.pushStyle(EngineTextStyle.only(color: white));
builder.addText('amet, consectetur ');
builder.pop();
builder.pushStyle(bg(green));
builder
.addText('adipiscing elit, sed do eiusmod tempor incididunt ut ');
builder.pushStyle(bg(yellow));
builder.addText(
'labore et dolore magna aliqua. Ut enim ad minim veniam, ');
builder.pushStyle(bg(red));
builder.addText('quis nostrud exercitation ullamco ');
builder.pushStyle(bg(green));
builder.addText('laboris nisi ut aliquip ex ea commodo consequat.');
},
);
paragraph.layout(constrain(250));
canvas.drawParagraph(paragraph, Offset.zero);
return takeScreenshot(
canvas, bounds, 'canvas_paragraph_justify_background');
});
}
EngineTextStyle bg(Color color) {
return EngineTextStyle.only(background: Paint()..color = color);
}

View File

@ -401,26 +401,26 @@ Future<void> testMain() async {
// There should be no "B" in the first line's boxes.
expect(firstLine.boxes, hasLength(2));
expect((firstLine.boxes![0] as SpanBox).toText(), 'AAA');
expect((firstLine.boxes![0] as SpanBox).left, 0.0);
expect((firstLine.boxes[0] as SpanBox).toText(), 'AAA');
expect((firstLine.boxes[0] as SpanBox).left, 0.0);
expect((firstLine.boxes![1] as SpanBox).toText(), ' ');
expect((firstLine.boxes![1] as SpanBox).left, 30.0);
expect((firstLine.boxes[1] as SpanBox).toText(), ' ');
expect((firstLine.boxes[1] as SpanBox).left, 30.0);
// Make sure the second line isn't missing any boxes.
expect(secondLine.boxes, hasLength(4));
expect((secondLine.boxes![0] as SpanBox).toText(), 'B');
expect((secondLine.boxes![0] as SpanBox).left, 0.0);
expect((secondLine.boxes[0] as SpanBox).toText(), 'B');
expect((secondLine.boxes[0] as SpanBox).left, 0.0);
expect((secondLine.boxes![1] as SpanBox).toText(), '_C');
expect((secondLine.boxes![1] as SpanBox).left, 10.0);
expect((secondLine.boxes[1] as SpanBox).toText(), '_C');
expect((secondLine.boxes[1] as SpanBox).left, 10.0);
expect((secondLine.boxes![2] as SpanBox).toText(), ' ');
expect((secondLine.boxes![2] as SpanBox).left, 30.0);
expect((secondLine.boxes[2] as SpanBox).toText(), ' ');
expect((secondLine.boxes[2] as SpanBox).left, 30.0);
expect((secondLine.boxes![3] as SpanBox).toText(), 'DD');
expect((secondLine.boxes![3] as SpanBox).left, 40.0);
expect((secondLine.boxes[3] as SpanBox).toText(), 'DD');
expect((secondLine.boxes[3] as SpanBox).left, 40.0);
});
});