| // 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'; |
| |
| 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)); |
| }); |
| } |
| |
| /// @returns true When the images are reasonably similar. |
| /// @todo Make the search actually fuzzy to a certain degree. |
| Future<bool> fuzzyCompareImages(Image golden, Image img) async { |
| if (golden.width != img.width || golden.height != img.height) { |
| return false; |
| } |
| int getPixel(ByteData data, int x, int y) => data.getUint32((x + y * golden.width) * 4); |
| final ByteData goldenData = (await golden.toByteData())!; |
| final ByteData imgData = (await img.toByteData())!; |
| for (int y = 0; y < golden.height; y++) { |
| for (int x = 0; x < golden.width; x++) { |
| if (getPixel(goldenData, x, y) != getPixel(imgData, x, y)) { |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
| |
| Future<void> saveTestImage(Image image, String filename) async { |
| final String imagesPath = path.join('flutter', 'testing', 'resources'); |
| final ByteData pngData = (await image.toByteData(format: ImageByteFormat.png))!; |
| final String outPath = path.join(imagesPath, filename); |
| File(outPath).writeAsBytesSync(pngData.buffer.asUint8List()); |
| print('wrote: $outPath'); |
| } |
| |
| /// @returns true When the images are reasonably similar. |
| Future<bool> fuzzyGoldenImageCompare( |
| Image image, String goldenImageName) async { |
| final String imagesPath = path.join('flutter', 'testing', 'resources'); |
| final File file = File(path.join(imagesPath, goldenImageName)); |
| |
| bool areEqual = false; |
| |
| if (file.existsSync()) { |
| final Uint8List goldenData = await file.readAsBytes(); |
| |
| final Codec codec = await instantiateImageCodec(goldenData); |
| final FrameInfo frame = await codec.getNextFrame(); |
| expect(frame.image.height, equals(image.height)); |
| expect(frame.image.width, equals(image.width)); |
| |
| areEqual = await fuzzyCompareImages(frame.image, image); |
| } |
| |
| if (!areEqual) { |
| saveTestImage(image, 'found_$goldenImageName'); |
| } |
| return areEqual; |
| } |
| |
| void main() { |
| 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)); |
| |
| final bool areEqual = |
| await fuzzyGoldenImageCompare(image, 'canvas_test_toImage.png'); |
| expect(areEqual, true); |
| }); |
| |
| Gradient makeGradient() { |
| return Gradient.linear( |
| Offset.zero, |
| const Offset(100, 100), |
| const <Color>[Color(0xFF4C4D52), Color(0xFF202124)], |
| ); |
| } |
| |
| test('Simple gradient', () async { |
| Paint.enableDithering = false; |
| 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)); |
| |
| final bool areEqual = |
| await fuzzyGoldenImageCompare(image, 'canvas_test_gradient.png'); |
| expect(areEqual, true); |
| }, skip: !Platform.isLinux); // https://github.com/flutter/flutter/issues/53784 |
| |
| test('Simple dithered gradient', () async { |
| Paint.enableDithering = true; |
| 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)); |
| |
| final bool areEqual = |
| await fuzzyGoldenImageCompare(image, 'canvas_test_dithered_gradient.png'); |
| expect(areEqual, true); |
| }, skip: !Platform.isLinux); // https://github.com/flutter/flutter/issues/53784 |
| |
| 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 fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); |
| |
| if (!areEqual) { |
| saveTestImage(incrementalMatrixImage, 'incremental_3D_transform_test_image.png'); |
| saveTestImage(combinedMatrixImage, 'combined_3D_transform_test_image.png'); |
| } |
| 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), |
| 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)); |
| |
| final bool areEqual = |
| await fuzzyGoldenImageCompare(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); |
| expect(areEqual, true); |
| }, skip: !Platform.isLinux); // https://github.com/flutter/flutter/issues/53784 |
| |
| 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)); |
| |
| final bool areEqual = |
| await fuzzyGoldenImageCompare(image, 'text_with_gradient_with_matrix.png'); |
| expect(areEqual, true); |
| }, skip: !Platform.isLinux); // https://github.com/flutter/flutter/issues/53784 |
| |
| 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); |
| |
| // 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 render target at specified size')); |
| 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('flutter', 'testing', 'resources', 'RobotoSlab-VariableFont_wght.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 fuzzyCompareImages(tabImage, spaceImage); |
| final bool tabToTofuComparison = await fuzzyCompareImages(tabImage, tofuImage); |
| |
| expect(tabToSpaceComparison, isTrue); |
| expect(tabToTofuComparison, isFalse); |
| }); |
| |
| 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 { |
| 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); |
| canvas.clipRect(clipBounds); |
| |
| // 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(clipBounds)); |
| |
| 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(clipBounds)); |
| // Previous return values have not changed |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); |
| 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 scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); |
| // 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.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); |
| canvas.translate(0, 10.0); |
| canvas.clipRect(clipBounds1); |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); |
| canvas.restore(); |
| |
| canvas.save(); |
| canvas.clipRect(clipBounds1); |
| canvas.translate(-10.0, -10.0); |
| canvas.clipRect(clipBounds2); |
| expect(canvas.getDestinationClipBounds(), clipBounds1); |
| canvas.restore(); |
| }); |
| |
| test('Canvas.clipRRect 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(clipBounds)); |
| |
| 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(clipBounds)); |
| // Previous return values have not changed |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); |
| 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 scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); |
| // 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); |
| canvas.translate(0, 10.0); |
| canvas.clipRRect(clip1); |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); |
| canvas.restore(); |
| |
| canvas.save(); |
| canvas.clipRRect(clip1); |
| canvas.translate(-10.0, -10.0); |
| canvas.clipRRect(clip2); |
| expect(canvas.getDestinationClipBounds(), clipBounds1); |
| canvas.restore(); |
| }); |
| |
| test('Canvas.clipPath 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(clipBounds)); |
| |
| 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(clipBounds)); |
| // Previous return values have not changed |
| expect(initialLocalBounds, closeToRect(clipExpandedBounds)); |
| 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 scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); |
| expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); |
| // 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); |
| canvas.translate(0, 10.0); |
| canvas.clipPath(clip1); |
| expect(canvas.getDestinationClipBounds().isEmpty, isTrue); |
| canvas.restore(); |
| |
| canvas.save(); |
| canvas.clipPath(clip1); |
| canvas.translate(-10.0, -10.0); |
| canvas.clipPath(clip2); |
| 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); |
| const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); |
| canvas.clipRect(clipBounds); |
| |
| // 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(clipBounds)); |
| |
| canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference); |
| 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)); |
| }); |
| } |
| |
| 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)); |
| } |
| }; |