diff --git a/Cargo.lock b/Cargo.lock index fc567b4a380b78..6773348ffe84ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,9 +635,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" @@ -645,6 +645,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" @@ -1420,9 +1426,13 @@ dependencies = [ name = "deno_canvas" version = "0.48.0" dependencies = [ + "bytemuck", "deno_core", + "deno_terminal 0.2.0", "deno_webgpu", "image", + "lcms2", + "num-traits", "serde", "thiserror", ] @@ -2736,6 +2746,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" @@ -3380,6 +3396,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" @@ -4134,15 +4160,29 @@ dependencies = [ [[package]] name = "image" -version = "0.24.9" +version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" dependencies = [ "bytemuck", - "byteorder", + "byteorder-lite", "color_quant", + "gif", + "image-webp", "num-traits", "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", ] [[package]] @@ -4458,6 +4498,29 @@ dependencies = [ "spin", ] +[[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 = "libc" version = "0.2.153" @@ -5014,9 +5077,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", @@ -5759,6 +5822,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" @@ -6072,7 +6141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" dependencies = [ "hostname", - "quick-error", + "quick-error 1.2.3", ] [[package]] @@ -8373,6 +8442,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" @@ -9001,3 +9076,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..75ec1b80f9e89f 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,12 @@ function createImageBitmap( options = undefined, ) { const prefix = "Failed to execute 'createImageBitmap'"; + // Add the value when implementing to add support for ImageBitmapSource + const imageBitmapSources = [ + "Blob", + "ImageData", + "ImageBitmap", + ]; // Overload: createImageBitmap(image [, options ]) if (arguments.length < 3) { @@ -184,6 +188,7 @@ function createImageBitmap( "Argument 6", ); + // 1. if (sw === 0) { return PromiseReject(new RangeError("sw has to be greater than 0")); } @@ -193,6 +198,7 @@ function createImageBitmap( } } + // 2. if (options.resizeWidth === 0) { return PromiseReject( new DOMException( @@ -204,7 +210,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 +218,143 @@ 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, + // 3. + const isBlob = ObjectPrototypeIsPrototypeOf(BlobPrototype, image); + const isImageData = ObjectPrototypeIsPrototypeOf(ImageDataPrototype, image); + const isImageBitmap = ObjectPrototypeIsPrototypeOf( + ImageBitmapPrototype, + image, + ); + if (!isBlob && !isImageData && !isImageBitmap) { + return PromiseReject( + new DOMException( + `${prefix}: The provided value for 'image' is not of type '(${ + ArrayPrototypeJoin(imageBitmapSources, " or ") + })'`, + "InvalidStateError", + ), ); - 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", + + // 4. + return (async () => { + // + // For performance reasons, the arguments passed to op are represented as numbers that don't need to be serialized. + // + + let width = 0; + let height = 0; + // If the image doesn't have a MIME type, mark it as 0. + let mimeType = 0; + let imageBitmapSource, buf; + if (isBlob) { + imageBitmapSource = 0; + buf = new Uint8Array(await image.arrayBuffer()); + const mimeTypeString = sniffImage(image.type); + + if (mimeTypeString === "image/png") { + mimeType = 1; + } else if (mimeTypeString === "image/jpeg") { + mimeType = 2; + } else if (mimeTypeString === "image/gif") { + mimeType = 3; + } else if (mimeTypeString === "image/bmp") { + mimeType = 4; + } else if (mimeTypeString === "image/x-icon") { + mimeType = 5; + } else if (mimeTypeString === "image/webp") { + mimeType = 6; + } else if (mimeTypeString === "") { + return PromiseReject( + new DOMException( + `The MIME type of source image is not specified\n +hint: When you want to get a "Blob" from "fetch", make sure to go through a file server that returns the appropriate content-type response header, + and specify the URL to the file server like "await(await fetch('http://localhost:8000/sample.png').blob()". + Alternatively, if you are reading a local file using 'Deno.readFile' etc., + set the appropriate MIME type like "new Blob([await Deno.readFile('sample.png')], { type: 'image/png' })".\n`, + "InvalidStateError", + ), + ); + } else { + return PromiseReject( + new DOMException( + `The the MIME type ${mimeTypeString} of source image is not a supported format\n +info: The following MIME types are supported. +docs: https://mimesniff.spec.whatwg.org/#image-type-pattern-matching-algorithm\n`, + "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")); - } -} + } else if (isImageData) { + width = image[_width]; + height = image[_height]; + imageBitmapSource = 1; + buf = new Uint8Array(TypedArrayPrototypeGetBuffer(image[_data])); + } else if (isImageBitmap) { + width = image[_width]; + height = image[_height]; + imageBitmapSource = 2; + buf = new Uint8Array(TypedArrayPrototypeGetBuffer(image[_bitmapData])); + } -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; - } + // If those options are not provided, assign 0 to mean undefined(None). + const _sx = typeof sxOrOptions === "number" ? sxOrOptions : 0; + const _sy = sy ?? 0; + const _sw = sw ?? 0; + const _sh = sh ?? 0; - let outputHeight; - if (options.resizeHeight !== undefined) { - outputHeight = options.resizeHeight; - } else if (options.resizeWidth !== undefined) { - outputHeight = MathCeil( - (heightOfSourceRect * options.resizeWidth) / widthOfSourceRect, - ); - } else { - outputHeight = heightOfSourceRect; - } + // If those options are not provided, assign 0 to mean undefined(None). + const resizeWidth = options.resizeWidth ?? 0; + const resizeHeight = options.resizeHeight ?? 0; - if (options.colorSpaceConversion === "none") { - throw new TypeError( - "Cannot create image: invalid colorSpaceConversion option, 'none' is not supported", - ); - } + // If the imageOrientation option is set "from-image" or not set, assign 0. + const imageOrientation = options.imageOrientation === "flipY" ? 1 : 0; - /* - * 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)), - { + // If the premultiplyAlpha option is "default" or not set, assign 0. + let premultiplyAlpha = 0; + if (options.premultiplyAlpha === "premultiply") { + premultiplyAlpha = 1; + } else if (options.premultiplyAlpha === "none") { + premultiplyAlpha = 2; + } + + // If the colorSpaceConversion option is "default" or not set, assign 0. + const colorSpaceConversion = options.colorSpaceConversion === "none" + ? 1 + : 0; + + // If the resizeQuality option is "low" or not set, assign 0. + let resizeQuality = 0; + if (options.resizeQuality === "pixelated") { + resizeQuality = 1; + } else if (options.resizeQuality === "medium") { + resizeQuality = 2; + } else if (options.resizeQuality === "high") { + resizeQuality = 3; + } + + 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, + imageOrientation, + premultiplyAlpha, + colorSpaceConversion, + resizeWidth, + resizeHeight, + resizeQuality, + 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 5dca4424376b8b..0f1301962bcd47 100644 --- a/ext/canvas/Cargo.toml +++ b/ext/canvas/Cargo.toml @@ -14,8 +14,17 @@ 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.4", 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"] } thiserror.workspace = true 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/image_ops.rs b/ext/canvas/image_ops.rs new file mode 100644 index 00000000000000..d275df15bcc978 --- /dev/null +++ b/ext/canvas/image_ops.rs @@ -0,0 +1,608 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use bytemuck::cast_slice; +use bytemuck::cast_slice_mut; +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; + +use crate::CanvasError; + +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 { + DynamicImage::ImageLumaA8(image) => { + Ok(process_premultiply_alpha(&image).into()) + } + DynamicImage::ImageLumaA16(image) => { + Ok(process_premultiply_alpha(&image).into()) + } + DynamicImage::ImageRgba8(image) => { + Ok(process_premultiply_alpha(&image).into()) + } + DynamicImage::ImageRgba16(image) => { + Ok(process_premultiply_alpha(&image).into()) + } + DynamicImage::ImageRgb32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + DynamicImage::ImageRgba32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + // 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 { + DynamicImage::ImageLumaA8(image) => Ok(if is_premultiplied_alpha(&image) { + process_unpremultiply_alpha(&image).into() + } else { + image.into() + }), + DynamicImage::ImageLumaA16(image) => { + Ok(if is_premultiplied_alpha(&image) { + process_unpremultiply_alpha(&image).into() + } else { + image.into() + }) + } + DynamicImage::ImageRgba8(image) => Ok(if is_premultiplied_alpha(&image) { + process_unpremultiply_alpha(&image).into() + } else { + image.into() + }), + DynamicImage::ImageRgba16(image) => Ok(if is_premultiplied_alpha(&image) { + process_unpremultiply_alpha(&image).into() + } else { + image.into() + }), + DynamicImage::ImageRgb32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + DynamicImage::ImageRgba32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + // 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, +) -> Result>, CanvasError> +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 => Ok(PixelFormat::GRAY_8), + ColorType::L16 => Ok(PixelFormat::GRAY_16), + ColorType::La8 => Ok(PixelFormat::GRAYA_8), + ColorType::La16 => Ok(PixelFormat::GRAYA_16), + ColorType::Rgb8 => Ok(PixelFormat::RGB_8), + ColorType::Rgb16 => Ok(PixelFormat::RGB_16), + ColorType::Rgba8 => Ok(PixelFormat::RGBA_8), + ColorType::Rgba16 => Ok(PixelFormat::RGBA_16), + _ => Err(CanvasError::UnsupportedColorType(color)), + }?; + let transformer = Transform::new( + &input_icc_profile, + pixel_format, + &output_icc_profile, + pixel_format, + output_icc_profile.header_rendering_intent(), + ) + .map_err(CanvasError::Lcms)?; + + for (x, y, mut pixel) in image.pixels() { + let pixel = pixel.transform_color_profile(&transformer); + + out.put_pixel(x, y, pixel); + } + + Ok(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>, +) -> 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 image { + DynamicImage::ImageLuma8(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageLuma16(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageLumaA8(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageLumaA16(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageRgb8(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageRgb16(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageRgba8(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageRgba16(image) => Ok( + process_icc_profile_conversion( + &image, + color, + icc_profile, + srgb_icc_profile, + )? + .into(), + ), + DynamicImage::ImageRgb32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + DynamicImage::ImageRgba32F(_) => { + Err(CanvasError::UnsupportedColorType(image.color())) + } + _ => Err(CanvasError::UnsupportedColorType(image.color())), + } + } + }, + } +} + +/// Create an image buffer from raw bytes. +fn process_image_buffer_from_raw_bytes( + width: u32, + height: u32, + buffer: &[u8], + bytes_per_pixel: usize, +) -> ImageBuffer> +where + P: Pixel + SliceToPixel + 'static, + S: Primitive + 'static, +{ + let mut out = ImageBuffer::new(width, height); + for (index, buffer) in buffer.chunks_exact(bytes_per_pixel).enumerate() { + let pixel = P::slice_to_pixel(buffer); + + out.put_pixel(index as u32, index as u32, pixel); + } + + out +} + +pub(crate) fn create_image_from_raw_bytes( + width: u32, + height: u32, + buffer: &[u8], +) -> Result { + let total_pixels = (width * height) as usize; + // avoid to divide by zero + let bytes_per_pixel = buffer + .len() + .checked_div(total_pixels) + .ok_or(CanvasError::InvalidSizeZero(width, height))?; + // convert from a bytes per pixel to the color type of the image + // https://github.com/image-rs/image/blob/2c986d353333d2604f0c3f1fcef262cc763c0001/src/color.rs#L38-L49 + match bytes_per_pixel { + 1 => Ok(DynamicImage::ImageLuma8( + process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + ), + )), + 2 => Ok( + // NOTE: ImageLumaA8 is also the same bytes per pixel. + DynamicImage::ImageLuma16(process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + )), + ), + 3 => Ok(DynamicImage::ImageRgb8( + process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + ), + )), + 4 => Ok( + // NOTE: ImageLumaA16 is also the same bytes per pixel. + DynamicImage::ImageRgba8(process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + )), + ), + 6 => Ok(DynamicImage::ImageRgb16( + process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + ), + )), + 8 => Ok(DynamicImage::ImageRgba16( + process_image_buffer_from_raw_bytes( + width, + height, + buffer, + bytes_per_pixel, + ), + )), + 12 => Err(CanvasError::UnsupportedColorType(ColorType::Rgb32F)), + 16 => Err(CanvasError::UnsupportedColorType(ColorType::Rgba32F)), + _ => Err(CanvasError::UnsupportedColorType(ColorType::L8)), + } +} + +#[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_process_image_buffer_from_raw_bytes() { + let buffer = &[255, 255, 0, 0, 0, 0, 255, 255]; + let color = ColorType::Rgba16; + let bytes_per_pixel = color.bytes_per_pixel() as usize; + let image = DynamicImage::ImageRgba16(process_image_buffer_from_raw_bytes( + 1, + 1, + buffer, + bytes_per_pixel, + )) + .to_rgba16(); + assert_eq!(image.get_pixel(0, 0), &Rgba::([65535, 0, 0, 65535])); + } +} diff --git a/ext/canvas/lib.deno_canvas.d.ts b/ext/canvas/lib.deno_canvas.d.ts index c695ba5cd8a973..6014a12756aadf 100644 --- a/ext/canvas/lib.deno_canvas.d.ts +++ b/ext/canvas/lib.deno_canvas.d.ts @@ -42,7 +42,7 @@ type ResizeQuality = "high" | "low" | "medium" | "pixelated"; * used to create an `ImageBitmap`. * * @category Canvas */ -type ImageBitmapSource = Blob | ImageData; +type ImageBitmapSource = Blob | ImageData | ImageBitmap; /** * The options of {@linkcode createImageBitmap}. diff --git a/ext/canvas/lib.rs b/ext/canvas/lib.rs index defb288ac9d2dd..1c990a5adc3ad2 100644 --- a/ext/canvas/lib.rs +++ b/ext/canvas/lib.rs @@ -1,156 +1,50 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -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; +mod image_ops; +mod op_create_image_bitmap; +use image::ColorType; +use op_create_image_bitmap::op_create_image_bitmap; + #[derive(Debug, thiserror::Error)] pub enum CanvasError { - #[error("Color type '{0:?}' not supported")] + /// Image formats that is 32-bit depth are not supported currently due to the following reasons: + /// - e.g. OpenEXR, it's not covered by the spec. + /// - JPEG XL supported by WebKit, but it cannot be called a standard today. + /// https://github.com/whatwg/mimesniff/issues/143 + /// + /// This error will be mapped to TypeError. + #[error("Unsupported color type and bit depth: '{0:?}'")] UnsupportedColorType(ColorType), + /// This error will be mapped to DOMExceptionInvalidStateError. + #[error("Cannot decode image '{0}'")] + InvalidImage(image::ImageError), + /// This error will be mapped to DOMExceptionInvalidStateError. + #[error("The chunk data is not big enough with the specified width: {0} and height: {1}")] + NotBigEnoughChunk(u32, u32), + /// This error will be mapped to DOMExceptionInvalidStateError. + #[error("The width: {0} or height: {1} could not be zero")] + InvalidSizeZero(u32, u32), + /// This error will be mapped to TypeError. + #[error(transparent)] + Lcms(#[from] lcms2::Error), #[error(transparent)] + /// This error will be mapped to TypeError. Image(#[from] image::ImageError), } -#[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, -) -> ToJsBuffer { - 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); +impl CanvasError { + /// Convert an [`image::ImageError`] to an [`CanvasError::InvalidImage`]. + fn image_error_to_invalid_image(error: image::ImageError) -> Self { + CanvasError::InvalidImage(error) } - - // 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 - }) - } - } - } - - 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(CanvasError::UnsupportedColorType(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, - }) } 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..bc14535452521a --- /dev/null +++ b/ext/canvas/op_create_image_bitmap.rs @@ -0,0 +1,557 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use std::io::BufReader; +use std::io::Cursor; + +use deno_core::op2; +use deno_core::JsBuffer; +use deno_core::ToJsBuffer; +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::metadata::Orientation; +use image::DynamicImage; +use image::ImageDecoder; +use image::RgbaImage; + +use crate::image_ops::create_image_from_raw_bytes; +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; +use crate::CanvasError; + +#[derive(Debug, PartialEq)] +enum ImageBitmapSource { + Blob, + ImageData, + ImageBitmap, +} + +#[derive(Debug, PartialEq)] +enum ImageOrientation { + FlipY, + FromImage, +} + +#[derive(Debug, PartialEq)] +enum PremultiplyAlpha { + Default, + Premultiply, + None, +} + +#[derive(Debug, PartialEq)] +enum ColorSpaceConversion { + Default, + None, +} + +#[derive(Debug, PartialEq)] +enum ResizeQuality { + Pixelated, + Low, + Medium, + High, +} + +#[derive(Debug, PartialEq)] +enum MimeType { + NoMatch, + Png, + Jpeg, + Gif, + Bmp, + Ico, + Webp, +} + +type DecodeBitmapDataReturn = + (DynamicImage, u32, u32, Option, Option>); + +fn decode_bitmap_data( + buf: &[u8], + width: u32, + height: u32, + image_bitmap_source: &ImageBitmapSource, + mime_type: MimeType, +) -> Result { + let (image, width, height, orientation, icc_profile) = + match image_bitmap_source { + ImageBitmapSource::Blob => { + // + // 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 + // + let (image, orientation, icc_profile) = match mime_type { + MimeType::Png => { + // If PngDecoder decodes an animated image, it returns the default image if one is set, or the first frame if not. + let mut decoder = PngDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + MimeType::Jpeg => { + let mut decoder = + JpegDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + MimeType::Gif => { + // The GifDecoder decodes the first frame. + let mut decoder = GifDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + MimeType::Bmp => { + let mut decoder = BmpDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + MimeType::Ico => { + let mut decoder = IcoDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + MimeType::Webp => { + // The WebPDecoder decodes the first frame. + let mut decoder = + WebPDecoder::new(BufReader::new(Cursor::new(buf))) + .map_err(CanvasError::image_error_to_invalid_image)?; + let orientation = decoder.orientation()?; + let icc_profile = decoder.icc_profile()?; + ( + DynamicImage::from_decoder(decoder) + .map_err(CanvasError::image_error_to_invalid_image)?, + orientation, + icc_profile, + ) + } + // This pattern is unreachable due to current block is already checked by the ImageBitmapSource above. + MimeType::NoMatch => unreachable!(), + }; + + let width = image.width(); + let height = image.height(); + + (image, width, height, Some(orientation), 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(CanvasError::NotBigEnoughChunk(width, height)); + } + }; + + (image, width, height, None, None) + } + ImageBitmapSource::ImageBitmap => { + let image = create_image_from_raw_bytes(width, height, buf)?; + + (image, width, height, None, None) + } + }; + + Ok((image, width, height, orientation, 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 => { + to_srgb_from_icc_profile(image, icc_profile) + } + } +} + +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) + } + } +} + +#[derive(Debug, PartialEq)] +struct ParsedArgs { + resize_width: Option, + resize_height: Option, + sx: Option, + sy: Option, + sw: Option, + sh: Option, + image_orientation: ImageOrientation, + premultiply_alpha: PremultiplyAlpha, + color_space_conversion: ColorSpaceConversion, + resize_quality: ResizeQuality, + image_bitmap_source: ImageBitmapSource, + mime_type: MimeType, +} + +#[allow(clippy::too_many_arguments)] +fn parse_args( + sx: i32, + sy: i32, + sw: i32, + sh: i32, + image_orientation: u8, + premultiply_alpha: u8, + color_space_conversion: u8, + resize_width: u32, + resize_height: u32, + resize_quality: u8, + image_bitmap_source: u8, + mime_type: u8, +) -> ParsedArgs { + let resize_width = if resize_width == 0 { + None + } else { + Some(resize_width) + }; + let resize_height = if resize_height == 0 { + None + } else { + Some(resize_height) + }; + let sx = if sx == 0 { None } else { Some(sx) }; + let sy = if sy == 0 { None } else { Some(sy) }; + let sw = if sw == 0 { None } else { Some(sw) }; + let sh = if sh == 0 { None } else { Some(sh) }; + + // Their unreachable wildcard patterns are validated in JavaScript-side. + let image_orientation = match image_orientation { + 0 => ImageOrientation::FromImage, + 1 => ImageOrientation::FlipY, + _ => unreachable!(), + }; + let premultiply_alpha = match premultiply_alpha { + 0 => PremultiplyAlpha::Default, + 1 => PremultiplyAlpha::Premultiply, + 2 => PremultiplyAlpha::None, + _ => unreachable!(), + }; + let color_space_conversion = match color_space_conversion { + 0 => ColorSpaceConversion::Default, + 1 => ColorSpaceConversion::None, + _ => unreachable!(), + }; + let resize_quality = match resize_quality { + 0 => ResizeQuality::Low, + 1 => ResizeQuality::Pixelated, + 2 => ResizeQuality::Medium, + 3 => ResizeQuality::High, + _ => unreachable!(), + }; + let image_bitmap_source = match image_bitmap_source { + 0 => ImageBitmapSource::Blob, + 1 => ImageBitmapSource::ImageData, + 2 => ImageBitmapSource::ImageBitmap, + _ => unreachable!(), + }; + let mime_type = match mime_type { + 0 => MimeType::NoMatch, + 1 => MimeType::Png, + 2 => MimeType::Jpeg, + 3 => MimeType::Gif, + 4 => MimeType::Bmp, + 5 => MimeType::Ico, + 6 => MimeType::Webp, + _ => unreachable!(), + }; + ParsedArgs { + resize_width, + resize_height, + sx, + sy, + sw, + sh, + image_orientation, + premultiply_alpha, + color_space_conversion, + resize_quality, + image_bitmap_source, + mime_type, + } +} + +#[op2] +#[serde] +#[allow(clippy::too_many_arguments)] +pub(super) fn op_create_image_bitmap( + #[buffer] buf: JsBuffer, + width: u32, + height: u32, + sx: i32, + sy: i32, + sw: i32, + sh: i32, + image_orientation: u8, + premultiply_alpha: u8, + color_space_conversion: u8, + resize_width: u32, + resize_height: u32, + resize_quality: u8, + image_bitmap_source: u8, + mime_type: u8, +) -> Result<(ToJsBuffer, u32, u32), CanvasError> { + let ParsedArgs { + resize_width, + resize_height, + sx, + sy, + sw, + sh, + image_orientation, + premultiply_alpha, + color_space_conversion, + resize_quality, + image_bitmap_source, + mime_type, + } = parse_args( + sx, + sy, + sw, + sh, + image_orientation, + premultiply_alpha, + color_space_conversion, + resize_width, + resize_height, + resize_quality, + image_bitmap_source, + mime_type, + ); + + // 6. Switch on image: + let (image, width, height, orientation, 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 mut image = image.resize_exact(output_width, output_height, filter_type); + + // 8. + let image = match image_bitmap_source { + ImageBitmapSource::Blob => { + // Note: According to browser behavior and wpt results, if Exif contains image orientation, + // it applies the rotation from it before following the value of imageOrientation. + // This is not stated in the spec but in MDN currently. + // https://github.com/mdn/content/pull/34366 + + // SAFETY: The orientation is always Some if the image is from a Blob. + let orientation = orientation.unwrap(); + DynamicImage::apply_orientation(&mut image, orientation); + + match image_orientation { + ImageOrientation::FlipY => image.flipv(), + ImageOrientation::FromImage => image, + } + } + ImageBitmapSource::ImageData | ImageBitmapSource::ImageBitmap => { + match image_orientation { + ImageOrientation::FlipY => image.flipv(), + ImageOrientation::FromImage => 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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_args() { + let parsed_args = parse_args(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + assert_eq!( + parsed_args, + ParsedArgs { + resize_width: None, + resize_height: None, + sx: None, + sy: None, + sw: None, + sh: None, + image_orientation: ImageOrientation::FromImage, + premultiply_alpha: PremultiplyAlpha::Default, + color_space_conversion: ColorSpaceConversion::Default, + resize_quality: ResizeQuality::Low, + image_bitmap_source: ImageBitmapSource::Blob, + mime_type: MimeType::NoMatch, + } + ); + } +} 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 4268fbd502df13..aacd81b2f8e6ee 100644 --- a/runtime/errors.rs +++ b/runtime/errors.rs @@ -661,7 +661,11 @@ pub fn get_cron_error_class(e: &CronError) -> &'static str { fn get_canvas_error(e: &CanvasError) -> &'static str { match e { CanvasError::UnsupportedColorType(_) => "TypeError", - CanvasError::Image(_) => "Error", + CanvasError::InvalidImage(_) => "DOMExceptionInvalidStateError", + CanvasError::NotBigEnoughChunk(_, _) => "DOMExceptionInvalidStateError", + CanvasError::InvalidSizeZero(_, _) => "DOMExceptionInvalidStateError", + CanvasError::Lcms(_) => "TypeError", + CanvasError::Image(_) => "TypeError", } } diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index e3b14fdafa8719..6a43fde7745aea 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -354,6 +354,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/squares_6.jpg b/tests/testdata/image/squares_6.jpg new file mode 100644 index 00000000000000..f197760a111e07 Binary files /dev/null and b/tests/testdata/image/squares_6.jpg 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..14555b6f45a126 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( @@ -19,6 +21,21 @@ Deno.test(async function imageBitmapDirect() { ); }); +Deno.test(async function imageBitmapRecivesImageBitmap() { + const imageData = new Blob( + [await Deno.readFile(`${prefix}/1x1-red16.png`)], + { type: "image/png" }, + ); + const imageBitmap1 = await createImageBitmap(imageData); + const imageBitmap2 = await createImageBitmap(imageBitmap1); + assertEquals( + // @ts-ignore: Deno[Deno.internal].core allowed + Deno[Deno.internal].getBitmapData(imageBitmap1), + // @ts-ignore: Deno[Deno.internal].core allowed + Deno[Deno.internal].getBitmapData(imageBitmap2), + ); +}); + Deno.test(async function imageBitmapCrop() { const data = generateNumberedData(3 * 3); const imageData = new ImageData(data, 3, 3); @@ -37,8 +54,8 @@ Deno.test(async function imageBitmapCropPartialNegative() { // @ts-ignore: Deno[Deno.internal].core allowed // deno-fmt-ignore assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 0, 0, 1 + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 1 ])); }); @@ -49,11 +66,11 @@ Deno.test(async function imageBitmapCropGreater() { // @ts-ignore: Deno[Deno.internal].core allowed // deno-fmt-ignore assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 4, 0, 0, 1, 5, 0, 0, 1, 6, 0, 0, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 7, 0, 0, 1, 8, 0, 0, 1, 9, 0, 0, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 4, 0, 0, 1, 5, 0, 0, 1, 6, 0, 0, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 7, 0, 0, 1, 8, 0, 0, 1, 9, 0, 0, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ])); }); @@ -68,36 +85,310 @@ Deno.test(async function imageBitmapScale() { // @ts-ignore: Deno[Deno.internal].core allowed // deno-fmt-ignore assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ - 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, - 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, - 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, - 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, - 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1 + 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, + 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, + 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, + 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1, + 1, 0, 0, 1, 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, 3, 0, 0, 1 ])); }); -Deno.test(async function imageBitmapFlipY() { - const data = generateNumberedData(9); - const imageData = new ImageData(data, 3, 3); - const imageBitmap = await createImageBitmap(imageData, { - imageOrientation: "flipY", +Deno.test("imageOrientation", async (t) => { + await t.step('"ImageData" imageOrientation: "flipY"', async () => { + const data = generateNumberedData(9); + const imageData = new ImageData(data, 3, 3); + const imageBitmap = await createImageBitmap(imageData, { + imageOrientation: "flipY", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + // deno-fmt-ignore + assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ + 7, 0, 0, 1, 8, 0, 0, 1, 9, 0, 0, 1, + 4, 0, 0, 1, 5, 0, 0, 1, 6, 0, 0, 1, + 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, + ])); + }); + + const imageData = new Blob( + [await Deno.readFile(`${prefix}/squares_6.jpg`)], + { type: "image/jpeg" }, + ); + const WIDTH = 320; + const CHANNELS = 3; + const TARGET_PIXEL_X = 40; + const START = TARGET_PIXEL_X * WIDTH * CHANNELS; + const END = START + CHANNELS; + // reference: + // https://github.com/web-platform-tests/wpt/blob/a1f4bbf4c6e1a9a861a145a34cd097ea260b5a49/html/canvas/element/manual/imagebitmap/createImageBitmap-exif-orientation.html#L30 + await t.step('"Blob" imageOrientation: "from-image"', async () => { + const imageBitmap = await createImageBitmap(imageData); + // @ts-ignore: Deno[Deno.internal].core allowed + const targetPixel = Deno[Deno.internal].getBitmapData(imageBitmap).slice( + START, + END, + ); + assertEquals(targetPixel, new Uint8Array([253, 0, 0])); + }); + // reference: + // https://github.com/web-platform-tests/wpt/blob/a1f4bbf4c6e1a9a861a145a34cd097ea260b5a49/html/canvas/element/manual/imagebitmap/createImageBitmap-exif-orientation.html#L55 + await t.step('"Blob" imageOrientation: "flipY"', async () => { + const imageBitmap = await createImageBitmap(imageData, { + imageOrientation: "flipY", + }); + // @ts-ignore: Deno[Deno.internal].core allowed + const targetPixel = Deno[Deno.internal].getBitmapData(imageBitmap).slice( + START, + END, + ); + assertEquals(targetPixel, new Uint8Array([253, 127, 127])); }); - // @ts-ignore: Deno[Deno.internal].core allowed - // deno-fmt-ignore - assertEquals(Deno[Deno.internal].getBitmapData(imageBitmap), new Uint8Array([ - 7, 0, 0, 1, 8, 0, 0, 1, 9, 0, 0, 1, - 4, 0, 0, 1, 5, 0, 0, 1, 6, 0, 0, 1, - 1, 0, 0, 1, 2, 0, 0, 1, 3, 0, 0, 1, - ])); }); -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])); });