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 170adb214ff..16c490c1250 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -167,6 +167,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..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 @@ -2,10 +2,16 @@ // 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}); + +/// 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) { @@ -15,6 +21,7 @@ class WebParagraphDebug { }()); } + /// Logs an API trace message if API logging is enabled. static void apiTrace(String arg) { assert(() { if (apiLogging || logging) { @@ -24,6 +31,7 @@ class WebParagraphDebug { }()); } + /// Logs an API warning message. static void warning(String arg) { assert(() { print('WARNING: $arg'); @@ -31,6 +39,7 @@ class WebParagraphDebug { }()); } + /// Logs an API error message. static void error(String arg) { assert(() { print('ERROR: $arg'); @@ -38,3 +47,37 @@ class WebParagraphDebug { }()); } } + +/// Profiler for WebParagraph related operations. +class WebParagraphProfiler { + static Map durations = {}; + static Map counts = {}; + + /// 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; + durations[name] = (durations[name] ?? Duration.zero) + Duration(microseconds: value.toInt()); + }; + } + + /// 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'); + } + } + + /// Resets the collected profiling information. + static void reset() { + durations = {}; + counts = {}; + } +} 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 2e2bfde432e..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) @@ -422,8 +411,10 @@ 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) + // We place the ellipsis block at the beginning of the line (for RTL paragraph) line.visualBlocks.insert(0, ellipsisBlock); } } @@ -438,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( @@ -456,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; } @@ -513,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; @@ -524,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; } @@ -574,20 +555,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; @@ -628,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; @@ -660,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; } @@ -692,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; @@ -711,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; @@ -724,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); @@ -964,6 +964,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)'; @@ -1002,6 +1004,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}'; @@ -1031,13 +1038,18 @@ 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'); + throw UnsupportedError('We should not call "fillOnContext" on an EmptyCluster'); } @override String toString() { return 'EmptyCluster [$start:$end)'; } + + @override + void addToContext(DomCanvasRenderingContext2D context, double x, double y) { + throw UnsupportedError('We should not call "addToContext" on an EmptyCluster'); + } } class PlaceholderCluster extends WebCluster { @@ -1065,6 +1077,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) { + throw UnsupportedError('We should not call "addToContext" on an PlaceholderCluster'); + } } // This is the minimal range of cluster that belongs to the same bidi run and to the same style block @@ -1146,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; } @@ -1181,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 @@ -1221,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 fd3c1811d71..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,250 +2,30 @@ // 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(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 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; + /// 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); - } - } - } - } + /// 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, @@ -298,6 +78,7 @@ 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, @@ -328,37 +109,197 @@ 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); } - 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); + /// 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, + 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; + } + } - WebParagraphDebug.log('paintLineOnCanvasKit.Shadows: ${line.textRange}'); - _paintByClusters(StyleElements.shadows, canvas, layout, line, x, y); + // 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('paintLineOnCanvasKit.Text: ${line.textRange}'); - _paintByClusters(StyleElements.text, canvas, layout, line, x, y); + 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}', + ); + } - WebParagraphDebug.log('paintLineOnCanvasKit.Decorations: ${line.textRange}'); - _paintByBlocks(StyleElements.decorations, canvas, layout, line, x, y); + return (sourceRect, targetRect); } - void paintLineOnCanvas2D( - DomHTMLCanvasElement canvas, - TextLayout layout, - TextLine line, + /// 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, + double height, + double ascent, + ) { + switch (decoration) { + case ui.TextDecoration.underline: + return thickness + ascent; + case ui.TextDecoration.overline: + return thickness / 2; + case ui.TextDecoration.lineThrough: + return height / 2; + } + return 0; + } + + /// Calculates and the position of the decoration line and paints it on Canvas2D + void calculateWaves( double x, double y, + WebTextStyle textStyle, + ui.Rect textBounds, + double thickness, ) { - WebParagraphDebug.log('paintLineOnCanvasKit.Text: ${line.textRange}'); - _paintByClustersOnCanvas2D(StyleElements.text, canvas, layout, line, x, y); + final quarterWave = thickness; + + var waveCount = 0; + double xStart = 0; + final double yStart = y + quarterWave; + + paintContext.beginPath(); + paintContext.moveTo(x, yStart); + 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; + 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 x2 = xStart + remaining; + final y2 = yStart; + paintContext.quadraticCurveTo(x1, y1, x2, y2); + } + paintContext.stroke(); } + + // 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; + } + 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; + } + + final double height = + block.multipliedFontBoundingBoxAscent + block.multipliedFontBoundingBoxDescent; + final double ascent = block.multipliedFontBoundingBoxAscent; + final double position = calculatePosition(decoration, thickness, height, ascent); + + 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(); + + 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(); + + case ui.TextDecorationStyle.solid: + paintContext.beginPath(); + paintContext.moveTo(x, y); + paintContext.lineTo(x + width, y); + paintContext.stroke(); + } + + paintContext.restore(); + } + } + + /// 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); } 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..a2c1f163694 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_clusters.dart @@ -0,0 +1,228 @@ +// 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, + ); + + 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: + 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); + 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'); + } + } + } + + 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 is PlaceholderBlock) { + continue; + } + + 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 as TextBlock).clusterRangeWithoutWhitespaces.start + : (block as TextBlock).clusterRangeWithoutWhitespaces.end - 1; + final int end = block.isLtr + ? (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 + ? 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); + 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'); + } + } + } + } + + @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; + + // 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 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, + ); + } + + @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..eefc5be4b7d --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paint_paragraph.dart @@ -0,0 +1,237 @@ +// 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); + 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; + } + + 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) { + 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 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 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 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); + case StyleElements.background: + throw Exception( + 'Background is drawn directly on the output canvas, not on the canvas2D', + ); + } + 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); + 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) { + 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 + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + 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 + : layout.paragraph.paragraphStyle.textDirection == ui.TextDirection.ltr, + ); + } + */ + } + + void _fillBlockShadows(TextLayout layout, TextBlock block) { + if (!block.style.hasElement(StyleElements.shadows) || block.style.shadows == null) { + return; + } + + 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 + ? layout.ellipsisClusters[i] + : layout.allClusters[i]; + + for (final ui.Shadow shadow in clusterText.style.shadows!) { + fillShadowCluster(clusterText, shadow, block.isLtr); + } + } + */ + } + + @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()}', + ); + + 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 + // 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) + } + + // 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 a5013464888..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 @@ -9,74 +9,84 @@ 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. -const int _paintWidth = 1000; -const int _paintHeight = 500; -double? currentDevicePixelRatio; -final DomOffscreenCanvas paintCanvas = createDomOffscreenCanvas(_paintWidth, _paintHeight); -final paintContext = paintCanvas.getContext('2d')! as DomCanvasRenderingContext2D; +import 'paint.dart'; /// Abstracts the interface for painting text clusters, shadows, and decorations. abstract class Painter { Painter(); - /// Fills out the information needed to paint the text cluster. - void fillTextCluster(WebCluster webTextCluster, bool isDefaultLtr); + bool get hasSingleImageCache => false; - /// 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 + void drawTextCluster(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 previously filled on Canvas2D text cluster shadow + void drawShadowCluster(ui.Canvas canvas, 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 background directly on canvas + void drawBackground(ui.Canvas canvas, TextBlock block, 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); + /// 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 decorations. - void fillDecorations(TextBlock block, ui.Rect sourceRect); + void drawParagraph(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect); - /// Paints the decorations previously filled by [fillDecorations]. - void paintDecorations(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) { + // 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()) { + // We need to resize canvas whenever the requested size changes 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. 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(); currentDevicePixelRatio = devicePixelRatio; - WebParagraphDebug.log( - 'resizePaintCanvas: ${paintCanvas.width}x${paintCanvas.height} @ $devicePixelRatio', - ); + if (WebParagraphDebug.logging) { + WebParagraphDebug.log( + 'resizePaintCanvas: ${paintCanvas.width}x${paintCanvas.height} @ $devicePixelRatio', + ); + } } } +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; + @override - void paintBackground(ui.Canvas canvas, LineBlock block, ui.Rect sourceRect, ui.Rect targetRect) { + bool get hasSingleImageCache => _singleImageCache != null; + + @override + 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). @@ -92,254 +102,81 @@ 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()}', - ); - } - } + void drawDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Decoration drawing is not implemented yet'); } @override - void paintDecorations(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { - 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, - ); + void drawShadowCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Shadow drawing is not implemented yet'); } @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, - ); + void drawTextCluster(ui.Canvas canvas, ui.Rect sourceRect, ui.Rect targetRect) { + throw UnimplementedError('Text cluster drawing is not implemented yet'); } @override - void paintShadow(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); - - 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 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) { - 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, - ); - } - - 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}', + 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) { + assert( + false, + 'resizePaintCanvas needed: ' + 'canvas=${paintCanvas.width}x${paintCanvas.height} vs bounds=${sourceRect.width}x${sourceRect.height}', ); - 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', + } + final DomImageData imageData = paintContext.getImageData( + 0, + 0, + sourceRect.width.ceil(), + sourceRect.height.ceil(), ); - paintContext.quadraticCurveTo(x1, y1, x2, y2); + + final imageInfo = SkImageInfo( + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + width: sourceRect.width, + height: sourceRect.height, + ); + + final SkImage? skImage = canvasKit.MakeImage( + imageInfo, + Uint8List.view(imageData.data.buffer), + 4 * sourceRect.width, + ); + + // 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); } - paintContext.stroke(); + + canvas.drawImageRect( + _singleImageCache!, + sourceRect, + targetRect, + ui.Paint()..filterQuality = ui.FilterQuality.none, + ); } - 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);'); + @override + void resetCache() { + if (_singleImageCache != null) { + _singleImageCache!.dispose(); + _singleImageCache = null; + } + } + + @override + bool hasCache() { + 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 536ff2e2d24..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 @@ -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 @@ -145,6 +146,8 @@ enum StyleElements { text, } +enum ShadowDirection { left, right, top, bottom } + class WebTextStyle implements ui.TextStyle { factory WebTextStyle({ String? fontFamily, @@ -967,17 +970,7 @@ 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); - } - } - - void paintOnCanvas2D(DomHTMLCanvasElement canvas, ui.Offset offset) { - _paint.painter.resizePaintCanvas(ui.window.devicePixelRatio); - 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 @@ -1087,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 { @@ -1294,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/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/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', () { 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..ba6a9e000fe --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_performance_test.dart @@ -0,0 +1,155 @@ +// 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, + ); +} 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..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( @@ -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); @@ -663,6 +667,7 @@ Future testMain() async { canvas.drawRect(rect.toRect(), bluePaint); } } + { final List rects = paragraph.getBoxesForRange( 0, @@ -674,6 +679,7 @@ Future testMain() async { canvas.drawRect(rect.toRect(), redPaint); } } + { final List rects = paragraph.getBoxesForRange( 0, @@ -983,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();