diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index d232f89703829..622fe8dd511da 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -61,6 +61,7 @@ export 'engine/frame_service.dart'; export 'engine/frame_timing_recorder.dart'; export 'engine/html_image_element_codec.dart'; export 'engine/image_decoder.dart'; +export 'engine/image_downscaler.dart'; export 'engine/image_format_detector.dart'; export 'engine/initialization.dart'; export 'engine/js_interop/js_app.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index a57e039810d89..03831e3f2899c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -12,6 +12,11 @@ import 'package:ui/ui.dart' as ui; /// every time we need it. final SkClipOp _clipOpIntersect = canvasKit.ClipOp.Intersect; +SkPaint? _sharedDownscalingPaint; +SkPaint _getDownscalingPaint() { + return _sharedDownscalingPaint ??= CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); +} + /// A Dart wrapper around Skia's [SkCanvas]. /// /// This is intentionally not memory-managing the underlying [SkCanvas]. See @@ -148,6 +153,47 @@ class CkCanvas implements LayerCanvas { void drawImageRect(ui.Image image, ui.Rect src, ui.Rect dst, ui.Paint paint) { assert(rectIsValid(src)); assert(rectIsValid(dst)); + + if (shouldIterativelyDownscale(src, dst, paint)) { + // Use iterative downscaling to avoid aliasing artifacts when downscaling + // by a large factor (scale < 0.5). This is a workaround for a Skia bug + // where mipmaps are not used for downscaling on the web. + // See: https://g-issues.skia.org/issues/500117356 + final int targetWidth = dst.width.toInt(); + final int targetHeight = dst.height.toInt(); + + final ui.Image downscaledImage = getOrCreateDownscaledImage( + box: (image as CkImage).box, + originalImage: image, + src: src, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + final SkCanvas tempSkCanvas = (canvas as CkCanvas).skCanvas; + tempSkCanvas.drawImageRectOptions( + (img as CkImage).skImage, + toSkRect(s), + toSkRect(d), + canvasKit.FilterMode.Linear, + canvasKit.MipmapMode.None, + _getDownscalingPaint(), + ); + }, + ); + + final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); + skCanvas.drawImageRectOptions( + (downscaledImage as CkImage).skImage, + toSkRect(ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble())), + toSkRect(dst), + toSkFilterMode(paint.filterQuality), + toSkMipmapMode(paint.filterQuality), + skPaint, + ); + skPaint.delete(); + return; + } + final ui.FilterQuality filterQuality = paint.filterQuality; final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); if (filterQuality == ui.FilterQuality.high) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart index 423163c8a4005..cf26270e9c336 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -405,7 +405,10 @@ class CkImage implements ui.Image, StackTraceDebugger { skImage, this, 'SkImage', - onDisposed: (CkImage image) => ui.Image.onDispose?.call(image), + onDisposed: (CkImage image) { + ui.Image.onDispose?.call(image); + DownscaledImageCache.instance.disposeForBox(image.box); + }, ); _init(); ui.Image.onCreate?.call(this); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart new file mode 100644 index 0000000000000..05b75911fc4fd --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -0,0 +1,201 @@ +// 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:math' as math; + +import 'package:meta/meta.dart'; +import 'package:ui/ui.dart' as ui; + +/// A callback type used to draw a portion of an image onto a canvas. +/// +/// This is used to abstract the drawing operation so it can be implemented +/// differently for CanvasKit and Skwasm. +typedef RawDrawImageRect = + void Function(ui.Canvas canvas, ui.Image image, ui.Rect src, ui.Rect dst); + +/// Determines whether an image draw operation should use iterative downscaling. +/// +/// Iterative downscaling is used when both dimensions are being scaled down +/// to less than half of their source size, and the filter quality is at least +/// [ui.FilterQuality.medium]. +bool shouldIterativelyDownscale(ui.Rect src, ui.Rect dst, ui.Paint paint) { + return (dst.width < src.width / 2 || dst.height < src.height / 2) && + dst.width >= 1 && + dst.height >= 1 && + paint.filterQuality.index >= ui.FilterQuality.medium.index; +} + +/// A cache for downscaled images. +/// +/// This cache is used to avoid repeatedly downscaling the same image to the +/// same target size. +class DownscaledImageCache { + DownscaledImageCache._(); + + /// The singleton instance of the cache. + static final DownscaledImageCache instance = DownscaledImageCache._(); + + /// The key is the ref-counting box of the image (CkCountedRef or CountedRef). + /// We use the box as the key so that cloned images (which share the same box) + /// can use the same cached downscaled image. + final Map> _cache = {}; + + /// The maximum number of downscaled variants to cache per image. + static const int _maxVariantsPerImage = 10; + + /// Gets a cached downscaled image for the given [box], source rect, and target size. + ui.Image? get(Object box, ui.Rect src, int width, int height) { + final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache[box]; + if (sizes == null) { + return null; + } + final key = (src, width, height); + final ui.Image? image = sizes[key]; + if (image != null) { + // Promote to most recent (insertion order). + sizes.remove(key); + sizes[key] = image; + } + return image; + } + + /// Puts a downscaled image into the cache for the given [box], source rect, and target size. + void put(Object box, ui.Rect src, int width, int height, ui.Image image) { + final Map<(ui.Rect, int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); + final key = (src, width, height); + + // Remove if exists to refresh insertion order. + final ui.Image? oldImage = sizes.remove(key); + if (oldImage != null && oldImage != image) { + oldImage.dispose(); + } + + sizes[key] = image; + + // Limit size. + if (sizes.length > _maxVariantsPerImage) { + final (ui.Rect, int, int) firstKey = sizes.keys.first; + final ui.Image? firstImage = sizes.remove(firstKey); + firstImage?.dispose(); + } + } + + /// Disposes all cached downscaled images for the given [box]. + void disposeForBox(Object box) { + final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache.remove(box); + if (sizes != null) { + for (final ui.Image image in sizes.values) { + image.dispose(); + } + } + } +} + +/// Retrieves a downscaled image from the cache or creates it if it doesn't exist. +/// +/// The [box] is the ref-counting box of the original image (e.g., `CkCountedRef` +/// or `CountedRef`). We use the box as the key so that cloned images (which +/// share the same box) can use the same cached downscaled image. +ui.Image getOrCreateDownscaledImage({ + required Object box, + required ui.Image originalImage, + required ui.Rect src, + required int targetWidth, + required int targetHeight, + required RawDrawImageRect rawDraw, +}) { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final ui.Image? cached = cache.get(box, src, targetWidth, targetHeight); + if (cached != null) { + return cached; + } + + final ui.Image downscaled = createSteppedDownscaledImage( + originalImage: originalImage, + src: src, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: rawDraw, + ); + + cache.put(box, src, targetWidth, targetHeight, downscaled); + return downscaled; +} + +/// Creates a high-quality downscaled image by repeatedly drawing the image at +/// half scale. +/// +/// This avoids aliasing artifacts that occur when downscaling an image by a +/// large factor in a single step due to Skia not using mipmaps on the web. +/// See also: https://g-issues.skia.org/issues/500117356 +@visibleForTesting +ui.Image createSteppedDownscaledImage({ + required ui.Image originalImage, + required ui.Rect src, + required int targetWidth, + required int targetHeight, + required RawDrawImageRect rawDraw, +}) { + assert(targetWidth < src.width / 2 || targetHeight < src.height / 2); + var currentImage = originalImage; + var currentSrc = src; + + ui.Image drawImageScaled({ + required RawDrawImageRect rawDraw, + required ui.Image image, + required ui.Rect src, + required int width, + required int height, + }) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + rawDraw(canvas, image, src, ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble())); + + final ui.Picture picture = recorder.endRecording(); + final ui.Image result = picture.toImageSync(width, height); + picture.dispose(); + return result; + } + + while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { + final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); + final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); + + final ui.Image nextImage = drawImageScaled( + rawDraw: rawDraw, + image: currentImage, + src: currentSrc, + width: nextWidth, + height: nextHeight, + ); + + if (currentImage != originalImage) { + currentImage.dispose(); + } + + currentImage = nextImage; + currentSrc = ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()); + } + + // Optimization: If we reached the target size exactly in the loop, we can + // return the last intermediate image directly. + if (currentSrc.width.toInt() == targetWidth && currentSrc.height.toInt() == targetHeight) { + return currentImage; + } + + final ui.Image finalImage = drawImageScaled( + rawDraw: rawDraw, + image: currentImage, + src: currentSrc, + width: targetWidth, + height: targetHeight, + ); + + if (currentImage != originalImage) { + currentImage.dispose(); + } + + return finalImage; +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index d78323cf8369c..26d2eae5db292 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -9,6 +9,17 @@ import 'package:ui/src/engine.dart'; import 'package:ui/src/engine/skwasm/skwasm_impl.dart'; import 'package:ui/ui.dart' as ui; +PaintHandle? _sharedDownscalingPaintHandle; +PaintHandle _getDownscalingPaintHandle() { + if (_sharedDownscalingPaintHandle == null) { + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; + _sharedDownscalingPaintHandle = (tempPaint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + } + return _sharedDownscalingPaintHandle!; +} + class SkwasmCanvas implements LayerCanvas { factory SkwasmCanvas(SkwasmPictureRecorder recorder, ui.Rect cullRect) => SkwasmCanvas.fromHandle( withStackScope( @@ -243,6 +254,58 @@ class SkwasmCanvas implements LayerCanvas { @override void drawImageRect(ui.Image image, ui.Rect src, ui.Rect dst, ui.Paint paint) { + if (shouldIterativelyDownscale(src, dst, paint)) { + // Use iterative downscaling to avoid aliasing artifacts when downscaling + // by a large factor (scale < 0.5). This is a workaround for a Skia bug + // where mipmaps are not used for downscaling on the web. + // See: https://g-issues.skia.org/issues/500117356 + final int targetWidth = dst.width.toInt(); + final int targetHeight = dst.height.toInt(); + + final ui.Image downscaledImage = getOrCreateDownscaledImage( + box: (image as SkwasmImage).box, + originalImage: image, + src: src, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + final CanvasHandle tempCanvasHandle = (canvas as SkwasmCanvas)._handle; + withStackScope((StackScope scope) { + final Pointer sourceRect = scope.convertRectToNative(s); + final Pointer destRect = scope.convertRectToNative(d); + canvasDrawImageRect( + tempCanvasHandle, + (img as SkwasmImage).handle, + sourceRect, + destRect, + _getDownscalingPaintHandle(), + ui.FilterQuality.low.index, + ); + }); + }, + ); + + withStackScope((StackScope scope) { + final Pointer sourceRect = scope.convertRectToNative( + ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), + ); + final Pointer destRect = scope.convertRectToNative(dst); + final PaintHandle paintHandle = (paint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + canvasDrawImageRect( + _handle, + (downscaledImage as SkwasmImage).handle, + sourceRect, + destRect, + paintHandle, + paint.filterQuality.index, + ); + paintDispose(paintHandle); + }); + return; + } + withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(src); final Pointer destRect = scope.convertRectToNative(dst); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart index 410f4e7668f71..b766a902b6394 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart @@ -17,7 +17,10 @@ class SkwasmImage implements ui.Image, StackTraceDebugger { this, 'SkImage', onDispose: (ImageHandle h) => imageDispose(h), - onDisposed: (SkwasmImage image) => ui.Image.onDispose?.call(image), + onDisposed: (SkwasmImage image) { + ui.Image.onDispose?.call(image); + DownscaledImageCache.instance.disposeForBox(image.box); + }, ); _init(); ui.Image.onCreate?.call(this); diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart new file mode 100644 index 0000000000000..e3a049ab6c101 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart @@ -0,0 +1,321 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +import '../common/test_initialization.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + setUpUnitTests(withImplicitView: true); + + group('DownscaledImageCache', () { + test('put and get', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image = MockImage(10, 10); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); + + cache.put(box, src, 5, 5, image); + expect(cache.get(box, src, 5, 5), equals(image)); + expect(cache.get(box, src, 10, 10), isNull); + }); + + test('disposeForBox', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image1 = MockImage(10, 10); + final image2 = MockImage(20, 20); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); + + cache.put(box, src, 5, 5, image1); + cache.put(box, src, 10, 10, image2); + + expect(cache.get(box, src, 5, 5), equals(image1)); + expect(cache.get(box, src, 10, 10), equals(image2)); + + cache.disposeForBox(box); + + expect(cache.get(box, src, 5, 5), isNull); + expect(cache.get(box, src, 10, 10), isNull); + expect(image1.disposed, isTrue); + expect(image2.disposed, isTrue); + }); + + test('overwrite value disposes old value', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image1 = MockImage(5, 5); + final image2 = MockImage(5, 5); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); + + cache.put(box, src, 5, 5, image1); + cache.put(box, src, 5, 5, image2); + + expect(cache.get(box, src, 5, 5), equals(image2)); + expect(image1.disposed, isTrue); + + cache.disposeForBox(box); + }); + + test('limits number of variants per image', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + // Add 10 variants. + for (var i = 0; i < 10; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + // All 10 should be there. + for (var i = 0; i < 10; i++) { + expect(cache.get(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5), isNotNull); + } + + // Add one more. + final image11 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5, image11); + + // The first one (i=0) should have been evicted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), isNull); + + // The rest should still be there. + for (var i = 1; i < 10; i++) { + expect(cache.get(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5), isNotNull); + } + + // The new one should be there. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5), equals(image11)); + + cache.disposeForBox(box); + }); + + test('evicted variant is disposed', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + final image0 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5, image0); + + // Add 10 more variants to evict the first one. + for (var i = 1; i <= 10; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), isNull); + expect(image0.disposed, isTrue); + + cache.disposeForBox(box); + }); + + test('get promotes item to most recent', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + final image0 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5, image0); + + // Add 8 more variants. + for (var i = 1; i <= 8; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + // Access the first one to promote it. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), equals(image0)); + + // Add 2 more variants. + cache.put(box, const ui.Rect.fromLTWH(0, 0, 19, 10), 5, 5, MockImage(5, 5)); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5, MockImage(5, 5)); + + // The first one should NOT be evicted because it was promoted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), equals(image0)); + + // The second one (i=1) should have been evicted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 11, 10), 5, 5), isNull); + + cache.disposeForBox(box); + }); + }); + + group('shouldIterativelyDownscale', () { + test('returns true for large downscaling with medium quality', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); + + test('returns false when target width is less than 1', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 0.5, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false when target height is less than 1', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 0.5); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false when not downscaling enough', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 60, 60); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false for low filter quality', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.low; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns true when only width is downscaled significantly', () { + const src = ui.Rect.fromLTWH(0, 0, 1000, 100); + const dst = ui.Rect.fromLTWH(0, 0, 100, 100); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); + + test('returns true when only height is downscaled significantly', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 1000); + const dst = ui.Rect.fromLTWH(0, 0, 100, 100); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); + }); + + group('createSteppedDownscaledImage', () { + test('calls rawDraw correct number of times', () { + final originalImage = MockImage(100, 100); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 100, 100), + targetWidth: 20, + targetHeight: 20, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + // Steps: 100 -> 50 -> 25 -> 20 (3 calls). + expect(drawCalls, equals(3)); + expect(targetSizes, equals([(50, 50), (25, 25), (20, 20)])); + }); + + test('uses src region in first step', () { + final originalImage = MockImage(100, 100); + const src = ui.Rect.fromLTRB(10, 10, 90, 90); + final List srcRects = []; + final List<(int, int)> dstSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: src, + targetWidth: 20, + targetHeight: 20, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + srcRects.add(s); + dstSizes.add((d.width.toInt(), d.height.toInt())); + }, + ); + + expect(srcRects, equals([src, const ui.Rect.fromLTRB(0, 0, 40, 40)])); + expect(dstSizes, equals([(40, 40), (20, 20)])); + }); + + test('handles extreme aspect ratio without skipping steps', () { + final originalImage = MockImage(100, 1000); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 100, 1000), + targetWidth: 10, + targetHeight: 10, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + expect(drawCalls, equals(7)); + expect( + targetSizes, + equals([(50, 500), (25, 250), (12, 125), (10, 62), (10, 31), (10, 15), (10, 10)]), + ); + }); + + test('ensures dimensions do not drop below 1', () { + final originalImage = MockImage(3, 100); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 3, 100), + targetWidth: 1, + targetHeight: 10, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + expect(drawCalls, equals(4)); + expect(targetSizes, equals([(1, 50), (1, 25), (1, 12), (1, 10)])); + }); + }); +} + +class MockImage implements ui.Image { + MockImage(this.width, this.height); + + @override + final int width; + @override + final int height; + + bool disposed = false; + + @override + void dispose() { + disposed = true; + } + + @override + bool get debugDisposed => disposed; + + @override + ui.Image clone() => this; + + @override + bool isCloneOf(ui.Image other) => other == this; + + @override + ui.ColorSpace get colorSpace => ui.ColorSpace.sRGB; + + @override + Future toByteData({ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) async => + null; + + @override + List? debugGetOpenHandleStackTraces() => null; +} diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart index e1db53986805c..ba655d1d18eb4 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart @@ -553,4 +553,43 @@ Future testMain() async { return info.image; }); } + + test('drawImageRect_downscale_text', () async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, const ui.Rect.fromLTWH(0, 0, 200, 200)); + + final paint = ui.Paint() + ..color = const ui.Color(0xFF00FF00) + ..strokeWidth = 1.0; + for (double i = 0; i < 200; i += 20) { + canvas.drawLine(ui.Offset(i, 0), ui.Offset(i, 200), paint); + canvas.drawLine(ui.Offset(0, i), ui.Offset(200, i), paint); + } + + final builder = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: 30)); + builder.pushStyle(ui.TextStyle(color: const ui.Color(0xFF000000))); + builder.addText('Flutter Web'); + final ui.Paragraph paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: 200)); + canvas.drawParagraph(paragraph, const ui.Offset(10, 80)); + + final ui.Image image = recorder.endRecording().toImageSync(200, 200); + + final recorder2 = ui.PictureRecorder(); + final canvas2 = ui.Canvas(recorder2, const ui.Rect.fromLTWH(0, 0, 100, 100)); + + canvas2.drawImageRect( + image, + const ui.Rect.fromLTWH(0, 0, 200, 200), + const ui.Rect.fromLTWH(10, 10, 50, 50), + ui.Paint()..filterQuality = ui.FilterQuality.medium, + ); + + await drawPictureUsingCurrentRenderer(recorder2.endRecording()); + + await matchGoldenFile( + 'canvas_drawImageRect_downscale_text.png', + region: const ui.Rect.fromLTWH(0, 0, 100, 100), + ); + }); }