diff --git a/Cargo.lock b/Cargo.lock index 13f757f0e3a708..fc44b8483bc377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,9 +588,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.15.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" +checksum = "773d90827bc3feecfb67fab12e24de0749aad83c74b9504ecde46237b5cd24e2" [[package]] name = "byteorder" @@ -598,6 +598,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.6.0" @@ -1367,9 +1373,13 @@ dependencies = [ name = "deno_canvas" version = "0.37.0" dependencies = [ + "bytemuck", "deno_core", + "deno_terminal 0.2.0", "deno_webgpu", "image", + "lcms2", + "num-traits", "serde", ] @@ -2573,6 +2583,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.17" @@ -3232,6 +3248,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.29.0" @@ -3761,15 +3787,29 @@ dependencies = [ [[package]] name = "image" -version = "0.24.9" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" dependencies = [ "bytemuck", - "byteorder", + "byteorder-lite", "color_quant", + "gif", + "image-webp", "num-traits", "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", ] [[package]] @@ -4068,6 +4108,29 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "lcms2" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680ec3fa42c36e0af9ca02f20a3742a82229c7f1ee0e6754294de46a80be6f74" +dependencies = [ + "bytemuck", + "foreign-types", + "lcms2-sys", +] + +[[package]] +name = "lcms2-sys" +version = "4.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "593265f9a3172180024fb62580ee31348f31be924b19416da174ebb7fb623d2e" +dependencies = [ + "cc", + "dunce", + "libc", + "pkg-config", +] + [[package]] name = "lexical-core" version = "0.8.5" @@ -4707,9 +4770,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", @@ -5442,6 +5505,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-junit" version = "0.3.6" @@ -5755,7 +5824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" dependencies = [ "hostname", - "quick-error", + "quick-error 1.2.3", ] [[package]] @@ -8123,6 +8192,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wgpu-core" version = "0.21.1" @@ -8726,3 +8801,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" +dependencies = [ + "zune-core", +] diff --git a/ext/canvas/01_image.js b/ext/canvas/01_image.js index 3ea72db6acc1aa..2716e14bf51350 100644 --- a/ext/canvas/01_image.js +++ b/ext/canvas/01_image.js @@ -1,7 +1,7 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { internals, primordials } from "ext:core/mod.js"; -import { op_image_decode_png, op_image_process } from "ext:core/ops"; +import { op_create_image_bitmap } from "ext:core/ops"; import * as webidl from "ext:deno_webidl/00_webidl.js"; import { DOMException } from "ext:deno_web/01_dom_exception.js"; import { createFilteredInspectProxy } from "ext:deno_console/01_console.js"; @@ -11,13 +11,11 @@ const { ObjectPrototypeIsPrototypeOf, Symbol, SymbolFor, - TypeError, TypedArrayPrototypeGetBuffer, Uint8Array, - MathCeil, - PromiseResolve, PromiseReject, RangeError, + ArrayPrototypeJoin, } = primordials; import { _data, @@ -164,6 +162,11 @@ function createImageBitmap( options = undefined, ) { const prefix = "Failed to execute 'createImageBitmap'"; + // Add the value when implementing to add support for ImageBitmapSource + const imageBitmapSources = [ + "Blob", + "ImageData", + ]; // Overload: createImageBitmap(image [, options ]) if (arguments.length < 3) { @@ -184,6 +187,7 @@ function createImageBitmap( "Argument 6", ); + // 1. if (sw === 0) { return PromiseReject(new RangeError("sw has to be greater than 0")); } @@ -193,6 +197,7 @@ function createImageBitmap( } } + // 2. if (options.resizeWidth === 0) { return PromiseReject( new DOMException( @@ -204,7 +209,7 @@ function createImageBitmap( if (options.resizeHeight === 0) { return PromiseReject( new DOMException( - "options.resizeWidth has to be greater than 0", + "options.resizeHeight has to be greater than 0", "InvalidStateError", ), ); @@ -212,139 +217,65 @@ function createImageBitmap( const imageBitmap = webidl.createBranded(ImageBitmap); - if (ObjectPrototypeIsPrototypeOf(ImageDataPrototype, image)) { - const processedImage = processImage( - image[_data], - image[_width], - image[_height], - sxOrOptions, - sy, - sw, - sh, - options, - ); - imageBitmap[_bitmapData] = processedImage.data; - imageBitmap[_width] = processedImage.outputWidth; - imageBitmap[_height] = processedImage.outputHeight; - return PromiseResolve(imageBitmap); - } - if (ObjectPrototypeIsPrototypeOf(BlobPrototype, image)) { - return (async () => { - const data = await image.arrayBuffer(); - const mimetype = sniffImage(image.type); - if (mimetype !== "image/png") { - throw new DOMException( - `Unsupported type '${image.type}'`, - "InvalidStateError", - ); - } - const { data: imageData, width, height } = op_image_decode_png( - new Uint8Array(data), - ); - const processedImage = processImage( - imageData, - width, - height, - sxOrOptions, - sy, - sw, - sh, - options, - ); - imageBitmap[_bitmapData] = processedImage.data; - imageBitmap[_width] = processedImage.outputWidth; - imageBitmap[_height] = processedImage.outputHeight; - return imageBitmap; - })(); - } else { - return PromiseReject(new TypeError("Invalid or unsupported image value")); - } -} - -function processImage(input, width, height, sx, sy, sw, sh, options) { - let sourceRectangle; - - if ( - sx !== undefined && sy !== undefined && sw !== undefined && sh !== undefined - ) { - sourceRectangle = [ - [sx, sy], - [sx + sw, sy], - [sx + sw, sy + sh], - [sx, sy + sh], - ]; - } else { - sourceRectangle = [ - [0, 0], - [width, 0], - [width, height], - [0, height], - ]; - } - const widthOfSourceRect = sourceRectangle[1][0] - sourceRectangle[0][0]; - const heightOfSourceRect = sourceRectangle[3][1] - sourceRectangle[0][1]; - - let outputWidth; - if (options.resizeWidth !== undefined) { - outputWidth = options.resizeWidth; - } else if (options.resizeHeight !== undefined) { - outputWidth = MathCeil( - (widthOfSourceRect * options.resizeHeight) / heightOfSourceRect, - ); - } else { - outputWidth = widthOfSourceRect; - } - - let outputHeight; - if (options.resizeHeight !== undefined) { - outputHeight = options.resizeHeight; - } else if (options.resizeWidth !== undefined) { - outputHeight = MathCeil( - (heightOfSourceRect * options.resizeWidth) / widthOfSourceRect, + // 3. + const isBlob = ObjectPrototypeIsPrototypeOf(BlobPrototype, image); + const isImageData = ObjectPrototypeIsPrototypeOf(ImageDataPrototype, image); + if (!isBlob && !isImageData) { + return PromiseReject( + new DOMException( + `${prefix}: The provided value for 'image' is not of type '(${ + ArrayPrototypeJoin(imageBitmapSources, " or ") + })'.`, + "InvalidStateError", + ), ); - } else { - outputHeight = heightOfSourceRect; } - if (options.colorSpaceConversion === "none") { - throw new TypeError( - "Cannot create image: invalid colorSpaceConversion option, 'none' is not supported", - ); - } + // 4. + return (async () => { + let width = 0; + let height = 0; + let mimeType = ""; + let imageBitmapSource, buf; + if (isBlob) { + imageBitmapSource = imageBitmapSources[0]; + buf = new Uint8Array(await image.arrayBuffer()); + mimeType = sniffImage(image.type); + } + if (isImageData) { + width = image[_width]; + height = image[_height]; + imageBitmapSource = imageBitmapSources[1]; + buf = new Uint8Array(TypedArrayPrototypeGetBuffer(image[_data])); + } - /* - * The cropping works differently than the spec specifies: - * The spec states to create an infinite surface and place the top-left corner - * of the image a 0,0 and crop based on sourceRectangle. - * - * We instead create a surface the size of sourceRectangle, and position - * the image at the correct location, which is the inverse of the x & y of - * sourceRectangle's top-left corner. - */ - const data = op_image_process( - new Uint8Array(TypedArrayPrototypeGetBuffer(input)), - { + let sx; + if (typeof sxOrOptions === "number") { + sx = sxOrOptions; + } + // TODO(Hajime-san): this should be real async + const processedImage = op_create_image_bitmap( + buf, width, height, - surfaceWidth: widthOfSourceRect, - surfaceHeight: heightOfSourceRect, - inputX: sourceRectangle[0][0] * -1, // input_x - inputY: sourceRectangle[0][1] * -1, // input_y - outputWidth, - outputHeight, - resizeQuality: options.resizeQuality, - flipY: options.imageOrientation === "flipY", - premultiply: options.premultiplyAlpha === "default" - ? null - : (options.premultiplyAlpha === "premultiply"), - }, - ); - - return { - data, - outputWidth, - outputHeight, - }; + sx, + sy, + sw, + sh, + options.imageOrientation ?? "from-image", + options.premultiplyAlpha ?? "default", + options.colorSpaceConversion ?? "default", + options.resizeWidth, + options.resizeHeight, + options.resizeQuality ?? "low", + imageBitmapSource, + mimeType, + ); + imageBitmap[_bitmapData] = processedImage[0]; + imageBitmap[_width] = processedImage[1]; + imageBitmap[_height] = processedImage[2]; + return imageBitmap; + })(); } function getBitmapData(imageBitmap) { diff --git a/ext/canvas/Cargo.toml b/ext/canvas/Cargo.toml index 96881682a4ac88..7266923ba6f2c4 100644 --- a/ext/canvas/Cargo.toml +++ b/ext/canvas/Cargo.toml @@ -14,7 +14,16 @@ description = "OffscreenCanvas implementation for Deno" path = "lib.rs" [dependencies] +bytemuck = "1.17.1" deno_core.workspace = true +deno_terminal.workspace = true deno_webgpu.workspace = true -image = { version = "0.24.7", default-features = false, features = ["png"] } +image = { version = "0.25.2", default-features = false, features = ["png", "jpeg", "bmp", "ico", "webp", "gif"] } +# NOTE: The qcms is a color space conversion crate which parses ICC profiles that used in Gecko, +# however it supports only 8-bit color depth currently. +# https://searchfox.org/mozilla-central/rev/f09e3f9603a08b5b51bf504846091579bc2ff531/gfx/qcms/src/transform.rs#130-137 +# It seems to be failed to build for aarch64-unknown-linux-gnu with pkg-config. +# https://github.com/kornelski/rust-lcms2-sys/blob/b8e9c3efcf266b88600318fb519c073b9ebb61b7/README.md#L26 +lcms2 = { version = "6.1.0", features = ["static"] } +num-traits = { version = "0.2.19" } serde = { workspace = true, features = ["derive"] } diff --git a/ext/canvas/README.md b/ext/canvas/README.md index cf013677e786b1..0303d1f739ad22 100644 --- a/ext/canvas/README.md +++ b/ext/canvas/README.md @@ -1,3 +1,32 @@ # deno_canvas Extension that implements various OffscreenCanvas related APIs. + +## Image processing architecture in Rust + +```mermaid +flowchart LR + Input["input binary
( &[u8] )"] + II["intermediate image
( DynamicImage )"] + Ops["processing pixel
( ImageBuffer< P, S > )"] + Output["output binary
( Box<[u8]> )"] + Input --> II + II --> Ops --> II + II --> Output +``` + +The architecture of image processing in Rust is rely on the structure of +[image](https://github.com/image-rs/image) crate.\ +If the input is a image of binary, it convert to an intermediate image +(`DynamicImage` in `image`) with using a decoder corresponding to its image +formats.\ +After converting to an intermediate image, it can process various way for +example, to use the pixel processong operation +[imageops](https://github.com/image-rs/image?tab=readme-ov-file#image-processing-functions) +supplied by `image`.\ +On the other hand, there can also to implement your own pixel processong +operation to refer to +[the implementation of imageops as here](https://github.com/image-rs/image/blob/4afe9572b5c867cf4d07cd88107e8c49354de9f3/src/imageops/colorops.rs#L156-L182) +or [image_ops.rs module](./image_ops.rs).\ +You can treat any bit depth that supported by `image` with generics in the +processing pixel layer. diff --git a/ext/canvas/error.rs b/ext/canvas/error.rs new file mode 100644 index 00000000000000..e2d160534552dc --- /dev/null +++ b/ext/canvas/error.rs @@ -0,0 +1,44 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use deno_core::error::AnyError; +use std::borrow::Cow; +use std::fmt; + +#[derive(Debug)] +pub struct DOMExceptionInvalidStateError { + pub msg: String, +} + +impl DOMExceptionInvalidStateError { + pub fn new(msg: &str) -> Self { + DOMExceptionInvalidStateError { + msg: msg.to_string(), + } + } +} + +impl fmt::Display for DOMExceptionInvalidStateError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.pad(&self.msg) + } +} + +impl std::error::Error for DOMExceptionInvalidStateError {} + +pub fn get_error_class_name(e: &AnyError) -> Option<&'static str> { + e.downcast_ref::() + .map(|_| "DOMExceptionInvalidStateError") +} + +/// Returns a string that represents the error message for the image. +pub(crate) fn image_error_message<'a, T: Into>>( + opreation: T, + reason: T, +) -> String { + format!( + "An error has occurred while {}. +reason: {}", + opreation.into(), + reason.into(), + ) +} diff --git a/ext/canvas/image_decoder.rs b/ext/canvas/image_decoder.rs new file mode 100644 index 00000000000000..02680da8e367d9 --- /dev/null +++ b/ext/canvas/image_decoder.rs @@ -0,0 +1,89 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use std::io::BufRead; +use std::io::BufReader; +use std::io::Cursor; +use std::io::Seek; + +use deno_core::error::AnyError; +use image::codecs::bmp::BmpDecoder; +use image::codecs::gif::GifDecoder; +use image::codecs::ico::IcoDecoder; +use image::codecs::jpeg::JpegDecoder; +use image::codecs::png::PngDecoder; +use image::codecs::webp::WebPDecoder; +use image::DynamicImage; +use image::ImageDecoder; +use image::ImageError; + +// +// About the animated image +// > Blob .4 +// > ... If this is an animated image, imageBitmap's bitmap data must only be taken from +// > the default image of the animation (the one that the format defines is to be used when animation is +// > not supported or is disabled), or, if there is no such image, the first frame of the animation. +// https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html +// +// see also browser implementations: (The implementation of Gecko and WebKit is hard to read.) +// https://source.chromium.org/chromium/chromium/src/+/bdbc054a6cabbef991904b5df9066259505cc686:third_party/blink/renderer/platform/image-decoders/image_decoder.h;l=175-189 +// + +pub(crate) trait ImageDecoderFromReader<'a, R: BufRead + Seek> { + fn to_decoder( + reader: R, + error_fn: fn(ImageError) -> AnyError, + ) -> Result + where + Self: Sized; + fn to_intermediate_image( + self, + error_fn: fn(ImageError) -> AnyError, + ) -> Result; + fn get_icc_profile(&mut self) -> Option>; +} + +pub(crate) type ImageDecoderFromReaderType<'a> = BufReader>; + +macro_rules! impl_image_decoder_from_reader { + ($decoder:ty, $reader:ty) => { + impl<'a, R: BufRead + Seek> ImageDecoderFromReader<'a, R> for $decoder { + fn to_decoder( + reader: R, + error_fn: fn(ImageError) -> AnyError, + ) -> Result + where + Self: Sized, + { + match <$decoder>::new(reader) { + Ok(decoder) => Ok(decoder), + Err(err) => return Err(error_fn(err)), + } + } + fn to_intermediate_image( + self, + error_fn: fn(ImageError) -> AnyError, + ) -> Result { + match DynamicImage::from_decoder(self) { + Ok(image) => Ok(image), + Err(err) => Err(error_fn(err)), + } + } + fn get_icc_profile(&mut self) -> Option> { + match self.icc_profile() { + Ok(profile) => profile, + Err(_) => None, + } + } + } + }; +} + +// If PngDecoder decodes an animated image, it returns the default image if one is set, or the first frame if not. +impl_image_decoder_from_reader!(PngDecoder, ImageDecoderFromReaderType); +impl_image_decoder_from_reader!(JpegDecoder, ImageDecoderFromReaderType); +// The GifDecoder decodes the first frame. +impl_image_decoder_from_reader!(GifDecoder, ImageDecoderFromReaderType); +impl_image_decoder_from_reader!(BmpDecoder, ImageDecoderFromReaderType); +impl_image_decoder_from_reader!(IcoDecoder, ImageDecoderFromReaderType); +// The WebPDecoder decodes the first frame. +impl_image_decoder_from_reader!(WebPDecoder, ImageDecoderFromReaderType); diff --git a/ext/canvas/image_ops.rs b/ext/canvas/image_ops.rs new file mode 100644 index 00000000000000..cbe8b63174031b --- /dev/null +++ b/ext/canvas/image_ops.rs @@ -0,0 +1,698 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use bytemuck::cast_slice; +use bytemuck::cast_slice_mut; +use deno_core::error::AnyError; +use image::ColorType; +use image::DynamicImage; +use image::GenericImageView; +use image::ImageBuffer; +use image::Luma; +use image::LumaA; +use image::Pixel; +use image::Primitive; +use image::Rgb; +use image::Rgba; +use lcms2::PixelFormat; +use lcms2::Pod; +use lcms2::Profile; +use lcms2::Transform; +use num_traits::NumCast; +use num_traits::SaturatingMul; + +pub(crate) trait PremultiplyAlpha { + fn premultiply_alpha(&self) -> Self; +} + +impl PremultiplyAlpha for LumaA { + fn premultiply_alpha(&self) -> Self { + let max_t = T::DEFAULT_MAX_VALUE; + + let mut pixel = [self.0[0], self.0[1]]; + let alpha_index = pixel.len() - 1; + let alpha = pixel[alpha_index]; + let normalized_alpha = alpha.to_f32().unwrap() / max_t.to_f32().unwrap(); + + if normalized_alpha == 0.0 { + return LumaA([pixel[0], pixel[alpha_index]]); + } + + for rgb in pixel.iter_mut().take(alpha_index) { + *rgb = NumCast::from((rgb.to_f32().unwrap() * normalized_alpha).round()) + .unwrap() + } + + LumaA([pixel[0], pixel[alpha_index]]) + } +} + +impl PremultiplyAlpha for Rgba { + fn premultiply_alpha(&self) -> Self { + let max_t = T::DEFAULT_MAX_VALUE; + + let mut pixel = [self.0[0], self.0[1], self.0[2], self.0[3]]; + let alpha_index = pixel.len() - 1; + let alpha = pixel[alpha_index]; + let normalized_alpha = alpha.to_f32().unwrap() / max_t.to_f32().unwrap(); + + if normalized_alpha == 0.0 { + return Rgba([pixel[0], pixel[1], pixel[2], pixel[alpha_index]]); + } + + for rgb in pixel.iter_mut().take(alpha_index) { + *rgb = NumCast::from((rgb.to_f32().unwrap() * normalized_alpha).round()) + .unwrap() + } + + Rgba([pixel[0], pixel[1], pixel[2], pixel[alpha_index]]) + } +} + +fn process_premultiply_alpha(image: &I) -> ImageBuffer> +where + I: GenericImageView, + P: Pixel + PremultiplyAlpha + 'static, + S: Primitive + 'static, +{ + let (width, height) = image.dimensions(); + let mut out = ImageBuffer::new(width, height); + + for (x, y, pixel) in image.pixels() { + let pixel = pixel.premultiply_alpha(); + + out.put_pixel(x, y, pixel); + } + + out +} + +/// Premultiply the alpha channel of the image. +pub(crate) fn premultiply_alpha( + image: DynamicImage, +) -> Result { + match image.color() { + ColorType::La8 => Ok(DynamicImage::ImageLumaA8(process_premultiply_alpha( + image.as_luma_alpha8().unwrap(), + ))), + ColorType::La16 => Ok(DynamicImage::ImageLumaA16( + process_premultiply_alpha(image.as_luma_alpha16().unwrap()), + )), + ColorType::Rgba8 => Ok(DynamicImage::ImageRgba8( + process_premultiply_alpha(image.as_rgba8().unwrap()), + )), + ColorType::Rgba16 => Ok(DynamicImage::ImageRgba16( + process_premultiply_alpha(image.as_rgba16().unwrap()), + )), + // If the image does not have an alpha channel, return the image as is. + _ => Ok(image), + } +} + +pub(crate) trait UnpremultiplyAlpha { + /// To determine if the image is premultiplied alpha, + /// checking premultiplied RGBA value is one where any of the R/G/B channel values exceeds the alpha channel value.\ + /// https://www.w3.org/TR/webgpu/#color-spaces + fn is_premultiplied_alpha(&self) -> bool; + fn unpremultiply_alpha(&self) -> Self; +} + +impl UnpremultiplyAlpha for Rgba { + fn is_premultiplied_alpha(&self) -> bool { + let max_t = T::DEFAULT_MAX_VALUE; + + let pixel = [self.0[0], self.0[1], self.0[2]]; + let alpha_index = self.0.len() - 1; + let alpha = self.0[alpha_index]; + + match pixel.iter().max() { + Some(rgb_max) => rgb_max < &max_t.saturating_mul(&alpha), + // usually doesn't reach here + None => false, + } + } + + fn unpremultiply_alpha(&self) -> Self { + let max_t = T::DEFAULT_MAX_VALUE; + + let mut pixel = [self.0[0], self.0[1], self.0[2], self.0[3]]; + let alpha_index = pixel.len() - 1; + let alpha = pixel[alpha_index]; + + for rgb in pixel.iter_mut().take(alpha_index) { + *rgb = NumCast::from( + (rgb.to_f32().unwrap() + / (alpha.to_f32().unwrap() / max_t.to_f32().unwrap())) + .round(), + ) + .unwrap(); + } + + Rgba([pixel[0], pixel[1], pixel[2], pixel[alpha_index]]) + } +} + +impl UnpremultiplyAlpha for LumaA { + fn is_premultiplied_alpha(&self) -> bool { + let max_t = T::DEFAULT_MAX_VALUE; + + let pixel = [self.0[0]]; + let alpha_index = self.0.len() - 1; + let alpha = self.0[alpha_index]; + + pixel[0] < max_t.saturating_mul(&alpha) + } + + fn unpremultiply_alpha(&self) -> Self { + let max_t = T::DEFAULT_MAX_VALUE; + + let mut pixel = [self.0[0], self.0[1]]; + let alpha_index = pixel.len() - 1; + let alpha = pixel[alpha_index]; + + for rgb in pixel.iter_mut().take(alpha_index) { + *rgb = NumCast::from( + (rgb.to_f32().unwrap() + / (alpha.to_f32().unwrap() / max_t.to_f32().unwrap())) + .round(), + ) + .unwrap(); + } + + LumaA([pixel[0], pixel[alpha_index]]) + } +} + +fn is_premultiplied_alpha(image: &I) -> bool +where + I: GenericImageView, + P: Pixel + UnpremultiplyAlpha + 'static, + S: Primitive + 'static, +{ + image + .pixels() + .any(|(_, _, pixel)| pixel.is_premultiplied_alpha()) +} + +fn process_unpremultiply_alpha(image: &I) -> ImageBuffer> +where + I: GenericImageView, + P: Pixel + UnpremultiplyAlpha + 'static, + S: Primitive + 'static, +{ + let (width, height) = image.dimensions(); + let mut out = ImageBuffer::new(width, height); + + for (x, y, pixel) in image.pixels() { + let pixel = pixel.unpremultiply_alpha(); + + out.put_pixel(x, y, pixel); + } + + out +} + +/// Invert the premultiplied alpha channel of the image. +pub(crate) fn unpremultiply_alpha( + image: DynamicImage, +) -> Result { + match image.color() { + ColorType::La8 => Ok(DynamicImage::ImageLumaA8( + if is_premultiplied_alpha(image.as_luma_alpha8().unwrap()) { + process_unpremultiply_alpha(image.as_luma_alpha8().unwrap()) + } else { + image.into_luma_alpha8() + }, + )), + ColorType::La16 => Ok(DynamicImage::ImageLumaA16( + if is_premultiplied_alpha(image.as_luma_alpha16().unwrap()) { + process_unpremultiply_alpha(image.as_luma_alpha16().unwrap()) + } else { + image.into_luma_alpha16() + }, + )), + ColorType::Rgba8 => Ok(DynamicImage::ImageRgba8( + if is_premultiplied_alpha(image.as_rgba8().unwrap()) { + process_unpremultiply_alpha(image.as_rgba8().unwrap()) + } else { + image.into_rgba8() + }, + )), + ColorType::Rgba16 => Ok(DynamicImage::ImageRgba16( + if is_premultiplied_alpha(image.as_rgba16().unwrap()) { + process_unpremultiply_alpha(image.as_rgba16().unwrap()) + } else { + image.into_rgba16() + }, + )), + // If the image does not have an alpha channel, return the image as is. + _ => Ok(image), + } +} + +pub(crate) trait SliceToPixel { + fn slice_to_pixel(pixel: &[u8]) -> Self; +} + +impl SliceToPixel for Luma { + fn slice_to_pixel(pixel: &[u8]) -> Self { + let pixel: &[T] = cast_slice(pixel); + let pixel = [pixel[0]]; + + Luma(pixel) + } +} + +impl SliceToPixel for LumaA { + fn slice_to_pixel(pixel: &[u8]) -> Self { + let pixel: &[T] = cast_slice(pixel); + let pixel = [pixel[0], pixel[1]]; + + LumaA(pixel) + } +} + +impl SliceToPixel for Rgb { + fn slice_to_pixel(pixel: &[u8]) -> Self { + let pixel: &[T] = cast_slice(pixel); + let pixel = [pixel[0], pixel[1], pixel[2]]; + + Rgb(pixel) + } +} + +impl SliceToPixel for Rgba { + fn slice_to_pixel(pixel: &[u8]) -> Self { + let pixel: &[T] = cast_slice(pixel); + let pixel = [pixel[0], pixel[1], pixel[2], pixel[3]]; + + Rgba(pixel) + } +} + +pub(crate) trait TransformColorProfile { + fn transform_color_profile( + &mut self, + transformer: &Transform, + ) -> P + where + P: Pixel + SliceToPixel + 'static, + S: Primitive + 'static; +} + +macro_rules! impl_transform_color_profile { + ($type:ty) => { + impl TransformColorProfile for $type { + fn transform_color_profile( + &mut self, + transformer: &Transform, + ) -> P + where + P: Pixel + SliceToPixel + 'static, + S: Primitive + 'static, + { + let mut pixel = cast_slice_mut(self.0.as_mut_slice()); + transformer.transform_in_place(&mut pixel); + + P::slice_to_pixel(&pixel) + } + } + }; +} + +impl_transform_color_profile!(Luma); +impl_transform_color_profile!(Luma); +impl_transform_color_profile!(LumaA); +impl_transform_color_profile!(LumaA); +impl_transform_color_profile!(Rgb); +impl_transform_color_profile!(Rgb); +impl_transform_color_profile!(Rgba); +impl_transform_color_profile!(Rgba); + +fn process_icc_profile_conversion( + image: &I, + color: ColorType, + input_icc_profile: Profile, + output_icc_profile: Profile, +) -> ImageBuffer> +where + I: GenericImageView, + P: Pixel + SliceToPixel + TransformColorProfile + 'static, + S: Primitive + 'static, +{ + let (width, height) = image.dimensions(); + let mut out = ImageBuffer::new(width, height); + let pixel_format = match color { + ColorType::L8 => PixelFormat::GRAY_8, + ColorType::L16 => PixelFormat::GRAY_16, + ColorType::La8 => PixelFormat::GRAYA_8, + ColorType::La16 => PixelFormat::GRAYA_16, + ColorType::Rgb8 => PixelFormat::RGB_8, + ColorType::Rgb16 => PixelFormat::RGB_16, + ColorType::Rgba8 => PixelFormat::RGBA_8, + ColorType::Rgba16 => PixelFormat::RGBA_16, + // This arm usually doesn't reach, but it should be handled with returning the original image. + _ => { + for (x, y, pixel) in image.pixels() { + out.put_pixel(x, y, pixel); + } + return out; + } + }; + let transformer = Transform::new( + &input_icc_profile, + pixel_format, + &output_icc_profile, + pixel_format, + output_icc_profile.header_rendering_intent(), + ); + + for (x, y, mut pixel) in image.pixels() { + let pixel = match transformer { + Ok(ref transformer) => pixel.transform_color_profile(transformer), + // This arm will reach when the ffi call fails. + Err(_) => pixel, + }; + + out.put_pixel(x, y, pixel); + } + + out +} + +/// Convert the color space of the image from the ICC profile to sRGB. +pub(crate) fn to_srgb_from_icc_profile( + image: DynamicImage, + icc_profile: Option>, + unmatch_color_handler: fn( + ColorType, + DynamicImage, + ) -> Result, +) -> Result { + match icc_profile { + // If there is no color profile information, return the image as is. + None => Ok(image), + Some(icc_profile) => match Profile::new_icc(&icc_profile) { + // If the color profile information is invalid, return the image as is. + Err(_) => Ok(image), + Ok(icc_profile) => { + let srgb_icc_profile = Profile::new_srgb(); + let color = image.color(); + match color { + ColorType::L8 => { + Ok(DynamicImage::ImageLuma8(process_icc_profile_conversion( + image.as_luma8().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::L16 => { + Ok(DynamicImage::ImageLuma16(process_icc_profile_conversion( + image.as_luma16().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::La8 => { + Ok(DynamicImage::ImageLumaA8(process_icc_profile_conversion( + image.as_luma_alpha8().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::La16 => { + Ok(DynamicImage::ImageLumaA16(process_icc_profile_conversion( + image.as_luma_alpha16().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::Rgb8 => { + Ok(DynamicImage::ImageRgb8(process_icc_profile_conversion( + image.as_rgb8().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::Rgb16 => { + Ok(DynamicImage::ImageRgb16(process_icc_profile_conversion( + image.as_rgb16().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::Rgba8 => { + Ok(DynamicImage::ImageRgba8(process_icc_profile_conversion( + image.as_rgba8().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + ColorType::Rgba16 => { + Ok(DynamicImage::ImageRgba16(process_icc_profile_conversion( + image.as_rgba16().unwrap(), + color, + icc_profile, + srgb_icc_profile, + ))) + } + x => unmatch_color_handler(x, image), + } + } + }, + } +} + +// NOTE: The following code is not used in the current implementation, +// but it is left as a reference for future use about implementing CanvasRenderingContext2D. +// https://github.com/denoland/deno/issues/5701#issuecomment-1833304511 + +// // reference +// // https://www.w3.org/TR/css-color-4/#color-conversion-code +// fn srgb_to_linear(value: T) -> f32 { +// if value.to_f32().unwrap() <= 0.04045 { +// value.to_f32().unwrap() / 12.92 +// } else { +// ((value.to_f32().unwrap() + 0.055) / 1.055).powf(2.4) +// } +// } + +// // reference +// // https://www.w3.org/TR/css-color-4/#color-conversion-code +// fn linear_to_display_p3(value: T) -> f32 { +// if value.to_f32().unwrap() <= 0.0031308 { +// value.to_f32().unwrap() * 12.92 +// } else { +// 1.055 * value.to_f32().unwrap().powf(1.0 / 2.4) - 0.055 +// } +// } + +// fn normalize_value_to_0_1(value: T) -> f32 { +// value.to_f32().unwrap() / T::DEFAULT_MAX_VALUE.to_f32().unwrap() +// } + +// fn unnormalize_value_from_0_1(value: f32) -> T { +// NumCast::from( +// (value.clamp(0.0, 1.0) * T::DEFAULT_MAX_VALUE.to_f32().unwrap()).round(), +// ) +// .unwrap() +// } + +// fn apply_conversion_matrix_srgb_to_display_p3( +// r: T, +// g: T, +// b: T, +// ) -> (T, T, T) { +// // normalize the value to 0.0 - 1.0 +// let (r, g, b) = ( +// normalize_value_to_0_1(r), +// normalize_value_to_0_1(g), +// normalize_value_to_0_1(b), +// ); + +// // sRGB -> Linear RGB +// let (r, g, b) = (srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)); + +// // Display-P3 (RGB) -> Display-P3 (XYZ) +// // +// // inv[ P3-D65 (D65) to XYZ ] * [ sRGB (D65) to XYZ ] +// // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html +// // https://fujiwaratko.sakura.ne.jp/infosci/colorspace/colorspace2_e.html + +// // [ sRGB (D65) to XYZ ] +// #[rustfmt::skip] +// let (m1x, m1y, m1z) = ( +// [0.4124564, 0.3575761, 0.1804375], +// [0.2126729, 0.7151522, 0.0721750], +// [0.0193339, 0.119_192, 0.9503041], +// ); + +// let (r, g, b) = ( +// r * m1x[0] + g * m1x[1] + b * m1x[2], +// r * m1y[0] + g * m1y[1] + b * m1y[2], +// r * m1z[0] + g * m1z[1] + b * m1z[2], +// ); + +// // inv[ P3-D65 (D65) to XYZ ] +// #[rustfmt::skip] +// let (m2x, m2y, m2z) = ( +// [ 2.493_497, -0.931_383_6, -0.402_710_8 ], +// [ -0.829_489, 1.762_664_1, 0.023_624_687 ], +// [ 0.035_845_83, -0.076_172_39, 0.956_884_5 ], +// ); + +// let (r, g, b) = ( +// r * m2x[0] + g * m2x[1] + b * m2x[2], +// r * m2y[0] + g * m2y[1] + b * m2y[2], +// r * m2z[0] + g * m2z[1] + b * m2z[2], +// ); + +// // This calculation is similar as above that it is a little faster, but less accurate. +// // let r = 0.8225 * r + 0.1774 * g + 0.0000 * b; +// // let g = 0.0332 * r + 0.9669 * g + 0.0000 * b; +// // let b = 0.0171 * r + 0.0724 * g + 0.9108 * b; + +// // Display-P3 (Linear) -> Display-P3 +// let (r, g, b) = ( +// linear_to_display_p3(r), +// linear_to_display_p3(g), +// linear_to_display_p3(b), +// ); + +// // unnormalize the value from 0.0 - 1.0 +// ( +// unnormalize_value_from_0_1(r), +// unnormalize_value_from_0_1(g), +// unnormalize_value_from_0_1(b), +// ) +// } + +// trait ColorSpaceConversion { +// /// Display P3 Color Encoding (v 1.0) +// /// https://www.color.org/chardata/rgb/DisplayP3.xalter +// fn srgb_to_display_p3(&self) -> Self; +// } + +// impl ColorSpaceConversion for Rgb { +// fn srgb_to_display_p3(&self) -> Self { +// let (r, g, b) = (self.0[0], self.0[1], self.0[2]); + +// let (r, g, b) = apply_conversion_matrix_srgb_to_display_p3(r, g, b); + +// Rgb([r, g, b]) +// } +// } + +// impl ColorSpaceConversion for Rgba { +// fn srgb_to_display_p3(&self) -> Self { +// let (r, g, b, a) = (self.0[0], self.0[1], self.0[2], self.0[3]); + +// let (r, g, b) = apply_conversion_matrix_srgb_to_display_p3(r, g, b); + +// Rgba([r, g, b, a]) +// } +// } + +// fn process_srgb_to_display_p3(image: &I) -> ImageBuffer> +// where +// I: GenericImageView, +// P: Pixel + ColorSpaceConversion + 'static, +// S: Primitive + 'static, +// { +// let (width, height) = image.dimensions(); +// let mut out = ImageBuffer::new(width, height); + +// for (x, y, pixel) in image.pixels() { +// let pixel = pixel.srgb_to_display_p3(); + +// out.put_pixel(x, y, pixel); +// } + +// out +// } + +// /// Convert the color space of the image from sRGB to Display-P3. +// fn srgb_to_display_p3( +// image: DynamicImage, +// unmatch_color_handler: fn( +// ColorType, +// DynamicImage, +// ) -> Result, +// ) -> Result { +// match image.color() { +// // The conversion of the lumincance color types to the display-p3 color space is meaningless. +// ColorType::L8 => Ok(DynamicImage::ImageLuma8(image.into_luma8())), +// ColorType::L16 => Ok(DynamicImage::ImageLuma16(image.into_luma16())), +// ColorType::La8 => Ok(DynamicImage::ImageLumaA8(image.into_luma_alpha8())), +// ColorType::La16 => { +// Ok(DynamicImage::ImageLumaA16(image.into_luma_alpha16())) +// } +// ColorType::Rgb8 => Ok(DynamicImage::ImageRgb8(process_srgb_to_display_p3( +// image.as_rgb8().unwrap(), +// ))), +// ColorType::Rgb16 => Ok(DynamicImage::ImageRgb16( +// process_srgb_to_display_p3(image.as_rgb16().unwrap()), +// )), +// ColorType::Rgba8 => Ok(DynamicImage::ImageRgba8( +// process_srgb_to_display_p3(image.as_rgba8().unwrap()), +// )), +// ColorType::Rgba16 => Ok(DynamicImage::ImageRgba16( +// process_srgb_to_display_p3(image.as_rgba16().unwrap()), +// )), +// x => unmatch_color_handler(x, image), +// } +// } + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + #[test] + fn test_premultiply_alpha() { + let rgba = Rgba::([255, 128, 0, 128]); + let rgba = rgba.premultiply_alpha(); + assert_eq!(rgba, Rgba::([128, 64, 0, 128])); + + let rgba = Rgba::([255, 255, 255, 255]); + let rgba = rgba.premultiply_alpha(); + assert_eq!(rgba, Rgba::([255, 255, 255, 255])); + } + + #[test] + fn test_unpremultiply_alpha() { + let rgba = Rgba::([127, 0, 0, 127]); + let rgba = rgba.unpremultiply_alpha(); + assert_eq!(rgba, Rgba::([255, 0, 0, 127])); + } + + // #[test] + // fn test_apply_conversion_matrix_srgb_to_display_p3() { + // let (r, g, b) = apply_conversion_matrix_srgb_to_display_p3(255_u8, 0, 0); + // assert_eq!(r, 234); + // assert_eq!(g, 51); + // assert_eq!(b, 35); + + // let (r, g, b) = apply_conversion_matrix_srgb_to_display_p3(0_u8, 255, 0); + // assert_eq!(r, 117); + // assert_eq!(g, 251); + // assert_eq!(b, 76); + + // let (r, g, b) = apply_conversion_matrix_srgb_to_display_p3(0_u8, 0, 255); + // assert_eq!(r, 0); + // assert_eq!(g, 0); + // assert_eq!(b, 245); + + // let (r, g, b) = + // apply_conversion_matrix_srgb_to_display_p3(255_u8, 255, 255); + // assert_eq!(r, 255); + // assert_eq!(g, 255); + // assert_eq!(b, 255); + // } +} diff --git a/ext/canvas/lib.rs b/ext/canvas/lib.rs index 72173f133158ba..462c85755a66ea 100644 --- a/ext/canvas/lib.rs +++ b/ext/canvas/lib.rs @@ -1,151 +1,17 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -use deno_core::error::type_error; -use deno_core::error::AnyError; -use deno_core::op2; -use deno_core::ToJsBuffer; -use image::imageops::FilterType; -use image::ColorType; -use image::ImageDecoder; -use image::Pixel; -use image::RgbaImage; -use serde::Deserialize; -use serde::Serialize; use std::path::PathBuf; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ImageResizeQuality { - Pixelated, - Low, - Medium, - High, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ImageProcessArgs { - width: u32, - height: u32, - surface_width: u32, - surface_height: u32, - input_x: i64, - input_y: i64, - output_width: u32, - output_height: u32, - resize_quality: ImageResizeQuality, - flip_y: bool, - premultiply: Option, -} - -#[op2] -#[serde] -fn op_image_process( - #[buffer] buf: &[u8], - #[serde] args: ImageProcessArgs, -) -> Result { - let view = - RgbaImage::from_vec(args.width, args.height, buf.to_vec()).unwrap(); - - let surface = if !(args.width == args.surface_width - && args.height == args.surface_height - && args.input_x == 0 - && args.input_y == 0) - { - let mut surface = RgbaImage::new(args.surface_width, args.surface_height); - - image::imageops::overlay(&mut surface, &view, args.input_x, args.input_y); - - surface - } else { - view - }; - - let filter_type = match args.resize_quality { - ImageResizeQuality::Pixelated => FilterType::Nearest, - ImageResizeQuality::Low => FilterType::Triangle, - ImageResizeQuality::Medium => FilterType::CatmullRom, - ImageResizeQuality::High => FilterType::Lanczos3, - }; - - let mut image_out = image::imageops::resize( - &surface, - args.output_width, - args.output_height, - filter_type, - ); - - if args.flip_y { - image::imageops::flip_vertical_in_place(&mut image_out); - } - - // ignore 9. - - if let Some(premultiply) = args.premultiply { - let is_not_premultiplied = image_out.pixels().any(|pixel| { - (pixel.0[0].max(pixel.0[1]).max(pixel.0[2])) > (255 * pixel.0[3]) - }); - - if premultiply { - if is_not_premultiplied { - for pixel in image_out.pixels_mut() { - let alpha = pixel.0[3]; - pixel.apply_without_alpha(|channel| { - (channel as f32 * (alpha as f32 / 255.0)) as u8 - }) - } - } - } else if !is_not_premultiplied { - for pixel in image_out.pixels_mut() { - let alpha = pixel.0[3]; - pixel.apply_without_alpha(|channel| { - (channel as f32 / (alpha as f32 / 255.0)) as u8 - }) - } - } - } - - Ok(image_out.to_vec().into()) -} - -#[derive(Debug, Serialize)] -struct DecodedPng { - data: ToJsBuffer, - width: u32, - height: u32, -} - -#[op2] -#[serde] -fn op_image_decode_png(#[buffer] buf: &[u8]) -> Result { - let png = image::codecs::png::PngDecoder::new(buf)?; - - let (width, height) = png.dimensions(); - - // TODO(@crowlKats): maybe use DynamicImage https://docs.rs/image/0.24.7/image/enum.DynamicImage.html ? - if png.color_type() != ColorType::Rgba8 { - return Err(type_error(format!( - "Color type '{:?}' not supported", - png.color_type() - ))); - } - - // read_image will assert that the buffer is the correct size, so we need to fill it with zeros - let mut png_data = vec![0_u8; png.total_bytes() as usize]; - - png.read_image(&mut png_data)?; - - Ok(DecodedPng { - data: png_data.into(), - width, - height, - }) -} +pub mod error; +mod image_decoder; +mod image_ops; +mod op_create_image_bitmap; +use op_create_image_bitmap::op_create_image_bitmap; deno_core::extension!( deno_canvas, deps = [deno_webidl, deno_web, deno_webgpu], - ops = [op_image_process, op_image_decode_png], + ops = [op_create_image_bitmap], lazy_loaded_esm = ["01_image.js"], ); diff --git a/ext/canvas/op_create_image_bitmap.rs b/ext/canvas/op_create_image_bitmap.rs new file mode 100644 index 00000000000000..0b361eb0e9afc0 --- /dev/null +++ b/ext/canvas/op_create_image_bitmap.rs @@ -0,0 +1,376 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use std::io::BufReader; +use std::io::Cursor; + +use deno_core::error::type_error; +use deno_core::error::AnyError; +use deno_core::op2; +use deno_core::JsBuffer; +use deno_core::ToJsBuffer; +use deno_terminal::colors::cyan; +use image::codecs::bmp::BmpDecoder; +use image::codecs::gif::GifDecoder; +use image::codecs::ico::IcoDecoder; +use image::codecs::jpeg::JpegDecoder; +use image::codecs::png::PngDecoder; +use image::codecs::webp::WebPDecoder; +use image::imageops::overlay; +use image::imageops::FilterType; +use image::ColorType; +use image::DynamicImage; +use image::ImageError; +use image::RgbaImage; +use serde::Deserialize; + +use crate::error::image_error_message; +use crate::error::DOMExceptionInvalidStateError; +use crate::image_decoder::ImageDecoderFromReader; +use crate::image_decoder::ImageDecoderFromReaderType; +use crate::image_ops::premultiply_alpha as process_premultiply_alpha; +use crate::image_ops::to_srgb_from_icc_profile; +use crate::image_ops::unpremultiply_alpha; + +#[derive(Debug, Deserialize, PartialEq)] +// Follow the cases defined in the spec +enum ImageBitmapSource { + Blob, + ImageData, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum ImageOrientation { + FlipY, + #[serde(rename = "from-image")] + FromImage, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum PremultiplyAlpha { + Default, + Premultiply, + None, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum ColorSpaceConversion { + Default, + None, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum ResizeQuality { + Pixelated, + Low, + Medium, + High, +} + +type DecodeBitmapDataReturn = (DynamicImage, u32, u32, Option>); + +fn decode_bitmap_data( + buf: &[u8], + width: u32, + height: u32, + image_bitmap_source: &ImageBitmapSource, + mime_type: &str, +) -> Result { + let (image, width, height, icc_profile) = match image_bitmap_source { + ImageBitmapSource::Blob => { + fn image_decoding_error(error: ImageError) -> AnyError { + DOMExceptionInvalidStateError::new(&image_error_message( + "decoding", + &error.to_string(), + )) + .into() + } + let (image, icc_profile) = match mime_type { + // Should we support the "image/apng" MIME type here? + "image/png" => { + let mut decoder: PngDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "image/jpeg" => { + let mut decoder: JpegDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "image/gif" => { + let mut decoder: GifDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "image/bmp" => { + let mut decoder: BmpDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "image/x-icon" => { + let mut decoder: IcoDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "image/webp" => { + let mut decoder: WebPDecoder = + ImageDecoderFromReader::to_decoder(BufReader::new(Cursor::new( + buf, + )), image_decoding_error)?; + let icc_profile = decoder.get_icc_profile(); + (decoder.to_intermediate_image(image_decoding_error)?, icc_profile) + } + "" => { + return Err( + DOMExceptionInvalidStateError::new( + &format!("The MIME type of source image is not specified. +INFO: The behavior of the Blob constructor in browsers is different from the spec. +It needs to specify the MIME type like {} that works well between Deno and browsers. +See: https://developer.mozilla.org/en-US/docs/Web/API/Blob/type\n", + cyan("new Blob([blobParts], { type: 'image/png' })") + )).into(), + ) + } + // return an error if the MIME type is not supported in the variable list of ImageTypePatternTable below + // ext/web/01_mimesniff.js + x => { + return Err( + DOMExceptionInvalidStateError::new( + &format!("The the MIME type {} of source image is not a supported format. +INFO: The following MIME types are supported: +See: https://mimesniff.spec.whatwg.org/#image-type-pattern-matching-algorithm\n", + x + )).into() + ) + } + }; + + let width = image.width(); + let height = image.height(); + + (image, width, height, icc_profile) + } + ImageBitmapSource::ImageData => { + // > 4.12.5.1.15 Pixel manipulation + // > imagedata.data + // > Returns the one-dimensional array containing the data in RGBA order, as integers in the range 0 to 255. + // https://html.spec.whatwg.org/multipage/canvas.html#pixel-manipulation + let image = match RgbaImage::from_raw(width, height, buf.into()) { + Some(image) => image.into(), + None => { + return Err(type_error(image_error_message( + "decoding", + "The Chunk Data is not big enough with the specified width and height.", + ))) + } + }; + + (image, width, height, None) + } + }; + + Ok((image, width, height, icc_profile)) +} + +/// According to the spec, it's not clear how to handle the color space conversion. +/// +/// Therefore, if you interpret the specification description from the implementation and wpt results, it will be as follows. +/// +/// Let val be the value of the colorSpaceConversion member of options, and then run these substeps: +/// 1. If val is "default", to convert to the sRGB color space. +/// 2. If val is "none", to use the decoded image data as is. +/// +/// related issue in whatwg +/// https://github.com/whatwg/html/issues/10578 +/// +/// reference in wpt +/// https://github.com/web-platform-tests/wpt/blob/d575dc75ede770df322fbc5da3112dcf81f192ec/html/canvas/element/manual/imagebitmap/createImageBitmap-colorSpaceConversion.html#L18 +/// https://wpt.live/html/canvas/element/manual/imagebitmap/createImageBitmap-colorSpaceConversion.html +fn apply_color_space_conversion( + image: DynamicImage, + icc_profile: Option>, + color_space_conversion: &ColorSpaceConversion, +) -> Result { + match color_space_conversion { + // return the decoded image as is. + ColorSpaceConversion::None => Ok(image), + ColorSpaceConversion::Default => { + fn unmatch_color_handler( + x: ColorType, + _: DynamicImage, + ) -> Result { + Err(type_error(image_error_message( + "apply colorspaceConversion: default", + &format!("The color type {:?} is not supported.", x), + ))) + } + to_srgb_from_icc_profile(image, icc_profile, unmatch_color_handler) + } + } +} + +fn apply_premultiply_alpha( + image: DynamicImage, + image_bitmap_source: &ImageBitmapSource, + premultiply_alpha: &PremultiplyAlpha, +) -> Result { + match premultiply_alpha { + // 1. + PremultiplyAlpha::Default => Ok(image), + + // https://html.spec.whatwg.org/multipage/canvas.html#convert-from-premultiplied + + // 2. + PremultiplyAlpha::Premultiply => process_premultiply_alpha(image), + // 3. + PremultiplyAlpha::None => { + // NOTE: It's not clear how to handle the case of ImageData. + // https://issues.chromium.org/issues/339759426 + // https://github.com/whatwg/html/issues/5365 + if *image_bitmap_source == ImageBitmapSource::ImageData { + return Ok(image); + } + + unpremultiply_alpha(image) + } + } +} + +#[op2] +#[serde] +#[allow(clippy::too_many_arguments)] +pub(super) fn op_create_image_bitmap( + #[buffer] buf: JsBuffer, + width: u32, + height: u32, + sx: Option, + sy: Option, + sw: Option, + sh: Option, + #[serde] image_orientation: ImageOrientation, + #[serde] premultiply_alpha: PremultiplyAlpha, + #[serde] color_space_conversion: ColorSpaceConversion, + resize_width: Option, + resize_height: Option, + #[serde] resize_quality: ResizeQuality, + #[serde] image_bitmap_source: ImageBitmapSource, + #[string] mime_type: &str, +) -> Result<(ToJsBuffer, u32, u32), AnyError> { + // 6. Switch on image: + let (image, width, height, icc_profile) = + decode_bitmap_data(&buf, width, height, &image_bitmap_source, mime_type)?; + + // crop bitmap data + // 2. + #[rustfmt::skip] + let source_rectangle: [[i32; 2]; 4] = + if let (Some(sx), Some(sy), Some(sw), Some(sh)) = (sx, sy, sw, sh) { + [ + [sx, sy], + [sx + sw, sy], + [sx + sw, sy + sh], + [sx, sy + sh] + ] + } else { + [ + [0, 0], + [width as i32, 0], + [width as i32, height as i32], + [0, height as i32], + ] + }; + + /* + * The cropping works differently than the spec specifies: + * The spec states to create an infinite surface and place the top-left corner + * of the image a 0,0 and crop based on sourceRectangle. + * + * We instead create a surface the size of sourceRectangle, and position + * the image at the correct location, which is the inverse of the x & y of + * sourceRectangle's top-left corner. + */ + let input_x = -(source_rectangle[0][0] as i64); + let input_y = -(source_rectangle[0][1] as i64); + + let surface_width = (source_rectangle[1][0] - source_rectangle[0][0]) as u32; + let surface_height = (source_rectangle[3][1] - source_rectangle[0][1]) as u32; + + // 3. + let output_width = if let Some(resize_width) = resize_width { + resize_width + } else if let Some(resize_height) = resize_height { + (surface_width * resize_height).div_ceil(surface_height) + } else { + surface_width + }; + + // 4. + let output_height = if let Some(resize_height) = resize_height { + resize_height + } else if let Some(resize_width) = resize_width { + (surface_height * resize_width).div_ceil(surface_width) + } else { + surface_height + }; + + // 5. + let image = if !(width == surface_width + && height == surface_height + && input_x == 0 + && input_y == 0) + { + let mut surface = + DynamicImage::new(surface_width, surface_height, image.color()); + overlay(&mut surface, &image, input_x, input_y); + + surface + } else { + image + }; + + // 7. + let filter_type = match resize_quality { + ResizeQuality::Pixelated => FilterType::Nearest, + ResizeQuality::Low => FilterType::Triangle, + ResizeQuality::Medium => FilterType::CatmullRom, + ResizeQuality::High => FilterType::Lanczos3, + }; + // should use resize_exact + // https://github.com/image-rs/image/issues/1220#issuecomment-632060015 + let image = image.resize_exact(output_width, output_height, filter_type); + + // 8. + let image = if image_orientation == ImageOrientation::FlipY { + image.flipv() + } else { + image + }; + + // 9. + let image = + apply_color_space_conversion(image, icc_profile, &color_space_conversion)?; + + // 10. + let image = + apply_premultiply_alpha(image, &image_bitmap_source, &premultiply_alpha)?; + + Ok((image.into_bytes().into(), output_width, output_height)) +} diff --git a/ext/web/01_mimesniff.js b/ext/web/01_mimesniff.js index e60783bbe171ac..67a0a24d639604 100644 --- a/ext/web/01_mimesniff.js +++ b/ext/web/01_mimesniff.js @@ -395,6 +395,10 @@ const ImageTypePatternTable = [ /** * Ref: https://mimesniff.spec.whatwg.org/#image-type-pattern-matching-algorithm + * NOTE: Some browsers have implementation-defined image formats. + * For example, The AVIF image format is supported by all browsers today. + * However, the standardization seems to have hard going. + * See: https://github.com/whatwg/mimesniff/issues/143 * @param {Uint8Array} input * @returns {string | undefined} */ diff --git a/runtime/errors.rs b/runtime/errors.rs index 694402773e5f99..bd92b5cef409eb 100644 --- a/runtime/errors.rs +++ b/runtime/errors.rs @@ -159,6 +159,7 @@ pub fn get_error_class_name(e: &AnyError) -> Option<&'static str> { .or_else(|| deno_web::get_error_class_name(e)) .or_else(|| deno_webstorage::get_not_supported_error_class_name(e)) .or_else(|| deno_websocket::get_network_error_class_name(e)) + .or_else(|| deno_canvas::error::get_error_class_name(e)) .or_else(|| { e.downcast_ref::() .map(get_dlopen_error_class) diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 8f53cffc42df92..7704c39182f838 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -350,6 +350,12 @@ core.registerErrorBuilder( return new DOMException(msg, "DataError"); }, ); +core.registerErrorBuilder( + "DOMExceptionInvalidStateError", + function DOMExceptionInvalidStateError(msg) { + return new DOMException(msg, "InvalidStateError"); + }, +); function runtimeStart( denoVersion, diff --git a/tests/testdata/image/1x1-2f-animated-has-def.png b/tests/testdata/image/1x1-2f-animated-has-def.png new file mode 100644 index 00000000000000..d460137ce83541 Binary files /dev/null and b/tests/testdata/image/1x1-2f-animated-has-def.png differ diff --git a/tests/testdata/image/1x1-3f-animated-no-def.png b/tests/testdata/image/1x1-3f-animated-no-def.png new file mode 100644 index 00000000000000..8f8e36b004f082 Binary files /dev/null and b/tests/testdata/image/1x1-3f-animated-no-def.png differ diff --git a/tests/testdata/image/1x1-3f-animated.gif b/tests/testdata/image/1x1-3f-animated.gif new file mode 100644 index 00000000000000..08d3cbc4008a20 Binary files /dev/null and b/tests/testdata/image/1x1-3f-animated.gif differ diff --git a/tests/testdata/image/1x1-3f-lossless-animated-semi-transparent.webp b/tests/testdata/image/1x1-3f-lossless-animated-semi-transparent.webp new file mode 100644 index 00000000000000..15d584d109e573 Binary files /dev/null and b/tests/testdata/image/1x1-3f-lossless-animated-semi-transparent.webp differ diff --git a/tests/testdata/image/1x1-red16.png b/tests/testdata/image/1x1-red16.png new file mode 100644 index 00000000000000..ee9e279c144f69 Binary files /dev/null and b/tests/testdata/image/1x1-red16.png differ diff --git a/tests/testdata/image/1x1-red32f.exr b/tests/testdata/image/1x1-red32f.exr new file mode 100644 index 00000000000000..23ab61731ed8d4 Binary files /dev/null and b/tests/testdata/image/1x1-red32f.exr differ diff --git a/tests/testdata/image/1x1-red8.bmp b/tests/testdata/image/1x1-red8.bmp new file mode 100644 index 00000000000000..c28d7968f81957 Binary files /dev/null and b/tests/testdata/image/1x1-red8.bmp differ diff --git a/tests/testdata/image/1x1-red8.gif b/tests/testdata/image/1x1-red8.gif new file mode 100644 index 00000000000000..0e5a2d361d355d Binary files /dev/null and b/tests/testdata/image/1x1-red8.gif differ diff --git a/tests/testdata/image/1x1-red8.ico b/tests/testdata/image/1x1-red8.ico new file mode 100644 index 00000000000000..4cdfe144bd9f7e Binary files /dev/null and b/tests/testdata/image/1x1-red8.ico differ diff --git a/tests/testdata/image/1x1-red8.jpeg b/tests/testdata/image/1x1-red8.jpeg new file mode 100644 index 00000000000000..3d042f466c62d3 Binary files /dev/null and b/tests/testdata/image/1x1-red8.jpeg differ diff --git a/tests/testdata/image/1x1-red8.png b/tests/testdata/image/1x1-red8.png new file mode 100644 index 00000000000000..8783fe799acaaf Binary files /dev/null and b/tests/testdata/image/1x1-red8.png differ diff --git a/tests/testdata/image/1x1-red8.webp b/tests/testdata/image/1x1-red8.webp new file mode 100644 index 00000000000000..1c35f348fbfa4d Binary files /dev/null and b/tests/testdata/image/1x1-red8.webp differ diff --git a/tests/testdata/image/1x1-white.png b/tests/testdata/image/1x1-white.png deleted file mode 100644 index dd43faec54ae60..00000000000000 Binary files a/tests/testdata/image/1x1-white.png and /dev/null differ diff --git a/tests/testdata/image/2x2-transparent8.png b/tests/testdata/image/2x2-transparent8.png new file mode 100644 index 00000000000000..153838d3e15d9a Binary files /dev/null and b/tests/testdata/image/2x2-transparent8.png differ diff --git a/tests/testdata/image/wide-gamut-pattern.png b/tests/testdata/image/wide-gamut-pattern.png new file mode 100644 index 00000000000000..f35cd4a2e1628b Binary files /dev/null and b/tests/testdata/image/wide-gamut-pattern.png differ diff --git a/tests/unit/image_bitmap_test.ts b/tests/unit/image_bitmap_test.ts index 0066311820eff3..67d163852e19b9 100644 --- a/tests/unit/image_bitmap_test.ts +++ b/tests/unit/image_bitmap_test.ts @@ -1,6 +1,8 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { assertEquals } from "./test_util.ts"; +import { assertEquals, assertRejects } from "./test_util.ts"; + +const prefix = "tests/testdata/image"; function generateNumberedData(n: number): Uint8ClampedArray { return new Uint8ClampedArray( @@ -91,13 +93,251 @@ Deno.test(async function imageBitmapFlipY() { ])); }); -Deno.test(async function imageBitmapFromBlob() { - const path = "tests/testdata/image/1x1-white.png"; - const imageData = new Blob([await Deno.readFile(path)], { - type: "image/png", +Deno.test("imageBitmapPremultiplyAlpha", async (t) => { + const imageData = new ImageData( + new Uint8ClampedArray([ + 255, + 255, + 0, + 153, + ]), + 1, + 1, + ); + await t.step('"ImageData" premultiplyAlpha: "default"', async () => { + const imageBitmap = await createImageBitmap(imageData, { + premultiplyAlpha: "default", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ + 255, 255, 0, 153, + ])); + }); + await t.step('"ImageData" premultiplyAlpha: "premultiply"', async () => { + const imageBitmap = await createImageBitmap(imageData, { + premultiplyAlpha: "premultiply", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ + 153, 153, 0, 153 + ])); + }); + await t.step('"ImageData" premultiplyAlpha: "none"', async () => { + const imageBitmap = await createImageBitmap(imageData, { + premultiplyAlpha: "none", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ + 255, 255, 0, 153, + ])); + }); + await t.step('"Blob" premultiplyAlpha: "none"', async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/2x2-transparent8.png`)], + { type: "image/png" }, + ); + const imageBitmap = await createImageBitmap(imageData, { + premultiplyAlpha: "none", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 0, 0, 127 + ])); + }); +}); + +Deno.test("imageBitmapFromBlob", async (t) => { + await t.step("8-bit png", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.png`)], + { type: "image/png" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("16-bit png", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red16.png`)], + { type: "image/png" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), + // deno-fmt-ignore + new Uint8Array( + [ + 255, 255, // R + 0, 0, // G + 0, 0, // B + 255, 255 // A + ] + ) + ); + }); + await t.step("8-bit jpeg", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.jpeg`)], + { type: "image/jpeg" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([254, 0, 0])); + }); + await t.step("8-bit bmp", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.bmp`)], + { type: "image/bmp" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("8-bit gif", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.gif`)], + { type: "image/gif" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("8-bit webp", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.webp`)], + { type: "image/webp" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("8-bit ico", async () => { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red8.ico`)], + { type: "image/x-icon" }, + ); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("flotat-32-bit exr", async () => { + // image/x-exr is a known mimetype for OpenEXR + // https://www.digipres.org/formats/sources/fdd/formats/#fdd000583 + const imageData = new Blob([ + await Deno.readFile(`${prefix}/1x1-red32f.exr`), + ], { type: "image/x-exr" }); + await assertRejects(() => createImageBitmap(imageData), DOMException); + }); +}); + +Deno.test("imageBitmapFromBlobAnimatedImage", async (t) => { + await t.step("animated png has a default image", async () => { + // the chunk of animated apng is below (2 frames, 1x1, 8-bit, RGBA), default [255, 0, 0, 255] image + // [ 0, 255, 0, 255, + // 0, 0, 255, 255 ] + const imageData = new Blob([ + await Deno.readFile(`${prefix}/1x1-2f-animated-has-def.png`), + ], { type: "image/png" }); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("animated png does not have any default image", async () => { + // the chunk of animated apng is below (3 frames, 1x1, 8-bit, RGBA) + // [ 255, 0, 0, 255, + // 0, 255, 0, 255, + // 0, 0, 255, 255 ] + const imageData = new Blob([ + await Deno.readFile(`${prefix}/1x1-3f-animated-no-def.png`), + ], { type: "image/png" }); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); + await t.step("animated webp", async () => { + // the chunk of animated webp is below (3 frames, 1x1, 8-bit, RGBA) + // + // [ 255, 0, 0, 127, + // 0, 255, 0, 127, + // 0, 0, 255, 127 ] + const imageData = new Blob([ + await Deno.readFile( + `${prefix}/1x1-3f-lossless-animated-semi-transparent.webp`, + ), + ], { type: "image/webp" }); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 127])); + }); + await t.step("animated gif", async () => { + // the chunk of animated gif is below (3 frames, 1x1, 8-bit, RGBA) + // [ 255, 0, 0, 255, + // 0, 255, 0, 255, + // 0, 0, 255, 255 ] + const imageData = new Blob([ + await Deno.readFile(`${prefix}/1x1-3f-animated.gif`), + ], { type: "image/gif" }); + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255, 0, 0, 255])); + }); +}); + +/** + * extract high bytes from Uint16Array + */ +function extractHighBytes(array: Uint8Array): Uint8Array { + const highBytes = new Uint8Array(array.length / 2); + for (let i = 0, j = 1; i < array.length; i++, j += 2) { + highBytes[i] = array[j]; + } + return highBytes; +} + +Deno.test("imageBitmapFromBlobColorspaceConversion", async (t) => { + // reference: + // https://github.com/web-platform-tests/wpt/blob/d575dc75ede770df322fbc5da3112dcf81f192ec/html/canvas/element/manual/imagebitmap/createImageBitmap-colorSpaceConversion.html#L18 + // https://wpt.fyi/results/html/canvas/element/manual/imagebitmap/createImageBitmap-colorSpaceConversion.html?label=experimental&label=master&aligned + await t.step('"Blob" colorSpaceConversion: "none"', async () => { + const imageData = new Blob([ + await Deno.readFile(`${prefix}/wide-gamut-pattern.png`), + ], { type: "image/png" }); + const imageBitmap = await createImageBitmap(imageData, { + colorSpaceConversion: "none", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + const firstPixel = extractHighBytes(Deno[Deno.internal].getBitmapData(imageBitmap)).slice(0, 4); + // picking the high bytes of the first pixel + assertEquals(firstPixel, new Uint8Array([123, 0, 27, 255])); + }); + await t.step('"Blob" colorSpaceConversion: "default"', async () => { + const imageData = new Blob([ + await Deno.readFile(`${prefix}/wide-gamut-pattern.png`), + ], { type: "image/png" }); + const imageBitmap = await createImageBitmap(imageData, { + colorSpaceConversion: "default", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + const firstPixel = extractHighBytes(Deno[Deno.internal].getBitmapData(imageBitmap)).slice(0, 4); + // picking the high bytes of the first pixel + assertEquals(firstPixel, new Uint8Array([255, 0, 0, 255])); }); - const imageBitmap = await createImageBitmap(imageData); - // @ts-ignore: Deno[Deno.internal].core allowed - // deno-fmt-ignore - assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([255,255,255,255])); });