| // 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:async'; | |
| import 'dart:io'; | |
| import 'dart:math'; | |
| import 'dart:typed_data'; | |
| import 'dart:ui'; | |
| import 'package:litetest/litetest.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'package:vector_math/vector_math_64.dart'; | |
| import 'goldens.dart'; | |
| import 'impeller_enabled.dart'; | |
| typedef CanvasCallback = void Function(Canvas canvas); | |
| Future<Image> createImage(int width, int height) { | |
| final Completer<Image> completer = Completer<Image>(); | |
| decodeImageFromPixels( | |
| Uint8List.fromList(List<int>.generate( | |
| width * height * 4, | |
| (int pixel) => pixel % 255, | |
| )), | |
| width, | |
| height, | |
| PixelFormat.rgba8888, | |
| (Image image) { | |
| completer.complete(image); | |
| }, | |
| ); | |
| return completer.future; | |
| } | |
| void testCanvas(CanvasCallback callback) { | |
| try { | |
| callback(Canvas(PictureRecorder(), Rect.zero)); | |
| } catch (error) { } // ignore: empty_catches | |
| } | |
| Future<Image> toImage(CanvasCallback callback, int width, int height) { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder, Rect.fromLTRB(0, 0, width.toDouble(), height.toDouble())); | |
| callback(canvas); | |
| final Picture picture = recorder.endRecording(); | |
| return picture.toImage(width, height); | |
| } | |
| void testNoCrashes() { | |
| test('canvas APIs should not crash', () async { | |
| final Paint paint = Paint(); | |
| const Rect rect = Rect.fromLTRB(double.nan, double.nan, double.nan, double.nan); | |
| final RRect rrect = RRect.fromRectAndCorners(rect); | |
| const Offset offset = Offset(double.nan, double.nan); | |
| final Path path = Path(); | |
| const Color color = Color(0x00000000); | |
| final Paragraph paragraph = ParagraphBuilder(ParagraphStyle()).build(); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas recorderCanvas = Canvas(recorder); | |
| recorderCanvas.scale(1.0, 1.0); | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = await picture.toImage(1, 1); | |
| try { Canvas(PictureRecorder()); } catch (error) { } // ignore: empty_catches | |
| try { Canvas(PictureRecorder(), rect); } catch (error) { } // ignore: empty_catches | |
| try { | |
| PictureRecorder() | |
| ..endRecording() | |
| ..endRecording() | |
| ..endRecording(); | |
| } catch (error) { } // ignore: empty_catches | |
| testCanvas((Canvas canvas) => canvas.clipPath(path)); | |
| testCanvas((Canvas canvas) => canvas.clipRect(rect)); | |
| testCanvas((Canvas canvas) => canvas.clipRRect(rrect)); | |
| testCanvas((Canvas canvas) => canvas.drawArc(rect, 0.0, 0.0, false, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawAtlas(image, <RSTransform>[], <Rect>[], <Color>[], BlendMode.src, rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawCircle(offset, double.nan, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawColor(color, BlendMode.src)); | |
| testCanvas((Canvas canvas) => canvas.drawDRRect(rrect, rrect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawImage(image, offset, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawImageNine(image, rect, rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawImageRect(image, rect, rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawLine(offset, offset, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawOval(rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawPaint(paint)); | |
| testCanvas((Canvas canvas) => canvas.drawParagraph(paragraph, offset)); | |
| testCanvas((Canvas canvas) => canvas.drawPath(path, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawPicture(picture)); | |
| testCanvas((Canvas canvas) => canvas.drawPoints(PointMode.points, <Offset>[], paint)); | |
| testCanvas((Canvas canvas) => canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawRawPoints(PointMode.points, Float32List(0), paint)); | |
| testCanvas((Canvas canvas) => canvas.drawRect(rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawRRect(rrect, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawShadow(path, color, double.nan, false)); | |
| testCanvas((Canvas canvas) => canvas.drawShadow(path, color, double.nan, true)); | |
| testCanvas((Canvas canvas) => canvas.drawVertices(Vertices(VertexMode.triangles, <Offset>[]), BlendMode.screen, paint)); | |
| testCanvas((Canvas canvas) => canvas.getSaveCount()); | |
| testCanvas((Canvas canvas) => canvas.restore()); | |
| testCanvas((Canvas canvas) => canvas.rotate(double.nan)); | |
| testCanvas((Canvas canvas) => canvas.save()); | |
| testCanvas((Canvas canvas) => canvas.saveLayer(rect, paint)); | |
| testCanvas((Canvas canvas) => canvas.saveLayer(null, paint)); | |
| testCanvas((Canvas canvas) => canvas.scale(double.nan, double.nan)); | |
| testCanvas((Canvas canvas) => canvas.skew(double.nan, double.nan)); | |
| testCanvas((Canvas canvas) => canvas.transform(Float64List(16))); | |
| testCanvas((Canvas canvas) => canvas.translate(double.nan, double.nan)); | |
| testCanvas((Canvas canvas) => canvas.drawVertices(Vertices(VertexMode.triangles, <Offset>[], | |
| indices: <int>[]), BlendMode.screen, paint)); | |
| testCanvas((Canvas canvas) => canvas.drawVertices(Vertices(VertexMode.triangles, <Offset>[])..dispose(), BlendMode.screen, paint)); | |
| // Regression test for https://github.com/flutter/flutter/issues/115143 | |
| testCanvas((Canvas canvas) => canvas.drawPaint(Paint()..imageFilter = const ColorFilter.mode(Color(0x00000000), BlendMode.xor))); | |
| // Regression test for https://github.com/flutter/flutter/issues/120278 | |
| testCanvas((Canvas canvas) => canvas.drawPaint(Paint()..imageFilter = ImageFilter.compose( | |
| outer: ImageFilter.matrix(Matrix4.identity().storage), | |
| inner: ImageFilter.blur()))); | |
| }); | |
| } | |
| const String kFlutterBuildDirectory = 'kFlutterBuildDirectory'; | |
| String get _flutterBuildPath { | |
| const String buildPath = String.fromEnvironment(kFlutterBuildDirectory); | |
| if (buildPath.isEmpty) { | |
| throw StateError('kFlutterBuildDirectory -D variable is not set.'); | |
| } | |
| return buildPath; | |
| } | |
| void main() async { | |
| final ImageComparer comparer = await ImageComparer.create(); | |
| testNoCrashes(); | |
| test('Simple .toImage', () async { | |
| final Image image = await toImage((Canvas canvas) { | |
| final Path circlePath = Path() | |
| ..addOval( | |
| Rect.fromCircle(center: const Offset(40.0, 40.0), radius: 20.0)); | |
| final Paint paint = Paint() | |
| ..isAntiAlias = false | |
| ..style = PaintingStyle.fill; | |
| canvas.drawPath(circlePath, paint); | |
| }, 100, 100); | |
| expect(image.width, equals(100)); | |
| expect(image.height, equals(100)); | |
| await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); | |
| }); | |
| Gradient makeGradient() { | |
| return Gradient.linear( | |
| Offset.zero, | |
| const Offset(100, 100), | |
| const <Color>[Color(0xFF4C4D52), Color(0xFF202124)], | |
| ); | |
| } | |
| test('Simple gradient, which is implicitly dithered', () async { | |
| final Image image = await toImage((Canvas canvas) { | |
| final Paint paint = Paint()..shader = makeGradient(); | |
| canvas.drawPaint(paint); | |
| }, 100, 100); | |
| expect(image.width, equals(100)); | |
| expect(image.height, equals(100)); | |
| await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); | |
| }); | |
| test('Null values allowed for drawAtlas methods', () async { | |
| final Image image = await createImage(100, 100); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect rect = Rect.fromLTWH(0, 0, 100, 100); | |
| final RSTransform transform = RSTransform(1, 0, 0, 0); | |
| const Color color = Color(0x00000000); | |
| final Paint paint = Paint(); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[color], BlendMode.src, rect, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[color], BlendMode.src, null, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[], null, rect, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], null, null, rect, paint); | |
| canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); | |
| canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, null, paint); | |
| canvas.drawRawAtlas(image, Float32List(0), Float32List(0), null, null, rect, paint); | |
| expectAssertion(() => canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[color], null, rect, paint)); | |
| }); | |
| test('Data lengths must match for drawAtlas methods', () async { | |
| final Image image = await createImage(100, 100); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect rect = Rect.fromLTWH(0, 0, 100, 100); | |
| final RSTransform transform = RSTransform(1, 0, 0, 0); | |
| const Color color = Color(0x00000000); | |
| final Paint paint = Paint(); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[color], BlendMode.src, rect, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform, transform], <Rect>[rect, rect], <Color>[color, color], BlendMode.src, rect, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[], null, rect, paint); | |
| canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], null, null, rect, paint); | |
| canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); | |
| canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(1), BlendMode.src, rect, paint); | |
| canvas.drawRawAtlas(image, Float32List(4), Float32List(4), null, null, rect, paint); | |
| expectArgumentError(() => canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[], <Color>[color], BlendMode.src, rect, paint)); | |
| expectArgumentError(() => canvas.drawAtlas(image, <RSTransform>[], <Rect>[rect], <Color>[color], BlendMode.src, rect, paint)); | |
| expectArgumentError(() => canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect], <Color>[color, color], BlendMode.src, rect, paint)); | |
| expectArgumentError(() => canvas.drawAtlas(image, <RSTransform>[transform], <Rect>[rect, rect], <Color>[color], BlendMode.src, rect, paint)); | |
| expectArgumentError(() => canvas.drawAtlas(image, <RSTransform>[transform, transform], <Rect>[rect], <Color>[color], BlendMode.src, rect, paint)); | |
| expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(3), Float32List(3), null, null, rect, paint)); | |
| expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(0), null, null, rect, paint)); | |
| expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(0), Float32List(4), null, null, rect, paint)); | |
| expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(2), BlendMode.src, rect, paint)); | |
| }); | |
| test('Canvas preserves perspective data in Matrix4', () async { | |
| const double rotateAroundX = pi / 6; // 30 degrees | |
| const double rotateAroundY = pi / 9; // 20 degrees | |
| const int width = 150; | |
| const int height = 150; | |
| const Color black = Color.fromARGB(255, 0, 0, 0); | |
| const Color green = Color.fromARGB(255, 0, 255, 0); | |
| void paint(Canvas canvas, CanvasCallback rotate) { | |
| canvas.translate(width * 0.5, height * 0.5); | |
| rotate(canvas); | |
| const double width3 = width / 3.0; | |
| const double width5 = width / 5.0; | |
| const double width10 = width / 10.0; | |
| canvas.drawRect(const Rect.fromLTRB(-width3, -width3, width3, width3), Paint()..color = green); | |
| canvas.drawRect(const Rect.fromLTRB(-width5, -width5, -width10, width5), Paint()..color = black); | |
| canvas.drawRect(const Rect.fromLTRB(-width5, -width5, width5, -width10), Paint()..color = black); | |
| } | |
| final Image incrementalMatrixImage = await toImage((Canvas canvas) { | |
| paint(canvas, (Canvas canvas) { | |
| final Matrix4 matrix = Matrix4.identity(); | |
| matrix.setEntry(3, 2, 0.001); | |
| canvas.transform(matrix.storage); | |
| matrix.setRotationX(rotateAroundX); | |
| canvas.transform(matrix.storage); | |
| matrix.setRotationY(rotateAroundY); | |
| canvas.transform(matrix.storage); | |
| }); | |
| }, width, height); | |
| final Image combinedMatrixImage = await toImage((Canvas canvas) { | |
| paint(canvas, (Canvas canvas) { | |
| final Matrix4 matrix = Matrix4.identity(); | |
| matrix.setEntry(3, 2, 0.001); | |
| matrix.rotateX(rotateAroundX); | |
| matrix.rotateY(rotateAroundY); | |
| canvas.transform(matrix.storage); | |
| }); | |
| }, width, height); | |
| final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); | |
| expect(areEqual, true); | |
| }); | |
| test('Path effects from Paragraphs do not affect further rendering', () async { | |
| void drawText(Canvas canvas, String content, Offset offset, | |
| {TextDecorationStyle style = TextDecorationStyle.solid}) { | |
| final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); | |
| builder.pushStyle(TextStyle( | |
| decoration: TextDecoration.underline, | |
| decorationColor: const Color(0xFF0000FF), | |
| fontFamily: 'Ahem', | |
| fontSize: 10, | |
| color: const Color(0xFF000000), | |
| decorationStyle: style, | |
| )); | |
| builder.addText(content); | |
| final Paragraph paragraph = builder.build(); | |
| paragraph.layout(const ParagraphConstraints(width: 100)); | |
| canvas.drawParagraph(paragraph, offset); | |
| } | |
| final Image image = await toImage((Canvas canvas) { | |
| canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.srcOver); | |
| final Paint paint = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 5; | |
| drawText(canvas, 'Hello World', const Offset(20, 10)); | |
| canvas.drawCircle(const Offset(150, 25), 15, paint..color = const Color(0xFF00FF00)); | |
| drawText(canvas, 'Regular text', const Offset(20, 60)); | |
| canvas.drawCircle(const Offset(150, 75), 15, paint..color = const Color(0xFFFFFF00)); | |
| drawText(canvas, 'Dotted text', const Offset(20, 110), style: TextDecorationStyle.dotted); | |
| canvas.drawCircle(const Offset(150, 125), 15, paint..color = const Color(0xFFFF0000)); | |
| drawText(canvas, 'Dashed text', const Offset(20, 160), style: TextDecorationStyle.dashed); | |
| canvas.drawCircle(const Offset(150, 175), 15, paint..color = const Color(0xFFFF0000)); | |
| drawText(canvas, 'Wavy text', const Offset(20, 210), style: TextDecorationStyle.wavy); | |
| canvas.drawCircle(const Offset(150, 225), 15, paint..color = const Color(0xFFFF0000)); | |
| }, 200, 250); | |
| expect(image.width, equals(200)); | |
| expect(image.height, equals(250)); | |
| await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); | |
| }); | |
| test('Gradients with matrices in Paragraphs render correctly', () async { | |
| final Image image = await toImage((Canvas canvas) { | |
| final Paint p = Paint(); | |
| final Float64List transform = Float64List.fromList(<double>[ | |
| 86.80000129342079, | |
| 0.0, | |
| 0.0, | |
| 0.0, | |
| 0.0, | |
| 94.5, | |
| 0.0, | |
| 0.0, | |
| 0.0, | |
| 0.0, | |
| 1.0, | |
| 0.0, | |
| 60.0, | |
| 224.310302734375, | |
| 0.0, | |
| 1.0 | |
| ]); | |
| p.shader = Gradient.radial( | |
| const Offset(2.5, 0.33), | |
| 0.8, | |
| <Color>[ | |
| const Color(0xffff0000), | |
| const Color(0xff00ff00), | |
| const Color(0xff0000ff), | |
| const Color(0xffff00ff) | |
| ], | |
| <double>[0.0, 0.3, 0.7, 0.9], | |
| TileMode.mirror, | |
| transform, | |
| const Offset(2.55, 0.4)); | |
| final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); | |
| builder.pushStyle(TextStyle( | |
| foreground: p, | |
| fontSize: 200, | |
| )); | |
| builder.addText('Woodstock!'); | |
| final Paragraph paragraph = builder.build(); | |
| paragraph.layout(const ParagraphConstraints(width: 1000)); | |
| canvas.drawParagraph(paragraph, const Offset(10, 150)); | |
| }, 600, 400); | |
| expect(image.width, equals(600)); | |
| expect(image.height, equals(400)); | |
| await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); | |
| }); | |
| test('toImageSync - too big', () async { | |
| PictureRecorder recorder = PictureRecorder(); | |
| Canvas canvas = Canvas(recorder); | |
| canvas.drawPaint(Paint()..color = const Color(0xFF123456)); | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = picture.toImageSync(300000, 4000000); | |
| picture.dispose(); | |
| expect(image.width, 300000); | |
| expect(image.height, 4000000); | |
| recorder = PictureRecorder(); | |
| canvas = Canvas(recorder); | |
| if (impellerEnabled) { | |
| // Impeller tries to automagically scale this. See | |
| // https://github.com/flutter/flutter/issues/128885 | |
| canvas.drawImage(image, Offset.zero, Paint()); | |
| return; | |
| } | |
| // On a slower CI machine, the raster thread may get behind the UI thread | |
| // here. However, once the image is in an error state it will immediately | |
| // throw on subsequent attempts. | |
| bool caughtException = false; | |
| for (int iterations = 0; iterations < 1000; iterations += 1) { | |
| try { | |
| canvas.drawImage(image, Offset.zero, Paint()); | |
| } on PictureRasterizationException catch (e) { | |
| caughtException = true; | |
| expect( | |
| e.message, | |
| contains('unable to create bitmap render target at specified size ${image.width}x${image.height}'), | |
| ); | |
| break; | |
| } | |
| // Let the event loop turn. | |
| await Future<void>.delayed(const Duration(milliseconds: 1)); | |
| } | |
| expect(caughtException, true); | |
| expect( | |
| () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), | |
| throwsException, | |
| ); | |
| expect( | |
| () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), | |
| throwsException, | |
| ); | |
| expect( | |
| () => canvas.drawAtlas(image, <RSTransform>[], <Rect>[], null, null, null, Paint()), | |
| throwsException, | |
| ); | |
| }); | |
| test('toImageSync - succeeds', () async { | |
| PictureRecorder recorder = PictureRecorder(); | |
| Canvas canvas = Canvas(recorder); | |
| canvas.drawPaint(Paint()..color = const Color(0xFF123456)); | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = picture.toImageSync(30, 40); | |
| picture.dispose(); | |
| expect(image.width, 30); | |
| expect(image.height, 40); | |
| recorder = PictureRecorder(); | |
| canvas = Canvas(recorder); | |
| expect( | |
| () => canvas.drawImage(image, Offset.zero, Paint()), | |
| returnsNormally, | |
| ); | |
| expect( | |
| () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), | |
| returnsNormally, | |
| ); | |
| expect( | |
| () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), | |
| returnsNormally, | |
| ); | |
| expect( | |
| () => canvas.drawAtlas(image, <RSTransform>[], <Rect>[], null, null, null, Paint()), | |
| returnsNormally, | |
| ); | |
| }); | |
| test('toImageSync - toByteData', () async { | |
| const Color color = Color(0xFF123456); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.drawPaint(Paint()..color = color); | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = picture.toImageSync(6, 8); | |
| picture.dispose(); | |
| expect(image.width, 6); | |
| expect(image.height, 8); | |
| final ByteData? data = await image.toByteData(); | |
| expect(data, isNotNull); | |
| expect(data!.lengthInBytes, 6 * 8 * 4); | |
| expect(data.buffer.asUint8List()[0], 0x12); | |
| expect(data.buffer.asUint8List()[1], 0x34); | |
| expect(data.buffer.asUint8List()[2], 0x56); | |
| expect(data.buffer.asUint8List()[3], 0xFF); | |
| }); | |
| test('toImage and toImageSync have identical contents', () async { | |
| // Note: on linux this stil seems to be different. | |
| // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/108835 | |
| if (Platform.isLinux) { | |
| return; | |
| } | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.drawRect( | |
| const Rect.fromLTWH(20, 20, 100, 100), | |
| Paint()..color = const Color(0xA0FF6D00), | |
| ); | |
| final Picture picture = recorder.endRecording(); | |
| final Image toImageImage = await picture.toImage(200, 200); | |
| final Image toImageSyncImage = picture.toImageSync(200, 200); | |
| // To trigger observable difference in alpha, draw image | |
| // on a second canvas. | |
| Future<ByteData> drawOnCanvas(Image image) async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.drawPaint(Paint()..color = const Color(0x4FFFFFFF)); | |
| canvas.drawImage(image, Offset.zero, Paint()); | |
| final Image resultImage = await recorder.endRecording().toImage(200, 200); | |
| return (await resultImage.toByteData())!; | |
| } | |
| final ByteData dataSync = await drawOnCanvas(toImageImage); | |
| final ByteData data = await drawOnCanvas(toImageSyncImage); | |
| expect(data, listEquals(dataSync)); | |
| }); | |
| test('Canvas.drawParagraph throws when Paragraph.layout was not called', () async { | |
| // Regression test for https://github.com/flutter/flutter/issues/97172 | |
| bool assertsEnabled = false; | |
| assert(() { | |
| assertsEnabled = true; | |
| return true; | |
| }()); | |
| Object? error; | |
| try { | |
| await toImage((Canvas canvas) { | |
| final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); | |
| builder.addText('Woodstock!'); | |
| final Paragraph woodstock = builder.build(); | |
| canvas.drawParagraph(woodstock, const Offset(0, 50)); | |
| }, 100, 100); | |
| } catch (e) { | |
| error = e; | |
| } | |
| if (assertsEnabled) { | |
| expect(error, isNotNull); | |
| } else { | |
| expect(error, isNull); | |
| } | |
| }); | |
| Future<Image> drawText(String text) { | |
| return toImage((Canvas canvas) { | |
| final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( | |
| fontFamily: 'RobotoSerif', | |
| fontStyle: FontStyle.normal, | |
| fontWeight: FontWeight.normal, | |
| fontSize: 15.0, | |
| )); | |
| builder.pushStyle(TextStyle(color: const Color(0xFF0000FF))); | |
| builder.addText(text); | |
| final Paragraph paragraph = builder.build(); | |
| paragraph.layout(const ParagraphConstraints(width: 20 * 5.0)); | |
| canvas.drawParagraph(paragraph, Offset.zero); | |
| }, 100, 100); | |
| } | |
| test('Canvas.drawParagraph renders tab as space instead of tofu', () async { | |
| // Skia renders a tofu if the font does not have a glyph for a character. | |
| // However, Flutter opts-in to a Skia feature to render tabs as a single space. | |
| // See: https://github.com/flutter/flutter/issues/79153 | |
| final File file = File(path.join(_flutterBuildPath, 'flutter', 'third_party', 'txt', 'assets', 'Roboto-Regular.ttf')); | |
| final Uint8List fontData = await file.readAsBytes(); | |
| await loadFontFromList(fontData, fontFamily: 'RobotoSerif'); | |
| // The backspace character, \b, does not have a corresponding glyph and is rendered as a tofu. | |
| final Image tabImage = await drawText('>\t<'); | |
| final Image spaceImage = await drawText('> <'); | |
| final Image tofuImage = await drawText('>\b<'); | |
| // The tab's image should be identical to the space's image but not the tofu's image. | |
| final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); | |
| final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); | |
| expect(tabToSpaceComparison, isTrue); | |
| expect(tabToTofuComparison, isFalse); | |
| }); | |
| test('drawRect, drawOval, and clipRect render with unsorted rectangles', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.drawColor(const Color(0xFFE0E0E0), BlendMode.src); | |
| void draw(Rect rect, double x, double y, Color color) { | |
| final Paint paint = Paint() | |
| ..color = color | |
| ..strokeWidth = 5.0; | |
| final Rect tallThin = Rect.fromLTRB( | |
| min(rect.left, rect.right) - 10, | |
| rect.top, | |
| min(rect.left, rect.right) - 10, | |
| rect.bottom, | |
| ); | |
| final Rect wideThin = Rect.fromLTRB( | |
| rect.left, | |
| min(rect.top, rect.bottom) - 10, | |
| rect.right, | |
| min(rect.top, rect.bottom) - 10, | |
| ); | |
| canvas.save(); | |
| canvas.translate(x, y); | |
| paint.style = PaintingStyle.fill; | |
| canvas.drawRect(rect, paint); | |
| canvas.drawRect(tallThin, paint); | |
| canvas.drawRect(wideThin, paint); | |
| canvas.save(); | |
| canvas.translate(0, 100); | |
| paint.style = PaintingStyle.stroke; | |
| canvas.drawRect(rect, paint); | |
| canvas.drawRect(tallThin, paint); | |
| canvas.drawRect(wideThin, paint); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.translate(100, 0); | |
| paint.style = PaintingStyle.fill; | |
| canvas.drawOval(rect, paint); | |
| canvas.drawOval(tallThin, paint); | |
| canvas.drawOval(wideThin, paint); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.translate(100, 100); | |
| paint.style = PaintingStyle.stroke; | |
| canvas.drawOval(rect, paint); | |
| canvas.drawOval(tallThin, paint); | |
| canvas.drawOval(wideThin, paint); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.translate(50, 50); | |
| canvas.save(); | |
| canvas.clipRect(rect); | |
| canvas.drawPaint(paint); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.clipRect(tallThin); | |
| canvas.drawPaint(paint); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.clipRect(wideThin); | |
| canvas.drawPaint(paint); | |
| canvas.restore(); | |
| canvas.restore(); | |
| canvas.restore(); | |
| } | |
| draw(const Rect.fromLTRB(10, 10, 40, 40), 50, 50, const Color(0xFF2196F3)); | |
| draw(const Rect.fromLTRB(40, 10, 10, 40), 250, 50, const Color(0xFF4CAF50)); | |
| draw(const Rect.fromLTRB(10, 40, 40, 10), 50, 250, const Color(0xFF9C27B0)); | |
| draw(const Rect.fromLTRB(40, 40, 10, 10), 250, 250, const Color(0xFFFF9800)); | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = await picture.toImage(450, 450); | |
| await comparer.addGoldenImage(image, 'render_unordered_rects.png'); | |
| }); | |
| Matcher closeToTransform(Float64List expected) => (dynamic v) { | |
| Expect.type<Float64List>(v); | |
| final Float64List value = v as Float64List; | |
| expect(expected.length, equals(16)); | |
| expect(value.length, equals(16)); | |
| for (int r = 0; r < 4; r++) { | |
| for (int c = 0; c < 4; c++) { | |
| final double vActual = value[r*4 + c]; | |
| final double vExpected = expected[r*4 + c]; | |
| if ((vActual - vExpected).abs() > 1e-10) { | |
| Expect.fail('matrix mismatch at $r, $c, $vActual not close to $vExpected'); | |
| } | |
| } | |
| } | |
| }; | |
| Matcher notCloseToTransform(Float64List expected) => (dynamic v) { | |
| Expect.type<Float64List>(v); | |
| final Float64List value = v as Float64List; | |
| expect(expected.length, equals(16)); | |
| expect(value.length, equals(16)); | |
| for (int r = 0; r < 4; r++) { | |
| for (int c = 0; c < 4; c++) { | |
| final double vActual = value[r*4 + c]; | |
| final double vExpected = expected[r*4 + c]; | |
| if ((vActual - vExpected).abs() > 1e-10) { | |
| return; | |
| } | |
| } | |
| } | |
| Expect.fail('$value is too close to $expected'); | |
| }; | |
| test('Canvas.translate affects canvas.getTransform', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.translate(12, 14.5); | |
| final Float64List matrix = Matrix4.translationValues(12, 14.5, 0).storage; | |
| final Float64List curMatrix = canvas.getTransform(); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| canvas.translate(10, 10); | |
| final Float64List newCurMatrix = canvas.getTransform(); | |
| expect(newCurMatrix, notCloseToTransform(matrix)); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| }); | |
| test('Canvas.scale affects canvas.getTransform', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.scale(12, 14.5); | |
| final Float64List matrix = Matrix4.diagonal3Values(12, 14.5, 1).storage; | |
| final Float64List curMatrix = canvas.getTransform(); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| canvas.scale(10, 10); | |
| final Float64List newCurMatrix = canvas.getTransform(); | |
| expect(newCurMatrix, notCloseToTransform(matrix)); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| }); | |
| test('Canvas.rotate affects canvas.getTransform', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.rotate(pi); | |
| final Float64List matrix = Matrix4.rotationZ(pi).storage; | |
| final Float64List curMatrix = canvas.getTransform(); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| canvas.rotate(pi / 2); | |
| final Float64List newCurMatrix = canvas.getTransform(); | |
| expect(newCurMatrix, notCloseToTransform(matrix)); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| }); | |
| test('Canvas.skew affects canvas.getTransform', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.skew(12, 14.5); | |
| final Float64List matrix = (Matrix4.identity()..setEntry(0, 1, 12)..setEntry(1, 0, 14.5)).storage; | |
| final Float64List curMatrix = canvas.getTransform(); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| canvas.skew(10, 10); | |
| final Float64List newCurMatrix = canvas.getTransform(); | |
| expect(newCurMatrix, notCloseToTransform(matrix)); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| }); | |
| test('Canvas.transform affects canvas.getTransform', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| final Float64List matrix = (Matrix4.identity()..translate(12.0, 14.5)..scale(12.0, 14.5)).storage; | |
| canvas.transform(matrix); | |
| final Float64List curMatrix = canvas.getTransform(); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| canvas.translate(10, 10); | |
| final Float64List newCurMatrix = canvas.getTransform(); | |
| expect(newCurMatrix, notCloseToTransform(matrix)); | |
| expect(curMatrix, closeToTransform(matrix)); | |
| }); | |
| Matcher closeToRect(Rect expected) => (dynamic v) { | |
| Expect.type<Rect>(v); | |
| final Rect value = v as Rect; | |
| expect(value.left, closeTo(expected.left, 1e-6)); | |
| expect(value.top, closeTo(expected.top, 1e-6)); | |
| expect(value.right, closeTo(expected.right, 1e-6)); | |
| expect(value.bottom, closeTo(expected.bottom, 1e-6)); | |
| }; | |
| Matcher notCloseToRect(Rect expected) => (dynamic v) { | |
| Expect.type<Rect>(v); | |
| final Rect value = v as Rect; | |
| if ((value.left - expected.left).abs() > 1e-6 || | |
| (value.top - expected.top).abs() > 1e-6 || | |
| (value.right - expected.right).abs() > 1e-6 || | |
| (value.bottom - expected.bottom).abs() > 1e-6) { | |
| return; | |
| } | |
| Expect.fail('$value is too close to $expected'); | |
| }; | |
| test('Canvas.clipRect affects canvas.getClipBounds', () async { | |
| void testRect(Rect clipRect, bool doAA) { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.clipRect(clipRect, doAntiAlias: doAA); | |
| final Rect clipSortedBounds = Rect.fromLTRB( | |
| min(clipRect.left, clipRect.right), | |
| min(clipRect.top, clipRect.bottom), | |
| max(clipRect.left, clipRect.right), | |
| max(clipRect.top, clipRect.bottom), | |
| ); | |
| Rect clipExpandedBounds; | |
| if (doAA) { | |
| clipExpandedBounds = Rect.fromLTRB( | |
| clipSortedBounds.left.floorToDouble(), | |
| clipSortedBounds.top.floorToDouble(), | |
| clipSortedBounds.right.ceilToDouble(), | |
| clipSortedBounds.bottom.ceilToDouble(), | |
| ); | |
| } else { | |
| clipExpandedBounds = clipSortedBounds; | |
| } | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.save(); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); | |
| // Both clip bounds have changed | |
| expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| // Previous return values have not changed | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| canvas.save(); | |
| canvas.scale(2, 2); | |
| final Rect scaledExpandedBounds = Rect.fromLTRB( | |
| clipExpandedBounds.left / 2.0, | |
| clipExpandedBounds.top / 2.0, | |
| clipExpandedBounds.right / 2.0, | |
| clipExpandedBounds.bottom / 2.0, | |
| ); | |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); | |
| // Destination bounds are unaffected by transform | |
| expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| } | |
| testRect(const Rect.fromLTRB(10.2, 11.3, 20.4, 25.7), false); | |
| testRect(const Rect.fromLTRB(10.2, 11.3, 20.4, 25.7), true); | |
| // LR swapped | |
| testRect(const Rect.fromLTRB(20.4, 11.3, 10.2, 25.7), false); | |
| testRect(const Rect.fromLTRB(20.4, 11.3, 10.2, 25.7), true); | |
| // TB swapped | |
| testRect(const Rect.fromLTRB(10.2, 25.7, 20.4, 11.3), false); | |
| testRect(const Rect.fromLTRB(10.2, 25.7, 20.4, 11.3), true); | |
| // LR and TB swapped | |
| testRect(const Rect.fromLTRB(20.4, 25.7, 10.2, 11.3), false); | |
| testRect(const Rect.fromLTRB(20.4, 25.7, 10.2, 11.3), true); | |
| }); | |
| test('Canvas.clipRect with matrix affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); | |
| const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); | |
| canvas.save(); | |
| canvas.clipRect(clipBounds1, doAntiAlias: false); | |
| canvas.translate(0, 10.0); | |
| canvas.clipRect(clipBounds1, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.clipRect(clipBounds1, doAntiAlias: false); | |
| canvas.translate(-10.0, -10.0); | |
| canvas.clipRect(clipBounds2, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds(), clipBounds1); | |
| canvas.restore(); | |
| }); | |
| test('Canvas.clipRRect(doAA=true) affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); | |
| const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); | |
| final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); | |
| canvas.clipRRect(clip); | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.save(); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); | |
| // Both clip bounds have changed | |
| expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| // Previous return values have not changed | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| canvas.save(); | |
| canvas.scale(2, 2); | |
| const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); | |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); | |
| // Destination bounds are unaffected by transform | |
| expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| }); | |
| test('Canvas.clipRRect(doAA=false) affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); | |
| final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); | |
| canvas.clipRRect(clip, doAntiAlias: false); | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipBounds)); | |
| canvas.save(); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); | |
| // Both clip bounds have changed | |
| expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); | |
| expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); | |
| // Previous return values have not changed | |
| expect(initialLocalBounds, closeToRect(clipBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| canvas.save(); | |
| canvas.scale(2, 2); | |
| const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); | |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); | |
| // Destination bounds are unaffected by transform | |
| expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| }); | |
| test('Canvas.clipRRect with matrix affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); | |
| const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); | |
| final RRect clip1 = RRect.fromRectAndRadius(clipBounds1, const Radius.circular(3)); | |
| final RRect clip2 = RRect.fromRectAndRadius(clipBounds2, const Radius.circular(3)); | |
| canvas.save(); | |
| canvas.clipRRect(clip1, doAntiAlias: false); | |
| canvas.translate(0, 10.0); | |
| canvas.clipRRect(clip1, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.clipRRect(clip1, doAntiAlias: false); | |
| canvas.translate(-10.0, -10.0); | |
| canvas.clipRRect(clip2, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds(), clipBounds1); | |
| canvas.restore(); | |
| }); | |
| test('Canvas.clipPath(doAA=true) affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); | |
| const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); | |
| final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); | |
| canvas.clipPath(clip); | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.save(); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); | |
| // Both clip bounds have changed | |
| expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); | |
| // Previous return values have not changed | |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| canvas.save(); | |
| canvas.scale(2, 2); | |
| const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); | |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); | |
| // Destination bounds are unaffected by transform | |
| expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| }); | |
| test('Canvas.clipPath(doAA=false) affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); | |
| final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); | |
| canvas.clipPath(clip, doAntiAlias: false); | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipBounds)); | |
| canvas.save(); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); | |
| // Both clip bounds have changed | |
| expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); | |
| expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); | |
| // Previous return values have not changed | |
| expect(initialLocalBounds, closeToRect(clipBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| canvas.save(); | |
| canvas.scale(2, 2); | |
| const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); | |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); | |
| // Destination bounds are unaffected by transform | |
| expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); | |
| canvas.restore(); | |
| // save/restore returned the values to their original values | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| }); | |
| test('Canvas.clipPath with matrix affects canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); | |
| const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); | |
| final Path clip1 = Path()..addRect(clipBounds1)..addOval(clipBounds1); | |
| final Path clip2 = Path()..addRect(clipBounds2)..addOval(clipBounds2); | |
| canvas.save(); | |
| canvas.clipPath(clip1, doAntiAlias: false); | |
| canvas.translate(0, 10.0); | |
| canvas.clipPath(clip1, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); | |
| canvas.restore(); | |
| canvas.save(); | |
| canvas.clipPath(clip1, doAntiAlias: false); | |
| canvas.translate(-10.0, -10.0); | |
| canvas.clipPath(clip2, doAntiAlias: false); | |
| expect(canvas.getDestinationClipBounds(), clipBounds1); | |
| canvas.restore(); | |
| }); | |
| test('Canvas.clipRect(diff) does not affect canvas.getClipBounds', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); | |
| canvas.clipRect(clipBounds, doAntiAlias: false); | |
| // Save initial return values for testing restored values | |
| final Rect initialLocalBounds = canvas.getLocalClipBounds(); | |
| final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); | |
| expect(initialLocalBounds, closeToRect(clipBounds)); | |
| expect(initialDestinationBounds, closeToRect(clipBounds)); | |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference, doAntiAlias: false); | |
| expect(canvas.getLocalClipBounds(), initialLocalBounds); | |
| expect(canvas.getDestinationClipBounds(), initialDestinationBounds); | |
| }); | |
| test('RestoreToCount can work', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| expect(canvas.getSaveCount(), equals(6)); | |
| canvas.restoreToCount(2); | |
| expect(canvas.getSaveCount(), equals(2)); | |
| canvas.restore(); | |
| expect(canvas.getSaveCount(), equals(1)); | |
| }); | |
| test('RestoreToCount count less than 1, the stack should be reset', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| expect(canvas.getSaveCount(), equals(6)); | |
| canvas.restoreToCount(0); | |
| expect(canvas.getSaveCount(), equals(1)); | |
| }); | |
| test('RestoreToCount count greater than current [getSaveCount], nothing would happend', () async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| canvas.save(); | |
| expect(canvas.getSaveCount(), equals(6)); | |
| canvas.restoreToCount(canvas.getSaveCount() + 1); | |
| expect(canvas.getSaveCount(), equals(6)); | |
| }); | |
| test('TextDecoration renders non-solid lines', () async { | |
| final File file = File(path.join(_flutterBuildPath, 'flutter', 'third_party', 'txt', 'assets', 'Roboto-Regular.ttf')); | |
| final Uint8List fontData = await file.readAsBytes(); | |
| await loadFontFromList(fontData, fontFamily: 'RobotoSlab'); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| for (final (int index, TextDecorationStyle style) in TextDecorationStyle.values.indexed) { | |
| final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); | |
| builder.pushStyle(TextStyle( | |
| decoration: TextDecoration.underline, | |
| decorationStyle: style, | |
| decorationThickness: 1.0, | |
| decorationColor: const Color(0xFFFF0000), | |
| fontFamily: 'RobotoSlab', | |
| fontSize: 24.0, | |
| foreground: Paint()..color = const Color(0xFF0000FF), | |
| )); | |
| builder.addText(style.name); | |
| final Paragraph paragraph = builder.build(); | |
| paragraph.layout(const ParagraphConstraints(width: 1000)); | |
| // Draw and layout based on the index vertically. | |
| canvas.drawParagraph(paragraph, Offset(0, index * 40.0)); | |
| } | |
| final Picture picture = recorder.endRecording(); | |
| final Image image = await picture.toImage(200, 200); | |
| await comparer.addGoldenImage(image, 'text_decoration.png'); | |
| }); | |
| test('Paint, when copied, has equivalent fields', () { | |
| final Paint paint = Paint() | |
| ..color = const Color(0xFF0000FF) | |
| ..strokeWidth = 10.0 | |
| ..strokeCap = StrokeCap.round | |
| ..strokeJoin = StrokeJoin.round | |
| ..style = PaintingStyle.stroke | |
| ..blendMode = BlendMode.srcOver | |
| ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10.0) | |
| ..filterQuality = FilterQuality.high | |
| ..colorFilter = const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color) | |
| ..imageFilter = ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0); | |
| final Paint paintCopy = Paint.from(paint); | |
| expect(paintCopy.color, equals(const Color(0xFF0000FF))); | |
| expect(paintCopy.strokeWidth, equals(10.0)); | |
| expect(paintCopy.strokeCap, equals(StrokeCap.round)); | |
| expect(paintCopy.strokeJoin, equals(StrokeJoin.round)); | |
| expect(paintCopy.style, equals(PaintingStyle.stroke)); | |
| expect(paintCopy.blendMode, equals(BlendMode.srcOver)); | |
| expect(paintCopy.maskFilter, equals(const MaskFilter.blur(BlurStyle.normal, 10.0))); | |
| expect(paintCopy.filterQuality, equals(FilterQuality.high)); | |
| expect(paintCopy.colorFilter, equals(const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color))); | |
| expect(paintCopy.imageFilter, equals(ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0))); | |
| }); | |
| test('Paint, when copied, does not mutate the original instance', () { | |
| final Paint paint = Paint() | |
| ..color = const Color(0xFF0000FF) | |
| ..strokeWidth = 10.0 | |
| ..strokeCap = StrokeCap.round | |
| ..strokeJoin = StrokeJoin.round | |
| ..style = PaintingStyle.stroke | |
| ..blendMode = BlendMode.srcOver | |
| ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10.0) | |
| ..filterQuality = FilterQuality.high | |
| ..colorFilter = const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color) | |
| ..imageFilter = ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0); | |
| // Make a copy, and change every field of the copy. | |
| Paint.from(paint) | |
| ..color = const Color(0xFF00FF00) | |
| ..strokeWidth = 20.0 | |
| ..strokeCap = StrokeCap.butt | |
| ..strokeJoin = StrokeJoin.bevel | |
| ..style = PaintingStyle.fill | |
| ..blendMode = BlendMode.srcIn | |
| ..maskFilter = const MaskFilter.blur(BlurStyle.solid, 20.0) | |
| ..filterQuality = FilterQuality.none | |
| ..colorFilter = const ColorFilter.mode(Color(0xFFFF0000), BlendMode.modulate) | |
| ..imageFilter = ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0); | |
| // The original paint should not have changed. | |
| expect(paint.color, equals(const Color(0xFF0000FF))); | |
| expect(paint.strokeWidth, equals(10.0)); | |
| expect(paint.strokeCap, equals(StrokeCap.round)); | |
| expect(paint.strokeJoin, equals(StrokeJoin.round)); | |
| expect(paint.style, equals(PaintingStyle.stroke)); | |
| expect(paint.blendMode, equals(BlendMode.srcOver)); | |
| expect(paint.maskFilter, equals(const MaskFilter.blur(BlurStyle.normal, 10.0))); | |
| expect(paint.filterQuality, equals(FilterQuality.high)); | |
| expect(paint.colorFilter, equals(const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color))); | |
| expect(paint.imageFilter, equals(ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0))); | |
| }); | |
| test('Paint, when copied, the original changing does not mutate the copy', () { | |
| final Paint paint = Paint() | |
| ..color = const Color(0xFF0000FF) | |
| ..strokeWidth = 10.0 | |
| ..strokeCap = StrokeCap.round | |
| ..strokeJoin = StrokeJoin.round | |
| ..style = PaintingStyle.stroke | |
| ..blendMode = BlendMode.srcOver | |
| ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10.0) | |
| ..filterQuality = FilterQuality.high | |
| ..colorFilter = const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color) | |
| ..imageFilter = ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0); | |
| // Make a copy, and change every field of the original. | |
| final Paint paintCopy = Paint.from(paint); | |
| paint | |
| ..color = const Color(0xFF00FF00) | |
| ..strokeWidth = 20.0 | |
| ..strokeCap = StrokeCap.butt | |
| ..strokeJoin = StrokeJoin.bevel | |
| ..style = PaintingStyle.fill | |
| ..blendMode = BlendMode.srcIn | |
| ..maskFilter = const MaskFilter.blur(BlurStyle.solid, 20.0) | |
| ..filterQuality = FilterQuality.none | |
| ..colorFilter = const ColorFilter.mode(Color(0xFFFF0000), BlendMode.modulate) | |
| ..imageFilter = ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0); | |
| // The copy should not have changed. | |
| expect(paintCopy.color, equals(const Color(0xFF0000FF))); | |
| expect(paintCopy.strokeWidth, equals(10.0)); | |
| expect(paintCopy.strokeCap, equals(StrokeCap.round)); | |
| expect(paintCopy.strokeJoin, equals(StrokeJoin.round)); | |
| expect(paintCopy.style, equals(PaintingStyle.stroke)); | |
| expect(paintCopy.blendMode, equals(BlendMode.srcOver)); | |
| expect(paintCopy.maskFilter, equals(const MaskFilter.blur(BlurStyle.normal, 10.0))); | |
| expect(paintCopy.filterQuality, equals(FilterQuality.high)); | |
| expect(paintCopy.colorFilter, equals(const ColorFilter.mode(Color(0xFF00FF00), BlendMode.color))); | |
| expect(paintCopy.imageFilter, equals(ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0))); | |
| }); | |
| test('DrawAtlas correctly copies color values into display list format', () async { | |
| final Image testImage = await createTestImage(); | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas canvas = Canvas(recorder); | |
| // Make a drawAtlas call that should be solid red. | |
| canvas.drawAtlas( | |
| testImage, | |
| [ | |
| RSTransform.fromComponents( | |
| rotation: 0, | |
| scale: 10, | |
| anchorX: 0, | |
| anchorY: 0, | |
| translateX: 0, | |
| translateY: 0, | |
| ), | |
| ], | |
| [ | |
| const Rect.fromLTWH(0, 0, 1, 1) | |
| ], | |
| [ | |
| const Color.fromARGB(255, 255, 0, 0) | |
| ], | |
| BlendMode.dst, | |
| null, | |
| Paint(), | |
| ); | |
| final Image resultImage = await recorder.endRecording().toImage(1, 1); | |
| final ByteData? data = await resultImage.toByteData(); | |
| if (data == null) { | |
| fail('Expected non-null byte data'); | |
| return; | |
| } | |
| final int rgba = data.buffer.asUint32List()[0]; | |
| expect(rgba, 0xFF0000FF); | |
| }); | |
| } | |
| Future<Image> createTestImage() async { | |
| final PictureRecorder recorder = PictureRecorder(); | |
| final Canvas recorderCanvas = Canvas(recorder); | |
| recorderCanvas.scale(1.0, 1.0); | |
| final Picture picture = recorder.endRecording(); | |
| return picture.toImage(1, 1); | |
| } | |
| Matcher listEquals(ByteData expected) => (dynamic v) { | |
| Expect.type<ByteData>(v); | |
| final ByteData value = v as ByteData; | |
| expect(value.lengthInBytes, expected.lengthInBytes); | |
| for (int i = 0; i < value.lengthInBytes; i++) { | |
| expect(value.getUint8(i), expected.getUint8(i)); | |
| } | |
| }; |