From f4412cb9f88391a06bdd745831e39631f75ce83f Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 15 Jan 2026 10:24:28 -0500 Subject: [PATCH 01/12] Paint as single image (not yet ready; ellipsis at least does not work) --- .../lib/src/engine/web_paragraph/layout.dart | 17 ++ .../lib/src/engine/web_paragraph/paint.dart | 183 ++++++++++++++++++ .../lib/src/engine/web_paragraph/painter.dart | 128 +++++++++++- .../src/engine/web_paragraph/paragraph.dart | 13 +- .../test/webparagraph/paragraph_test.dart | 4 +- 5 files changed, 329 insertions(+), 16 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index a88a805b2da..eec722ebd34 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -954,6 +954,8 @@ abstract class WebCluster { void fillOnContext(DomCanvasRenderingContext2D context, {required double x, required double y}); + void addToContext(DomCanvasRenderingContext2D context, double x, double y); + @override String toString() { return 'WebCluster [$start:$end)'; @@ -992,6 +994,11 @@ class TextCluster extends WebCluster { ); } + @override + void addToContext(DomCanvasRenderingContext2D context, double x, double y) { + context.fillTextCluster(_cluster, /*left:*/ x, /*top:*/ y + span.fontBoundingBoxAscent); + } + @override String toString() { return 'TextCluster [$start:$end) ${end - start}'; @@ -1028,6 +1035,11 @@ class EmptyCluster extends WebCluster { String toString() { return 'EmptyCluster [$start:$end)'; } + + @override + void addToContext(DomCanvasRenderingContext2D context, double x, double y) { + assert(false, 'We should not call addToContext method on this object'); + } } class PlaceholderCluster extends WebCluster { @@ -1055,6 +1067,11 @@ class PlaceholderCluster extends WebCluster { void fillOnContext(DomCanvasRenderingContext2D context, {required double x, required double y}) { // No-op. Placeholders don't draw anything. } + + @override + void addToContext(DomCanvasRenderingContext2D context, double x, double y) { + assert(false, 'We should not call addToContext method on this object'); + } } // This is the minimal range of cluster that belongs to the same bidi run and to the same style block diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index fd3c1811d71..3d9666b1c16 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -361,4 +361,187 @@ class TextPaint { WebParagraphDebug.log('paintLineOnCanvasKit.Text: ${line.textRange}'); _paintByClustersOnCanvas2D(StyleElements.text, canvas, layout, line, x, y); } + + void fillAsSingleImage( + ui.Canvas canvas, + TextLayout layout, + ui.Rect sourceRect, + ui.Offset offset, + ) { + if (painter.hasSingleImageCache) { + return; + } + + painter.resizePaintCanvas(ui.window.devicePixelRatio, sourceRect.width, sourceRect.height); + // Paint the entire paragraph as a single image on Canvas2D + double yOffset = 0; + for (final TextLine line in layout.lines) { + paintContext.save(); + paintContext.translate(line.formattingShift, yOffset); + WebParagraphDebug.log('fillAsSingleImage line at ${line.formattingShift}, $yOffset'); + yOffset += line.advance.height; + + for (final LineBlock block in line.visualBlocks) { + // Placeholders do not need painting, just reserving the space + if (block.clusterRange.size == 1 && + layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { + continue; + } + + WebParagraphDebug.log( + '+addClustersToCanvas2D: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' + '${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' + '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', + ); + + paintContext.save(); + paintContext.translate(block.spanShiftFromLineStart, 0); + addTextClusters(layout, block); + paintContext.restore(); + + paintContext.save(); + paintContext.translate(block.spanShiftFromLineStart, 0); + addShadows(layout, block); + paintContext.restore(); + } + + paintContext.restore(); + } + } + + void paintAsSingleImage( + ui.Canvas canvas, + TextLayout layout, + ui.Rect sourceRectParagraph, + ui.Rect targetRectParagraph, + ui.Offset offset, + ) { + for (final TextLine line in layout.lines) { + for (final LineBlock block in line.visualBlocks) { + // Placeholders do not need painting, just reserving the space + if (block is! TextBlock) { + continue; + } + // Let's calculate the sizes + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block, + ui.Offset( + line.advance.left + line.formattingShift + block.shiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + offset, + ui.window.devicePixelRatio, + ); + if (block.style.hasElement(StyleElements.background)) { + painter.paintBackground(canvas, block, sourceRect, targetRect); + } + } + } + + painter.paintTextBlockAsSingleImage(canvas, sourceRectParagraph, targetRectParagraph); + + for (final TextLine line in layout.lines) { + for (final LineBlock block in line.visualBlocks) { + // Placeholders do not need painting, just reserving the space + if (block is! TextBlock) { + continue; + } + // Let's calculate the sizes + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block, + ui.Offset( + line.advance.left + line.formattingShift + block.shiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + offset, + ui.window.devicePixelRatio, + ); + if (block.style.hasElement(StyleElements.decorations)) { + painter.fillDecorations(block, sourceRect); + painter.paintDecorations(canvas, sourceRect, targetRect); + } + } + } + } + + void addTextClusters(TextLayout layout, TextBlock block) { + final int start = block.isLtr + ? block.clusterRangeWithoutWhitespaces.start + : block.clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? block.clusterRangeWithoutWhitespaces.end + : block.clusterRangeWithoutWhitespaces.start - 1; + final step = block.isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = block is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + painter.addTextCluster(clusterText); + } + } + + void addShadows(TextLayout layout, TextBlock block) { + if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { + return; + } + + final int start = block.isLtr + ? block.clusterRangeWithoutWhitespaces.start + : block.clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? block.clusterRangeWithoutWhitespaces.end + : block.clusterRangeWithoutWhitespaces.start - 1; + final step = block.isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = block is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + for (final ui.Shadow shadow in clusterText.style.shadows!) { + painter.addShadow(clusterText, shadow, block.isLtr); + } + } + } + + (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph( + TextLayout layout, + ui.Offset offset, + double devicePixelRatio, + ) { + // Calculate the longest line taking in account the formatting shifts + double maxWidth = 0; + for (final TextLine line in layout.lines) { + final double lineWidth = line.advance.width + line.formattingShift + line.trailingSpacesWidth; + if (lineWidth > maxWidth) { + maxWidth = lineWidth; + } + } + + // Define the paragraph rect (using advances, not selected rects) + // Source rect must take in account the scaling + final sourceRect = ui.Rect.fromLTWH( + 0, + 0, + (maxWidth * devicePixelRatio).ceilToDouble(), + (layout.paragraph.height * devicePixelRatio).ceilToDouble(), + ); + // Target rect will be scaled by the canvas transform, so we don't scale it here + final zeroRect = ui.Rect.fromLTWH( + 0, + 0, + maxWidth.ceilToDouble(), + layout.paragraph.height.ceilToDouble(), + ); + final ui.Rect targetRect = zeroRect.translate(offset.dx, offset.dy); + + WebParagraphDebug.log( + 'calculateParagraph source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' + 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', + ); + + return (sourceRect, targetRect); + } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index a5013464888..9957b19d7c1 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -17,16 +17,17 @@ import 'paragraph.dart'; // TODO(mdebbar): Discuss it: we use this canvas for painting the entire block (entire line) // so we need to make sure it's big enough to hold the biggest line. // Also, we use it to paint shadows (with vertical shifts) so we need to make it tall enough as well. -const int _paintWidth = 1000; -const int _paintHeight = 500; double? currentDevicePixelRatio; -final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(_paintWidth, _paintHeight); -final paintContext = paintCanvas.getContext('2d')! as DomCanvasRenderingContext2D; +final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(0, 0); +final paintContext = + paintCanvas.getContext('2d', {'willReadFrequently': true})! as DomCanvasRenderingContext2D; /// Abstracts the interface for painting text clusters, shadows, and decorations. abstract class Painter { Painter(); + bool get hasSingleImageCache => false; + /// Fills out the information needed to paint the text cluster. void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr); @@ -48,10 +49,20 @@ abstract class Painter { /// Paints the decorations previously filled by [fillDecorations]. void paintDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); + void addTextCluster(WebCluster webTextCluster); + void addShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr); + + void paintTextBlockAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); + void paintShadowAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); + void resetCache(); + bool hasCache(); + /// Adjust the _paintCanvas scale based on device pixel ratio - void resizePaintCanvas(double devicePixelRatio) { - if (currentDevicePixelRatio == devicePixelRatio) { - // Nothing changed + void resizePaintCanvas(double devicePixelRatio, double width, double height) { + if (currentDevicePixelRatio == devicePixelRatio && + paintCanvas.width == (width * devicePixelRatio).ceilToDouble() && + paintCanvas.height == (height * devicePixelRatio).ceilToDouble()) { + // We need to resize canvas whenever the requested size changes return; } @@ -61,8 +72,8 @@ abstract class Painter { if (currentDevicePixelRatio != null) { paintContext.restore(); // Restore to unscaled state } - paintCanvas.width = (_paintWidth * devicePixelRatio).ceilToDouble(); - paintCanvas.height = (_paintHeight * devicePixelRatio).ceilToDouble(); + paintCanvas.width = (width * devicePixelRatio).ceilToDouble(); + paintCanvas.height = (height * devicePixelRatio).ceilToDouble(); paintContext.scale(devicePixelRatio, devicePixelRatio); paintContext.save(); @@ -75,6 +86,11 @@ abstract class Painter { } class CanvasKitPainter extends Painter { + CkImage? singleImageCache; + + @override + bool get hasSingleImageCache => singleImageCache != null; + @override void paintBackground(ui.Canvas canvas, LineBlock block, ui.Rect sourceRect, ui.Rect targetRect) { // We need to snap the block edges because Skia draws rectangles with subpixel accuracy @@ -262,6 +278,100 @@ class CanvasKitPainter extends Painter { ); } + @override + void addTextCluster(WebCluster webTextCluster) { + final WebTextStyle style = webTextCluster.style; + paintContext.fillStyle = style.getForegroundColor().toCssString(); + webTextCluster.addToContext(paintContext, 0, 0); + } + + @override + void addShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr) { + final WebTextStyle style = webTextCluster.style; + + // TODO(jlavrova): see if we can implement shadowing ourself avoiding redrawing text clusters many times. + // Answer: we cannot, and also there is a question of calculating the size of the shadow which we have to + // take from Chrome as well (performing another measure text operation with shadow attribute set). + paintContext.fillStyle = style.getForegroundColor().toCssString(); + paintContext.shadowColor = shadow.color.toCssString(); + paintContext.shadowBlur = shadow.blurRadius; + paintContext.shadowOffsetX = shadow.offset.dx; + paintContext.shadowOffsetY = shadow.offset.dy; + WebParagraphDebug.log( + 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', + ); + + // TODO(jlavrova): calculate the proper shift for the shadow + webTextCluster.addToContext(paintContext, 0, 0); + } + + DomImageBitmap _createSmallBitmapSync(ui.Rect bounds) { + // We should have resized the small canvas before calling this method + if (bounds.width != paintCanvas.width || bounds.height != paintCanvas.height) { + WebParagraphDebug.error( + '_resizePaintCanvas needed: ' + 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${bounds.width}x${bounds.height}', + ); + assert(false); + } + // Transfer the buffer from the small canvas + // This is synchronous and returns the handle immediately + final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); + return bitmap; + } + + @override + void paintTextBlockAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + if (!hasSingleImageCache) { + final DomImageBitmap bitmap = _createSmallBitmapSync(sourceRect); + + final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); + if (skImage == null) { + throw Exception('Failed to convert text image bitmap to an SkImage.'); + } + singleImageCache = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); + } + + canvas.drawImageRect( + singleImageCache!, + sourceRect, + targetRect, + ui.Paint()..filterQuality = ui.FilterQuality.none, + ); + } + + @override + void paintShadowAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + // TODO(jlavrova): calculate the shadow bounds properly + final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); + final ui.Rect shadowTargetRect = targetRect.inflate(100); + // TODO(jlavrova): we could cache the shadow image as well but should we?.. + final DomImageBitmap bitmap = _createSmallBitmapSync(shadowSourceRect); + + final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); + if (skImage == null) { + throw Exception('Failed to convert text image bitmap to an SkImage.'); + } + singleImageCache = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); + + canvas.drawImageRect( + singleImageCache!, + shadowSourceRect, + shadowTargetRect, + ui.Paint()..filterQuality = ui.FilterQuality.none, + ); + } + + @override + void resetCache() { + singleImageCache = null; + } + + @override + bool hasCache() { + return singleImageCache != null; + } + double calculateThickness(WebTextStyle textStyle) { return (textStyle.fontSize! / 14.0) * (textStyle.decorationThickness ?? 1.0); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart index 16fe134e2b8..5c811101b5c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart @@ -969,14 +969,17 @@ class WebParagraph implements ui.Paragraph { } void paint(ui.Canvas canvas, ui.Offset offset) { - _paint.painter.resizePaintCanvas(ui.window.devicePixelRatio); - for (final TextLine line in _layout.lines) { - _paint.paintLine(canvas, _layout, line, offset.dx, offset.dy); - } + final (ui.Rect sourceRect, ui.Rect targetRect) = _paint.calculateParagraph( + _layout, + offset, + ui.window.devicePixelRatio, + ); + _paint.fillAsSingleImage(canvas, _layout, sourceRect, offset); + _paint.paintAsSingleImage(canvas, _layout, sourceRect, targetRect, offset); } void paintOnCanvas2D(DomHTMLCanvasElement canvas, ui.Offset offset) { - _paint.painter.resizePaintCanvas(ui.window.devicePixelRatio); + _paint.painter.resizePaintCanvas(ui.window.devicePixelRatio, 1000.0, 1000.0); for (final TextLine line in _layout.lines) { _paint.paintLineOnCanvas2D(canvas, _layout, line, offset.dx, offset.dy); } diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart index b5b6da3dbc7..f25cc443c33 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart @@ -993,7 +993,7 @@ Future testMain() async { } await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('ellipsisLTR.png', region: region); - }); + }, solo: true); test('Ellipsis RTL', () async { final recorder = PictureRecorder(); @@ -1030,7 +1030,7 @@ Future testMain() async { } await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('ellipsisRTL.png', region: region); - }); + }, solo: true); test('MaxLines, no ellipsis', () async { final recorder = PictureRecorder(); From 65fe7aa649b9e85a1d2a4dc759e1fa78a3adc913 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Wed, 28 Jan 2026 10:28:27 -0500 Subject: [PATCH 02/12] Most of refactoring done, decorations don't work --- .../flutter/lib/web_ui/lib/src/engine.dart | 2 + .../lib/src/engine/web_paragraph/debug.dart | 12 +- .../lib/src/engine/web_paragraph/layout.dart | 33 +- .../lib/src/engine/web_paragraph/paint.dart | 566 +++++------------- .../engine/web_paragraph/paint_clusters.dart | 223 +++++++ .../engine/web_paragraph/paint_paragraph.dart | 285 +++++++++ .../lib/src/engine/web_paragraph/painter.dart | 324 +--------- .../src/engine/web_paragraph/paragraph.dart | 19 +- .../paragraph_performance_test.dart | 223 +++++++ .../test/webparagraph/paragraph_test.dart | 6 +- .../web_ui/test/webparagraph/statistics.txt | 66 ++ 11 files changed, 1023 insertions(+), 736 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart create mode 100644 engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart create mode 100644 engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 0887374a7c0..98218bf68bc 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -166,6 +166,8 @@ export 'engine/web_paragraph/debug.dart'; export 'engine/web_paragraph/font_collection.dart'; export 'engine/web_paragraph/layout.dart'; export 'engine/web_paragraph/paint.dart'; +export 'engine/web_paragraph/paint_clusters.dart'; +export 'engine/web_paragraph/paint_paragraph.dart'; export 'engine/web_paragraph/painter.dart'; export 'engine/web_paragraph/paragraph.dart'; export 'engine/web_paragraph/wrapper.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index f17ab5bec60..a5b0d544a20 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -7,12 +7,12 @@ class WebParagraphDebug { static bool apiLogging = false; static void log(String arg) { - assert(() { - if (logging) { - print(arg); - } - return true; - }()); + //assert(() { + if (logging) { + print(arg); + } + // return true; + //}()); } static void apiTrace(String arg) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index eec722ebd34..bb873bff0a5 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -564,20 +564,29 @@ class TextLayout { strutStyle.strutAscent; bottom = top + strutStyle.strutAscent + strutStyle.strutDescent; case ui.BoxHeightStyle.includeLineSpacingMiddle: - top = - line.advance.top + - (line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent) / 2; - bottom = - line.advance.top + - line.fontBoundingBoxAscent + - (line.fontBoundingBoxDescent + block.rawFontBoundingBoxDescent) / 2; + final double shift = (line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent) / 2; + top = line.advance.top + shift; + bottom = line.advance.bottom + shift; + if (lineIndex == 0) { + top += shift; + } + if (lineIndex == lines.length - 1) { + bottom -= shift; + } case ui.BoxHeightStyle.includeLineSpacingTop: - top = line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent; - bottom = line.advance.top + line.fontBoundingBoxAscent + line.fontBoundingBoxDescent; - case ui.BoxHeightStyle.includeLineSpacingBottom: + final double shift = line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent; top = line.advance.top; - bottom = - line.advance.top + line.fontBoundingBoxAscent + block.rawFontBoundingBoxDescent; + bottom = line.advance.bottom; + if (lineIndex == 0) { + top += shift; + } + case ui.BoxHeightStyle.includeLineSpacingBottom: + final double shift = line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent; + top = line.advance.top + shift; + bottom = line.advance.bottom + shift; + if (lineIndex == lines.length - 1) { + bottom -= shift; + } } left = firstRect.left - (line.advance.left + line.formattingShift); right = left + firstRect.width; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index 3d9666b1c16..fd2a9a98d99 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -2,249 +2,27 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:ui/ui.dart' as ui; import '../../engine.dart'; +// TODO(mdebbar): Discuss it: we use this canvas for painting the entire block (entire line) +// so we need to make sure it's big enough to hold the biggest line. +// Also, we use it to paint shadows (with vertical shifts) so we need to make it tall enough as well. +double? currentDevicePixelRatio; +final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(0, 0); +final paintContext = + paintCanvas.getContext('2d', {'willReadFrequently': true})! as DomCanvasRenderingContext2D; + /// Paints on a [WebParagraph]. /// /// It uses a [DomCanvasElement] to get text information -class TextPaint { - TextPaint(this.paragraph, this.painter); +abstract class TextPaint { + TextPaint(this.paragraph); final WebParagraph paragraph; - final Painter painter; - - // TODO(jlavrova): painting the entire block could require a really big canvas - // Answer: we only do blocks for background and decorations which we do not draw on canvas - // but rather implement ourselves via CanvasKit API - void _paintByBlocks( - StyleElements styleElement, - ui.Canvas canvas, - TextLayout layout, - TextLine line, - double x, - double y, - ) { - // We traverse text in visual blocks order (broken by text styles and bidi runs, then reordered) - for (final LineBlock block in line.visualBlocks) { - if (!block.style.hasElement(styleElement)) { - continue; - } - // Placeholders do not need painting, just reserving the space - if (block is PlaceholderBlock) { - continue; - } - - // Let's calculate the sizes - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( - layout, - block as TextBlock, - ui.Offset( - line.advance.left + line.formattingShift + block.shiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - ui.Offset(x, y), - ui.window.devicePixelRatio, - ); - - WebParagraphDebug.log( - '+_paintByBlocks: ${block.textRange} ${block.spanShiftFromLineStart} ${block.shiftFromLineStart} ' - '${line.advance} + ${line.formattingShift} ' - '\nsourceRect: $sourceRect targetRect: $targetRect', - ); - // Let's draw whatever has to be drawn - switch (styleElement) { - case StyleElements.background: - painter.paintBackground(canvas, block, sourceRect, targetRect); - case StyleElements.decorations: - painter.fillDecorations(block, sourceRect); - painter.paintDecorations(canvas, sourceRect, targetRect); - default: - assert(false); - } - } - } - - void _paintByClusters( - StyleElements styleElement, - ui.Canvas canvas, - TextLayout layout, - TextLine line, - double x, - double y, - ) { - // We traverse clusters in the order of visual blocks (broken by text styles and bidi runs, then reordered) - // and then in visual order inside blocks - for (final LineBlock block in line.visualBlocks) { - if (!block.style.hasElement(styleElement)) { - continue; - } - // Placeholders do not need painting, just reserving the space - if (block.clusterRange.size == 1 && - layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { - continue; - } - - WebParagraphDebug.log( - '+paintByClusters: ${block.textRange} ${block.clusterRange} ${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ${block.isLtr} ${line.advance.left} + ${line.formattingShift} + ${block.shiftFromLineStart}', - ); - - // We are painting clusters in visual order so that if they step on each other, the paint - // order is correct. - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; - final step = block.isLtr ? 1 : -1; - for (var i = start; i != end; i += step) { - final WebCluster clusterText = block is EllipsisBlock - ? layout.ellipsisClusters[i] - : layout.allClusters[i]; - // We need to adjust the canvas size to fit the block in case there is scaling or zoom involved - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateCluster( - layout, - block, - clusterText, - ui.Offset( - // TODO(mdebbar): Avoid use of `block.spanShiftFromLineStart` (similar to `getPositionForOffset`) - line.advance.left + line.formattingShift + block.spanShiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - ui.Offset(x, y), - ui.window.devicePixelRatio, - ); - - if (sourceRect.isEmpty) { - // Let's skip empty clusters - continue; - } - switch (styleElement) { - case StyleElements.shadows: - paintContext.save(); - for (final ui.Shadow shadow in clusterText.style.shadows!) { - painter.fillShadow( - clusterText, - shadow, - // We shape ellipsis with default direction coming from the attaching block - // and all the other blocks with the default paragraph direction - block is EllipsisBlock - ? block.isLtr - : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, - ); - painter.paintShadow(canvas, sourceRect, targetRect); - } - paintContext.restore(); - case StyleElements.text: - painter.fillTextCluster( - clusterText, - // We shape ellipsis with default direction coming from the attaching block - // and all the other blocks with the default paragraph direction. - // The reason for shaping ellipsis this way is that we literally attach it to the block - // that overflows and we want to keep all the styling attributes (including text direction) consistent. - block is EllipsisBlock - ? block.isLtr - : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, - ); - painter.paintTextCluster(canvas, sourceRect, targetRect); - default: - assert(false); - } - } - } - } - - void _paintByClustersOnCanvas2D( - StyleElements styleElement, - DomHTMLCanvasElement canvas, - TextLayout layout, - TextLine line, - double x, - double y, - ) { - // We traverse clusters in the order of visual blocks (broken by text styles and bidi runs, then reordered) - // and then in visual order inside blocks - for (final LineBlock block in line.visualBlocks) { - if (!block.style.hasElement(styleElement)) { - continue; - } - // Placeholders do not need painting, just reserving the space - if (block.clusterRange.size == 1 && - layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { - continue; - } - - WebParagraphDebug.log( - '+paintByClusters: ${block.textRange} ${block.clusterRange} ${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ${block.isLtr} ${line.advance.left} + ${line.formattingShift} + ${block.shiftFromLineStart}', - ); - - // We are painting clusters in visual order so that if they step on each other, the paint - // order is correct. - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; - final step = block.isLtr ? 1 : -1; - for (var i = start; i != end; i += step) { - final WebCluster clusterText = block is EllipsisBlock - ? layout.ellipsisClusters[i] - : layout.allClusters[i]; - // We need to adjust the canvas size to fit the block in case there is scaling or zoom involved - // We need to adjust the canvas size to fit the block in case there is scaling or zoom involved - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateCluster( - layout, - block, - clusterText, - ui.Offset( - // TODO(mdebbar): Avoid use of `block.spanShiftFromLineStart` (similar to `getPositionForOffset`) - line.advance.left + line.formattingShift + block.spanShiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - ui.Offset(x, y), - ui.window.devicePixelRatio, - ); - - if (sourceRect.isEmpty) { - // Let's skip empty clusters - continue; - } - switch (styleElement) { - case StyleElements.text: - final WebTextStyle style = clusterText.style; - paintContext.fillStyle = style.getForegroundColor().toCssString(); - // We fill the text cluster into a rectange [0,0,w,h] - // but we need to shift the y coordinate by the font ascent - // becase the text is drawn at the ascent, not at 0 - clusterText.fillOnContext( - paintContext, - /*ignore the text cluster shift from the text run*/ - x: (block.isLtr ? 0 : clusterText.advance.width), - y: 0, - ); - - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - canvas.context2D.drawImage( - bitmap, - sourceRect.left, - sourceRect.top, - sourceRect.width, - sourceRect.height, - targetRect.left, - targetRect.top, - targetRect.width, - targetRect.height, - ); - - default: - assert(false); - } - } - } - } (ui.Rect sourceRect, ui.Rect targetRect) calculateCluster( TextLayout layout, @@ -337,175 +115,6 @@ class TextPaint { return (sourceRect, targetRect); } - void paintLine(ui.Canvas canvas, TextLayout layout, TextLine line, double x, double y) { - WebParagraphDebug.log('paintLineOnCanvasKit.Background: ${line.textRange}'); - _paintByBlocks(StyleElements.background, canvas, layout, line, x, y); - - WebParagraphDebug.log('paintLineOnCanvasKit.Shadows: ${line.textRange}'); - _paintByClusters(StyleElements.shadows, canvas, layout, line, x, y); - - WebParagraphDebug.log('paintLineOnCanvasKit.Text: ${line.textRange}'); - _paintByClusters(StyleElements.text, canvas, layout, line, x, y); - - WebParagraphDebug.log('paintLineOnCanvasKit.Decorations: ${line.textRange}'); - _paintByBlocks(StyleElements.decorations, canvas, layout, line, x, y); - } - - void paintLineOnCanvas2D( - DomHTMLCanvasElement canvas, - TextLayout layout, - TextLine line, - double x, - double y, - ) { - WebParagraphDebug.log('paintLineOnCanvasKit.Text: ${line.textRange}'); - _paintByClustersOnCanvas2D(StyleElements.text, canvas, layout, line, x, y); - } - - void fillAsSingleImage( - ui.Canvas canvas, - TextLayout layout, - ui.Rect sourceRect, - ui.Offset offset, - ) { - if (painter.hasSingleImageCache) { - return; - } - - painter.resizePaintCanvas(ui.window.devicePixelRatio, sourceRect.width, sourceRect.height); - // Paint the entire paragraph as a single image on Canvas2D - double yOffset = 0; - for (final TextLine line in layout.lines) { - paintContext.save(); - paintContext.translate(line.formattingShift, yOffset); - WebParagraphDebug.log('fillAsSingleImage line at ${line.formattingShift}, $yOffset'); - yOffset += line.advance.height; - - for (final LineBlock block in line.visualBlocks) { - // Placeholders do not need painting, just reserving the space - if (block.clusterRange.size == 1 && - layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { - continue; - } - - WebParagraphDebug.log( - '+addClustersToCanvas2D: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' - '${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' - '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', - ); - - paintContext.save(); - paintContext.translate(block.spanShiftFromLineStart, 0); - addTextClusters(layout, block); - paintContext.restore(); - - paintContext.save(); - paintContext.translate(block.spanShiftFromLineStart, 0); - addShadows(layout, block); - paintContext.restore(); - } - - paintContext.restore(); - } - } - - void paintAsSingleImage( - ui.Canvas canvas, - TextLayout layout, - ui.Rect sourceRectParagraph, - ui.Rect targetRectParagraph, - ui.Offset offset, - ) { - for (final TextLine line in layout.lines) { - for (final LineBlock block in line.visualBlocks) { - // Placeholders do not need painting, just reserving the space - if (block is! TextBlock) { - continue; - } - // Let's calculate the sizes - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( - layout, - block, - ui.Offset( - line.advance.left + line.formattingShift + block.shiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - offset, - ui.window.devicePixelRatio, - ); - if (block.style.hasElement(StyleElements.background)) { - painter.paintBackground(canvas, block, sourceRect, targetRect); - } - } - } - - painter.paintTextBlockAsSingleImage(canvas, sourceRectParagraph, targetRectParagraph); - - for (final TextLine line in layout.lines) { - for (final LineBlock block in line.visualBlocks) { - // Placeholders do not need painting, just reserving the space - if (block is! TextBlock) { - continue; - } - // Let's calculate the sizes - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( - layout, - block, - ui.Offset( - line.advance.left + line.formattingShift + block.shiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - offset, - ui.window.devicePixelRatio, - ); - if (block.style.hasElement(StyleElements.decorations)) { - painter.fillDecorations(block, sourceRect); - painter.paintDecorations(canvas, sourceRect, targetRect); - } - } - } - } - - void addTextClusters(TextLayout layout, TextBlock block) { - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; - final step = block.isLtr ? 1 : -1; - for (var i = start; i != end; i += step) { - final WebCluster clusterText = block is EllipsisBlock - ? layout.ellipsisClusters[i] - : layout.allClusters[i]; - - painter.addTextCluster(clusterText); - } - } - - void addShadows(TextLayout layout, TextBlock block) { - if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { - return; - } - - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; - final step = block.isLtr ? 1 : -1; - for (var i = start; i != end; i += step) { - final WebCluster clusterText = block is EllipsisBlock - ? layout.ellipsisClusters[i] - : layout.allClusters[i]; - - for (final ui.Shadow shadow in clusterText.style.shadows!) { - painter.addShadow(clusterText, shadow, block.isLtr); - } - } - } - (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph( TextLayout layout, ui.Offset offset, @@ -544,4 +153,155 @@ class TextPaint { return (sourceRect, targetRect); } + + double calculateThickness(WebTextStyle textStyle) { + return (textStyle.fontSize! / 14.0) * (textStyle.decorationThickness ?? 1.0); + } + + double calculatePosition( + ui.TextDecoration decoration, + double thickness, + double height, + double ascent, + ) { + switch (decoration) { + case ui.TextDecoration.underline: + WebParagraphDebug.log( + 'calculatePosition underline: $thickness + $ascent = ${thickness + ascent}', + ); + return thickness + ascent; + case ui.TextDecoration.overline: + WebParagraphDebug.log('calculatePosition overline: 0'); + return thickness / 2; + case ui.TextDecoration.lineThrough: + WebParagraphDebug.log('calculatePosition through: $height / 2 = ${height / 2}'); + return height / 2; + } + return 0; + } + + void calculateWaves( + double x, + double y, + WebTextStyle textStyle, + ui.Rect textBounds, + double thickness, + ) { + final quarterWave = thickness; + + var waveCount = 0; + double xStart = 0; + final double yStart = y + quarterWave; + + WebParagraphDebug.log( + 'calculateWaves($x, $y, ' + '${textBounds.left}:${textBounds.right}x${textBounds.top}:${textBounds.bottom} )' + '$thickness $xStart $yStart', + ); + paintContext.beginPath(); + //paintContext.moveTo(x, y + quarterWave); + while (xStart + quarterWave * 2 < textBounds.width) { + final x1 = xStart; + final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); + final double x2 = xStart + quarterWave * 2; + final y2 = yStart; + WebParagraphDebug.log('wave: $x1, $y1, $x2, $y2'); + paintContext.quadraticCurveTo(x1, y1, x2, y2); + xStart += quarterWave * 2; + ++waveCount; + } + + // The rest of the wave + final double remaining = textBounds.width - xStart; + if (remaining > 0) { + final x1 = xStart; + final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); + //final double y1 = yStart + remaining / 2 * (waveCount.isEven ? 1 : -1); + final double x2 = xStart + remaining; + final y2 = yStart; + //final double y2 = yStart + remaining + remaining / quarterWave * y1; + WebParagraphDebug.log( + 'remaining: ${textBounds.width} - $xStart = $remaining ' + '$x1, $y1, $x2, $y2', + ); + paintContext.quadraticCurveTo(x1, y1, x2, y2); + } + paintContext.stroke(); + } + + void fillDecorations(TextBlock block, ui.Rect sourceRect) { + paintContext.fillStyle = block.style.getForegroundColor().toCssString(); + + final double thickness = calculateThickness(block.style); + + const DoubleDecorationSpacing = 3.0; + + for (final ui.TextDecoration decoration in [ + ui.TextDecoration.lineThrough, + ui.TextDecoration.underline, + ui.TextDecoration.overline, + ]) { + if (!block.style.decoration!.contains(decoration)) { + continue; + } + + // TODO(jlavrova): Why using these instead of multiplied values? + final double height = block.rawFontBoundingBoxAscent + block.rawFontBoundingBoxDescent; + final double ascent = block.rawFontBoundingBoxAscent; + final double position = calculatePosition(decoration, thickness, height, ascent); + WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); + + final double width = sourceRect.width; + final double x = sourceRect.left; + final double y = sourceRect.top + position; + + paintContext.reset(); + paintContext.lineWidth = thickness; + paintContext.strokeStyle = block.style.decorationColor!.toCssString(); + + switch (block.style.decorationStyle!) { + case ui.TextDecorationStyle.wavy: + calculateWaves(x, y, block.style, sourceRect, thickness); + + case ui.TextDecorationStyle.double: + final double bottom = y + DoubleDecorationSpacing + thickness; + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.moveTo(x, bottom); + paintContext.lineTo(x + width, bottom); + paintContext.stroke(); + WebParagraphDebug.log('double: $x:${x + width}, $y:$bottom'); + + case ui.TextDecorationStyle.dashed: + case ui.TextDecorationStyle.dotted: + final dashes = Float32List(2) + ..[0] = + thickness * (block.style.decorationStyle! == ui.TextDecorationStyle.dotted ? 1 : 4) + ..[1] = thickness; + + paintContext.setLineDash(dashes); + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.stroke(); + WebParagraphDebug.log('dashed/dotted: $x:${x + width}, $y'); + + case ui.TextDecorationStyle.solid: + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.stroke(); + WebParagraphDebug.log( + 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', + ); + } + } + } + + void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr); + + void fillShadowCluster(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr); + + void paint(ui.Canvas canvas, TextLayout layout, Painter painter, double x, double y); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart new file mode 100644 index 00000000000..e12770b4182 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart @@ -0,0 +1,223 @@ +// 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 '../../engine.dart'; + +/// Paints on a [WebParagraph]. +/// +/// It uses a [DomCanvasElement] to get text information +class PaintClusters extends TextPaint { + PaintClusters(super.paragraph); + + // TODO(jlavrova): painting the entire block could require a really big canvas + // Answer: we only do blocks for background and decorations which we do not draw on canvas + // but rather implement ourselves via CanvasKit API + void _paintByBlocks( + StyleElements styleElement, + ui.Canvas canvas, + TextLayout layout, + TextLine line, + Painter painter, + double x, + double y, + ) { + // We traverse text in visual blocks order (broken by text styles and bidi runs, then reordered) + for (final LineBlock block in line.visualBlocks) { + if (!block.style.hasElement(styleElement)) { + continue; + } + // Placeholders do not need painting, just reserving the space + if (block is PlaceholderBlock) { + continue; + } + + // Let's calculate the sizes + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block as TextBlock, + ui.Offset( + line.advance.left + line.formattingShift + block.shiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + ui.Offset(x, y), + ui.window.devicePixelRatio, + ); + + WebParagraphDebug.log( + '+_paintByBlocks: ${block.textRange} ${block.spanShiftFromLineStart} ${block.shiftFromLineStart} ' + '${line.advance} + ${line.formattingShift} ' + '\nsourceRect: $sourceRect targetRect: $targetRect', + ); + // Let's draw whatever has to be drawn + switch (styleElement) { + case StyleElements.background: + painter.drawBackground(canvas, block, sourceRect, targetRect); + case StyleElements.decorations: + painter.resizePaintCanvas( + ui.window.devicePixelRatio, + sourceRect.width, + sourceRect.height, + ); + fillDecorations(block, sourceRect); + painter.drawDecorations(canvas, sourceRect, targetRect); + default: + assert(false); + } + } + } + + void _paintByClusters( + StyleElements styleElement, + ui.Canvas canvas, + TextLayout layout, + TextLine line, + Painter painter, + double x, + double y, + ) { + // We traverse clusters in the order of visual blocks (broken by text styles and bidi runs, then reordered) + // and then in visual order inside blocks + for (final LineBlock block in line.visualBlocks) { + if (!block.style.hasElement(styleElement)) { + continue; + } + // Placeholders do not need painting, just reserving the space + if (block.clusterRange.size == 1 && + layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { + continue; + } + + WebParagraphDebug.log( + '+paintByClusters: ${block.textRange} ${block.clusterRange} ${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ${block.isLtr} ${line.advance.left} + ${line.formattingShift} + ${block.shiftFromLineStart}', + ); + + // We are painting clusters in visual order so that if they step on each other, the paint + // order is correct. + final int start = block.isLtr + ? block.clusterRangeWithoutWhitespaces.start + : block.clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? block.clusterRangeWithoutWhitespaces.end + : block.clusterRangeWithoutWhitespaces.start - 1; + final step = block.isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = block is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + // We need to adjust the canvas size to fit the block in case there is scaling or zoom involved + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateCluster( + layout, + block, + clusterText, + ui.Offset( + // TODO(mdebbar): Avoid use of `block.spanShiftFromLineStart` (similar to `getPositionForOffset`) + line.advance.left + line.formattingShift + block.spanShiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + ui.Offset(x, y), + ui.window.devicePixelRatio, + ); + + if (sourceRect.isEmpty) { + // Let's skip empty clusters + continue; + } + painter.resizePaintCanvas(ui.window.devicePixelRatio, sourceRect.width, sourceRect.height); + switch (styleElement) { + case StyleElements.shadows: + paintContext.save(); + for (final ui.Shadow shadow in clusterText.style.shadows!) { + fillShadowCluster( + clusterText, + shadow, + // We shape ellipsis with default direction coming from the attaching block + // and all the other blocks with the default paragraph direction + block is EllipsisBlock + ? block.isLtr + : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, + ); + painter.drawShadowCluster(canvas, sourceRect, targetRect); + } + paintContext.restore(); + case StyleElements.text: + fillTextCluster( + clusterText, + // We shape ellipsis with default direction coming from the attaching block + // and all the other blocks with the default paragraph direction. + // The reason for shaping ellipsis this way is that we literally attach it to the block + // that overflows and we want to keep all the styling attributes (including text direction) consistent. + block is EllipsisBlock + ? block.isLtr + : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, + ); + painter.drawTextCluster(canvas, sourceRect, targetRect); + default: + assert(false); + } + } + } + } + + @override + void paint(ui.Canvas canvas, TextLayout layout, Painter painter, double x, double y) { + for (final TextLine line in layout.lines) { + // Paint background first + _paintByBlocks(StyleElements.background, canvas, layout, line, painter, x, y); + + // Paint all shadows on the line + _paintByClusters(StyleElements.shadows, canvas, layout, line, painter, x, y); + + // Paint the text on the line + _paintByClusters(StyleElements.text, canvas, layout, line, painter, x, y); + + // Paint decorations last + _paintByBlocks(StyleElements.decorations, canvas, layout, line, painter, x, y); + } + } + + @override + void fillShadowCluster(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr) { + final WebTextStyle style = webTextCluster.style; + + // TODO(jlavrova): see if we can implement shadowing ourself avoiding redrawing text clusters many times. + // Answer: we cannot, and also there is a question of calculating the size of the shadow which we have to + // take from Chrome as well (performing another measure text operation with shadow attribute set). + paintContext.fillStyle = style.getForegroundColor().toCssString(); + paintContext.shadowColor = shadow.color.toCssString(); + paintContext.shadowBlur = shadow.blurRadius; + paintContext.shadowOffsetX = shadow.offset.dx; + paintContext.shadowOffsetY = shadow.offset.dy; + WebParagraphDebug.log( + 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', + ); + + // We fill the text cluster into a rectange [0,0,w,h] + // but we need to shift the y coordinate by the font ascent + // becase the text is drawn at the ascent, not at 0 + webTextCluster.fillOnContext( + paintContext, + /*ignore the text cluster shift from the text run*/ + // TODO(jlavrova): calculate the proper shift for the shadow + x: (isDefaultLtr ? 0 : webTextCluster.advance.width) + 100, + y: 100, + ); + } + + @override + void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr) { + final WebTextStyle style = webTextCluster.style; + paintContext.fillStyle = style.getForegroundColor().toCssString(); + // We fill the text cluster into a rectange [0,0,w,h] + // but we need to shift the y coordinate by the font ascent + // becase the text is drawn at the ascent, not at 0 + webTextCluster.fillOnContext( + paintContext, + /*ignore the text cluster shift from the text run*/ + x: (isDefaultLtr ? 0 : webTextCluster.advance.width), + y: 0, + ); + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart new file mode 100644 index 00000000000..82637de1d86 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -0,0 +1,285 @@ +// 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:typed_data'; + +import 'package:ui/ui.dart' as ui; + +import '../../engine.dart'; + +/// Paints on a [WebParagraph]. +/// +/// It uses a [DomCanvasElement] to get text information +class PaintParagraph extends TextPaint { + PaintParagraph(super.paragraph); + + void _fillAllBlocks(StyleElements styleElement, TextLayout layout) { + // Paint the entire paragraph as a single image on Canvas2D + double yOffset = 0; + for (final TextLine line in layout.lines) { + paintContext.save(); + paintContext.translate(line.formattingShift, yOffset); + WebParagraphDebug.log('_fillAllBlocks line at ${line.formattingShift}, $yOffset}'); + + yOffset += line.advance.height; + + for (final LineBlock block in line.visualBlocks) { + if (block is PlaceholderBlock) { + // Placeholders do not need painting, just reserving the space + continue; + } + + WebParagraphDebug.log( + '+_fillAllBlocks: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' + '${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' + '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', + ); + + paintContext.save(); + paintContext.translate( + block.spanShiftFromLineStart, + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ); + switch (styleElement) { + case StyleElements.shadows: + _fillBlockShadows(layout, block); + case StyleElements.text: + _fillBlockText(layout, block); + case StyleElements.decorations: + // Let's calculate the sizes + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block, + ui.Offset(line.advance.left + line.formattingShift, line.advance.top), + ui.Offset.zero, // We only need sourceRect here so we don't need the offset + ui.window.devicePixelRatio, + ); + _fillBlockDecorations(block, sourceRect); + default: + // We only need to draw backgrounds only + assert(false); + } + paintContext.restore(); + } + + paintContext.restore(); + } + } + + void _drawAllBlocks( + StyleElements styleElement, + ui.Canvas canvas, + TextLayout layout, + Painter painter, + double x, + double y, + ) { + for (final TextLine line in layout.lines) { + for (final LineBlock block in line.visualBlocks) { + if (block is PlaceholderBlock) { + // Placeholders do not need painting, just reserving the space + continue; + } + + // Let's calculate the sizes + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block as TextBlock, + ui.Offset( + line.advance.left + line.formattingShift + block.shiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + ui.Offset(x, y), + ui.window.devicePixelRatio, + ); + + WebParagraphDebug.log( + '+_drawAllBlocks: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' + '${block.clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' + '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', + ); + + switch (styleElement) { + case StyleElements.background: + painter.drawBackground(canvas, block, sourceRect, targetRect); + default: + // We only need to draw backgrounds only + assert(false); + } + } + } + } + + void _fillBlockText(TextLayout layout, TextBlock block) { + final int start = block.isLtr + ? block.clusterRangeWithoutWhitespaces.start + : block.clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? block.clusterRangeWithoutWhitespaces.end + : block.clusterRangeWithoutWhitespaces.start - 1; + final step = block.isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = block is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + fillTextCluster( + clusterText, + block is EllipsisBlock + ? block.isLtr + // TODO(jlavrova): override isLtr for ellipsis block? + : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, + ); + } + } + + void _fillBlockShadows(TextLayout layout, TextBlock block) { + if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { + return; + } + + final int start = block.isLtr + ? block.clusterRangeWithoutWhitespaces.start + : block.clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? block.clusterRangeWithoutWhitespaces.end + : block.clusterRangeWithoutWhitespaces.start - 1; + final step = block.isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = block is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + for (final ui.Shadow shadow in clusterText.style.shadows!) { + fillShadowCluster(clusterText, shadow, block.isLtr); + } + } + } + + void _fillBlockDecorations(TextBlock block, ui.Rect sourceRect) { + if (!block.style.hasElement(StyleElements.decorations) || block.style.decoration == null) { + return; + } + paintContext.fillStyle = block.style.getForegroundColor().toCssString(); + + final double thickness = calculateThickness(block.style); + + const DoubleDecorationSpacing = 3.0; + + for (final ui.TextDecoration decoration in [ + ui.TextDecoration.lineThrough, + ui.TextDecoration.underline, + ui.TextDecoration.overline, + ]) { + if (!block.style.decoration!.contains(decoration)) { + continue; + } + + // TODO(jlavrova): Why using these instead of multiplied values? + final double height = block.rawFontBoundingBoxAscent + block.rawFontBoundingBoxDescent; + final double ascent = block.rawFontBoundingBoxAscent; + final double position = calculatePosition(decoration, thickness, height, ascent); + WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); + + final double width = sourceRect.width; + final double x = sourceRect.left; + final double y = sourceRect.top + position; + + paintContext.reset(); + paintContext.lineWidth = thickness; + paintContext.strokeStyle = block.style.decorationColor!.toCssString(); + + switch (block.style.decorationStyle!) { + case ui.TextDecorationStyle.wavy: + calculateWaves(x, y, block.style, sourceRect, thickness); + + case ui.TextDecorationStyle.double: + final double bottom = y + DoubleDecorationSpacing + thickness; + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.moveTo(x, bottom); + paintContext.lineTo(x + width, bottom); + paintContext.stroke(); + WebParagraphDebug.log('double: $x:${x + width}, $y:$bottom'); + + case ui.TextDecorationStyle.dashed: + case ui.TextDecorationStyle.dotted: + final dashes = Float32List(2) + ..[0] = + thickness * (block.style.decorationStyle! == ui.TextDecorationStyle.dotted ? 1 : 4) + ..[1] = thickness; + + paintContext.setLineDash(dashes); + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.stroke(); + WebParagraphDebug.log('dashed/dotted: $x:${x + width}, $y'); + + case ui.TextDecorationStyle.solid: + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.stroke(); + WebParagraphDebug.log( + 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', + ); + } + } + } + + @override + void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr) { + final WebTextStyle style = webTextCluster.style; + paintContext.fillStyle = style.getForegroundColor().toCssString(); + webTextCluster.addToContext(paintContext, 0, 0); + } + + @override + void fillShadowCluster(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr) { + final WebTextStyle style = webTextCluster.style; + + // TODO(jlavrova): see if we can implement shadowing ourself avoiding redrawing text clusters many times. + // Answer: we cannot, and also there is a question of calculating the size of the shadow which we have to + // take from Chrome as well (performing another measure text operation with shadow attribute set). + paintContext.fillStyle = style.getForegroundColor().toCssString(); + paintContext.shadowColor = shadow.color.toCssString(); + paintContext.shadowBlur = shadow.blurRadius; + paintContext.shadowOffsetX = shadow.offset.dx; + paintContext.shadowOffsetY = shadow.offset.dy; + WebParagraphDebug.log( + 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', + ); + + // TODO(jlavrova): calculate the proper shift for the shadow + webTextCluster.addToContext(paintContext, 0, 0); + } + + @override + void paint(ui.Canvas canvas, TextLayout layout, Painter painter, double x, double y) { + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateParagraph( + layout, + ui.Offset(x, y), + ui.window.devicePixelRatio, + ); + // TODO(jlavrova): How resizing affects the cached image? + painter.resizePaintCanvas(ui.window.devicePixelRatio, sourceRect.width, sourceRect.height); + + if (!painter.hasSingleImageCache) { + // Fill out all the blocks on Canvas2D canvas + _fillAllBlocks(StyleElements.shadows, layout); + _fillAllBlocks(StyleElements.text, layout); + _fillAllBlocks(StyleElements.decorations, layout); + + // Draw background blocks directly on the output canvas + _drawAllBlocks(StyleElements.background, canvas, layout, painter, x, y); + } else { + // We already have cached image for the entire paragraph (including the backgrounds) + } + + // Draw the content of Canvas2D on the output canvas + painter.drawParagraph(canvas, sourceRect, targetRect); + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 9957b19d7c1..07efb0acd64 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -2,25 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:typed_data'; - import 'package:ui/ui.dart' as ui; import '../canvaskit/canvaskit_api.dart'; import '../canvaskit/image.dart'; import '../dom.dart'; -import '../util.dart'; import 'debug.dart'; import 'layout.dart'; -import 'paragraph.dart'; - -// TODO(mdebbar): Discuss it: we use this canvas for painting the entire block (entire line) -// so we need to make sure it's big enough to hold the biggest line. -// Also, we use it to paint shadows (with vertical shifts) so we need to make it tall enough as well. -double? currentDevicePixelRatio; -final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(0, 0); -final paintContext = - paintCanvas.getContext('2d', {'willReadFrequently': true})! as DomCanvasRenderingContext2D; +import 'paint.dart'; /// Abstracts the interface for painting text clusters, shadows, and decorations. abstract class Painter { @@ -28,32 +17,20 @@ abstract class Painter { bool get hasSingleImageCache => false; - /// Fills out the information needed to paint the text cluster. - void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr); + /// Draws the previously filled on Canvas2D text cluster + void drawTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - /// Paints the text cluster previously filled by [fillTextCluster]. - void paintTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); + /// Draws the previously filled on Canvas2D text cluster shadow + void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - /// Fills out the information needed to paint the text cluster shadow. - void fillShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr); + /// Draws the background directly on canvas + void drawBackground(ui.Canvas canvas, TextBlock block, ui.Rect sourceRect, ui.Rect targetRect); - /// Paints the text cluster shadow previously filled by [fillShadow]. - void paintShadow(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); + /// Draws the previously filled on Canvas2D text decorations + void drawDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - /// Fills out the information needed to paint the background. - void paintBackground(ui.Canvas canvas, TextBlock block, ui.Rect sourceRect, ui.Rect targetRect); + void drawParagraph(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - /// Fills out the information needed to paint the decorations. - void fillDecorations(TextBlock block, ui.Rect sourceRect); - - /// Paints the decorations previously filled by [fillDecorations]. - void paintDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - - void addTextCluster(WebCluster webTextCluster); - void addShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr); - - void paintTextBlockAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - void paintShadowAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); void resetCache(); bool hasCache(); @@ -92,7 +69,7 @@ class CanvasKitPainter extends Painter { bool get hasSingleImageCache => singleImageCache != null; @override - void paintBackground(ui.Canvas canvas, LineBlock block, ui.Rect sourceRect, ui.Rect targetRect) { + void drawBackground(ui.Canvas canvas, LineBlock block, ui.Rect sourceRect, ui.Rect targetRect) { // We need to snap the block edges because Skia draws rectangles with subpixel accuracy // and we end up with overlaps (this is only a problem when colors have transparency) // or gaps between blocks (which looks unacceptable - vertical lines between blocks). @@ -108,78 +85,7 @@ class CanvasKitPainter extends Painter { } @override - void fillDecorations(TextBlock block, ui.Rect sourceRect) { - paintContext.fillStyle = block.style.getForegroundColor().toCssString(); - - final double thickness = calculateThickness(block.style); - - const DoubleDecorationSpacing = 3.0; - - for (final ui.TextDecoration decoration in [ - ui.TextDecoration.lineThrough, - ui.TextDecoration.underline, - ui.TextDecoration.overline, - ]) { - if (!block.style.decoration!.contains(decoration)) { - continue; - } - - // TODO(jlavrova): Why using these instead of multiplied values? - final double height = block.rawFontBoundingBoxAscent + block.rawFontBoundingBoxDescent; - final double ascent = block.rawFontBoundingBoxAscent; - final double position = calculatePosition(decoration, thickness, height, ascent); - WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); - - final double width = sourceRect.width; - final double x = sourceRect.left; - final double y = sourceRect.top + position; - - paintContext.reset(); - paintContext.lineWidth = thickness; - paintContext.strokeStyle = block.style.decorationColor!.toCssString(); - - switch (block.style.decorationStyle!) { - case ui.TextDecorationStyle.wavy: - calculateWaves(x, y, block.style, sourceRect, thickness); - - case ui.TextDecorationStyle.double: - final double bottom = y + DoubleDecorationSpacing + thickness; - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.moveTo(x, bottom); - paintContext.lineTo(x + width, bottom); - paintContext.stroke(); - WebParagraphDebug.log('double: $x:${x + width}, $y:$bottom'); - - case ui.TextDecorationStyle.dashed: - case ui.TextDecorationStyle.dotted: - final dashes = Float32List(2) - ..[0] = - thickness * (block.style.decorationStyle! == ui.TextDecorationStyle.dotted ? 1 : 4) - ..[1] = thickness; - - paintContext.setLineDash(dashes); - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.stroke(); - WebParagraphDebug.log('dashed/dotted: $x:${x + width}, $y'); - - case ui.TextDecorationStyle.solid: - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.stroke(); - WebParagraphDebug.log( - 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', - ); - } - } - } - - @override - void paintDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + void drawDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); @@ -197,35 +103,7 @@ class CanvasKitPainter extends Painter { } @override - void fillShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr) { - final WebTextStyle style = webTextCluster.style; - - // TODO(jlavrova): see if we can implement shadowing ourself avoiding redrawing text clusters many times. - // Answer: we cannot, and also there is a question of calculating the size of the shadow which we have to - // take from Chrome as well (performing another measure text operation with shadow attribute set). - paintContext.fillStyle = style.getForegroundColor().toCssString(); - paintContext.shadowColor = shadow.color.toCssString(); - paintContext.shadowBlur = shadow.blurRadius; - paintContext.shadowOffsetX = shadow.offset.dx; - paintContext.shadowOffsetY = shadow.offset.dy; - WebParagraphDebug.log( - 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', - ); - - // We fill the text cluster into a rectange [0,0,w,h] - // but we need to shift the y coordinate by the font ascent - // becase the text is drawn at the ascent, not at 0 - webTextCluster.fillOnContext( - paintContext, - /*ignore the text cluster shift from the text run*/ - // TODO(jlavrova): calculate the proper shift for the shadow - x: (isDefaultLtr ? 0 : webTextCluster.advance.width) + 100, - y: 100, - ); - } - - @override - void paintShadow(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { // TODO(jlavrova): calculate the shadow bounds properly final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); final ui.Rect shadowTargetRect = targetRect.inflate(100); @@ -246,22 +124,7 @@ class CanvasKitPainter extends Painter { } @override - void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr) { - final WebTextStyle style = webTextCluster.style; - paintContext.fillStyle = style.getForegroundColor().toCssString(); - // We fill the text cluster into a rectange [0,0,w,h] - // but we need to shift the y coordinate by the font ascent - // becase the text is drawn at the ascent, not at 0 - webTextCluster.fillOnContext( - paintContext, - /*ignore the text cluster shift from the text run*/ - x: (isDefaultLtr ? 0 : webTextCluster.advance.width), - y: 0, - ); - } - - @override - void paintTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + void drawTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); @@ -279,51 +142,19 @@ class CanvasKitPainter extends Painter { } @override - void addTextCluster(WebCluster webTextCluster) { - final WebTextStyle style = webTextCluster.style; - paintContext.fillStyle = style.getForegroundColor().toCssString(); - webTextCluster.addToContext(paintContext, 0, 0); - } - - @override - void addShadow(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr) { - final WebTextStyle style = webTextCluster.style; - - // TODO(jlavrova): see if we can implement shadowing ourself avoiding redrawing text clusters many times. - // Answer: we cannot, and also there is a question of calculating the size of the shadow which we have to - // take from Chrome as well (performing another measure text operation with shadow attribute set). - paintContext.fillStyle = style.getForegroundColor().toCssString(); - paintContext.shadowColor = shadow.color.toCssString(); - paintContext.shadowBlur = shadow.blurRadius; - paintContext.shadowOffsetX = shadow.offset.dx; - paintContext.shadowOffsetY = shadow.offset.dy; - WebParagraphDebug.log( - 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', - ); - - // TODO(jlavrova): calculate the proper shift for the shadow - webTextCluster.addToContext(paintContext, 0, 0); - } - - DomImageBitmap _createSmallBitmapSync(ui.Rect bounds) { - // We should have resized the small canvas before calling this method - if (bounds.width != paintCanvas.width || bounds.height != paintCanvas.height) { - WebParagraphDebug.error( - '_resizePaintCanvas needed: ' - 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${bounds.width}x${bounds.height}', - ); - assert(false); - } - // Transfer the buffer from the small canvas - // This is synchronous and returns the handle immediately - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - return bitmap; - } - - @override - void paintTextBlockAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + void drawParagraph(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { if (!hasSingleImageCache) { - final DomImageBitmap bitmap = _createSmallBitmapSync(sourceRect); + // We should have resized the small canvas before calling this method + if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { + WebParagraphDebug.error( + '_resizePaintCanvas needed: ' + 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', + ); + assert(false); + } + // Transfer the buffer from the small canvas + // This is synchronous and returns the handle immediately + final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); if (skImage == null) { @@ -340,28 +171,6 @@ class CanvasKitPainter extends Painter { ); } - @override - void paintShadowAsSingleImage(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { - // TODO(jlavrova): calculate the shadow bounds properly - final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); - final ui.Rect shadowTargetRect = targetRect.inflate(100); - // TODO(jlavrova): we could cache the shadow image as well but should we?.. - final DomImageBitmap bitmap = _createSmallBitmapSync(shadowSourceRect); - - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - if (skImage == null) { - throw Exception('Failed to convert text image bitmap to an SkImage.'); - } - singleImageCache = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); - - canvas.drawImageRect( - singleImageCache!, - shadowSourceRect, - shadowTargetRect, - ui.Paint()..filterQuality = ui.FilterQuality.none, - ); - } - @override void resetCache() { singleImageCache = null; @@ -371,85 +180,4 @@ class CanvasKitPainter extends Painter { bool hasCache() { return singleImageCache != null; } - - double calculateThickness(WebTextStyle textStyle) { - return (textStyle.fontSize! / 14.0) * (textStyle.decorationThickness ?? 1.0); - } - - double calculatePosition( - ui.TextDecoration decoration, - double thickness, - double height, - double ascent, - ) { - switch (decoration) { - case ui.TextDecoration.underline: - WebParagraphDebug.log( - 'calculatePosition underline: $thickness + $ascent = ${thickness + ascent}', - ); - return thickness + ascent; - case ui.TextDecoration.overline: - WebParagraphDebug.log('calculatePosition overline: 0'); - return thickness / 2; - case ui.TextDecoration.lineThrough: - WebParagraphDebug.log('calculatePosition through: $height / 2 = ${height / 2}'); - return height / 2; - } - return 0; - } - - void calculateWaves( - double x, - double y, - WebTextStyle textStyle, - ui.Rect textBounds, - double thickness, - ) { - final quarterWave = thickness; - - var waveCount = 0; - double xStart = 0; - final double yStart = y + quarterWave; - - WebParagraphDebug.log( - 'calculateWaves($x, $y, ' - '${textBounds.left}:${textBounds.right}x${textBounds.top}:${textBounds.bottom} )' - '$thickness $xStart $yStart', - ); - paintContext.beginPath(); - //paintContext.moveTo(x, y + quarterWave); - while (xStart + quarterWave * 2 < textBounds.width) { - final x1 = xStart; - final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); - final double x2 = xStart + quarterWave * 2; - final y2 = yStart; - WebParagraphDebug.log('wave: $x1, $y1, $x2, $y2'); - paintContext.quadraticCurveTo(x1, y1, x2, y2); - xStart += quarterWave * 2; - ++waveCount; - } - - // The rest of the wave - final double remaining = textBounds.width - xStart; - if (remaining > 0) { - final x1 = xStart; - final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); - //final double y1 = yStart + remaining / 2 * (waveCount.isEven ? 1 : -1); - final double x2 = xStart + remaining; - final y2 = yStart; - //final double y2 = yStart + remaining + remaining / quarterWave * y1; - WebParagraphDebug.log( - 'remaining: ${textBounds.width} - $xStart = $remaining ' - '$x1, $y1, $x2, $y2', - ); - paintContext.quadraticCurveTo(x1, y1, x2, y2); - } - paintContext.stroke(); - } - - void drawLineAsRect(double x, double y, double width, double thickness) { - final double radius = thickness / 2; - paintContext.fillRect(x, y - radius, x + width, y + radius); - WebParagraphDebug.log('paintContext.fillRect($x, $y - $radius, $x + $width, $y + $radius);'); - } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart index 5c811101b5c..aaebca8919e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart @@ -14,6 +14,7 @@ import '../view_embedder/style_manager.dart'; import 'debug.dart'; import 'layout.dart'; import 'paint.dart'; +import 'paint_paragraph.dart'; import 'painter.dart'; @visibleForTesting @@ -969,20 +970,7 @@ class WebParagraph implements ui.Paragraph { } void paint(ui.Canvas canvas, ui.Offset offset) { - final (ui.Rect sourceRect, ui.Rect targetRect) = _paint.calculateParagraph( - _layout, - offset, - ui.window.devicePixelRatio, - ); - _paint.fillAsSingleImage(canvas, _layout, sourceRect, offset); - _paint.paintAsSingleImage(canvas, _layout, sourceRect, targetRect, offset); - } - - void paintOnCanvas2D(DomHTMLCanvasElement canvas, ui.Offset offset) { - _paint.painter.resizePaintCanvas(ui.window.devicePixelRatio, 1000.0, 1000.0); - for (final TextLine line in _layout.lines) { - _paint.paintLineOnCanvas2D(canvas, _layout, line, offset.dx, offset.dy); - } + _paint.paint(canvas, _layout, _painter, offset.dx, offset.dy); } @override @@ -1092,7 +1080,8 @@ class WebParagraph implements ui.Paragraph { } late final TextLayout _layout = TextLayout(this); - late final TextPaint _paint = TextPaint(this, CanvasKitPainter()); + late final TextPaint _paint = PaintParagraph(this); + late final Painter _painter = CanvasKitPainter(); } class WebLineMetrics implements ui.LineMetrics { diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart new file mode 100644 index 00000000000..a48cff59a55 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart @@ -0,0 +1,223 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:web_engine_tester/golden_tester.dart'; + +import '../common/test_initialization.dart'; +import '../ui/utils.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +typedef AsyncAction = Future Function(); + +Future timeActionAsync(String name, AsyncAction action) async { + if (!Profiler.isBenchmarkMode) { + return action(); + } else { + final stopwatch = Stopwatch()..start(); + final R result = await action(); + stopwatch.stop(); + Profiler.instance.benchmark(name, stopwatch.elapsedMicroseconds.toDouble()); + return result; + } +} + +Future testMain() async { + WebParagraphProfiler.register(); + setUpUnitTests(withImplicitView: true, setUpTestViewDimensions: false); + + Future draw( + String image, + String text, + String testName, + int countLayouts, + int countPaints, + ) async { + WebParagraphProfiler.reset(); + final recorder = PictureRecorder(); + const region = Rect.fromLTWH(0, 0, 1000, 1000); + final paragraphs = []; + for (var i = 0; i < countLayouts; i++) { + final Paragraph paragraph = timeAction((i == 0 ? 'build.first' : 'build'), () { + final arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); + final builder = ParagraphBuilder(arialStyle); + builder.pushStyle(TextStyle(color: const Color(0xFF000000))); + builder.addText('$text$i'); + return builder.build(); + }); + paragraphs.add(paragraph); + timeAction((i == 0 ? 'layout.first' : 'layout'), () { + paragraph.layout(const ParagraphConstraints(width: 1000)); + }); + } + for (var j = 0; j < countPaints; ++j) { + for (final paragraph in paragraphs) { + final canvas = Canvas(recorder, region); + canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.src); + await timeActionAsync((j == 0 ? 'paint.first' : 'paint'), () async { + canvas.drawParagraph(paragraph, const Offset(20, 20)); + await drawPictureUsingCurrentRenderer( + recorder.endRecording(), + ); // This is a hack to make sure the canvas is flushed + }); + } + } + + await matchGoldenFile('$image.png', region: region); + WebParagraphProfiler.log(); + } + + test('Dummy test to warm up GPU', () async { + await draw('dummyText', 'Dummy text', 'Dummy text', 1, 1); + }, timeout: Timeout.none); + + test('Build/Layout/Paint small text', () async { + await draw('smallText', 'Abcdef', 'Small text', 10, 100); + }, timeout: Timeout.none); + + test('Build/Layout/Paint medium text', () async { + await draw('mediumText', 'Abcdef ghijkl mnopqrs tuvwxyz.', 'Medium text', 10, 100); + }, timeout: Timeout.none); + + test('Build/Layout/Paint large text', () async { + await draw( + 'largeText', + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz', + 'Large text', + 10, + 100, + ); + }, timeout: Timeout.none); + + test( + 'Paint text by sizes', + () async { + WebParagraphProfiler.reset(); + final recorder = PictureRecorder(); + const region = Rect.fromLTWH(0, 0, 1000, 1000); + for (var textSize = 10; textSize < 1000; textSize += (textSize == 10 ? 40 : 50)) { + final Paragraph paragraph = timeAction('build$textSize', () { + final arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); + final builder = ParagraphBuilder(arialStyle); + builder.pushStyle(TextStyle(color: const Color(0xFF000000))); + builder.addText('0123456789' * (textSize ~/ 10)); + return builder.build(); + }); + timeAction('layout$textSize', () { + paragraph.layout(const ParagraphConstraints(width: 1000)); + }); + final canvas = Canvas(recorder, region); + canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.src); + await timeActionAsync('paint$textSize', () async { + canvas.drawParagraph(paragraph, const Offset(20, 20)); + await drawPictureUsingCurrentRenderer( + recorder.endRecording(), + ); // This is a hack to make sure the canvas is flushed + }); + } + + await matchGoldenFile('textSize.png', region: region); + WebParagraphProfiler.log(); + }, + timeout: Timeout.none, + skip: true, + ); + + /* + test('Subsequent layout small text no cache', () async { + final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); + final ParagraphBuilder builder = ParagraphBuilder(arialStyle); + builder.addText('Small text.'); + final Paragraph paragraph = builder.build(); + final layoutWatch = Stopwatch()..start(); + for (int i = 0; i < count; i++) { + paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); + } + layoutWatch.stop(); + print('layout("Small text#N") * $count executed in ${layoutWatch.elapsed}'); + }); + + test('Subsequent layout medium text no cache', () async { + final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); + final ParagraphBuilder builder = ParagraphBuilder(arialStyle); + builder.addText( + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz.', + ); + final Paragraph paragraph = builder.build(); + final layoutWatch = Stopwatch()..start(); + for (int i = 0; i < count; i++) { + paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); + } + layoutWatch.stop(); + print('layout("{Medium text}*#N") * $count executed in ${layoutWatch.elapsed}'); + }); + + test('Subsequent large medium text no cache', () async { + final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); + final ParagraphBuilder builder = ParagraphBuilder(arialStyle); + builder.addText( + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' + 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz.', + ); + final Paragraph paragraph = builder.build(); + final layoutWatch = Stopwatch()..start(); + for (int i = 0; i < count; i++) { + paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); + } + layoutWatch.stop(); + print('layout("{Large text}*#N") * $count executed in ${layoutWatch.elapsed}'); + }); +*/ +} diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart index f25cc443c33..32fa5db27d9 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart @@ -663,6 +663,7 @@ Future testMain() async { canvas.drawRect(rect.toRect(), bluePaint); } } + { final List rects = paragraph.getBoxesForRange( 0, @@ -674,6 +675,7 @@ Future testMain() async { canvas.drawRect(rect.toRect(), redPaint); } } + { final List rects = paragraph.getBoxesForRange( 0, @@ -993,7 +995,7 @@ Future testMain() async { } await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('ellipsisLTR.png', region: region); - }, solo: true); + }); test('Ellipsis RTL', () async { final recorder = PictureRecorder(); @@ -1030,7 +1032,7 @@ Future testMain() async { } await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('ellipsisRTL.png', region: region); - }, solo: true); + }); test('MaxLines, no ellipsis', () async { final recorder = PictureRecorder(); diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt b/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt new file mode 100644 index 00000000000..d384de89d85 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt @@ -0,0 +1,66 @@ +mall: layout=10, paint=100 +medium: layout=10, paint=100 +large: layout=10, paint=100 + +CPU Text layout for Chrome, single +bool useCPUTextLayout = true; +bool withCacheId = true; +bool singleImagePaint = true; + +Dummy test running first to wark up GPU + +SKPARAGRAPH +=========== +00:06 +1: Build/Layout/Paint small text +build.first: 0ms +layout.first: 0ms +build: 1ms +layout: 0ms +paint.first: 58ms +paint: 4504ms + +00:11 +2: Build/Layout/Paint medium text +build.first: 0ms +layout.first: 0ms +build: 1ms +layout: 0ms +paint.first: 46ms +paint: 4628ms + +00:18 +3: Build/Layout/Paint large text +build.first: 2ms +layout.first: 2ms +build: 7ms +layout: 11ms +paint.first: 77ms +paint: 6203ms + + +WEBPARAGRAPH +============ +00:06 +1: Build/Layout/Paint small text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 2ms +paint.first: 61ms +paint: 4349ms + +00:11 +2: Build/Layout/Paint medium text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 2ms +preroll_frame: 51ms +apply_frame: 10ms +paint.first: 52ms +paint: 4371ms + +00:18 +3: Build/Layout/Paint large text +build.first: 0ms +layout.first: 9ms +build: 0ms +layout: 75ms +paint.first: 572ms +paint: 5977ms + From bf036178ef57f8ddfd3fbf81f67bba4b0ce2096a Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Wed, 28 Jan 2026 11:25:41 -0500 Subject: [PATCH 03/12] Creating SkImage syncronously Didn't matter, the results are even slightly worser --- .../lib/src/engine/web_paragraph/debug.dart | 33 +++++++++ .../lib/src/engine/web_paragraph/painter.dart | 67 +++++++++++++++++++ .../test/ui/paragraph_performance_test.dart | 1 + .../web_ui/test/webparagraph/statistics.txt | 52 ++++++++++++++ 4 files changed, 153 insertions(+) create mode 120000 engine/src/flutter/lib/web_ui/test/ui/paragraph_performance_test.dart diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index a5b0d544a20..fef9bb5dd37 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import '../../engine.dart'; + +typedef Entry = ({String group, String name}); + class WebParagraphDebug { static bool logging = false; static bool apiLogging = false; @@ -38,3 +42,32 @@ class WebParagraphDebug { }()); } } + +class WebParagraphProfiler { + static Map durations = {}; + static Map counts = {}; + + static void register() { + Profiler.ensureInitialized(); + engineBenchmarkValueCallback = (String name, double value) { + counts[name] = (counts[name] ?? 0) + 1; + durations[name] = (durations[name] ?? Duration.zero) + Duration(microseconds: value.toInt()); + }; + } + + static void log() { + for (final MapEntry entry in durations.entries) { + //print('${entry.key}: ${entry.value.inMicroseconds}μs'); + print( + '${entry.key}: ${entry.value.inMilliseconds}ms', + //entry.key.contains('/') + // ? '${entry.key}: ${entry.value.inMilliseconds}ms / ${counts[entry.key] ?? 1} = ${(entry.value.inMilliseconds / (counts[entry.key] ?? 1)).toStringAsFixed(3)}ms' + // : '${entry.key}: ${entry.value.inMilliseconds}ms', + ); + } + } + + static void reset() { + durations = {}; + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 07efb0acd64..43de33655aa 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:ui/ui.dart' as ui; import '../canvaskit/canvaskit_api.dart'; @@ -62,6 +64,9 @@ abstract class Painter { } } +final DomHTMLCanvasElement? _domHtmlCanvasElement = null; + //domDocument.createElement('canvas') as DomHTMLCanvasElement; + class CanvasKitPainter extends Painter { CkImage? singleImageCache; @@ -143,6 +148,68 @@ class CanvasKitPainter extends Painter { @override void drawParagraph(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + if (!hasSingleImageCache) { + // We should have resized the small canvas before calling this method + if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { + WebParagraphDebug.error( + '_resizePaintCanvas needed: ' + 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', + ); + assert(false); + } + + SkImage? skImage; + if (_domHtmlCanvasElement != null) { + _domHtmlCanvasElement!.width = sourceRect.width; + _domHtmlCanvasElement!.height = sourceRect.height; + + final context2D = + _domHtmlCanvasElement!.getContext('2d', {'willReadFrequently': true})! + as DomCanvasRenderingContext2D; + context2D.drawImage(paintCanvas, 0, 0); + + final DomImageData imageData = context2D.getImageData( + 0, + 0, + sourceRect.width.ceil(), + sourceRect.height.ceil(), + ); + + final imageInfo = SkImageInfo( + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + width: sourceRect.width, + height: sourceRect.height, + ); + + skImage = canvasKit.MakeImage( + imageInfo, + Uint8List.view(imageData.data.buffer), + 4 * sourceRect.width, + ); + } else { + // Transfer the buffer from the small canvas + // This is synchronous and returns the handle immediately + final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); + skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); + } + + if (skImage == null) { + throw Exception('Failed to convert text image bitmap to an SkImage.'); + } + singleImageCache = CkImage(skImage); + } + + canvas.drawImageRect( + singleImageCache!, + sourceRect, + targetRect, + ui.Paint()..filterQuality = ui.FilterQuality.none, + ); + } + + void drawParagraph1(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { if (!hasSingleImageCache) { // We should have resized the small canvas before calling this method if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { diff --git a/engine/src/flutter/lib/web_ui/test/ui/paragraph_performance_test.dart b/engine/src/flutter/lib/web_ui/test/ui/paragraph_performance_test.dart new file mode 120000 index 00000000000..5b24b2e4aae --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/ui/paragraph_performance_test.dart @@ -0,0 +1 @@ +../webparagraph/paragraph_performance_test.dart \ No newline at end of file diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt b/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt index d384de89d85..6a59cd36347 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt @@ -64,3 +64,55 @@ layout: 75ms paint.first: 572ms paint: 5977ms + + + +DomElement +00:06 +1: Build/Layout/Paint small text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 2ms +preroll_frame: 66ms +paint: 4474ms + +00:11 +2: Build/Layout/Paint medium text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 1ms +paint.first: 55ms +paint: 4357ms + +00:18 +3: Build/Layout/Paint large text +build.first: 0ms +layout.first: 10ms +build: 0ms +layout: 70ms +paint.first: 561ms +paint: 5909ms + +OffscreenCanvas +00:06 +1: Build/Layout/Paint small text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 2ms +paint.first: 60ms +paint: 4371ms + +00:11 +2: Build/Layout/Paint medium text +build.first: 0ms +layout.first: 0ms +build: 0ms +layout: 2ms +paint.first: 51ms +paint: 4346ms + +00:18 +3: Build/Layout/Paint large text +build.first: 0ms +layout.first: 11ms +build: 0ms +layout: 70ms +paint.first: 565ms +paint: 5801ms From 4e4aaa7ac9c9e626af5dbe63c808e746b54df558 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Wed, 28 Jan 2026 13:34:15 -0500 Subject: [PATCH 04/12] Few shadow changes There still a question of drawing a paragraph with the shadow outside of the paragraph boundaries (see the shadow test) --- .../lib/src/engine/web_paragraph/paint.dart | 126 ++++++++++++++++++ .../engine/web_paragraph/paint_paragraph.dart | 22 +-- .../src/engine/web_paragraph/paragraph.dart | 2 + .../test/webparagraph/paragraph_test.dart | 8 +- 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index fd2a9a98d99..ae0272e3bc7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; import 'dart:typed_data'; import 'package:ui/ui.dart' as ui; @@ -115,6 +116,131 @@ abstract class TextPaint { return (sourceRect, targetRect); } + double calculateShadowOffset( + TextLayout layout, + TextLine line, + LineBlock block, + ShadowDirection direction, + ) { + if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { + return 0; + } + + final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( + layout, + block as TextBlock, + ui.Offset( + line.advance.left + line.formattingShift + block.shiftFromLineStart, + line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ), + ui.Offset.zero, + ui.window.devicePixelRatio, + ); + + for (final ui.Shadow shadow in block.style.shadows!) { + switch (direction) { + case ShadowDirection.left: + if (shadow.offset.dx < 0) { + print('shadow left offset: ${sourceRect.left - 100}'); + return sourceRect.left - 100; + } + case ShadowDirection.right: + if (shadow.offset.dx > 0) { + print('shadow right offset: ${sourceRect.right + 100}'); + return sourceRect.right + 100; + } + case ShadowDirection.top: + if (shadow.offset.dy < 0) { + print('shadow top offset: ${sourceRect.top - 100}'); + return sourceRect.top - 100; + } + case ShadowDirection.bottom: + if (shadow.offset.dy > 0) { + print('shadow bottom offset: ${sourceRect.bottom + 100}'); + return sourceRect.bottom + 100; + } + } + } + return 0; + } + + (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph1( + TextLayout layout, + ui.Offset offset, + double devicePixelRatio, + ) { + // Calculate the line edges taking in account the formatting shifts, shadows, etc. + double minLeft = 0; + double maxRight = paragraph.longestLine; + for (final TextLine line in layout.lines) { + final double left = calculateShadowOffset( + layout, + line, + line.visualBlocks.first, + ShadowDirection.left, + ); + final double right = calculateShadowOffset( + layout, + line, + line.visualBlocks.last, + ShadowDirection.right, + ); + if (left < minLeft) { + minLeft = left; + } + if (right > maxRight) { + maxRight = right; + } + } + double minTop = 0; + for (final LineBlock lineBlock in layout.lines.first.visualBlocks) { + final double top = calculateShadowOffset( + layout, + layout.lines.first, + lineBlock, + ShadowDirection.top, + ); + if (top < minTop) { + minTop = top; + } + } + double maxBottom = paragraph.height; + for (final LineBlock lineBlock in layout.lines.last.visualBlocks) { + final double bottom = calculateShadowOffset( + layout, + layout.lines.last, + lineBlock, + ShadowDirection.bottom, + ); + if (bottom > maxBottom) { + maxBottom = bottom; + } + } + // Define the paragraph rect (using advances, not selected rects) + // Source rect must take in account the scaling + final sourceRect = ui.Rect.fromLTWH( + minLeft * devicePixelRatio, + minTop, + ((maxRight - minLeft) * devicePixelRatio).ceilToDouble(), + ((maxBottom - minTop) * devicePixelRatio).ceilToDouble(), + ); + // Target rect will be scaled by the canvas transform, so we don't scale it here + final zeroRect = ui.Rect.fromLTWH( + math.min(0, minLeft).ceilToDouble(), + math.min(0, minTop).ceilToDouble(), + (maxRight - minLeft).ceilToDouble(), + (maxBottom - minTop).ceilToDouble(), + ); + final ui.Rect targetRect = zeroRect.translate(offset.dx, offset.dy); + + print( + 'calculateParagraph source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' + 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', + ); + + return (sourceRect, targetRect); + } + (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph( TextLayout layout, ui.Offset offset, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart index 82637de1d86..4e4338edf16 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -20,8 +20,6 @@ class PaintParagraph extends TextPaint { for (final TextLine line in layout.lines) { paintContext.save(); paintContext.translate(line.formattingShift, yOffset); - WebParagraphDebug.log('_fillAllBlocks line at ${line.formattingShift}, $yOffset}'); - yOffset += line.advance.height; for (final LineBlock block in line.visualBlocks) { @@ -37,16 +35,24 @@ class PaintParagraph extends TextPaint { ); paintContext.save(); - paintContext.translate( - block.spanShiftFromLineStart, - line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ); switch (styleElement) { case StyleElements.shadows: + // For text and shadows we need to shift to the start of the block + paintContext.translate( + block.spanShiftFromLineStart, + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ); _fillBlockShadows(layout, block); case StyleElements.text: + // For text and shadows we need to shift to the start of the block + paintContext.translate( + block.spanShiftFromLineStart, + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, + ); _fillBlockText(layout, block); case StyleElements.decorations: + // For decorations we need to shift to the start of the line + paintContext.translate(block.shiftFromLineStart, 0); // Let's calculate the sizes final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( layout, @@ -164,7 +170,6 @@ class PaintParagraph extends TextPaint { paintContext.fillStyle = block.style.getForegroundColor().toCssString(); final double thickness = calculateThickness(block.style); - const DoubleDecorationSpacing = 3.0; for (final ui.TextDecoration decoration in [ @@ -186,7 +191,7 @@ class PaintParagraph extends TextPaint { final double x = sourceRect.left; final double y = sourceRect.top + position; - paintContext.reset(); + paintContext.save(); paintContext.lineWidth = thickness; paintContext.strokeStyle = block.style.decorationColor!.toCssString(); @@ -227,6 +232,7 @@ class PaintParagraph extends TextPaint { 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', ); } + paintContext.restore(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart index aaebca8919e..a339e9f09ac 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart @@ -146,6 +146,8 @@ enum StyleElements { text, } +enum ShadowDirection { left, right, top, bottom } + class WebTextStyle implements ui.TextStyle { factory WebTextStyle({ String? fontFamily, diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart index 32fa5db27d9..2f55ae947d4 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart @@ -443,7 +443,7 @@ Future testMain() async { paragraph.paint(canvas, const Offset(50, 50)); await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('web_paragraph_multishadows.png', region: region); - }); + }, solo: true); test('Draw WebParagraph multiple decorations on text', () async { final recorder = PictureRecorder(); @@ -455,7 +455,11 @@ Future testMain() async { const greenColor = Color(0xFF00FF00); const grayColor = Color(0xFF888888); - final paragraphStyle = WebParagraphStyle(fontFamily: 'Roboto', fontSize: 40); + final paragraphStyle = WebParagraphStyle( + fontFamily: 'Roboto', + fontSize: 40, + color: const Color(0xFF000000), + ); final defaultStyle = WebTextStyle(foreground: blackPaint); From 6ce7e4236961dc18282078159c73891f060ea80b Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Tue, 3 Feb 2026 11:49:06 -0500 Subject: [PATCH 05/12] Ellipsis fixed So far all the tests are visually Ok --- .../lib/web_ui/lib/src/engine/web_paragraph/layout.dart | 2 ++ .../web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart | 5 ++++- .../flutter/lib/web_ui/test/webparagraph/paragraph_test.dart | 3 +-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index bb873bff0a5..2071fed5bbd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -412,6 +412,8 @@ class TextLayout { ellipsisBlock.shiftFromLineStart = blockShiftFromLineStart; ellipsisBlock.spanShiftFromLineStart = blockShiftFromLineStart; line.visualBlocks.add(ellipsisBlock); + line.trailingSpacesWidth = 0.0; + blockShiftFromLineStart += ellipsisBlock.advance.width; } else { // We place the ellipsis block aat the beginning of the line (for RTL paragraph) line.visualBlocks.insert(0, ellipsisBlock); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart index 4e4338edf16..622deefab26 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -133,8 +133,11 @@ class PaintParagraph extends TextPaint { fillTextCluster( clusterText, block is EllipsisBlock + // We shape ellipsis with default direction coming from the attaching block + // and all the other blocks with the default paragraph direction. + // The reason for shaping ellipsis this way is that we literally attach it to the block + // that overflows and we want to keep all the styling attributes (including text direction) consistent. ? block.isLtr - // TODO(jlavrova): override isLtr for ellipsis block? : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, ); } diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart index 2f55ae947d4..356b68a19b9 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart @@ -443,7 +443,7 @@ Future testMain() async { paragraph.paint(canvas, const Offset(50, 50)); await drawPictureUsingCurrentRenderer(recorder.endRecording()); await matchGoldenFile('web_paragraph_multishadows.png', region: region); - }, solo: true); + }); test('Draw WebParagraph multiple decorations on text', () async { final recorder = PictureRecorder(); @@ -989,7 +989,6 @@ Future testMain() async { { final builder = WebParagraphBuilder(paragraphStyle); - builder.pushStyle(style30); builder.addText('This is a long text that should be ellipsized at the end'); builder.pop(); From e43e55b4cecd50510005c7b92a74004e1afcbf94 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Tue, 3 Feb 2026 12:50:45 -0500 Subject: [PATCH 06/12] Fixing getBoxes again --- .../webparagraph/paragraph_get_boxes_test.dart | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart index 03cee0e4194..52cff2b2b6e 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_get_boxes_test.dart @@ -167,11 +167,21 @@ Future testMain() async { final ui.TextBox top0 = rectsTop[0]; final ui.TextBox top1 = rectsTop[1]; + final ui.TextBox bottom0 = rectsBottom[0]; final ui.TextBox bottom1 = rectsBottom[1]; + final ui.TextBox middle0 = rectsMiddle[0]; final ui.TextBox middle1 = rectsMiddle[1]; - expect((top0.bottom - bottom1.top).abs() < EPSILON, true); - expect(middle1.top > bottom1.top, true); - expect(middle1.top < top1.top, true); + + expect((top0.top - bottom0.top).abs() < EPSILON, true); + expect((top0.top - middle0.top).abs() < EPSILON, true); + expect(top0.bottom < middle0.bottom, true); + expect(middle0.bottom < bottom0.bottom, true); + + expect((top0.bottom - top1.top).abs() < EPSILON, true); + expect((middle0.bottom - middle1.top).abs() < EPSILON, true); + expect((bottom0.bottom - bottom1.top).abs() < EPSILON, true); + expect(top1.bottom < middle1.bottom, true); + expect(middle1.bottom < bottom1.bottom, true); }); test('Paragraph getBoxesForRange 1 finite line', () { From 782b15bd4b8eabc1c9a3b107de7429498cf77b20 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 5 Feb 2026 10:41:41 -0500 Subject: [PATCH 07/12] Addressing code review comments --- .../lib/src/engine/web_paragraph/layout.dart | 6 +++--- .../engine/web_paragraph/paint_clusters.dart | 3 +-- .../lib/src/engine/web_paragraph/painter.dart | 18 +++++++++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index 2071fed5bbd..82103eabdd2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -1039,7 +1039,7 @@ class EmptyCluster extends WebCluster { @override void fillOnContext(DomCanvasRenderingContext2D context, {required double x, required double y}) { - assert(false, 'We should not call fillOnContext method on this object'); + assert(false, 'We should not call "fillOnContext" on an EmptyCluster'); } @override @@ -1049,7 +1049,7 @@ class EmptyCluster extends WebCluster { @override void addToContext(DomCanvasRenderingContext2D context, double x, double y) { - assert(false, 'We should not call addToContext method on this object'); + assert(false, 'We should not call "fillOnContext" on an EmptyCluster'); } } @@ -1081,7 +1081,7 @@ class PlaceholderCluster extends WebCluster { @override void addToContext(DomCanvasRenderingContext2D context, double x, double y) { - assert(false, 'We should not call addToContext method on this object'); + assert(false, 'We should not call "addToContext" on an PlaceholderCluster'); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart index e12770b4182..9e1509ee6db 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart @@ -85,8 +85,7 @@ class PaintClusters extends TextPaint { continue; } // Placeholders do not need painting, just reserving the space - if (block.clusterRange.size == 1 && - layout.allClusters[block.clusterRange.start] is PlaceholderCluster) { + if (block is PlaceholderBlock) { continue; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 43de33655aa..2569b778758 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -38,6 +38,10 @@ abstract class Painter { /// Adjust the _paintCanvas scale based on device pixel ratio void resizePaintCanvas(double devicePixelRatio, double width, double height) { + // TODO(jlavrova): we need to investigate different approaches to resizing the canvas. + // 1. Do we resize to 0, 0 at the end of each paint so we do not hold on to large buffers? + // 2. Do we keep the canvas around (even big ones) and only resize when needed? + // 3. Do we have a max size and reuse the canvas up to that size? if (currentDevicePixelRatio == devicePixelRatio && paintCanvas.width == (width * devicePixelRatio).ceilToDouble() && paintCanvas.height == (height * devicePixelRatio).ceilToDouble()) { @@ -65,7 +69,7 @@ abstract class Painter { } final DomHTMLCanvasElement? _domHtmlCanvasElement = null; - //domDocument.createElement('canvas') as DomHTMLCanvasElement; +//domDocument.createElement('canvas') as DomHTMLCanvasElement; class CanvasKitPainter extends Painter { CkImage? singleImageCache; @@ -151,11 +155,11 @@ class CanvasKitPainter extends Painter { if (!hasSingleImageCache) { // We should have resized the small canvas before calling this method if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { - WebParagraphDebug.error( - '_resizePaintCanvas needed: ' + assert( + false, + 'resizePaintCanvas needed: ' 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', ); - assert(false); } SkImage? skImage; @@ -213,11 +217,11 @@ class CanvasKitPainter extends Painter { if (!hasSingleImageCache) { // We should have resized the small canvas before calling this method if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { - WebParagraphDebug.error( - '_resizePaintCanvas needed: ' + assert( + false, + 'resizePaintCanvas needed: ' 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', ); - assert(false); } // Transfer the buffer from the small canvas // This is synchronous and returns the handle immediately From c005984ec26828d4704a16284ad0291ef32a6258 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 5 Feb 2026 11:55:47 -0500 Subject: [PATCH 08/12] Addressing Gemini codereview comments --- .../lib/src/engine/web_paragraph/debug.dart | 20 ++--- .../lib/src/engine/web_paragraph/layout.dart | 2 +- .../lib/src/engine/web_paragraph/paint.dart | 89 ++----------------- .../engine/web_paragraph/paint_clusters.dart | 3 +- .../engine/web_paragraph/paint_paragraph.dart | 76 +--------------- .../lib/src/engine/web_paragraph/painter.dart | 37 ++------ .../paragraph_performance_test.dart | 68 -------------- 7 files changed, 23 insertions(+), 272 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index fef9bb5dd37..d1d777f0f76 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -11,12 +11,12 @@ class WebParagraphDebug { static bool apiLogging = false; static void log(String arg) { - //assert(() { - if (logging) { - print(arg); - } - // return true; - //}()); + assert(() { + if (logging) { + print(arg); + } + return true; + }()); } static void apiTrace(String arg) { @@ -57,13 +57,7 @@ class WebParagraphProfiler { static void log() { for (final MapEntry entry in durations.entries) { - //print('${entry.key}: ${entry.value.inMicroseconds}μs'); - print( - '${entry.key}: ${entry.value.inMilliseconds}ms', - //entry.key.contains('/') - // ? '${entry.key}: ${entry.value.inMilliseconds}ms / ${counts[entry.key] ?? 1} = ${(entry.value.inMilliseconds / (counts[entry.key] ?? 1)).toStringAsFixed(3)}ms' - // : '${entry.key}: ${entry.value.inMilliseconds}ms', - ); + print('${entry.key}: ${entry.value.inMilliseconds}ms'); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index 6eb1e909f28..4b00fb4cef8 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -1059,7 +1059,7 @@ class EmptyCluster extends WebCluster { @override void addToContext(DomCanvasRenderingContext2D context, double x, double y) { - assert(false, 'We should not call "fillOnContext" on an EmptyCluster'); + assert(false, 'We should not call "addToContext" on an EmptyCluster'); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index ae0272e3bc7..2b0a44b0b46 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -141,22 +141,18 @@ abstract class TextPaint { switch (direction) { case ShadowDirection.left: if (shadow.offset.dx < 0) { - print('shadow left offset: ${sourceRect.left - 100}'); return sourceRect.left - 100; } case ShadowDirection.right: if (shadow.offset.dx > 0) { - print('shadow right offset: ${sourceRect.right + 100}'); return sourceRect.right + 100; } case ShadowDirection.top: if (shadow.offset.dy < 0) { - print('shadow top offset: ${sourceRect.top - 100}'); return sourceRect.top - 100; } case ShadowDirection.bottom: if (shadow.offset.dy > 0) { - print('shadow bottom offset: ${sourceRect.bottom + 100}'); return sourceRect.bottom + 100; } } @@ -164,83 +160,6 @@ abstract class TextPaint { return 0; } - (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph1( - TextLayout layout, - ui.Offset offset, - double devicePixelRatio, - ) { - // Calculate the line edges taking in account the formatting shifts, shadows, etc. - double minLeft = 0; - double maxRight = paragraph.longestLine; - for (final TextLine line in layout.lines) { - final double left = calculateShadowOffset( - layout, - line, - line.visualBlocks.first, - ShadowDirection.left, - ); - final double right = calculateShadowOffset( - layout, - line, - line.visualBlocks.last, - ShadowDirection.right, - ); - if (left < minLeft) { - minLeft = left; - } - if (right > maxRight) { - maxRight = right; - } - } - double minTop = 0; - for (final LineBlock lineBlock in layout.lines.first.visualBlocks) { - final double top = calculateShadowOffset( - layout, - layout.lines.first, - lineBlock, - ShadowDirection.top, - ); - if (top < minTop) { - minTop = top; - } - } - double maxBottom = paragraph.height; - for (final LineBlock lineBlock in layout.lines.last.visualBlocks) { - final double bottom = calculateShadowOffset( - layout, - layout.lines.last, - lineBlock, - ShadowDirection.bottom, - ); - if (bottom > maxBottom) { - maxBottom = bottom; - } - } - // Define the paragraph rect (using advances, not selected rects) - // Source rect must take in account the scaling - final sourceRect = ui.Rect.fromLTWH( - minLeft * devicePixelRatio, - minTop, - ((maxRight - minLeft) * devicePixelRatio).ceilToDouble(), - ((maxBottom - minTop) * devicePixelRatio).ceilToDouble(), - ); - // Target rect will be scaled by the canvas transform, so we don't scale it here - final zeroRect = ui.Rect.fromLTWH( - math.min(0, minLeft).ceilToDouble(), - math.min(0, minTop).ceilToDouble(), - (maxRight - minLeft).ceilToDouble(), - (maxBottom - minTop).ceilToDouble(), - ); - final ui.Rect targetRect = zeroRect.translate(offset.dx, offset.dy); - - print( - 'calculateParagraph source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' - 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', - ); - - return (sourceRect, targetRect); - } - (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph( TextLayout layout, ui.Offset offset, @@ -355,7 +274,11 @@ abstract class TextPaint { paintContext.stroke(); } + // TODO(jlavrova): implement decorations entirely on the resulting Canvas void fillDecorations(TextBlock block, ui.Rect sourceRect) { + if (!block.style.hasElement(StyleElements.decorations) || block.style.decoration == null) { + return; + } paintContext.fillStyle = block.style.getForegroundColor().toCssString(); final double thickness = calculateThickness(block.style); @@ -381,7 +304,7 @@ abstract class TextPaint { final double x = sourceRect.left; final double y = sourceRect.top + position; - paintContext.reset(); + paintContext.save(); paintContext.lineWidth = thickness; paintContext.strokeStyle = block.style.decorationColor!.toCssString(); @@ -422,6 +345,8 @@ abstract class TextPaint { 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', ); } + + paintContext.restore(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart index 9e1509ee6db..2bc8832dd36 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart @@ -199,7 +199,8 @@ class PaintClusters extends TextPaint { webTextCluster.fillOnContext( paintContext, /*ignore the text cluster shift from the text run*/ - // TODO(jlavrova): calculate the proper shift for the shadow + // TODO(jlavrova): calculate the shadow bounds without hardcoding the inflation + // values. It is good enough for now to demonstrate the shadow effect x: (isDefaultLtr ? 0 : webTextCluster.advance.width) + 100, y: 100, ); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart index 622deefab26..c9daae32c0e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -61,7 +61,7 @@ class PaintParagraph extends TextPaint { ui.Offset.zero, // We only need sourceRect here so we don't need the offset ui.window.devicePixelRatio, ); - _fillBlockDecorations(block, sourceRect); + fillDecorations(block, sourceRect); default: // We only need to draw backgrounds only assert(false); @@ -166,79 +166,6 @@ class PaintParagraph extends TextPaint { } } - void _fillBlockDecorations(TextBlock block, ui.Rect sourceRect) { - if (!block.style.hasElement(StyleElements.decorations) || block.style.decoration == null) { - return; - } - paintContext.fillStyle = block.style.getForegroundColor().toCssString(); - - final double thickness = calculateThickness(block.style); - const DoubleDecorationSpacing = 3.0; - - for (final ui.TextDecoration decoration in [ - ui.TextDecoration.lineThrough, - ui.TextDecoration.underline, - ui.TextDecoration.overline, - ]) { - if (!block.style.decoration!.contains(decoration)) { - continue; - } - - // TODO(jlavrova): Why using these instead of multiplied values? - final double height = block.rawFontBoundingBoxAscent + block.rawFontBoundingBoxDescent; - final double ascent = block.rawFontBoundingBoxAscent; - final double position = calculatePosition(decoration, thickness, height, ascent); - WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); - - final double width = sourceRect.width; - final double x = sourceRect.left; - final double y = sourceRect.top + position; - - paintContext.save(); - paintContext.lineWidth = thickness; - paintContext.strokeStyle = block.style.decorationColor!.toCssString(); - - switch (block.style.decorationStyle!) { - case ui.TextDecorationStyle.wavy: - calculateWaves(x, y, block.style, sourceRect, thickness); - - case ui.TextDecorationStyle.double: - final double bottom = y + DoubleDecorationSpacing + thickness; - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.moveTo(x, bottom); - paintContext.lineTo(x + width, bottom); - paintContext.stroke(); - WebParagraphDebug.log('double: $x:${x + width}, $y:$bottom'); - - case ui.TextDecorationStyle.dashed: - case ui.TextDecorationStyle.dotted: - final dashes = Float32List(2) - ..[0] = - thickness * (block.style.decorationStyle! == ui.TextDecorationStyle.dotted ? 1 : 4) - ..[1] = thickness; - - paintContext.setLineDash(dashes); - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.stroke(); - WebParagraphDebug.log('dashed/dotted: $x:${x + width}, $y'); - - case ui.TextDecorationStyle.solid: - paintContext.beginPath(); - paintContext.moveTo(x, y); - paintContext.lineTo(x + width, y); - paintContext.stroke(); - WebParagraphDebug.log( - 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', - ); - } - paintContext.restore(); - } - } - @override void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr) { final WebTextStyle style = webTextCluster.style; @@ -262,7 +189,6 @@ class PaintParagraph extends TextPaint { 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', ); - // TODO(jlavrova): calculate the proper shift for the shadow webTextCluster.addToContext(paintContext, 0, 0); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 2569b778758..26742fb24cf 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -68,8 +68,9 @@ abstract class Painter { } } -final DomHTMLCanvasElement? _domHtmlCanvasElement = null; -//domDocument.createElement('canvas') as DomHTMLCanvasElement; +const DomHTMLCanvasElement? _domHtmlCanvasElement = null; +// TODO(jlavrova): uncommend the next line if you want to use an alternative approach +// final DomHTMLCanvasElement? _domHtmlCanvasElement = domDocument.createElement('canvas') as DomHTMLCanvasElement; class CanvasKitPainter extends Painter { CkImage? singleImageCache; @@ -113,7 +114,8 @@ class CanvasKitPainter extends Painter { @override void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { - // TODO(jlavrova): calculate the shadow bounds properly + // TODO(jlavrova): calculate the shadow bounds without hardcoding the inflation + // values. It is good enough for now to demonstrate the shadow effect final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); final ui.Rect shadowTargetRect = targetRect.inflate(100); @@ -213,35 +215,6 @@ class CanvasKitPainter extends Painter { ); } - void drawParagraph1(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { - if (!hasSingleImageCache) { - // We should have resized the small canvas before calling this method - if (sourceRect.width != paintCanvas.width || sourceRect.height != paintCanvas.height) { - assert( - false, - 'resizePaintCanvas needed: ' - 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', - ); - } - // Transfer the buffer from the small canvas - // This is synchronous and returns the handle immediately - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - if (skImage == null) { - throw Exception('Failed to convert text image bitmap to an SkImage.'); - } - singleImageCache = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); - } - - canvas.drawImageRect( - singleImageCache!, - sourceRect, - targetRect, - ui.Paint()..filterQuality = ui.FilterQuality.none, - ); - } - @override void resetCache() { singleImageCache = null; diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart index a48cff59a55..ba6a9e000fe 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart @@ -152,72 +152,4 @@ Future testMain() async { timeout: Timeout.none, skip: true, ); - - /* - test('Subsequent layout small text no cache', () async { - final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); - final ParagraphBuilder builder = ParagraphBuilder(arialStyle); - builder.addText('Small text.'); - final Paragraph paragraph = builder.build(); - final layoutWatch = Stopwatch()..start(); - for (int i = 0; i < count; i++) { - paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); - } - layoutWatch.stop(); - print('layout("Small text#N") * $count executed in ${layoutWatch.elapsed}'); - }); - - test('Subsequent layout medium text no cache', () async { - final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); - final ParagraphBuilder builder = ParagraphBuilder(arialStyle); - builder.addText( - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz.', - ); - final Paragraph paragraph = builder.build(); - final layoutWatch = Stopwatch()..start(); - for (int i = 0; i < count; i++) { - paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); - } - layoutWatch.stop(); - print('layout("{Medium text}*#N") * $count executed in ${layoutWatch.elapsed}'); - }); - - test('Subsequent large medium text no cache', () async { - final ParagraphStyle arialStyle = ParagraphStyle(fontFamily: 'Roboto', fontSize: 20); - final ParagraphBuilder builder = ParagraphBuilder(arialStyle); - builder.addText( - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. ' - 'Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz. Abcdef ghijkl mnopqrs tuvwxyz.', - ); - final Paragraph paragraph = builder.build(); - final layoutWatch = Stopwatch()..start(); - for (int i = 0; i < count; i++) { - paragraph.layout(ParagraphConstraints(width: 495 + (i.isEven ? 0 : 5))); - } - layoutWatch.stop(); - print('layout("{Large text}*#N") * $count executed in ${layoutWatch.elapsed}'); - }); -*/ } From 03b7c6686ee19da75b6d85783fa854225c167828 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 5 Feb 2026 13:16:57 -0500 Subject: [PATCH 09/12] Fixing Gemini code review comments --- .../lib/src/engine/web_paragraph/debug.dart | 9 +++ .../lib/src/engine/web_paragraph/layout.dart | 2 +- .../lib/src/engine/web_paragraph/paint.dart | 61 +++++-------------- 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index d1d777f0f76..040b39b4af3 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -6,10 +6,12 @@ import '../../engine.dart'; typedef Entry = ({String group, String name}); +/// Debugging utilities for WebParagraph. class WebParagraphDebug { static bool logging = false; static bool apiLogging = false; + /// Logs a debug message if logging is enabled. static void log(String arg) { assert(() { if (logging) { @@ -19,6 +21,7 @@ class WebParagraphDebug { }()); } + /// Logs an API trace message if API logging is enabled. static void apiTrace(String arg) { assert(() { if (apiLogging || logging) { @@ -28,6 +31,7 @@ class WebParagraphDebug { }()); } + /// Logs an API warning message. static void warning(String arg) { assert(() { print('WARNING: $arg'); @@ -35,6 +39,7 @@ class WebParagraphDebug { }()); } + /// Logs an API error message. static void error(String arg) { assert(() { print('ERROR: $arg'); @@ -43,10 +48,12 @@ class WebParagraphDebug { } } +/// Profiler for WebParagraph related operations. class WebParagraphProfiler { static Map durations = {}; static Map counts = {}; + /// static void register() { Profiler.ensureInitialized(); engineBenchmarkValueCallback = (String name, double value) { @@ -55,12 +62,14 @@ class WebParagraphProfiler { }; } + /// Logs the collected profiling information to the console. static void log() { for (final MapEntry entry in durations.entries) { print('${entry.key}: ${entry.value.inMilliseconds}ms'); } } + /// Resets the collected profiling information. static void reset() { durations = {}; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index 4b00fb4cef8..02213b7f330 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -425,7 +425,7 @@ class TextLayout { line.trailingSpacesWidth = 0.0; blockShiftFromLineStart += ellipsisBlock.advance.width; } else { - // We place the ellipsis block aat the beginning of the line (for RTL paragraph) + // We place the ellipsis block at the beginning of the line (for RTL paragraph) line.visualBlocks.insert(0, ellipsisBlock); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index 2b0a44b0b46..fcd9be05c24 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -25,6 +25,7 @@ abstract class TextPaint { final WebParagraph paragraph; + /// Calculates the source (on Canvas2D) and target (on the output canvas) rectangles for a text cluster (ui.Rect sourceRect, ui.Rect targetRect) calculateCluster( TextLayout layout, LineBlock block, @@ -77,6 +78,7 @@ abstract class TextPaint { return (sourceRect, targetRect); } + /// Calculates the source (on Canvas2D) and target (on the output canvas) rectangles for a text block (ui.Rect sourceRect, ui.Rect targetRect) calculateBlock( TextLayout layout, TextBlock block, @@ -116,50 +118,7 @@ abstract class TextPaint { return (sourceRect, targetRect); } - double calculateShadowOffset( - TextLayout layout, - TextLine line, - LineBlock block, - ShadowDirection direction, - ) { - if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { - return 0; - } - - final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( - layout, - block as TextBlock, - ui.Offset( - line.advance.left + line.formattingShift + block.shiftFromLineStart, - line.advance.top + line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, - ), - ui.Offset.zero, - ui.window.devicePixelRatio, - ); - - for (final ui.Shadow shadow in block.style.shadows!) { - switch (direction) { - case ShadowDirection.left: - if (shadow.offset.dx < 0) { - return sourceRect.left - 100; - } - case ShadowDirection.right: - if (shadow.offset.dx > 0) { - return sourceRect.right + 100; - } - case ShadowDirection.top: - if (shadow.offset.dy < 0) { - return sourceRect.top - 100; - } - case ShadowDirection.bottom: - if (shadow.offset.dy > 0) { - return sourceRect.bottom + 100; - } - } - } - return 0; - } - + /// Calculates the source (on Canvas2D) and target (on the output canvas) rectangles for the entire paragraph (ui.Rect sourceRect, ui.Rect targetRect) calculateParagraph( TextLayout layout, ui.Offset offset, @@ -199,10 +158,12 @@ abstract class TextPaint { return (sourceRect, targetRect); } + /// Calculates the thickness of the decoration line double calculateThickness(WebTextStyle textStyle) { return (textStyle.fontSize! / 14.0) * (textStyle.decorationThickness ?? 1.0); } + /// Calculates the position of the decoration line double calculatePosition( ui.TextDecoration decoration, double thickness, @@ -225,6 +186,7 @@ abstract class TextPaint { return 0; } + /// Calculates and the position of the decoration line and paints it on Canvas2D void calculateWaves( double x, double y, @@ -244,7 +206,7 @@ abstract class TextPaint { '$thickness $xStart $yStart', ); paintContext.beginPath(); - //paintContext.moveTo(x, y + quarterWave); + paintContext.moveTo(x, yStart); while (xStart + quarterWave * 2 < textBounds.width) { final x1 = xStart; final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); @@ -275,6 +237,7 @@ abstract class TextPaint { } // TODO(jlavrova): implement decorations entirely on the resulting Canvas + /// Paints text decorations on Canvas2D void fillDecorations(TextBlock block, ui.Rect sourceRect) { if (!block.style.hasElement(StyleElements.decorations) || block.style.decoration == null) { return; @@ -295,8 +258,9 @@ abstract class TextPaint { } // TODO(jlavrova): Why using these instead of multiplied values? - final double height = block.rawFontBoundingBoxAscent + block.rawFontBoundingBoxDescent; - final double ascent = block.rawFontBoundingBoxAscent; + final double height = + block.multipliedFontBoundingBoxAscent + block.multipliedFontBoundingBoxDescent; + final double ascent = block.multipliedFontBoundingBoxAscent; final double position = calculatePosition(decoration, thickness, height, ascent); WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); @@ -350,9 +314,12 @@ abstract class TextPaint { } } + /// Paints shadows of a text cluster on Canvas2D void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr); + /// Paints shadows of a text cluster on Canvas2D void fillShadowCluster(WebCluster webTextCluster, ui.Shadow shadow, bool isDefaultLtr); + /// Paints the entire paragraph on Canvas2D void paint(ui.Canvas canvas, TextLayout layout, Painter painter, double x, double y); } From 575e708e1797c421c5fca81452d11f678598d42b Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Tue, 10 Feb 2026 12:54:56 -0500 Subject: [PATCH 10/12] Addressing Gemini code review comments --- .../flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart | 2 +- .../flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart | 1 - .../web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index 040b39b4af3..8518ceaab5e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -53,7 +53,7 @@ class WebParagraphProfiler { static Map durations = {}; static Map counts = {}; - /// + /// Register an engine benchmark callback to collect profiling data for WebParagraph operations. static void register() { Profiler.ensureInitialized(); engineBenchmarkValueCallback = (String name, double value) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index fcd9be05c24..6d62c9d8e31 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -257,7 +257,6 @@ abstract class TextPaint { continue; } - // TODO(jlavrova): Why using these instead of multiplied values? final double height = block.multipliedFontBoundingBoxAscent + block.multipliedFontBoundingBoxDescent; final double ascent = block.multipliedFontBoundingBoxAscent; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart index c9daae32c0e..62051effb05 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -209,6 +209,7 @@ class PaintParagraph extends TextPaint { _fillAllBlocks(StyleElements.decorations, layout); // Draw background blocks directly on the output canvas + // so it will be cached together with the text blocks on Canvas2D canvas _drawAllBlocks(StyleElements.background, canvas, layout, painter, x, y); } else { // We already have cached image for the entire paragraph (including the backgrounds) From 3fd92303327acb8722b65cc51102b1679a23acd4 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 12 Feb 2026 11:01:57 -0500 Subject: [PATCH 11/12] Response to code review comments After several attempts to compare performance on DomHTMLElementCanvas vs OffscreenCanvas I conclude that there is no difference in performance. Leaving it for now with OffscreenCanvas (and sync MakeImage, too). --- .../lib/src/engine/web_paragraph/layout.dart | 112 +++++++++-------- .../lib/src/engine/web_paragraph/paint.dart | 59 +++------ .../engine/web_paragraph/paint_clusters.dart | 43 ++++--- .../engine/web_paragraph/paint_paragraph.dart | 68 ++++++---- .../lib/src/engine/web_paragraph/painter.dart | 93 +++++++------- .../src/engine/web_paragraph/paragraph.dart | 8 +- .../lib/src/engine/web_paragraph/wrapper.dart | 32 +---- .../test/webparagraph/paragraph_test.dart | 2 +- .../web_ui/test/webparagraph/statistics.txt | 118 ------------------ 9 files changed, 206 insertions(+), 329 deletions(-) delete mode 100644 engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart index 02213b7f330..c53c96a89f7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart @@ -143,15 +143,11 @@ class TextLayout { paragraph.paragraphStyle.textDirection, ); - WebParagraphDebug.log('Bidis ${paragraph.paragraphStyle.textDirection}:${regions.length}'); for (final region in regions) { // Regions operate in text indexes, not cluster indexes (one cluster can contain several text points) // We need to convert one into another final ClusterRange clusterRange = _mapping.toClusterRange(region.start, region.end); final run = BidiRun(clusterRange, region.level); - WebParagraphDebug.log( - 'region ${region.level.isEven ? 'ltr' : 'rtl'} [${region.start}:${region.end}) => $clusterRange', - ); bidiRuns.add(run); } } @@ -333,10 +329,6 @@ class TextLayout { // This is the intersection of the bidi region + line + span. final ui.TextRange bidiLineSpanTextRange = bidiLineTextRange.intersect(span); - WebParagraphDebug.log( - 'Style: ${span as ui.TextRange} & $bidiLineTextRange = $bidiLineSpanTextRange ', - ); - final ClusterRange bidiLineSpanRange = _mapping.toClusterRange( bidiLineSpanTextRange.start, bidiLineSpanTextRange.end, @@ -384,9 +376,6 @@ class TextLayout { (line.visualBlocks.last as TextBlock).clusterRangeWithoutWhitespaces = _mapping .toClusterRange(blockLineNoWhitespaces.start, blockLineNoWhitespaces.end); (line.visualBlocks.last as TextBlock).whitespacesWidth = trailingSpacesWidth; - WebParagraphDebug.log( - 'TRAILING: $bidiLineSpanTextRange $blockLineNoWhitespaces $trailingSpacesWidth', - ); } // Line always counts multipled metrics (no need for the others) @@ -440,11 +429,6 @@ class TextLayout { // TODO(jlavrova): sort our alphabetic/ideographic baseline and how it affects ascent & descent line.fontBoundingBoxAscent = math.max(line.fontBoundingBoxAscent, block.ascent); line.fontBoundingBoxDescent = math.max(line.fontBoundingBoxDescent, block.descent); - WebParagraphDebug.log( - 'Adjusted metrics: ' - '${line.fontBoundingBoxAscent} => ${math.max(line.fontBoundingBoxAscent, block.ascent)} ' - '${line.fontBoundingBoxDescent} => ${math.max(line.fontBoundingBoxDescent, block.descent)} ', - ); } line.advance = ui.Rect.fromLTWH( @@ -458,11 +442,6 @@ class TextLayout { line.trailingSpacesWidth = trailingSpacesWidth; lines.add(line); - WebParagraphDebug.log( - 'Line [${line.textClusterRange.start}:${line.textClusterRange.end}) ${line.advance.left},${line.advance.top} ${line.advance.width}x${line.advance.height} ' - '${ellipsisClusters.isNotEmpty ? 'Ellipsis: "${paragraph.paragraphStyle.ellipsis}" ${ellipsisClusters.length}' : ''}', - ); - return line.advance.height; } @@ -515,10 +494,12 @@ class TextLayout { // TODO(mdebbar): Instead of nested loops, make them two consecutive loops. for (var lineIndex = 0; lineIndex < lines.length; ++lineIndex) { final TextLine line = lines[lineIndex]; - WebParagraphDebug.log( - 'Line: ${line.textClusterRange} & $textRange ' - '[${line.advance.left}:${line.advance.right} x ${line.advance.top}:${line.advance.bottom}] ', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'Line: ${line.textClusterRange} & $textRange ' + '[${line.advance.left}:${line.advance.right} x ${line.advance.top}:${line.advance.bottom}] ', + ); + } // We take whitespaces in account if (!line.allLineTextRange.overlapsWith(start, end)) { continue; @@ -526,14 +507,12 @@ class TextLayout { for (final LineBlock block in line.visualBlocks) { final ui.TextRange intersect = block.textRange.intersect(textRange); - //if (boxWidthStyle == ui.BoxWidthStyle.tight) { - // // Ignore whitespaces at the end of the line - // intersect = intersect.intersect(line.textRange); - //} - WebParagraphDebug.log( - 'block: ${block.textRange} & $textRange = $intersect ' - '${block.span.start}', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'block: ${block.textRange} & $textRange = $intersect ' + '${block.span.start}', + ); + } if (intersect.size <= 0) { continue; } @@ -639,11 +618,13 @@ class TextLayout { ); } } - WebParagraphDebug.log( - 'getBoxesForRange: [${line.advance.left}:${line.advance.right}x${line.advance.top}:${line.advance.bottom}]', - ); - for (final rect in result) { - WebParagraphDebug.log('[${rect.left}:${rect.right}x${rect.top}:${rect.bottom}]'); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'getBoxesForRange: [${line.advance.left}:${line.advance.right}x${line.advance.top}:${line.advance.bottom}]', + ); + for (final rect in result) { + WebParagraphDebug.log('[${rect.left}:${rect.right}x${rect.top}:${rect.bottom}]'); + } } } return result; @@ -671,9 +652,11 @@ class TextLayout { ); } } - WebParagraphDebug.log('getBoxesForPlaceholders:'); - for (final rect in result) { - WebParagraphDebug.log('[${rect.left}:${rect.right}x${rect.top}:${rect.bottom}]'); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log('getBoxesForPlaceholders:'); + for (final rect in result) { + WebParagraphDebug.log('[${rect.left}:${rect.right}x${rect.top}:${rect.bottom}]'); + } } return result; } @@ -703,7 +686,9 @@ class TextLayout { // We are not there yet; we need a line closest to the offset. continue; } - WebParagraphDebug.log('found line: ${line.textClusterRange} ${line.advance} vs $offset'); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log('found line: ${line.textClusterRange} ${line.advance} vs $offset'); + } // We found the line that contains the offset; let's go through all the visual blocks to find the position final double lineShift = line.advance.left + line.formattingShift; @@ -722,7 +707,9 @@ class TextLayout { ); } - WebParagraphDebug.log('found block: $block $left:$right vs $offset'); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log('found block: $block $left:$right vs $offset'); + } // Found the block; let's go through all the clusters IN VISUAL ORDER to find the position final int start = block.isLtr ? block.clusterRange.start : block.clusterRange.end - 1; final int end = block.isLtr ? block.clusterRange.end : block.clusterRange.start - 1; @@ -735,7 +722,9 @@ class TextLayout { final double right = cluster.advance.right + lineShift + block.spanShiftFromLineStart + epsilon; - WebParagraphDebug.log('test cluster: $left:$right vs $offset'); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log('test cluster: $left:$right vs $offset'); + } if (left <= offset.dx && right > offset.dx) { if (offset.dx - left <= right - offset.dx) { return ui.TextPosition(offset: cluster.start); @@ -1049,7 +1038,7 @@ class EmptyCluster extends WebCluster { @override void fillOnContext(DomCanvasRenderingContext2D context, {required double x, required double y}) { - assert(false, 'We should not call "fillOnContext" on an EmptyCluster'); + throw UnsupportedError('We should not call "fillOnContext" on an EmptyCluster'); } @override @@ -1059,7 +1048,7 @@ class EmptyCluster extends WebCluster { @override void addToContext(DomCanvasRenderingContext2D context, double x, double y) { - assert(false, 'We should not call "addToContext" on an EmptyCluster'); + throw UnsupportedError('We should not call "addToContext" on an EmptyCluster'); } } @@ -1091,7 +1080,7 @@ class PlaceholderCluster extends WebCluster { @override void addToContext(DomCanvasRenderingContext2D context, double x, double y) { - assert(false, 'We should not call "addToContext" on an PlaceholderCluster'); + throw UnsupportedError('We should not call "addToContext" on an PlaceholderCluster'); } } @@ -1174,6 +1163,31 @@ class TextBlock extends LineBlock { // TODO(jlavrova): Why are we defaulting to 1.0? In Chrome, the default line-height is `1.2` most of the time. double get _heightMultiplier => style.height == null ? 1.0 : style.height!; + int get visualClusterStart => isLtr ? clusterRange.start : clusterRange.end - 1; + int get visualClusterEnd => isLtr ? clusterRange.end : clusterRange.start - 1; + + /// Returns a list of pairs of clusters and their directions in the visual order. + Iterable<(WebCluster, bool)> getTextClustersInVisualOrder(TextLayout layout) sync* { + final int start = visualClusterStart; + final int end = visualClusterEnd; + final step = isLtr ? 1 : -1; + for (var i = start; i != end; i += step) { + final WebCluster clusterText = this is EllipsisBlock + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + yield ( + clusterText, + // We shape ellipsis with default direction coming from the attaching block + // and all the other blocks with the default paragraph direction. + // The reason for shaping ellipsis this way is that we literally attach it to the block + // that overflows and we want to keep all the styling attributes (including text direction) consistent. + this is EllipsisBlock + ? isLtr + : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, + ); + } + } + ClusterRange clusterRangeWithoutWhitespaces; double whitespacesWidth; } @@ -1209,9 +1223,6 @@ class PlaceholderBlock extends LineBlock { final double height = span.height; final double offset = span.baselineOffset; - WebParagraphDebug.log( - 'calculatePlaceholderAdvance($lineAscent, $lineDescent): height=$height offset=$offset', - ); switch (span.alignment) { case ui.PlaceholderAlignment.baseline: // Matches the baseline of the placeholder with the text baseline. You'll need to specify the TextBaseline to use @@ -1249,7 +1260,6 @@ class PlaceholderBlock extends LineBlock { // The advance needs to be calculated relative to the line. In order to do that, we need to start // from the span's own advance within the line. advance = ui.Rect.fromLTWH(spanShiftFromLineStart, top, span.width, span.height); - WebParagraphDebug.log('PlaceholderBlock calculated advance: $advance $ascent $descent'); } // TODO(jlavrova): Why are we using separate properties instead of `rawFontBoundingBoxAscent` and `rawFontBoundingBoxDescent`? diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart index 6d62c9d8e31..889e386bd9e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint.dart @@ -2,18 +2,18 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; import 'dart:typed_data'; - import 'package:ui/ui.dart' as ui; - import '../../engine.dart'; -// TODO(mdebbar): Discuss it: we use this canvas for painting the entire block (entire line) -// so we need to make sure it's big enough to hold the biggest line. -// Also, we use it to paint shadows (with vertical shifts) so we need to make it tall enough as well. +// TODO(jlavrova): We use it to paint shadows (with vertical shifts) so we need to make it tall enough as well. double? currentDevicePixelRatio; -final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(0, 0); +//final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(0, 0); +//final paintContext = +// paintCanvas.getContext('2d', {'willReadFrequently': true})! as DomCanvasRenderingContext2D; + +final DomHTMLCanvasElement paintCanvas = + domDocument.createElement('canvas') as DomHTMLCanvasElement; final paintContext = paintCanvas.getContext('2d', {'willReadFrequently': true})! as DomCanvasRenderingContext2D; @@ -109,11 +109,13 @@ abstract class TextPaint { .translate(blockOffset.dx, blockOffset.dy) .translate(paragraphOffset.dx, paragraphOffset.dy); - WebParagraphDebug.log( - 'calculateBlock "${block.span.text}" ${block.textRange}-${block.span.start} ${block.clusterRange} ' - 'source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' - 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'calculateBlock "${block.span.text}" ${block.textRange}-${block.span.start} ${block.clusterRange} ' + 'source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' + 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', + ); + } return (sourceRect, targetRect); } @@ -150,10 +152,12 @@ abstract class TextPaint { ); final ui.Rect targetRect = zeroRect.translate(offset.dx, offset.dy); - WebParagraphDebug.log( - 'calculateParagraph source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' - 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'calculateParagraph source: ${sourceRect.left}:${sourceRect.right}x${sourceRect.top}:${sourceRect.bottom} => ' + 'target: ${targetRect.left}:${targetRect.right}x${targetRect.top}:${targetRect.bottom}', + ); + } return (sourceRect, targetRect); } @@ -172,15 +176,10 @@ abstract class TextPaint { ) { switch (decoration) { case ui.TextDecoration.underline: - WebParagraphDebug.log( - 'calculatePosition underline: $thickness + $ascent = ${thickness + ascent}', - ); return thickness + ascent; case ui.TextDecoration.overline: - WebParagraphDebug.log('calculatePosition overline: 0'); return thickness / 2; case ui.TextDecoration.lineThrough: - WebParagraphDebug.log('calculatePosition through: $height / 2 = ${height / 2}'); return height / 2; } return 0; @@ -200,11 +199,6 @@ abstract class TextPaint { double xStart = 0; final double yStart = y + quarterWave; - WebParagraphDebug.log( - 'calculateWaves($x, $y, ' - '${textBounds.left}:${textBounds.right}x${textBounds.top}:${textBounds.bottom} )' - '$thickness $xStart $yStart', - ); paintContext.beginPath(); paintContext.moveTo(x, yStart); while (xStart + quarterWave * 2 < textBounds.width) { @@ -212,7 +206,6 @@ abstract class TextPaint { final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); final double x2 = xStart + quarterWave * 2; final y2 = yStart; - WebParagraphDebug.log('wave: $x1, $y1, $x2, $y2'); paintContext.quadraticCurveTo(x1, y1, x2, y2); xStart += quarterWave * 2; ++waveCount; @@ -223,14 +216,8 @@ abstract class TextPaint { if (remaining > 0) { final x1 = xStart; final double y1 = yStart + quarterWave * (waveCount.isEven ? 1 : -1); - //final double y1 = yStart + remaining / 2 * (waveCount.isEven ? 1 : -1); final double x2 = xStart + remaining; final y2 = yStart; - //final double y2 = yStart + remaining + remaining / quarterWave * y1; - WebParagraphDebug.log( - 'remaining: ${textBounds.width} - $xStart = $remaining ' - '$x1, $y1, $x2, $y2', - ); paintContext.quadraticCurveTo(x1, y1, x2, y2); } paintContext.stroke(); @@ -261,7 +248,6 @@ abstract class TextPaint { block.multipliedFontBoundingBoxAscent + block.multipliedFontBoundingBoxDescent; final double ascent = block.multipliedFontBoundingBoxAscent; final double position = calculatePosition(decoration, thickness, height, ascent); - WebParagraphDebug.log('decoration=$decoration thickness=$thickness position=$position'); final double width = sourceRect.width; final double x = sourceRect.left; @@ -283,7 +269,6 @@ abstract class TextPaint { paintContext.moveTo(x, bottom); paintContext.lineTo(x + width, bottom); paintContext.stroke(); - WebParagraphDebug.log('double: $x:${x + width}, $y:$bottom'); case ui.TextDecorationStyle.dashed: case ui.TextDecorationStyle.dotted: @@ -297,16 +282,12 @@ abstract class TextPaint { paintContext.moveTo(x, y); paintContext.lineTo(x + width, y); paintContext.stroke(); - WebParagraphDebug.log('dashed/dotted: $x:${x + width}, $y'); case ui.TextDecorationStyle.solid: paintContext.beginPath(); paintContext.moveTo(x, y); paintContext.lineTo(x + width, y); paintContext.stroke(); - WebParagraphDebug.log( - 'solid: $x:${x + width}, $y ${block.style.decorationColor!.toCssString()}', - ); } paintContext.restore(); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart index 2bc8832dd36..a2c1f163694 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart @@ -46,11 +46,13 @@ class PaintClusters extends TextPaint { ui.window.devicePixelRatio, ); - WebParagraphDebug.log( - '+_paintByBlocks: ${block.textRange} ${block.spanShiftFromLineStart} ${block.shiftFromLineStart} ' - '${line.advance} + ${line.formattingShift} ' - '\nsourceRect: $sourceRect targetRect: $targetRect', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + '+_paintByBlocks: ${block.textRange} ${block.spanShiftFromLineStart} ${block.shiftFromLineStart} ' + '${line.advance} + ${line.formattingShift} ' + '\nsourceRect: $sourceRect targetRect: $targetRect', + ); + } // Let's draw whatever has to be drawn switch (styleElement) { case StyleElements.background: @@ -63,8 +65,10 @@ class PaintClusters extends TextPaint { ); fillDecorations(block, sourceRect); painter.drawDecorations(canvas, sourceRect, targetRect); - default: - assert(false); + case StyleElements.text: + throw Exception('Text should be drawn by clusters, not blocks'); + case StyleElements.shadows: + throw Exception('Shadows should be drawn by clusters, not blocks'); } } } @@ -89,18 +93,20 @@ class PaintClusters extends TextPaint { continue; } - WebParagraphDebug.log( - '+paintByClusters: ${block.textRange} ${block.clusterRange} ${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ${block.isLtr} ${line.advance.left} + ${line.formattingShift} + ${block.shiftFromLineStart}', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + '+paintByClusters: ${block.textRange} ${block.clusterRange} ${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ${block.isLtr} ${line.advance.left} + ${line.formattingShift} + ${block.shiftFromLineStart}', + ); + } // We are painting clusters in visual order so that if they step on each other, the paint // order is correct. final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; + ? (block as TextBlock).clusterRangeWithoutWhitespaces.start + : (block as TextBlock).clusterRangeWithoutWhitespaces.end - 1; final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; + ? (block as TextBlock).clusterRangeWithoutWhitespaces.end + : (block as TextBlock).clusterRangeWithoutWhitespaces.start - 1; final step = block.isLtr ? 1 : -1; for (var i = start; i != end; i += step) { final WebCluster clusterText = block is EllipsisBlock @@ -153,8 +159,10 @@ class PaintClusters extends TextPaint { : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, ); painter.drawTextCluster(canvas, sourceRect, targetRect); - default: - assert(false); + case StyleElements.background: + throw Exception('Background should be drawn by blocks, not clusters'); + case StyleElements.decorations: + throw Exception('Decorations should be drawn by blocks, not clusters'); } } } @@ -189,9 +197,6 @@ class PaintClusters extends TextPaint { paintContext.shadowBlur = shadow.blurRadius; paintContext.shadowOffsetX = shadow.offset.dx; paintContext.shadowOffsetY = shadow.offset.dy; - WebParagraphDebug.log( - 'Shadow: x=${shadow.offset.dx} y=${shadow.offset.dy} blur=${shadow.blurRadius} color=${shadow.color.toCssString()}', - ); // We fill the text cluster into a rectange [0,0,w,h] // but we need to shift the y coordinate by the font ascent diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart index 62051effb05..eefc5be4b7d 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -28,11 +28,13 @@ class PaintParagraph extends TextPaint { continue; } - WebParagraphDebug.log( - '+_fillAllBlocks: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' - '${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' - '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + '+_fillAllBlocks: ${block.textRange} ${block.clusterRange} ${paragraph.getText(block.textRange.start, block.textRange.end)} ' + '${(block as TextBlock).clusterRangeWithoutWhitespaces} ${block.whitespacesWidth} ' + '${block.isLtr} ${line.advance.left} + ${block.spanShiftFromLineStart}', + ); + } paintContext.save(); switch (styleElement) { @@ -42,29 +44,30 @@ class PaintParagraph extends TextPaint { block.spanShiftFromLineStart, line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, ); - _fillBlockShadows(layout, block); + _fillBlockShadows(layout, block as TextBlock); case StyleElements.text: // For text and shadows we need to shift to the start of the block paintContext.translate( block.spanShiftFromLineStart, line.fontBoundingBoxAscent - block.rawFontBoundingBoxAscent, ); - _fillBlockText(layout, block); + _fillBlockText(layout, block as TextBlock); case StyleElements.decorations: // For decorations we need to shift to the start of the line paintContext.translate(block.shiftFromLineStart, 0); // Let's calculate the sizes final (ui.Rect sourceRect, ui.Rect targetRect) = calculateBlock( layout, - block, + block as TextBlock, ui.Offset(line.advance.left + line.formattingShift, line.advance.top), ui.Offset.zero, // We only need sourceRect here so we don't need the offset ui.window.devicePixelRatio, ); fillDecorations(block, sourceRect); - default: - // We only need to draw backgrounds only - assert(false); + case StyleElements.background: + throw Exception( + 'Background is drawn directly on the output canvas, not on the canvas2D', + ); } paintContext.restore(); } @@ -109,21 +112,30 @@ class PaintParagraph extends TextPaint { switch (styleElement) { case StyleElements.background: painter.drawBackground(canvas, block, sourceRect, targetRect); - default: - // We only need to draw backgrounds only - assert(false); + case StyleElements.decorations: + throw Exception( + 'Decorations are painted on the canvas2D and then drawn as an image on the output canvas, not drawn directly on the output canvas', + ); + case StyleElements.shadows: + throw Exception( + 'Shadows are painted on the canvas2D and then drawn as an image on the output canvas, not drawn directly on the output canvas', + ); + case StyleElements.text: + throw Exception( + 'Texts are painted on the canvas2D and then drawn as an image on the output canvas, not drawn directly on the output canvas', + ); } } } } void _fillBlockText(TextLayout layout, TextBlock block) { - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; + for (final (WebCluster clusterText, bool isLtr) in block.getTextClustersInVisualOrder(layout)) { + fillTextCluster(clusterText, isLtr); + } + /* + final int start = block.visualClusterStart; + final int end = block.visualClusterEnd; final step = block.isLtr ? 1 : -1; for (var i = start; i != end; i += step) { final WebCluster clusterText = block is EllipsisBlock @@ -141,6 +153,7 @@ class PaintParagraph extends TextPaint { : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, ); } + */ } void _fillBlockShadows(TextLayout layout, TextBlock block) { @@ -148,12 +161,14 @@ class PaintParagraph extends TextPaint { return; } - final int start = block.isLtr - ? block.clusterRangeWithoutWhitespaces.start - : block.clusterRangeWithoutWhitespaces.end - 1; - final int end = block.isLtr - ? block.clusterRangeWithoutWhitespaces.end - : block.clusterRangeWithoutWhitespaces.start - 1; + for (final (WebCluster clusterText, bool isLtr) in block.getTextClustersInVisualOrder(layout)) { + for (final ui.Shadow shadow in clusterText.style.shadows!) { + fillShadowCluster(clusterText, shadow, isLtr); + } + } + /* + final int start = block.visualClusterStart; + final int end = block.visualClusterEnd; final step = block.isLtr ? 1 : -1; for (var i = start; i != end; i += step) { final WebCluster clusterText = block is EllipsisBlock @@ -164,6 +179,7 @@ class PaintParagraph extends TextPaint { fillShadowCluster(clusterText, shadow, block.isLtr); } } + */ } @override diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 26742fb24cf..166bfa6e05d 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -49,6 +49,11 @@ abstract class Painter { return; } + if (currentDevicePixelRatio != devicePixelRatio) { + // We need to reset the scale transform whenever the device pixel ratio changes + resetCache(); + } + // Since the output canvas is zoomed by device pixel ratio, // we need to adjust our offscreen canvas accordingly to avoid pixelation // that would happen if didn't resize it. @@ -62,9 +67,11 @@ abstract class Painter { currentDevicePixelRatio = devicePixelRatio; - WebParagraphDebug.log( - 'resizePaintCanvas: ${paintCanvas.width}x${paintCanvas.height} @ $devicePixelRatio', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'resizePaintCanvas: ${paintCanvas.width}x${paintCanvas.height} @ $devicePixelRatio', + ); + } } } @@ -73,10 +80,10 @@ const DomHTMLCanvasElement? _domHtmlCanvasElement = null; // final DomHTMLCanvasElement? _domHtmlCanvasElement = domDocument.createElement('canvas') as DomHTMLCanvasElement; class CanvasKitPainter extends Painter { - CkImage? singleImageCache; + CkImage? _singleImageCache; @override - bool get hasSingleImageCache => singleImageCache != null; + bool get hasSingleImageCache => _singleImageCache != null; @override void drawBackground(ui.Canvas canvas, LineBlock block, ui.Rect sourceRect, ui.Rect targetRect) { @@ -96,6 +103,8 @@ class CanvasKitPainter extends Painter { @override void drawDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Decoration drawing is not implemented yet'); + /* final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); @@ -110,10 +119,13 @@ class CanvasKitPainter extends Painter { targetRect, ui.Paint()..filterQuality = ui.FilterQuality.none, ); + */ } @override void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Shadow drawing is not implemented yet'); + /* // TODO(jlavrova): calculate the shadow bounds without hardcoding the inflation // values. It is good enough for now to demonstrate the shadow effect final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); @@ -132,10 +144,13 @@ class CanvasKitPainter extends Painter { shadowTargetRect, ui.Paint()..filterQuality = ui.FilterQuality.none, ); + */ } @override void drawTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Text cluster drawing is not implemented yet'); + /* final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); @@ -150,6 +165,7 @@ class CanvasKitPainter extends Painter { targetRect, ui.Paint()..filterQuality = ui.FilterQuality.none, ); + */ } @override @@ -163,52 +179,40 @@ class CanvasKitPainter extends Painter { 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', ); } + final DomImageData imageData = paintContext.getImageData( + 0, + 0, + sourceRect.width.ceil(), + sourceRect.height.ceil(), + ); - SkImage? skImage; - if (_domHtmlCanvasElement != null) { - _domHtmlCanvasElement!.width = sourceRect.width; - _domHtmlCanvasElement!.height = sourceRect.height; + final imageInfo = SkImageInfo( + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + width: sourceRect.width, + height: sourceRect.height, + ); - final context2D = - _domHtmlCanvasElement!.getContext('2d', {'willReadFrequently': true})! - as DomCanvasRenderingContext2D; - context2D.drawImage(paintCanvas, 0, 0); + final SkImage? skImage = canvasKit.MakeImage( + imageInfo, + Uint8List.view(imageData.data.buffer), + 4 * sourceRect.width, + ); - final DomImageData imageData = context2D.getImageData( - 0, - 0, - sourceRect.width.ceil(), - sourceRect.height.ceil(), - ); - - final imageInfo = SkImageInfo( - alphaType: canvasKit.AlphaType.Premul, - colorType: canvasKit.ColorType.RGBA_8888, - colorSpace: SkColorSpaceSRGB, - width: sourceRect.width, - height: sourceRect.height, - ); - - skImage = canvasKit.MakeImage( - imageInfo, - Uint8List.view(imageData.data.buffer), - 4 * sourceRect.width, - ); - } else { - // Transfer the buffer from the small canvas - // This is synchronous and returns the handle immediately - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - } + // Transfer the buffer from the small canvas + // This is synchronous and returns the handle immediately + //final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); + //final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); if (skImage == null) { throw Exception('Failed to convert text image bitmap to an SkImage.'); } - singleImageCache = CkImage(skImage); + _singleImageCache = CkImage(skImage); } canvas.drawImageRect( - singleImageCache!, + _singleImageCache!, sourceRect, targetRect, ui.Paint()..filterQuality = ui.FilterQuality.none, @@ -217,11 +221,14 @@ class CanvasKitPainter extends Painter { @override void resetCache() { - singleImageCache = null; + if (_singleImageCache != null) { + _singleImageCache!.dispose(); + _singleImageCache = null; + } } @override bool hasCache() { - return singleImageCache != null; + return _singleImageCache != null; } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart index 5ad19432e39..c2d3852be3b 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart @@ -1288,9 +1288,11 @@ class WebParagraphBuilder implements ui.ParagraphBuilder { final text = _fullTextBuffer.toString(); final paragraph = WebParagraph(_paragraphStyle, _spans, text); - WebParagraphDebug.apiTrace('WebParagraphBuilder.build(): "$text" ${_spans.length}'); - for (var i = 0; i < _spans.length; ++i) { - WebParagraphDebug.log('$i: ${_spans[i]}'); + if (WebParagraphDebug.apiLogging) { + WebParagraphDebug.apiTrace('WebParagraphBuilder.build(): "$text" ${_spans.length}'); + for (var i = 0; i < _spans.length; ++i) { + WebParagraphDebug.log('$i: ${_spans[i]}'); + } } return paragraph; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/wrapper.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/wrapper.dart index fddf9293202..43cd8a56a4f 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/wrapper.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/wrapper.dart @@ -58,8 +58,6 @@ class TextWrapper { if (hardLineBreak) { // Break the line and then continue with the current cluster as usual - WebParagraphDebug.log('isHardLineBreak: $index'); - line.consumePendingText(); // This is the case when the ellipsis will be added to the empty line; weird... @@ -70,7 +68,6 @@ class TextWrapper { } } else if (_isSoftLineBreak(cluster) && line.isNotEmpty) { // Mark the potential line break and then continue with the current cluster as usual - WebParagraphDebug.log('isSoftLineBreak: $index'); if (line.hasLeadingWhitespaces) { // There is one case when we have to ignore this soft line break: if we only had whitespaces so far - // these are the leading spaces and Flutter wants them to be preserved @@ -164,20 +161,6 @@ class TextWrapper { _top +=_layout.addLine(emptyClusterRange, 0.0, emptyClusterRange, 0.0, false, _top,); } */ - /* - if (WebParagraphDebug.logging) { - for (int i = 0; i < _layout.lines.length; ++i) { - final TextLine line = _layout.lines[i]; - final String text = _text.substring(line.textRange.start, line.textRange.end); - final String whitespaces = - !line.whitespacesRange.isEmpty ? '${line.whitespacesRange.width}' : 'no'; - final String hardLineBreak = line.hardLineBreak ? 'hardlineBreak' : ''; - WebParagraphDebug.log( - '$i: "$text" [${line.textRange.start}:${line.textRange.end}) $width $hardLineBreak ($whitespaces trailing whitespaces)', - ); - } - } - */ } } @@ -412,8 +395,9 @@ class _LineBuilder { // We have removed all the clusters in this line and still can't fit the ellipsis // Not sure what to do in this case // TODO(jlavrova): Implement this case - assert(false, 'Ellipsizing requires removing the whole line, not implemented yet'); - return false; + throw UnimplementedError( + 'Ellipsizing requires removing the whole line, not implemented yet', + ); } final WebCluster cluster = _layout.allClusters[clusterIndex - 1]; final double widthCluster = cluster.advance.width; @@ -426,33 +410,23 @@ class _LineBuilder { ? ui.TextDirection.ltr : ui.TextDirection.rtl, ); - WebParagraphDebug.log( - 'Ellipsize: $clusterIndex $_widthConsumedText $_widthWhitespaces $_widthPendingText - $cutOffWidth - $widthCluster + ${ellipsisSpan.advanceWidth()!} ??? $_maxWidth', - ); cutOffWidth += widthCluster; if (_isWhitespace(cluster)) { // We skip whitespaces when cutting off for ellipsis, so just continue - WebParagraphDebug.log('Ellipsize: whitespace'); } else if (canFit(ellipsisSpan.advanceWidth()! - cutOffWidth)) { - WebParagraphDebug.log('Ellipsize: stop $clusterIndex'); // We can fit the ellipsis now _layout.ellipsisClusters = ellipsisSpan.extractClusters(); break; - } else { - WebParagraphDebug.log('Ellipsize: continue $clusterIndex'); } // Remove this cluster, correct the structures and try again clusterIndex -= 1; if (clusterIndex >= _whitespaceEnd) { - WebParagraphDebug.log('Ellipsize: pending text >= $_whitespaceEnd'); _widthPendingText -= widthCluster; _pendingTextEnd = clusterIndex; } else if (clusterIndex >= _whitespaceStart) { - WebParagraphDebug.log('Ellipsize: whitespaces => $_whitespaceStart'); _widthWhitespaces -= widthCluster; _whitespaceEnd = clusterIndex; } else { - WebParagraphDebug.log('Ellipsize: consumed text >= $start'); _widthConsumedText -= widthCluster; _whitespaceStart = clusterIndex; _whitespaceEnd = clusterIndex; diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart index 356b68a19b9..7d43c217923 100644 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_test.dart @@ -390,7 +390,7 @@ Future testMain() async { Shadow(color: Color(0xFF00FF00), offset: Offset(0, -10), blurRadius: 2.0), Shadow(color: Color(0xFFFF0000), offset: Offset(-10, 0), blurRadius: 2.0), Shadow(color: Color(0xFF0000FF), offset: Offset(10, 0), blurRadius: 2.0), - Shadow(color: Color(0xFF888888), offset: Offset(0, 10), blurRadius: 2.0), + Shadow(color: Color(0xFFFF00FF), offset: Offset(0, 10), blurRadius: 2.0), ], ); final leftShadow = WebTextStyle( diff --git a/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt b/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt deleted file mode 100644 index 6a59cd36347..00000000000 --- a/engine/src/flutter/lib/web_ui/test/webparagraph/statistics.txt +++ /dev/null @@ -1,118 +0,0 @@ -mall: layout=10, paint=100 -medium: layout=10, paint=100 -large: layout=10, paint=100 - -CPU Text layout for Chrome, single -bool useCPUTextLayout = true; -bool withCacheId = true; -bool singleImagePaint = true; - -Dummy test running first to wark up GPU - -SKPARAGRAPH -=========== -00:06 +1: Build/Layout/Paint small text -build.first: 0ms -layout.first: 0ms -build: 1ms -layout: 0ms -paint.first: 58ms -paint: 4504ms - -00:11 +2: Build/Layout/Paint medium text -build.first: 0ms -layout.first: 0ms -build: 1ms -layout: 0ms -paint.first: 46ms -paint: 4628ms - -00:18 +3: Build/Layout/Paint large text -build.first: 2ms -layout.first: 2ms -build: 7ms -layout: 11ms -paint.first: 77ms -paint: 6203ms - - -WEBPARAGRAPH -============ -00:06 +1: Build/Layout/Paint small text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 2ms -paint.first: 61ms -paint: 4349ms - -00:11 +2: Build/Layout/Paint medium text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 2ms -preroll_frame: 51ms -apply_frame: 10ms -paint.first: 52ms -paint: 4371ms - -00:18 +3: Build/Layout/Paint large text -build.first: 0ms -layout.first: 9ms -build: 0ms -layout: 75ms -paint.first: 572ms -paint: 5977ms - - - - -DomElement -00:06 +1: Build/Layout/Paint small text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 2ms -preroll_frame: 66ms -paint: 4474ms - -00:11 +2: Build/Layout/Paint medium text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 1ms -paint.first: 55ms -paint: 4357ms - -00:18 +3: Build/Layout/Paint large text -build.first: 0ms -layout.first: 10ms -build: 0ms -layout: 70ms -paint.first: 561ms -paint: 5909ms - -OffscreenCanvas -00:06 +1: Build/Layout/Paint small text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 2ms -paint.first: 60ms -paint: 4371ms - -00:11 +2: Build/Layout/Paint medium text -build.first: 0ms -layout.first: 0ms -build: 0ms -layout: 2ms -paint.first: 51ms -paint: 4346ms - -00:18 +3: Build/Layout/Paint large text -build.first: 0ms -layout.first: 11ms -build: 0ms -layout: 70ms -paint.first: 565ms -paint: 5801ms From 19188b4e6e1c10e11706fe7e70f5e703588d5ad8 Mon Sep 17 00:00:00 2001 From: Julia Lavrova Date: Thu, 19 Feb 2026 12:38:09 -0500 Subject: [PATCH 12/12] Fixing few minor details --- .../lib/src/engine/web_paragraph/debug.dart | 7 +++ .../lib/src/engine/web_paragraph/painter.dart | 52 ------------------- 2 files changed, 7 insertions(+), 52 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart index 8518ceaab5e..fe1f54664b6 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/debug.dart @@ -55,6 +55,9 @@ class WebParagraphProfiler { /// Register an engine benchmark callback to collect profiling data for WebParagraph operations. static void register() { + if (!Profiler.isBenchmarkMode) { + return; + } Profiler.ensureInitialized(); engineBenchmarkValueCallback = (String name, double value) { counts[name] = (counts[name] ?? 0) + 1; @@ -64,6 +67,9 @@ class WebParagraphProfiler { /// Logs the collected profiling information to the console. static void log() { + if (!Profiler.isBenchmarkMode) { + return; + } for (final MapEntry entry in durations.entries) { print('${entry.key}: ${entry.value.inMilliseconds}ms'); } @@ -72,5 +78,6 @@ class WebParagraphProfiler { /// Resets the collected profiling information. static void reset() { durations = {}; + counts = {}; } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart index 166bfa6e05d..f2dfe719dfb 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/painter.dart @@ -104,68 +104,16 @@ class CanvasKitPainter extends Painter { @override void drawDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { throw UnimplementedError('Decoration drawing is not implemented yet'); - /* - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - if (skImage == null) { - throw Exception('Failed to convert text image bitmap to an SkImage.'); - } - - final ckImage = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); - canvas.drawImageRect( - ckImage, - sourceRect, - targetRect, - ui.Paint()..filterQuality = ui.FilterQuality.none, - ); - */ } @override void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { throw UnimplementedError('Shadow drawing is not implemented yet'); - /* - // TODO(jlavrova): calculate the shadow bounds without hardcoding the inflation - // values. It is good enough for now to demonstrate the shadow effect - final ui.Rect shadowSourceRect = sourceRect.inflate(100).translate(100, 100); - final ui.Rect shadowTargetRect = targetRect.inflate(100); - - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - if (skImage == null) { - throw Exception('Failed to convert text image bitmap to an SkImage.'); - } - final ckImage = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); - canvas.drawImageRect( - ckImage, - shadowSourceRect, - shadowTargetRect, - ui.Paint()..filterQuality = ui.FilterQuality.none, - ); - */ } @override void drawTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { throw UnimplementedError('Text cluster drawing is not implemented yet'); - /* - final DomImageBitmap bitmap = paintCanvas.transferToImageBitmap(); - - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); - if (skImage == null) { - throw Exception('Failed to convert text image bitmap to an SkImage.'); - } - - final ckImage = CkImage(skImage, imageSource: ImageBitmapImageSource(bitmap)); - canvas.drawImageRect( - ckImage, - sourceRect, - targetRect, - ui.Paint()..filterQuality = ui.FilterQuality.none, - ); - */ } @override