From d941aefa61cce2f9f2467845c7c70056613e96ce Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 17 Dec 2020 18:19:02 -0800 Subject: [PATCH] [web] Rich text painting on bitmap canvas (#23136) --- ci/licenses_golden/licenses_flutter | 1 + lib/web_ui/dev/goldens_lock.yaml | 2 +- lib/web_ui/lib/src/engine.dart | 1 + .../lib/src/engine/text/canvas_paragraph.dart | 15 +- .../lib/src/engine/text/layout_service.dart | 14 ++ .../lib/src/engine/text/paint_service.dart | 80 +++++++++ .../engine/canvas_paragraph/general_test.dart | 168 ++++++++++++++++++ .../engine/canvas_paragraph/helper.dart | 34 ++++ .../canvas_paragraph/overflow_test.dart | 130 ++++++++++++++ .../test/text/layout_service_helper.dart | 3 +- 10 files changed, 437 insertions(+), 11 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/text/paint_service.dart create mode 100644 lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart create mode 100644 lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart create mode 100644 lib/web_ui/test/golden_tests/engine/canvas_paragraph/overflow_test.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index b71703ece..7c23ce807 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -1184,6 +1184,7 @@ 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 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/unicode_range.dart diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 280ea2ccb..67ef9b56c 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: c808c28c81b6c3143ae969e8c49bed4a6d49aabb +revision: 4946ab2de031c14d30502efcaf51220e0be4d1f1 diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 5b8595125..42040eb1a 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -129,6 +129,7 @@ part 'engine/text/layout_service.dart'; part 'engine/text/line_break_properties.dart'; part 'engine/text/line_breaker.dart'; part 'engine/text/measurement.dart'; +part 'engine/text/paint_service.dart'; part 'engine/text/paragraph.dart'; part 'engine/text/canvas_paragraph.dart'; part 'engine/text/ruler.dart'; diff --git a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart index ca8c7a9e0..5284d63ed 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -58,9 +58,13 @@ class CanvasParagraph implements EngineParagraph { @override bool get didExceedMaxLines => _layoutService.didExceedMaxLines; + @override + bool isLaidOut = false; + ui.ParagraphConstraints? _lastUsedConstraints; late final TextLayoutService _layoutService = TextLayoutService(this); + late final TextPaintService _paintService = TextPaintService(this); @override void layout(ui.ParagraphConstraints constraints) { @@ -90,6 +94,7 @@ class CanvasParagraph implements EngineParagraph { .benchmark('text_layout', stopwatch.elapsedMicroseconds.toDouble()); } + isLaidOut = true; _lastUsedConstraints = constraints; } @@ -100,10 +105,7 @@ class CanvasParagraph implements EngineParagraph { @override void paint(BitmapCanvas canvas, ui.Offset offset) { - // TODO(mdebbar): Loop through the spans and for each box in the span: - // 1. Paint the background rect. - // 2. Paint the text shadows? - // 3. Paint the text. + _paintService.paint(canvas, offset); } @override @@ -182,9 +184,6 @@ class CanvasParagraph implements EngineParagraph { @override final bool drawOnCanvas = true; - @override - bool isLaidOut = false; - @override List getBoxesForRange( int start, @@ -217,7 +216,7 @@ class CanvasParagraph implements EngineParagraph { } @override - List computeLineMetrics() { + List computeLineMetrics() { return _layoutService.lines; } } diff --git a/lib/web_ui/lib/src/engine/text/layout_service.dart b/lib/web_ui/lib/src/engine/text/layout_service.dart index 4c470bf54..cbb927b4b 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -370,6 +370,14 @@ class RangeBox { return startIndex < this.end.index && this.start.index < endIndex; } + /// Returns a [ui.TextBox] representing this range box in the given [line]. + /// + /// The coordinates of the resulting [ui.TextBox] are relative to the + /// paragraph, not to the line. + ui.TextBox toTextBox(EngineLineMetrics line) { + return intersect(line, start.index, end.index); + } + /// Performs the intersection of this box with the range given by [start] and /// [end] indices, and returns a [ui.TextBox] representing that intersection. /// @@ -772,6 +780,12 @@ class LineBuilder { ); 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(); + } } LineBreakResult get _boxStart { diff --git a/lib/web_ui/lib/src/engine/text/paint_service.dart b/lib/web_ui/lib/src/engine/text/paint_service.dart new file mode 100644 index 000000000..d861257e5 --- /dev/null +++ b/lib/web_ui/lib/src/engine/text/paint_service.dart @@ -0,0 +1,80 @@ +// 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. + +// @dart = 2.12 +part of engine; + +/// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas]. +class TextPaintService { + TextPaintService(this.paragraph); + + 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 + // individually. + final List lines = paragraph.computeLineMetrics(); + + for (final EngineLineMetrics line in lines) { + for (final RangeBox box in line.boxes!) { + _paintBox(canvas, offset, line, box); + } + } + } + + void _paintBox( + BitmapCanvas canvas, + ui.Offset offset, + EngineLineMetrics line, + RangeBox box, + ) { + final ParagraphSpan span = box.span; + + // Placeholder spans don't need any painting. Their boxes should remain + // empty so that their underlying widgets do their own painting. + 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) { + canvas.drawRect( + box.toTextBox(line).toRect().shift(offset), + background.paintData, + ); + } + + // 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, + ); + canvas.fillText(text, x, y); + + // 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.fillText(ellipsis, x, y); + } + + canvas._tearDownPaint(); + } + } + + void _applySpanStyleToCanvas(FlatTextSpan span, BitmapCanvas canvas) { + final SurfacePaint? paint; + final ui.Paint? foreground = span.style._foreground; + if (foreground != null) { + paint = foreground as SurfacePaint; + } else { + paint = (ui.Paint()..color = span.style._color!) as SurfacePaint; + } + + canvas.setCssFont(span.style.cssFontString); + canvas._setUpPaint(paint.paintData, null); + } +} diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart new file mode 100644 index 000000000..f429f26bc --- /dev/null +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart @@ -0,0 +1,168 @@ +// 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. + +// @dart = 2.6 +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 Function(EngineCanvas canvas); + +const Rect bounds = Rect.fromLTWH(0, 0, 800, 600); + +const Color white = Color(0xFFFFFFFF); +const Color black = Color(0xFF000000); +const Color red = Color(0xFFFF0000); +const Color green = Color(0xFF00FF00); +const Color blue = Color(0xFF0000FF); + +ParagraphConstraints constrain(double width) { + return ParagraphConstraints(width: width); +} + +CanvasParagraph rich( + EngineParagraphStyle style, + void Function(CanvasParagraphBuilder) callback, +) { + final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); + callback(builder); + return builder.build(); +} + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() async { + setUpStableTestFonts(); + + test('paints spans and lines correctly', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset.zero; + CanvasParagraph paragraph; + + // Single-line multi-span. + paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only( + color: green, + background: Paint()..color = red, + )); + builder.addText('ipsum '); + builder.pop(); + builder.addText('.'); + }) + ..layout(constrain(double.infinity)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + // Multi-line single-span. + paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) { + builder.addText('Lorem ipsum dolor sit'); + }) + ..layout(constrain(90.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + // Multi-line multi-span. + paragraph = rich(ParagraphStyle(fontFamily: 'Roboto'), (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem ipsum '); + builder.pushStyle(EngineTextStyle.only(background: Paint()..color = red)); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('dolor '); + builder.pop(); + builder.addText('sit'); + }) + ..layout(constrain(90.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_general'); + }); + + test('respects alignment', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset.zero; + CanvasParagraph paragraph; + + void build(CanvasParagraphBuilder builder) { + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('dolor '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('sit'); + } + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.left), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.center), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.right), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_align'); + }); + + test('paints spans with varying heights/baselines', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + final CanvasParagraph paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto'), + (builder) { + builder.pushStyle(EngineTextStyle.only(fontSize: 20.0)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only( + fontSize: 40.0, + background: Paint()..color = green, + )); + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only( + fontSize: 10.0, + color: white, + background: Paint()..color = black, + )); + builder.addText('dolor '); + builder.pushStyle(EngineTextStyle.only(fontSize: 30.0)); + builder.addText('sit '); + builder.pop(); + builder.pop(); + builder.pushStyle(EngineTextStyle.only( + fontSize: 20.0, + background: Paint()..color = blue, + )); + builder.addText('amet'); + }, + )..layout(constrain(220.0)); + canvas.drawParagraph(paragraph, Offset.zero); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_varying_heights'); + }); +} diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart new file mode 100644 index 000000000..6d2a4eec9 --- /dev/null +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/helper.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.12 +import 'dart:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:web_engine_tester/golden_tester.dart'; + +Future takeScreenshot( + EngineCanvas canvas, + Rect region, + String fileName, { + bool write = false, + double? maxDiffRatePercent, +}) async { + final html.Element sceneElement = html.Element.tag('flt-scene'); + try { + sceneElement.append(canvas.rootElement); + html.document.body!.append(sceneElement); + await matchGoldenFile( + '$fileName.png', + region: region, + maxDiffRatePercent: maxDiffRatePercent, + write: write, + ); + } finally { + // The page is reused across tests, so remove the element after taking the + // Scuba screenshot. + sceneElement.remove(); + } +} diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/overflow_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/overflow_test.dart new file mode 100644 index 000000000..f86c5c7db --- /dev/null +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/overflow_test.dart @@ -0,0 +1,130 @@ +// 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. + +// @dart = 2.6 +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 Function(EngineCanvas canvas); + +const Rect bounds = Rect.fromLTWH(0, 0, 800, 600); + +const Color white = Color(0xFFFFFFFF); +const Color black = Color(0xFF000000); +const Color red = Color(0xFFFF0000); +const Color green = Color(0xFF00FF00); +const Color blue = Color(0xFF0000FF); + +ParagraphConstraints constrain(double width) { + return ParagraphConstraints(width: width); +} + +CanvasParagraph rich( + EngineParagraphStyle style, + void Function(CanvasParagraphBuilder) callback, +) { + final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); + callback(builder); + return builder.build(); +} + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() async { + setUpStableTestFonts(); + + test('ellipsis', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset.zero; + CanvasParagraph paragraph; + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', ellipsis: '...'), + (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('ipsum'); + }, + )..layout(constrain(80.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', ellipsis: '...'), + (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem\n'); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('dolor sit'); + }, + )..layout(constrain(80.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', ellipsis: '...'), + (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem\n'); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('d'); + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText('o'); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('l'); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('o'); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('r'); + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText(' '); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('s'); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('i'); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('t'); + }, + )..layout(constrain(80.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', maxLines: 2, ellipsis: '...'), + (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem'); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('ipsum'); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('dolor'); + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText('sit'); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('amet'); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('consectetur'); + }, + )..layout(constrain(80.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_ellipsis'); + }); +} diff --git a/lib/web_ui/test/text/layout_service_helper.dart b/lib/web_ui/test/text/layout_service_helper.dart index 9dbb84e81..c8e94886a 100644 --- a/lib/web_ui/test/text/layout_service_helper.dart +++ b/lib/web_ui/test/text/layout_service_helper.dart @@ -35,8 +35,7 @@ TestLine l( void expectLines(CanvasParagraph paragraph, List expectedLines) { final String text = paragraph.toPlainText(); - final List lines = - paragraph.computeLineMetrics() as List; + final List lines = paragraph.computeLineMetrics(); expect(lines, hasLength(expectedLines.length)); for (int i = 0; i < lines.length; i++) { final EngineLineMetrics line = lines[i]; -- GitLab