From 6ba54e4d79e5ff89d561576b2d96b3d93a36d5ac Mon Sep 17 00:00:00 2001 From: Andrei Volykhin Date: Fri, 4 Jul 2025 09:58:12 +0300 Subject: [PATCH] canvas: Add OffscreenCanvas 'convertToBlob' method (#37786) Follow the HTML speficication and add missing 'convertToBlob' method to OffscreenCanvas interface. https://html.spec.whatwg.org/multipage/#dom-offscreencanvas-converttoblob Testing: Improvements in the following tests - html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob* - html/canvas/offscreen/manual/wide-gamut-canvas/2d.color.space.p3.convertToBlobp3.canvas.html Fixes: #24272 Signed-off-by: Andrei Volykhin --- components/pixels/lib.rs | 36 +++++ components/pixels/snapshot.rs | 64 ++++++++- components/script/dom/htmlcanvaselement.rs | 123 ++---------------- components/script/dom/offscreencanvas.rs | 78 ++++++++++- .../script_bindings/codegen/Bindings.conf | 2 +- .../webidls/OffscreenCanvas.webidl | 4 +- .../offscreencanvas.convert.to.blob.html.ini | 19 --- ...offscreencanvas.convert.to.blob.w.html.ini | 39 ------ ...r.space.p3.convertToBlobp3.canvas.html.ini | 3 - tests/wpt/meta/html/dom/idlharness.any.js.ini | 3 - .../meta/html/dom/idlharness.https.html.ini | 3 - 11 files changed, 187 insertions(+), 187 deletions(-) delete mode 100644 tests/wpt/meta/html/canvas/offscreen/manual/wide-gamut-canvas/2d.color.space.p3.convertToBlobp3.canvas.html.ini diff --git a/components/pixels/lib.rs b/components/pixels/lib.rs index 3f7b3ec6bfc..395b32252f8 100644 --- a/components/pixels/lib.rs +++ b/components/pixels/lib.rs @@ -226,6 +226,42 @@ pub fn clip( .filter(|rect| !rect.is_empty()) } +#[derive(PartialEq)] +pub enum EncodedImageType { + Png, + Jpeg, + Webp, +} + +impl From for EncodedImageType { + // From: https://html.spec.whatwg.org/multipage/#serialising-bitmaps-to-a-file + // User agents must support PNG ("image/png"). User agents may support other + // types. If the user agent does not support the requested type, then it + // must create the file using the PNG format. + // Anything different than image/jpeg or image/webp is thus treated as PNG. + fn from(mime_type: String) -> Self { + let mime = mime_type.to_lowercase(); + if mime == "image/jpeg" { + Self::Jpeg + } else if mime == "image/webp" { + Self::Webp + } else { + Self::Png + } + } +} + +impl EncodedImageType { + pub fn as_mime_type(&self) -> String { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Webp => "image/webp", + } + .to_owned() + } +} + /// Whether this response passed any CORS checks, and is thus safe to read from /// in cross-origin environments. #[derive(Clone, Copy, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] diff --git a/components/pixels/snapshot.rs b/components/pixels/snapshot.rs index 7b04aaa7e23..5cb8308db7a 100644 --- a/components/pixels/snapshot.rs +++ b/components/pixels/snapshot.rs @@ -5,11 +5,15 @@ use std::ops::{Deref, DerefMut}; use euclid::default::Size2D; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::PngEncoder; +use image::codecs::webp::WebPEncoder; +use image::{ColorType, ImageEncoder, ImageError}; use ipc_channel::ipc::IpcSharedMemory; use malloc_size_of_derive::MallocSizeOf; use serde::{Deserialize, Serialize}; -use crate::{Multiply, transform_inplace}; +use crate::{EncodedImageType, Multiply, transform_inplace}; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, MallocSizeOf, PartialEq, Serialize)] pub enum SnapshotPixelFormat { @@ -289,6 +293,64 @@ impl Snapshot { alpha_mode, } } + + pub fn encode_for_mime_type( + &mut self, + image_type: &EncodedImageType, + quality: Option, + encoder: &mut W, + ) -> Result<(), ImageError> { + let width = self.size.width; + let height = self.size.height; + + let (data, _, _) = self.as_bytes( + if *image_type == EncodedImageType::Jpeg { + Some(SnapshotAlphaMode::AsOpaque { + premultiplied: true, + }) + } else { + Some(SnapshotAlphaMode::Transparent { + premultiplied: false, + }) + }, + Some(SnapshotPixelFormat::RGBA), + ); + + match image_type { + EncodedImageType::Png => { + // FIXME(nox): https://github.com/image-rs/image-png/issues/86 + // FIXME(nox): https://github.com/image-rs/image-png/issues/87 + PngEncoder::new(encoder).write_image(data, width, height, ColorType::Rgba8) + }, + EncodedImageType::Jpeg => { + let jpeg_encoder = if let Some(quality) = quality { + // The specification allows quality to be in [0.0..1.0] but the JPEG encoder + // expects it to be in [1..100] + if (0.0..=1.0).contains(&quality) { + JpegEncoder::new_with_quality( + encoder, + (quality * 100.0).round().clamp(1.0, 100.0) as u8, + ) + } else { + JpegEncoder::new(encoder) + } + } else { + JpegEncoder::new(encoder) + }; + + jpeg_encoder.write_image(data, width, height, ColorType::Rgba8) + }, + EncodedImageType::Webp => { + // No quality support because of https://github.com/image-rs/image/issues/1984 + WebPEncoder::new_lossless(encoder).write_image( + data, + width, + height, + ColorType::Rgba8, + ) + }, + } + } } impl Snapshot { diff --git a/components/script/dom/htmlcanvaselement.rs b/components/script/dom/htmlcanvaselement.rs index 3cf45b243db..158bfb9def0 100644 --- a/components/script/dom/htmlcanvaselement.rs +++ b/components/script/dom/htmlcanvaselement.rs @@ -13,16 +13,12 @@ use constellation_traits::ScriptToConstellationMessage; use dom_struct::dom_struct; use euclid::default::Size2D; use html5ever::{LocalName, Prefix, local_name, ns}; -use image::codecs::jpeg::JpegEncoder; -use image::codecs::png::PngEncoder; -use image::codecs::webp::WebPEncoder; -use image::{ColorType, ImageEncoder, ImageError}; #[cfg(feature = "webgpu")] use ipc_channel::ipc::{self as ipcchan}; use js::error::throw_type_error; use js::rust::{HandleObject, HandleValue}; use layout_api::HTMLCanvasData; -use pixels::{Snapshot, SnapshotAlphaMode, SnapshotPixelFormat}; +use pixels::{EncodedImageType, Snapshot}; use script_bindings::weakref::WeakRef; use servo_media::streams::MediaStreamType; use servo_media::streams::registry::MediaStreamId; @@ -70,41 +66,6 @@ use crate::script_runtime::{CanGc, JSContext}; const DEFAULT_WIDTH: u32 = 300; const DEFAULT_HEIGHT: u32 = 150; -#[derive(PartialEq)] -enum EncodedImageType { - Png, - Jpeg, - Webp, -} - -impl From for EncodedImageType { - // From: https://html.spec.whatwg.org/multipage/#serialising-bitmaps-to-a-file - // User agents must support PNG ("image/png"). User agents may support other types. - // If the user agent does not support the requested type, then it must create the file using the PNG format. - // Anything different than image/jpeg or image/webp is thus treated as PNG. - fn from(mime_type: DOMString) -> Self { - let mime = mime_type.to_string().to_lowercase(); - if mime == "image/jpeg" { - Self::Jpeg - } else if mime == "image/webp" { - Self::Webp - } else { - Self::Png - } - } -} - -impl EncodedImageType { - fn as_mime_type(&self) -> String { - match self { - Self::Png => "image/png", - Self::Jpeg => "image/jpeg", - Self::Webp => "image/webp", - } - .to_owned() - } -} - /// #[dom_struct] pub(crate) struct HTMLCanvasElement { @@ -385,68 +346,6 @@ impl HTMLCanvasElement { None } } - - fn encode_for_mime_type( - &self, - image_type: &EncodedImageType, - quality: Option, - mut snapshot: Snapshot, - encoder: &mut W, - ) -> Result<(), ImageError> { - // We can't use self.Width() or self.Height() here, since the size of the canvas - // may have changed since the snapshot was created. Truncating the dimensions to a - // u32 can't panic, since the data comes from a canvas which is always smaller than - // u32::MAX. - let width = snapshot.size().width; - let height = snapshot.size().height; - let (canvas_data, _, _) = snapshot.as_bytes( - if *image_type == EncodedImageType::Jpeg { - Some(SnapshotAlphaMode::AsOpaque { - premultiplied: true, - }) - } else { - Some(SnapshotAlphaMode::Transparent { - premultiplied: false, - }) - }, - Some(SnapshotPixelFormat::RGBA), - ); - - match image_type { - EncodedImageType::Png => { - // FIXME(nox): https://github.com/image-rs/image-png/issues/86 - // FIXME(nox): https://github.com/image-rs/image-png/issues/87 - PngEncoder::new(encoder).write_image(canvas_data, width, height, ColorType::Rgba8) - }, - EncodedImageType::Jpeg => { - let jpeg_encoder = if let Some(quality) = quality { - // The specification allows quality to be in [0.0..1.0] but the JPEG encoder - // expects it to be in [1..100] - if (0.0..=1.0).contains(&quality) { - JpegEncoder::new_with_quality( - encoder, - (quality * 100.0).round().clamp(1.0, 100.0) as u8, - ) - } else { - JpegEncoder::new(encoder) - } - } else { - JpegEncoder::new(encoder) - }; - - jpeg_encoder.write_image(canvas_data, width, height, ColorType::Rgba8) - }, - EncodedImageType::Webp => { - // No quality support because of https://github.com/image-rs/image/issues/1984 - WebPEncoder::new_lossless(encoder).write_image( - canvas_data, - width, - height, - ColorType::Rgba8, - ) - }, - } - } } impl HTMLCanvasElementMethods for HTMLCanvasElement { @@ -548,11 +447,11 @@ impl HTMLCanvasElementMethods for HTMLCanvasElement { // Step 3: Let file be a serialization of this canvas element's bitmap as a file, // passing type and quality if given. - let Some(snapshot) = self.get_image_data() else { + let Some(mut snapshot) = self.get_image_data() else { return Ok(USVString("data:,".into())); }; - let image_type = EncodedImageType::from(mime_type); + let image_type = EncodedImageType::from(mime_type.to_string()); let mut url = format!("data:{};base64,", image_type.as_mime_type()); @@ -561,13 +460,8 @@ impl HTMLCanvasElementMethods for HTMLCanvasElement { &base64::engine::general_purpose::STANDARD, ); - if self - .encode_for_mime_type( - &image_type, - Self::maybe_quality(quality), - snapshot, - &mut encoder, - ) + if snapshot + .encode_for_mime_type(&image_type, Self::maybe_quality(quality), &mut encoder) .is_err() { // Step 4. If file is null, then return "data:,". @@ -612,7 +506,8 @@ impl HTMLCanvasElementMethods for HTMLCanvasElement { .borrow_mut() .insert(callback_id, callback); let quality = Self::maybe_quality(quality); - let image_type = EncodedImageType::from(mime_type); + let image_type = EncodedImageType::from(mime_type.to_string()); + self.global() .task_manager() .canvas_blob_task_source() @@ -622,7 +517,7 @@ impl HTMLCanvasElementMethods for HTMLCanvasElement { return error!("Expected blob callback, but found none!"); }; - let Some(snapshot) = result else { + let Some(mut snapshot) = result else { let _ = callback.Call__(None, ExceptionHandling::Report, CanGc::note()); return; }; @@ -634,7 +529,7 @@ impl HTMLCanvasElementMethods for HTMLCanvasElement { let mut encoded: Vec = vec![]; let blob_impl; let blob; - let result = match this.encode_for_mime_type(&image_type, quality, snapshot, &mut encoded) { + let result = match snapshot.encode_for_mime_type(&image_type, quality, &mut encoded) { Ok(..) => { // Step 4.2.1: If result is non-null, then set result to a new Blob // object, created in the relevant realm of this canvas element, diff --git a/components/script/dom/offscreencanvas.rs b/components/script/dom/offscreencanvas.rs index 80caeef9dce..fff98ae11e3 100644 --- a/components/script/dom/offscreencanvas.rs +++ b/components/script/dom/offscreencanvas.rs @@ -3,26 +3,33 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use std::cell::Cell; +use std::rc::Rc; +use constellation_traits::BlobImpl; use dom_struct::dom_struct; use euclid::default::Size2D; use js::rust::{HandleObject, HandleValue}; -use pixels::Snapshot; +use pixels::{EncodedImageType, Snapshot}; use script_bindings::weakref::WeakRef; use crate::canvas_context::{CanvasContext, OffscreenRenderingContext}; use crate::dom::bindings::cell::{DomRefCell, Ref}; use crate::dom::bindings::codegen::Bindings::OffscreenCanvasBinding::{ - OffscreenCanvasMethods, OffscreenRenderingContext as RootedOffscreenRenderingContext, + ImageEncodeOptions, OffscreenCanvasMethods, + OffscreenRenderingContext as RootedOffscreenRenderingContext, }; use crate::dom::bindings::error::{Error, Fallible}; +use crate::dom::bindings::refcounted::{Trusted, TrustedPromise}; use crate::dom::bindings::reflector::{DomGlobal, reflect_dom_object_with_proto}; use crate::dom::bindings::root::{Dom, DomRoot}; use crate::dom::bindings::str::DOMString; +use crate::dom::blob::Blob; use crate::dom::eventtarget::EventTarget; use crate::dom::globalscope::GlobalScope; use crate::dom::htmlcanvaselement::HTMLCanvasElement; use crate::dom::offscreencanvasrenderingcontext2d::OffscreenCanvasRenderingContext2D; +use crate::dom::promise::Promise; +use crate::realms::{AlreadyInRealm, InRealm}; use crate::script_runtime::{CanGc, JSContext}; /// @@ -206,4 +213,71 @@ impl OffscreenCanvasMethods for OffscreenCanvas { canvas.set_natural_height(value as _, can_gc) } } + + /// + fn ConvertToBlob(&self, options: &ImageEncodeOptions, can_gc: CanGc) -> Rc { + // Step 5. Let result be a new promise object. + let in_realm_proof = AlreadyInRealm::assert::(); + let promise = Promise::new_in_current_realm(InRealm::Already(&in_realm_proof), can_gc); + + // Step 2. If this's context mode is 2d and the rendering context's + // output bitmap's origin-clean flag is set to false, then return a + // promise rejected with a "SecurityError" DOMException. + if !self.origin_is_clean() { + promise.reject_error(Error::Security, can_gc); + return promise; + } + + // Step 3. If this's bitmap has no pixels (i.e., either its horizontal + // dimension or its vertical dimension is zero), then return a promise + // rejected with an "IndexSizeError" DOMException. + if self.Width() == 0 || self.Height() == 0 { + promise.reject_error(Error::IndexSize, can_gc); + return promise; + } + + // Step 4. Let bitmap be a copy of this's bitmap. + let Some(mut snapshot) = self.get_image_data() else { + promise.reject_error(Error::InvalidState, can_gc); + return promise; + }; + + // Step 7. Run these steps in parallel: + // Step 7.1. Let file be a serialization of bitmap as a file, with + // options's type and quality if present. + // Step 7.2. Queue a global task on the canvas blob serialization task + // source given global to run these steps: + let trusted_this = Trusted::new(self); + let trusted_promise = TrustedPromise::new(promise.clone()); + + let image_type = EncodedImageType::from(options.type_.to_string()); + let quality = options.quality; + + self.global() + .task_manager() + .canvas_blob_task_source() + .queue(task!(convert_to_blob: move || { + let this = trusted_this.root(); + let promise = trusted_promise.root(); + + let mut encoded: Vec = vec![]; + + if snapshot.encode_for_mime_type(&image_type, quality, &mut encoded).is_err() { + // Step 7.2.1. If file is null, then reject result with an + // "EncodingError" DOMException. + promise.reject_error(Error::Encoding, CanGc::note()); + return; + }; + + // Step 7.2.2. Otherwise, resolve result with a new Blob object, + // created in global's relevant realm, representing file. + let blob_impl = BlobImpl::new_from_bytes(encoded, image_type.as_mime_type()); + let blob = Blob::new(&this.global(), blob_impl, CanGc::note()); + + promise.resolve_native(&blob, CanGc::note()); + })); + + // Step 8. Return result. + promise + } } diff --git a/components/script_bindings/codegen/Bindings.conf b/components/script_bindings/codegen/Bindings.conf index 23547d8ac5c..295ff111017 100644 --- a/components/script_bindings/codegen/Bindings.conf +++ b/components/script_bindings/codegen/Bindings.conf @@ -519,7 +519,7 @@ DOMInterfaces = { }, 'OffscreenCanvas': { - 'canGc': ['GetContext', 'SetHeight', 'SetWidth'], + 'canGc': ['ConvertToBlob', 'GetContext', 'SetHeight', 'SetWidth'], }, 'OffscreenCanvasRenderingContext2D': { diff --git a/components/script_bindings/webidls/OffscreenCanvas.webidl b/components/script_bindings/webidls/OffscreenCanvas.webidl index cc19cefa868..9468dc98f32 100644 --- a/components/script_bindings/webidls/OffscreenCanvas.webidl +++ b/components/script_bindings/webidls/OffscreenCanvas.webidl @@ -8,7 +8,7 @@ OffscreenRenderingContext; dictionary ImageEncodeOptions { DOMString type = "image/png"; - unrestricted double quality = 1.0; + unrestricted double quality; }; //enum OffscreenRenderingContextId { "2d", "webgl", "webgl2" }; @@ -21,5 +21,5 @@ interface OffscreenCanvas : EventTarget { [Throws] OffscreenRenderingContext? getContext(DOMString contextId, optional any options = null); //ImageBitmap transferToImageBitmap(); - //Promise convertToBlob(optional ImageEncodeOptions options); + Promise convertToBlob(optional ImageEncodeOptions options = {}); }; diff --git a/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.html.ini b/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.html.ini index 4a35ddad23c..f4b88f25626 100644 --- a/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.html.ini +++ b/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.html.ini @@ -1,22 +1,3 @@ [offscreencanvas.convert.to.blob.html] - [Test that convertToBlob with webp produces correct result] - expected: FAIL - - [Test that convertToBlob with default type produces correct result] - expected: FAIL - - [Test that convertToBlob with png produces correct result] - expected: FAIL - - [Test that convertToBlob with jpge produces correct result] - expected: FAIL - - [Test that call convertToBlob on a OffscreenCanvas with size 0 throws exception] - expected: FAIL - [Test that call convertToBlob on a detached OffscreenCanvas throws exception] expected: FAIL - - [Test that call convertToBlob on a OffscreenCanvas with tainted origin throws exception] - expected: FAIL - diff --git a/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.w.html.ini b/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.w.html.ini index 4022f1ad2b5..7f090338c72 100644 --- a/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.w.html.ini +++ b/tests/wpt/meta/html/canvas/offscreen/manual/convert-to-blob/offscreencanvas.convert.to.blob.w.html.ini @@ -1,43 +1,4 @@ [offscreencanvas.convert.to.blob.w.html] expected: ERROR - [Test that convertToBlob with jpeg/default quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with png/0.2 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with webp/1.0 quality produces correct result in a worker] - expected: FAIL - - [Test that call convertToBlob on a OffscreenCanvas with size 0 throws exception in a worker] - expected: FAIL - - [Test that convertToBlob with png/1.0 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with default type/1.0 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with jpeg/1.0 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with webp/0.2 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with webp/default quality produces correct result in a worker] - expected: FAIL - [Test that call convertToBlob on a detached OffscreenCanvas throws exception in a worker] expected: FAIL - - [Test that convertToBlob with jpeg/0.2 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with default type/0.2 quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with png/default quality produces correct result in a worker] - expected: FAIL - - [Test that convertToBlob with default arguments produces correct result in a worker] - expected: FAIL diff --git a/tests/wpt/meta/html/canvas/offscreen/manual/wide-gamut-canvas/2d.color.space.p3.convertToBlobp3.canvas.html.ini b/tests/wpt/meta/html/canvas/offscreen/manual/wide-gamut-canvas/2d.color.space.p3.convertToBlobp3.canvas.html.ini deleted file mode 100644 index 8e07abb10aa..00000000000 --- a/tests/wpt/meta/html/canvas/offscreen/manual/wide-gamut-canvas/2d.color.space.p3.convertToBlobp3.canvas.html.ini +++ /dev/null @@ -1,3 +0,0 @@ -[2d.color.space.p3.convertToBlobp3.canvas.html] - [test if toblob returns p3 data from p3 color space canvas] - expected: FAIL diff --git a/tests/wpt/meta/html/dom/idlharness.any.js.ini b/tests/wpt/meta/html/dom/idlharness.any.js.ini index efaf0579c6c..896925f2ee0 100644 --- a/tests/wpt/meta/html/dom/idlharness.any.js.ini +++ b/tests/wpt/meta/html/dom/idlharness.any.js.ini @@ -29,9 +29,6 @@ [OffscreenCanvas interface: operation transferToImageBitmap()] expected: FAIL - [OffscreenCanvas interface: operation convertToBlob(optional ImageEncodeOptions)] - expected: FAIL - [OffscreenCanvas interface: attribute oncontextlost] expected: FAIL diff --git a/tests/wpt/meta/html/dom/idlharness.https.html.ini b/tests/wpt/meta/html/dom/idlharness.https.html.ini index 08a952dac64..074077a1c50 100644 --- a/tests/wpt/meta/html/dom/idlharness.https.html.ini +++ b/tests/wpt/meta/html/dom/idlharness.https.html.ini @@ -4373,9 +4373,6 @@ [OffscreenCanvas interface: operation transferToImageBitmap()] expected: FAIL - [OffscreenCanvas interface: operation convertToBlob(optional ImageEncodeOptions)] - expected: FAIL - [OffscreenCanvas interface: attribute oncontextlost] expected: FAIL