Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit d941aef

Browse files
authored
[web] Rich text painting on bitmap canvas (#23136)
1 parent a8c360d commit d941aef

File tree

10 files changed

+437
-11
lines changed

10 files changed

+437
-11
lines changed

ci/licenses_golden/licenses_flutter

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_service.dart
11841184
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_break_properties.dart
11851185
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart
11861186
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/measurement.dart
1187+
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paint_service.dart
11871188
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/paragraph.dart
11881189
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/ruler.dart
11891190
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart

lib/web_ui/dev/goldens_lock.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
repository: https://github.com/flutter/goldens.git
2-
revision: c808c28c81b6c3143ae969e8c49bed4a6d49aabb
2+
revision: 4946ab2de031c14d30502efcaf51220e0be4d1f1

lib/web_ui/lib/src/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ part 'engine/text/layout_service.dart';
129129
part 'engine/text/line_break_properties.dart';
130130
part 'engine/text/line_breaker.dart';
131131
part 'engine/text/measurement.dart';
132+
part 'engine/text/paint_service.dart';
132133
part 'engine/text/paragraph.dart';
133134
part 'engine/text/canvas_paragraph.dart';
134135
part 'engine/text/ruler.dart';

lib/web_ui/lib/src/engine/text/canvas_paragraph.dart

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ class CanvasParagraph implements EngineParagraph {
5858
@override
5959
bool get didExceedMaxLines => _layoutService.didExceedMaxLines;
6060

61+
@override
62+
bool isLaidOut = false;
63+
6164
ui.ParagraphConstraints? _lastUsedConstraints;
6265

6366
late final TextLayoutService _layoutService = TextLayoutService(this);
67+
late final TextPaintService _paintService = TextPaintService(this);
6468

6569
@override
6670
void layout(ui.ParagraphConstraints constraints) {
@@ -90,6 +94,7 @@ class CanvasParagraph implements EngineParagraph {
9094
.benchmark('text_layout', stopwatch.elapsedMicroseconds.toDouble());
9195
}
9296

97+
isLaidOut = true;
9398
_lastUsedConstraints = constraints;
9499
}
95100

@@ -100,10 +105,7 @@ class CanvasParagraph implements EngineParagraph {
100105

101106
@override
102107
void paint(BitmapCanvas canvas, ui.Offset offset) {
103-
// TODO(mdebbar): Loop through the spans and for each box in the span:
104-
// 1. Paint the background rect.
105-
// 2. Paint the text shadows?
106-
// 3. Paint the text.
108+
_paintService.paint(canvas, offset);
107109
}
108110

109111
@override
@@ -182,9 +184,6 @@ class CanvasParagraph implements EngineParagraph {
182184
@override
183185
final bool drawOnCanvas = true;
184186

185-
@override
186-
bool isLaidOut = false;
187-
188187
@override
189188
List<ui.TextBox> getBoxesForRange(
190189
int start,
@@ -217,7 +216,7 @@ class CanvasParagraph implements EngineParagraph {
217216
}
218217

219218
@override
220-
List<ui.LineMetrics> computeLineMetrics() {
219+
List<EngineLineMetrics> computeLineMetrics() {
221220
return _layoutService.lines;
222221
}
223222
}

lib/web_ui/lib/src/engine/text/layout_service.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,14 @@ class RangeBox {
370370
return startIndex < this.end.index && this.start.index < endIndex;
371371
}
372372

373+
/// Returns a [ui.TextBox] representing this range box in the given [line].
374+
///
375+
/// The coordinates of the resulting [ui.TextBox] are relative to the
376+
/// paragraph, not to the line.
377+
ui.TextBox toTextBox(EngineLineMetrics line) {
378+
return intersect(line, start.index, end.index);
379+
}
380+
373381
/// Performs the intersection of this box with the range given by [start] and
374382
/// [end] indices, and returns a [ui.TextBox] representing that intersection.
375383
///
@@ -772,6 +780,12 @@ class LineBuilder {
772780
);
773781
extendTo(
774782
LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited));
783+
784+
// There's a possibility that the end of line has moved backwards, so we
785+
// need to remove some boxes in that case.
786+
while (_boxes.length > 0 && _boxes.last.end.index > breakingPoint) {
787+
_boxes.removeLast();
788+
}
775789
}
776790

777791
LineBreakResult get _boxStart {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
// @dart = 2.12
6+
part of engine;
7+
8+
/// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas].
9+
class TextPaintService {
10+
TextPaintService(this.paragraph);
11+
12+
final CanvasParagraph paragraph;
13+
14+
void paint(BitmapCanvas canvas, ui.Offset offset) {
15+
// Loop through all the lines, for each line, loop through all the boxes and
16+
// paint them. The boxes have enough information so they can be painted
17+
// individually.
18+
final List<EngineLineMetrics> lines = paragraph.computeLineMetrics();
19+
20+
for (final EngineLineMetrics line in lines) {
21+
for (final RangeBox box in line.boxes!) {
22+
_paintBox(canvas, offset, line, box);
23+
}
24+
}
25+
}
26+
27+
void _paintBox(
28+
BitmapCanvas canvas,
29+
ui.Offset offset,
30+
EngineLineMetrics line,
31+
RangeBox box,
32+
) {
33+
final ParagraphSpan span = box.span;
34+
35+
// Placeholder spans don't need any painting. Their boxes should remain
36+
// empty so that their underlying widgets do their own painting.
37+
if (span is FlatTextSpan) {
38+
// Paint the background of the box, if the span has a background.
39+
final SurfacePaint? background = span.style._background as SurfacePaint?;
40+
if (background != null) {
41+
canvas.drawRect(
42+
box.toTextBox(line).toRect().shift(offset),
43+
background.paintData,
44+
);
45+
}
46+
47+
// Paint the actual text.
48+
_applySpanStyleToCanvas(span, canvas);
49+
final double x = offset.dx + line.left + box.left;
50+
final double y = offset.dy + line.baseline;
51+
final String text = paragraph.toPlainText().substring(
52+
box.start.index,
53+
box.end.indexWithoutTrailingNewlines,
54+
);
55+
canvas.fillText(text, x, y);
56+
57+
// Paint the ellipsis using the same span styles.
58+
final String? ellipsis = line.ellipsis;
59+
if (ellipsis != null && box == line.boxes!.last) {
60+
final double x = offset.dx + line.left + box.right;
61+
canvas.fillText(ellipsis, x, y);
62+
}
63+
64+
canvas._tearDownPaint();
65+
}
66+
}
67+
68+
void _applySpanStyleToCanvas(FlatTextSpan span, BitmapCanvas canvas) {
69+
final SurfacePaint? paint;
70+
final ui.Paint? foreground = span.style._foreground;
71+
if (foreground != null) {
72+
paint = foreground as SurfacePaint;
73+
} else {
74+
paint = (ui.Paint()..color = span.style._color!) as SurfacePaint;
75+
}
76+
77+
canvas.setCssFont(span.style.cssFontString);
78+
canvas._setUpPaint(paint.paintData, null);
79+
}
80+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
// @dart = 2.6
6+
import 'dart:async';
7+
8+
import 'package:test/bootstrap/browser.dart';
9+
import 'package:test/test.dart';
10+
import 'package:ui/ui.dart' hide window;
11+
import 'package:ui/src/engine.dart';
12+
13+
import '../scuba.dart';
14+
import 'helper.dart';
15+
16+
typedef CanvasTest = FutureOr<void> Function(EngineCanvas canvas);
17+
18+
const Rect bounds = Rect.fromLTWH(0, 0, 800, 600);
19+
20+
const Color white = Color(0xFFFFFFFF);
21+
const Color black = Color(0xFF000000);
22+
const Color red = Color(0xFFFF0000);
23+
const Color green = Color(0xFF00FF00);
24+
const Color blue = Color(0xFF0000FF);
25+
26+
ParagraphConstraints constrain(double width) {
27+
return ParagraphConstraints(width: width);
28+
}
29+
30+
CanvasParagraph rich(
31+
EngineParagraphStyle style,
32+
void Function(CanvasParagraphBuilder) callback,
33+
) {
34+
final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style);
35+
callback(builder);
36+
return builder.build();
37+
}
38+
39+
void main() {
40+
internalBootstrapBrowserTest(() => testMain);
41+
}
42+
43+
void testMain() async {
44+
setUpStableTestFonts();
45+
46+
test('paints spans and lines correctly', () {
47+
final canvas = BitmapCanvas(bounds, RenderStrategy());
48+
49+
Offset offset = Offset.zero;
50+
CanvasParagraph paragraph;
51+
52+
// Single-line multi-span.
53+
paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) {
54+
builder.pushStyle(EngineTextStyle.only(color: blue));
55+
builder.addText('Lorem ');
56+
builder.pushStyle(EngineTextStyle.only(
57+
color: green,
58+
background: Paint()..color = red,
59+
));
60+
builder.addText('ipsum ');
61+
builder.pop();
62+
builder.addText('.');
63+
})
64+
..layout(constrain(double.infinity));
65+
canvas.drawParagraph(paragraph, offset);
66+
offset = offset.translate(0, paragraph.height + 10);
67+
68+
// Multi-line single-span.
69+
paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) {
70+
builder.addText('Lorem ipsum dolor sit');
71+
})
72+
..layout(constrain(90.0));
73+
canvas.drawParagraph(paragraph, offset);
74+
offset = offset.translate(0, paragraph.height + 10);
75+
76+
// Multi-line multi-span.
77+
paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) {
78+
builder.pushStyle(EngineTextStyle.only(color: blue));
79+
builder.addText('Lorem ipsum ');
80+
builder.pushStyle(EngineTextStyle.only(background: Paint()..color = red));
81+
builder.pushStyle(EngineTextStyle.only(color: green));
82+
builder.addText('dolor ');
83+
builder.pop();
84+
builder.addText('sit');
85+
})
86+
..layout(constrain(90.0));
87+
canvas.drawParagraph(paragraph, offset);
88+
offset = offset.translate(0, paragraph.height + 10);
89+
90+
return takeScreenshot(canvas, bounds, 'canvas_paragraph_general');
91+
});
92+
93+
test('respects alignment', () {
94+
final canvas = BitmapCanvas(bounds, RenderStrategy());
95+
96+
Offset offset = Offset.zero;
97+
CanvasParagraph paragraph;
98+
99+
void build(CanvasParagraphBuilder builder) {
100+
builder.pushStyle(EngineTextStyle.only(color: black));
101+
builder.addText('Lorem ');
102+
builder.pushStyle(EngineTextStyle.only(color: blue));
103+
builder.addText('ipsum ');
104+
builder.pushStyle(EngineTextStyle.only(color: green));
105+
builder.addText('dolor ');
106+
builder.pushStyle(EngineTextStyle.only(color: red));
107+
builder.addText('sit');
108+
}
109+
110+
paragraph = rich(
111+
ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.left),
112+
build,
113+
)..layout(constrain(100.0));
114+
canvas.drawParagraph(paragraph, offset);
115+
offset = offset.translate(0, paragraph.height + 10);
116+
117+
paragraph = rich(
118+
ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.center),
119+
build,
120+
)..layout(constrain(100.0));
121+
canvas.drawParagraph(paragraph, offset);
122+
offset = offset.translate(0, paragraph.height + 10);
123+
124+
paragraph = rich(
125+
ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.right),
126+
build,
127+
)..layout(constrain(100.0));
128+
canvas.drawParagraph(paragraph, offset);
129+
offset = offset.translate(0, paragraph.height + 10);
130+
131+
return takeScreenshot(canvas, bounds, 'canvas_paragraph_align');
132+
});
133+
134+
test('paints spans with varying heights/baselines', () {
135+
final canvas = BitmapCanvas(bounds, RenderStrategy());
136+
137+
final CanvasParagraph paragraph = rich(
138+
ParagraphStyle(fontFamily: 'Roboto'),
139+
(builder) {
140+
builder.pushStyle(EngineTextStyle.only(fontSize: 20.0));
141+
builder.addText('Lorem ');
142+
builder.pushStyle(EngineTextStyle.only(
143+
fontSize: 40.0,
144+
background: Paint()..color = green,
145+
));
146+
builder.addText('ipsum ');
147+
builder.pushStyle(EngineTextStyle.only(
148+
fontSize: 10.0,
149+
color: white,
150+
background: Paint()..color = black,
151+
));
152+
builder.addText('dolor ');
153+
builder.pushStyle(EngineTextStyle.only(fontSize: 30.0));
154+
builder.addText('sit ');
155+
builder.pop();
156+
builder.pop();
157+
builder.pushStyle(EngineTextStyle.only(
158+
fontSize: 20.0,
159+
background: Paint()..color = blue,
160+
));
161+
builder.addText('amet');
162+
},
163+
)..layout(constrain(220.0));
164+
canvas.drawParagraph(paragraph, Offset.zero);
165+
166+
return takeScreenshot(canvas, bounds, 'canvas_paragraph_varying_heights');
167+
});
168+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
// @dart = 2.12
6+
import 'dart:html' as html;
7+
8+
import 'package:ui/src/engine.dart';
9+
import 'package:ui/ui.dart';
10+
import 'package:web_engine_tester/golden_tester.dart';
11+
12+
Future<void> takeScreenshot(
13+
EngineCanvas canvas,
14+
Rect region,
15+
String fileName, {
16+
bool write = false,
17+
double? maxDiffRatePercent,
18+
}) async {
19+
final html.Element sceneElement = html.Element.tag('flt-scene');
20+
try {
21+
sceneElement.append(canvas.rootElement);
22+
html.document.body!.append(sceneElement);
23+
await matchGoldenFile(
24+
'$fileName.png',
25+
region: region,
26+
maxDiffRatePercent: maxDiffRatePercent,
27+
write: write,
28+
);
29+
} finally {
30+
// The page is reused across tests, so remove the element after taking the
31+
// Scuba screenshot.
32+
sceneElement.remove();
33+
}
34+
}

0 commit comments

Comments
 (0)