Merge 19188b4e6e1c10e11706fe7e70f5e703588d5ad8 into a8911cbac88ef8f73c083465d0cbc35a7537f35f

This commit is contained in:
Rusino 2026-02-19 12:39:01 -05:00 committed by GitHub
commit bb8a5d239e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1103 additions and 636 deletions

View File

@ -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';

View File

@ -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<String, Duration> durations = {};
static Map<String, int> 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<String, Duration> entry in durations.entries) {
print('${entry.key}: ${entry.value.inMilliseconds}ms');
}
}
/// Resets the collected profiling information.
static void reset() {
durations = {};
counts = {};
}
}

View File

@ -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`?

View File

@ -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);
}

View File

@ -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,
);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1 @@
../webparagraph/paragraph_performance_test.dart

View File

@ -167,11 +167,21 @@ Future<void> 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', () {

View File

@ -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<R> = Future<R> Function();
Future<R> timeActionAsync<R>(String name, AsyncAction<R> 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<void> testMain() async {
WebParagraphProfiler.register();
setUpUnitTests(withImplicitView: true, setUpTestViewDimensions: false);
Future<void> 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 = <Paragraph>[];
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,
);
}

View File

@ -390,7 +390,7 @@ Future<void> 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<void> 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<void> testMain() async {
canvas.drawRect(rect.toRect(), bluePaint);
}
}
{
final List<TextBox> rects = paragraph.getBoxesForRange(
0,
@ -674,6 +679,7 @@ Future<void> testMain() async {
canvas.drawRect(rect.toRect(), redPaint);
}
}
{
final List<TextBox> rects = paragraph.getBoxesForRange(
0,
@ -983,7 +989,6 @@ Future<void> 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();