From eb8a6834f07e0086b48d66ba3707cd6b8972b322 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Mon, 16 Dec 2024 16:31:16 -0500 Subject: [PATCH] [CP-stable][web] Reland: Add `crossOrigin` property to `` tag used for decoding --- .../lib/src/engine/canvaskit/image.dart | 8 +- lib/web_ui/lib/src/engine/dom.dart | 16 ++ .../src/engine/html_image_element_codec.dart | 11 +- .../test/canvaskit/image_golden_test.dart | 13 ++ .../image/html_image_element_codec_test.dart | 38 ++++- .../image/html_image_element_codec_test.dart | 150 ++++++++++++++++++ lib/web_ui/test/ui/image/sample_image1.png | Bin 0 -> 14746 bytes 7 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 lib/web_ui/test/ui/image/html_image_element_codec_test.dart create mode 100644 lib/web_ui/test/ui/image/sample_image1.png diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 5b4edb6a2939f..306ff752c2563 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -161,7 +161,7 @@ ui.Image createCkImageFromImageElement( } class CkImageElementCodec extends HtmlImageElementCodec { - CkImageElementCodec(super.src); + CkImageElementCodec(super.src, {super.chunkCallback}); @override ui.Image createImageFromHTMLImageElement( @@ -170,7 +170,7 @@ class CkImageElementCodec extends HtmlImageElementCodec { } class CkImageBlobCodec extends HtmlBlobCodec { - CkImageBlobCodec(super.blob); + CkImageBlobCodec(super.blob, {super.chunkCallback}); @override ui.Image createImageFromHTMLImageElement( @@ -326,7 +326,7 @@ const String _kNetworkImageMessage = 'Failed to load network image.'; /// requesting from URI. Future skiaInstantiateWebImageCodec( String url, ui_web.ImageCodecChunkCallback? chunkCallback) async { - final CkImageElementCodec imageElementCodec = CkImageElementCodec(url); + final CkImageElementCodec imageElementCodec = CkImageElementCodec(url, chunkCallback: chunkCallback); try { await imageElementCodec.decode(); return imageElementCodec; @@ -339,7 +339,7 @@ Future skiaInstantiateWebImageCodec( data: list, contentType: imageType.mimeType, debugSource: url); } else { final DomBlob blob = createDomBlob([list.buffer]); - final CkImageBlobCodec codec = CkImageBlobCodec(blob); + final CkImageBlobCodec codec = CkImageBlobCodec(blob, chunkCallback: chunkCallback); try { await codec.decode(); diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index caf4888bbc05a..336ddadbab602 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -990,6 +990,22 @@ extension DomHTMLImageElementExtension on DomHTMLImageElement { external set _height(JSNumber? value); set height(double? value) => _height = value?.toJS; + @JS('crossOrigin') + external JSString? get _crossOrigin; + String? get crossOrigin => _crossOrigin?.toDart; + + @JS('crossOrigin') + external set _crossOrigin(JSString? value); + set crossOrigin(String? value) => _crossOrigin = value?.toJS; + + @JS('decoding') + external JSString? get _decoding; + String? get decoding => _decoding?.toDart; + + @JS('decoding') + external set _decoding(JSString? value); + set decoding(String? value) => _decoding = value?.toJS; + @JS('decode') external JSPromise _decode(); Future decode() => js_util.promiseToFuture(_decode()); diff --git a/lib/web_ui/lib/src/engine/html_image_element_codec.dart b/lib/web_ui/lib/src/engine/html_image_element_codec.dart index 2bd6ccabc54f0..9784345e999f3 100644 --- a/lib/web_ui/lib/src/engine/html_image_element_codec.dart +++ b/lib/web_ui/lib/src/engine/html_image_element_codec.dart @@ -43,8 +43,13 @@ abstract class HtmlImageElementCodec implements ui.Codec { // builders to create UI. chunkCallback?.call(0, 100); imgElement = createDomHTMLImageElement(); - imgElement!.src = src; - setJsProperty(imgElement!, 'decoding', 'async'); + if (renderer is! HtmlRenderer) { + imgElement!.crossOrigin = 'anonymous'; + } + imgElement! + ..decoding = 'async' + ..src = src; + // Ignoring the returned future on purpose because we're communicating // through the `completer`. @@ -91,7 +96,7 @@ abstract class HtmlImageElementCodec implements ui.Codec { } abstract class HtmlBlobCodec extends HtmlImageElementCodec { - HtmlBlobCodec(this.blob) + HtmlBlobCodec(this.blob, {super.chunkCallback}) : super( domWindow.URL.createObjectURL(blob), debugSource: 'encoded image bytes', diff --git a/lib/web_ui/test/canvaskit/image_golden_test.dart b/lib/web_ui/test/canvaskit/image_golden_test.dart index b5c5c6085b113..e0681ce6e1f02 100644 --- a/lib/web_ui/test/canvaskit/image_golden_test.dart +++ b/lib/web_ui/test/canvaskit/image_golden_test.dart @@ -253,6 +253,19 @@ Future testMain() async { } }); + test('crossOrigin requests cause an error', () async { + final String otherOrigin = + domWindow.location.origin.replaceAll('localhost', '127.0.0.1'); + bool gotError = false; + try { + final ui.Codec _ = await renderer.instantiateImageCodecFromUrl( + Uri.parse('$otherOrigin/test_images/1x1.png')); + } catch (e) { + gotError = true; + } + expect(gotError, isTrue, reason: 'Should have got CORS error'); + }); + _testCkAnimatedImage(); test('isAvif', () { diff --git a/lib/web_ui/test/engine/image/html_image_element_codec_test.dart b/lib/web_ui/test/engine/image/html_image_element_codec_test.dart index bbbf9ed2a58aa..772aeeb31cabb 100644 --- a/lib/web_ui/test/engine/image/html_image_element_codec_test.dart +++ b/lib/web_ui/test/engine/image/html_image_element_codec_test.dart @@ -7,12 +7,15 @@ import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; +import 'package:ui/src/engine/canvaskit/image.dart'; +import 'package:ui/src/engine/dom.dart'; import 'package:ui/src/engine/html/image.dart'; import 'package:ui/src/engine/html_image_element_codec.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../../common/test_initialization.dart'; +import '../../ui/utils.dart'; void main() { internalBootstrapBrowserTest(() => testMain); @@ -60,16 +63,20 @@ Future testMain() async { expect(image.height, height); }); test('loads sample image', () async { - final HtmlImageElementCodec codec = - HtmlRendererImageCodec('sample_image1.png'); + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + expect(frameInfo.image, isNotNull); expect(frameInfo.image.width, 100); expect(frameInfo.image.toString(), '[100×100]'); }); test('dispose image image', () async { - final HtmlImageElementCodec codec = - HtmlRendererImageCodec('sample_image1.png'); + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); final ui.FrameInfo frameInfo = await codec.getNextFrame(); expect(frameInfo.image, isNotNull); expect(frameInfo.image.debugDisposed, isFalse); @@ -78,7 +85,7 @@ Future testMain() async { }); test('provides image loading progress', () async { final StringBuffer buffer = StringBuffer(); - final HtmlImageElementCodec codec = HtmlRendererImageCodec( + final HtmlImageElementCodec codec = createImageElementCodec( 'sample_image1.png', chunkCallback: (int loaded, int total) { buffer.write('$loaded/$total,'); }); @@ -89,7 +96,7 @@ Future testMain() async { /// Regression test for Firefox /// https://github.com/flutter/flutter/issues/66412 test('Returns nonzero natural width/height', () async { - final HtmlImageElementCodec codec = HtmlRendererImageCodec( + final HtmlImageElementCodec codec = createImageElementCodec( 'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9I' 'jAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dG' 'l0bGU+QWJzdHJhY3QgaWNvbjwvdGl0bGU+PHBhdGggZD0iTTEyIDBjOS42MDEgMCAx' @@ -103,7 +110,7 @@ Future testMain() async { final ui.FrameInfo frameInfo = await codec.getNextFrame(); expect(frameInfo.image.width, isNot(0)); }); - }); + }, skip: isSkwasm); group('ImageCodecUrl', () { test('loads sample image from web', () async { @@ -111,6 +118,12 @@ Future testMain() async { final HtmlImageElementCodec codec = await ui_web.createImageCodecFromUrl(uri) as HtmlImageElementCodec; final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + expect(frameInfo.image, isNotNull); expect(frameInfo.image.width, 100); }); @@ -124,5 +137,14 @@ Future testMain() async { await codec.getNextFrame(); expect(buffer.toString(), '0/100,100/100,'); }); - }); + }, skip: isSkwasm); +} + +HtmlImageElementCodec createImageElementCodec( + String src, { + ui_web.ImageCodecChunkCallback? chunkCallback, +}) { + return isHtml + ? HtmlRendererImageCodec(src, chunkCallback: chunkCallback) + : CkImageElementCodec(src, chunkCallback: chunkCallback); } diff --git a/lib/web_ui/test/ui/image/html_image_element_codec_test.dart b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart new file mode 100644 index 0000000000000..772aeeb31cabb --- /dev/null +++ b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart @@ -0,0 +1,150 @@ +// 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:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/canvaskit/image.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/html/image.dart'; +import 'package:ui/src/engine/html_image_element_codec.dart'; +import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; + +import '../../common/test_initialization.dart'; +import '../../ui/utils.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + setUpUnitTests(); + group('$HtmlImageElementCodec', () { + test('supports raw images - RGBA8888', () async { + final Completer completer = Completer(); + const int width = 200; + const int height = 300; + final Uint32List list = Uint32List(width * height); + for (int index = 0; index < list.length; index += 1) { + list[index] = 0xFF0000FF; + } + ui.decodeImageFromPixels( + list.buffer.asUint8List(), + width, + height, + ui.PixelFormat.rgba8888, + (ui.Image image) => completer.complete(image), + ); + final ui.Image image = await completer.future; + expect(image.width, width); + expect(image.height, height); + }); + test('supports raw images - BGRA8888', () async { + final Completer completer = Completer(); + const int width = 200; + const int height = 300; + final Uint32List list = Uint32List(width * height); + for (int index = 0; index < list.length; index += 1) { + list[index] = 0xFF0000FF; + } + ui.decodeImageFromPixels( + list.buffer.asUint8List(), + width, + height, + ui.PixelFormat.bgra8888, + (ui.Image image) => completer.complete(image), + ); + final ui.Image image = await completer.future; + expect(image.width, width); + expect(image.height, height); + }); + test('loads sample image', () async { + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.width, 100); + expect(frameInfo.image.toString(), '[100×100]'); + }); + test('dispose image image', () async { + final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.debugDisposed, isFalse); + frameInfo.image.dispose(); + expect(frameInfo.image.debugDisposed, isTrue); + }); + test('provides image loading progress', () async { + final StringBuffer buffer = StringBuffer(); + final HtmlImageElementCodec codec = createImageElementCodec( + 'sample_image1.png', chunkCallback: (int loaded, int total) { + buffer.write('$loaded/$total,'); + }); + await codec.getNextFrame(); + expect(buffer.toString(), '0/100,100/100,'); + }); + + /// Regression test for Firefox + /// https://github.com/flutter/flutter/issues/66412 + test('Returns nonzero natural width/height', () async { + final HtmlImageElementCodec codec = createImageElementCodec( + 'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9I' + 'jAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dG' + 'l0bGU+QWJzdHJhY3QgaWNvbjwvdGl0bGU+PHBhdGggZD0iTTEyIDBjOS42MDEgMCAx' + 'MiAyLjM5OSAxMiAxMiAwIDkuNjAxLTIuMzk5IDEyLTEyIDEyLTkuNjAxIDAtMTItMi' + '4zOTktMTItMTJDMCAyLjM5OSAyLjM5OSAwIDEyIDB6bS0xLjk2OSAxOC41NjRjMi41' + 'MjQuMDAzIDQuNjA0LTIuMDcgNC42MDktNC41OTUgMC0yLjUyMS0yLjA3NC00LjU5NS' + '00LjU5NS00LjU5NVM1LjQ1IDExLjQ0OSA1LjQ1IDEzLjk2OWMwIDIuNTE2IDIuMDY1' + 'IDQuNTg4IDQuNTgxIDQuNTk1em04LjM0NC0uMTg5VjUuNjI1SDUuNjI1djIuMjQ3aD' + 'EwLjQ5OHYxMC41MDNoMi4yNTJ6bS04LjM0NC02Ljc0OGEyLjM0MyAyLjM0MyAwIDEx' + 'LS4wMDIgNC42ODYgMi4zNDMgMi4zNDMgMCAwMS4wMDItNC42ODZ6Ii8+PC9zdmc+'); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + expect(frameInfo.image.width, isNot(0)); + }); + }, skip: isSkwasm); + + group('ImageCodecUrl', () { + test('loads sample image from web', () async { + final Uri uri = Uri.base.resolve('sample_image1.png'); + final HtmlImageElementCodec codec = + await ui_web.createImageCodecFromUrl(uri) as HtmlImageElementCodec; + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + + expect(codec.imgElement, isNotNull); + expect(codec.imgElement!.src, contains('sample_image1.png')); + expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous'); + expect(codec.imgElement!.decoding, 'async'); + + expect(frameInfo.image, isNotNull); + expect(frameInfo.image.width, 100); + }); + test('provides image loading progress from web', () async { + final Uri uri = Uri.base.resolve('sample_image1.png'); + final StringBuffer buffer = StringBuffer(); + final HtmlImageElementCodec codec = await ui_web + .createImageCodecFromUrl(uri, chunkCallback: (int loaded, int total) { + buffer.write('$loaded/$total,'); + }) as HtmlImageElementCodec; + await codec.getNextFrame(); + expect(buffer.toString(), '0/100,100/100,'); + }); + }, skip: isSkwasm); +} + +HtmlImageElementCodec createImageElementCodec( + String src, { + ui_web.ImageCodecChunkCallback? chunkCallback, +}) { + return isHtml + ? HtmlRendererImageCodec(src, chunkCallback: chunkCallback) + : CkImageElementCodec(src, chunkCallback: chunkCallback); +} diff --git a/lib/web_ui/test/ui/image/sample_image1.png b/lib/web_ui/test/ui/image/sample_image1.png new file mode 100644 index 0000000000000000000000000000000000000000..0d1393b8052134902c82d85a4c478db6a716684c GIT binary patch literal 14746 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 e(}mphinJVDosk2bwfW}