diff --git a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart index b2b93abbb6700..a051183395568 100644 --- a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart +++ b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart @@ -440,13 +440,10 @@ class BitmapCanvas extends EngineCanvas { @override void drawRect(ui.Rect rect, SurfacePaintData paint) { if (_useDomForRenderingFillAndStroke(paint)) { + rect = adjustRectForDom(rect, paint); final DomHTMLElement element = buildDrawRectElement( rect, paint, 'draw-rect', _canvasPool.currentTransform); - _drawElement( - element, - ui.Offset( - math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)), - paint); + _drawElement(element, rect.topLeft, paint); } else { setUpPaint(paint, rect); _canvasPool.drawRect(rect, paint.style); @@ -482,16 +479,12 @@ class BitmapCanvas extends EngineCanvas { @override void drawRRect(ui.RRect rrect, SurfacePaintData paint) { - final ui.Rect rect = rrect.outerRect; if (_useDomForRenderingFillAndStroke(paint)) { + final ui.Rect rect = adjustRectForDom(rrect.outerRect, paint); final DomHTMLElement element = buildDrawRectElement( rect, paint, 'draw-rrect', _canvasPool.currentTransform); applyRRectBorderRadius(element.style, rrect); - _drawElement( - element, - ui.Offset( - math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)), - paint); + _drawElement(element, rect.topLeft, paint); } else { setUpPaint(paint, rrect.outerRect); _canvasPool.drawRRect(rrect, paint.style); @@ -509,13 +502,10 @@ class BitmapCanvas extends EngineCanvas { @override void drawOval(ui.Rect rect, SurfacePaintData paint) { if (_useDomForRenderingFill(paint)) { + rect = adjustRectForDom(rect, paint); final DomHTMLElement element = buildDrawRectElement( rect, paint, 'draw-oval', _canvasPool.currentTransform); - _drawElement( - element, - ui.Offset( - math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)), - paint); + _drawElement(element, rect.topLeft, paint); element.style.borderRadius = '${rect.width / 2.0}px / ${rect.height / 2.0}px'; } else { @@ -527,15 +517,11 @@ class BitmapCanvas extends EngineCanvas { @override void drawCircle(ui.Offset c, double radius, SurfacePaintData paint) { - final ui.Rect rect = ui.Rect.fromCircle(center: c, radius: radius); if (_useDomForRenderingFillAndStroke(paint)) { + final ui.Rect rect = adjustRectForDom(ui.Rect.fromCircle(center: c, radius: radius), paint); final DomHTMLElement element = buildDrawRectElement( rect, paint, 'draw-circle', _canvasPool.currentTransform); - _drawElement( - element, - ui.Offset( - math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)), - paint); + _drawElement(element, rect.topLeft, paint); element.style.borderRadius = '50%'; } else { setUpPaint( @@ -555,21 +541,19 @@ class BitmapCanvas extends EngineCanvas { final SurfacePath surfacePath = path as SurfacePath; final ui.Rect? pathAsLine = surfacePath.toStraightLine(); if (pathAsLine != null) { - final ui.Rect rect = (pathAsLine.top == pathAsLine.bottom) + ui.Rect rect = (pathAsLine.top == pathAsLine.bottom) ? ui.Rect.fromLTWH( pathAsLine.left, pathAsLine.top, pathAsLine.width, 1) : ui.Rect.fromLTWH( pathAsLine.left, pathAsLine.top, 1, pathAsLine.height); + rect = adjustRectForDom(rect, paint); final DomHTMLElement element = buildDrawRectElement( rect, paint, 'draw-rect', _canvasPool.currentTransform); - _drawElement( - element, - ui.Offset(math.min(rect.left, rect.right), - math.min(rect.top, rect.bottom)), - paint); + _drawElement(element, rect.topLeft, paint); return; } + final ui.Rect? pathAsRect = surfacePath.toRect(); if (pathAsRect != null) { drawRect(pathAsRect, paint); diff --git a/lib/web_ui/lib/src/engine/html/dom_canvas.dart b/lib/web_ui/lib/src/engine/html/dom_canvas.dart index 41956fd11f064..c7caeea6cde89 100644 --- a/lib/web_ui/lib/src/engine/html/dom_canvas.dart +++ b/lib/web_ui/lib/src/engine/html/dom_canvas.dart @@ -75,14 +75,16 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { @override void drawRect(ui.Rect rect, SurfacePaintData paint) { + rect = adjustRectForDom(rect, paint); currentElement.append( buildDrawRectElement(rect, paint, 'draw-rect', currentTransform)); } @override void drawRRect(ui.RRect rrect, SurfacePaintData paint) { + final ui.Rect outerRect = adjustRectForDom(rrect.outerRect, paint); final DomElement element = buildDrawRectElement( - rrect.outerRect, paint, 'draw-rrect', currentTransform); + outerRect, paint, 'draw-rrect', currentTransform); applyRRectBorderRadius(element.style, rrect); currentElement.append(element); } @@ -160,8 +162,77 @@ ui.Color blurColor(ui.Color color, double sigma) { return ui.Color((reducedAlpha & 0xff) << 24 | (color.value & 0x00ffffff)); } +/// When drawing a shape (rect, rrect, circle, etc) in DOM/CSS, the [rect] given +/// by Flutter needs to be adjusted to what DOM/CSS expect. +/// +/// This method takes Flutter's [rect] and produces a new rect that can be used +/// to generate the correct CSS properties to match Flutter's expectations. +/// +/// +/// Here's what Flutter's given [rect] and [paint.strokeWidth] represent: +/// +/// top-left ↓ +/// ┌──↓──────────────────────┐ +/// →→→→x x │←← +/// │ ┌───────────────┐ │ | +/// │ │ │ │ | +/// │ │ │ │ | height +/// │ │ │ │ | +/// │ └───────────────┘ │ | +/// │ x x │←← +/// └─────────────────────────┘ +/// stroke-width ↑----↑ ↑ +/// ↑-------------------↑ width +/// +/// +/// +/// In the DOM/CSS, here's how the coordinates should look like: +/// +/// top-left ↓ +/// →→x─────────────────────────┐ +/// │ │ +/// │ x───────────────x │←← +/// │ │ │ │ | +/// │ │ │ │ | height +/// │ │ │ │ | +/// │ x───────────────x │←← +/// │ │ +/// └─────────────────────────┘ +/// border-width ↑----↑ ↑ +/// ↑---------------↑ width +/// +/// As shown in the drawing above, the width/height don't start at the top-left +/// coordinates. Instead, they start from the inner top-left (inside the border). +ui.Rect adjustRectForDom(ui.Rect rect, SurfacePaintData paint) { + double left = math.min(rect.left, rect.right); + double top = math.min(rect.top, rect.bottom); + double width = rect.width.abs(); + double height = rect.height.abs(); + + final bool isStroke = paint.style == ui.PaintingStyle.stroke; + final double strokeWidth = paint.strokeWidth ?? 0.0; + if (isStroke && strokeWidth > 0.0) { + left -= strokeWidth / 2.0; + top -= strokeWidth / 2.0; + + // width and height shouldn't go below zero. + width = math.max(0, width - strokeWidth); + height = math.max(0, height - strokeWidth); + } + + if (left != rect.left || + top != rect.top || + width != rect.width || + height != rect.height) { + return ui.Rect.fromLTWH(left, top, width, height); + } + return rect; +} + DomHTMLElement buildDrawRectElement( ui.Rect rect, SurfacePaintData paint, String tagName, Matrix4 transform) { + assert(rect.left <= rect.right); + assert(rect.top <= rect.bottom); final DomHTMLElement rectangle = domDocument.createElement(tagName) as DomHTMLElement; assert(() { @@ -172,26 +243,11 @@ DomHTMLElement buildDrawRectElement( String effectiveTransform; final bool isStroke = paint.style == ui.PaintingStyle.stroke; final double strokeWidth = paint.strokeWidth ?? 0.0; - final double left = math.min(rect.left, rect.right); - final double right = math.max(rect.left, rect.right); - final double top = math.min(rect.top, rect.bottom); - final double bottom = math.max(rect.top, rect.bottom); if (transform.isIdentity()) { - if (isStroke) { - effectiveTransform = - 'translate(${left - (strokeWidth / 2.0)}px, ${top - (strokeWidth / 2.0)}px)'; - } else { - effectiveTransform = 'translate(${left}px, ${top}px)'; - } + effectiveTransform = 'translate(${rect.left}px, ${rect.top}px)'; } else { - // Clone to avoid mutating _transform. - final Matrix4 translated = transform.clone(); - if (isStroke) { - translated.translate( - left - (strokeWidth / 2.0), top - (strokeWidth / 2.0)); - } else { - translated.translate(left, top); - } + // Clone to avoid mutating `transform`. + final Matrix4 translated = transform.clone()..translate(rect.left, rect.top); effectiveTransform = matrix4ToCssTransform(translated); } final DomCSSStyleDeclaration style = rectangle.style; @@ -216,15 +272,14 @@ DomHTMLElement buildDrawRectElement( } } + style + ..width = '${rect.width}px' + ..height = '${rect.height}px'; + if (isStroke) { - style - ..width = '${right - left - strokeWidth}px' - ..height = '${bottom - top - strokeWidth}px' - ..border = '${_borderStrokeToCssUnit(strokeWidth)} solid $cssColor'; + style.border = '${_borderStrokeToCssUnit(strokeWidth)} solid $cssColor'; } else { style - ..width = '${right - left}px' - ..height = '${bottom - top}px' ..backgroundColor = cssColor ..backgroundImage = _getBackgroundImageCssValue(paint.shader, rect); } diff --git a/lib/web_ui/test/html/dom_canvas_test.dart b/lib/web_ui/test/html/dom_canvas_test.dart new file mode 100644 index 0000000000000..a81c36031dcdf --- /dev/null +++ b/lib/web_ui/test/html/dom_canvas_test.dart @@ -0,0 +1,77 @@ +// 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'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + group('$adjustRectForDom', () { + + test('does not change rect when not necessary', () async { + const Rect rect = Rect.fromLTWH(10, 20, 140, 160); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.fill), + rect, + ); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=0), + rect, + ); + }); + + test('takes stroke width into consideration', () async { + const Rect rect = Rect.fromLTWH(10, 20, 140, 160); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=1), + const Rect.fromLTWH(9.5, 19.5, 139, 159), + ); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=10), + const Rect.fromLTWH(5, 15, 130, 150), + ); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=15), + const Rect.fromLTWH(2.5, 12.5, 125, 145), + ); + }); + + test('flips rect when necessary', () { + Rect rect = const Rect.fromLTWH(100, 200, -40, -60); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.fill), + const Rect.fromLTWH(60, 140, 40, 60), + ); + + rect = const Rect.fromLTWH(100, 200, 40, -60); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.fill), + const Rect.fromLTWH(100, 140, 40, 60), + ); + + rect = const Rect.fromLTWH(100, 200, -40, 60); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.fill), + const Rect.fromLTWH(60, 200, 40, 60), + ); + }); + + test('handles stroke width greater than width or height', () { + const Rect rect = Rect.fromLTWH(100, 200, 20, 70); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=50), + const Rect.fromLTWH(75, 175, 0, 20), + ); + expect( + adjustRectForDom(rect, SurfacePaintData()..style=PaintingStyle.stroke..strokeWidth=80), + const Rect.fromLTWH(60, 160, 0, 0), + ); + }); + }); +} diff --git a/lib/web_ui/test/html/drawing/dom_clip_stroke_golden_test.dart b/lib/web_ui/test/html/drawing/dom_clip_stroke_golden_test.dart new file mode 100644 index 0000000000000..648353e924749 --- /dev/null +++ b/lib/web_ui/test/html/drawing/dom_clip_stroke_golden_test.dart @@ -0,0 +1,142 @@ +// 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'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + test('rect stroke with clip', () async { + const Rect region = Rect.fromLTWH(0, 0, 250, 250); + // Set `hasParagraphs` to true to force DOM rendering. + final BitmapCanvas canvas = + BitmapCanvas(region, RenderStrategy()..hasParagraphs = true); + + const Rect rect = Rect.fromLTWH(0, 0, 150, 150); + + canvas.clipRect(rect.inflate(10.0), ClipOp.intersect); + + canvas.drawRect( + rect, + SurfacePaintData() + ..color = const Color(0x6fff0000) + ..strokeWidth = 20.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawRect( + rect, + SurfacePaintData() + ..color = const Color(0x6f0000ff) + ..strokeWidth = 10.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawRect( + rect, + SurfacePaintData() + ..color = const Color(0xff000000) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke, + ); + + domDocument.body!.style.margin = '0px'; + domDocument.body!.append(canvas.rootElement); + await matchGoldenFile('rect_clip_strokes_dom.png', region: region); + canvas.rootElement.remove(); + }); + + test('rrect stroke with clip', () async { + const Rect region = Rect.fromLTWH(0, 0, 250, 250); + // Set `hasParagraphs` to true to force DOM rendering. + final BitmapCanvas canvas = + BitmapCanvas(region, RenderStrategy()..hasParagraphs = true); + + final RRect rrect = RRect.fromRectAndRadius( + const Rect.fromLTWH(0, 0, 150, 150), + const Radius.circular(20), + ); + + canvas.clipRect(rrect.outerRect.inflate(10.0), ClipOp.intersect); + + canvas.drawRRect( + rrect, + SurfacePaintData() + ..color = const Color(0x6fff0000) + ..strokeWidth = 20.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawRRect( + rrect, + SurfacePaintData() + ..color = const Color(0x6f0000ff) + ..strokeWidth = 10.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawRRect( + rrect, + SurfacePaintData() + ..color = const Color(0xff000000) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke, + ); + + domDocument.body!.style.margin = '0px'; + domDocument.body!.append(canvas.rootElement); + await matchGoldenFile('rrect_clip_strokes_dom.png', region: region); + canvas.rootElement.remove(); + }); + + test('circle stroke with clip', () async { + const Rect region = Rect.fromLTWH(0, 0, 250, 250); + // Set `hasParagraphs` to true to force DOM rendering. + final BitmapCanvas canvas = + BitmapCanvas(region, RenderStrategy()..hasParagraphs = true); + + const Rect rect = Rect.fromLTWH(0, 0, 150, 150); + + canvas.clipRect(rect.inflate(10.0), ClipOp.intersect); + + canvas.drawCircle( + rect.center, + rect.width / 2, + SurfacePaintData() + ..color = const Color(0x6fff0000) + ..strokeWidth = 20.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawCircle( + rect.center, + rect.width / 2, + SurfacePaintData() + ..color = const Color(0x6f0000ff) + ..strokeWidth = 10.0 + ..style = PaintingStyle.stroke, + ); + + canvas.drawCircle( + rect.center, + rect.width / 2, + SurfacePaintData() + ..color = const Color(0xff000000) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke, + ); + + domDocument.body!.style.margin = '0px'; + domDocument.body!.append(canvas.rootElement); + await matchGoldenFile('circle_clip_strokes_dom.png', region: region); + canvas.rootElement.remove(); + }); +}