diff --git a/ext/canvas/01_image.js b/ext/canvas/01_image.js index 2c74584d43..b87d30ebf0 100644 --- a/ext/canvas/01_image.js +++ b/ext/canvas/01_image.js @@ -352,9 +352,7 @@ function processImage(input, width, height, sx, sy, sw, sh, options) { outputHeight, resizeQuality: options.resizeQuality, flipY: options.imageOrientation === "flipY", - premultiply: options.premultiplyAlpha === "default" - ? null - : (options.premultiplyAlpha === "premultiply"), + premultiplyAlpha: options.premultiplyAlpha, imageBitmapSource: options.imageBitmapSource, }, ); diff --git a/ext/canvas/lib.rs b/ext/canvas/lib.rs index ef5516bef5..97adfdbb93 100644 --- a/ext/canvas/lib.rs +++ b/ext/canvas/lib.rs @@ -6,6 +6,7 @@ use deno_core::op2; use deno_core::ToJsBuffer; use image::imageops::FilterType; use image::AnimationDecoder; +use image::GenericImage; use image::GenericImageView; use image::Pixel; use serde::Deserialize; @@ -26,13 +27,21 @@ enum ImageResizeQuality { High, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, PartialEq)] // Follow the cases defined in the spec enum ImageBitmapSource { Blob, ImageData, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum PremultiplyAlpha { + Default, + Premultiply, + None, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ImageProcessArgs { @@ -46,7 +55,7 @@ struct ImageProcessArgs { output_height: u32, resize_quality: ImageResizeQuality, flip_y: bool, - premultiply: Option, + premultiply_alpha: PremultiplyAlpha, image_bitmap_source: ImageBitmapSource, } @@ -106,27 +115,56 @@ fn op_image_process( // ignore 9. + // 10. if color.has_alpha() { - if let Some(premultiply) = args.premultiply { - let is_not_premultiplied = image_out.pixels().any(|(_, _, pixel)| { - (pixel[0].max(pixel[1]).max(pixel[2])) > (255 * pixel[3]) - }); + match args.premultiply_alpha { + // 1. + PremultiplyAlpha::Default => { /* noop */ } - if premultiply { - if is_not_premultiplied { - for (_, _, mut pixel) in &mut image_out.pixels() { - let alpha = pixel[3]; - pixel.apply_without_alpha(|channel| { - (channel as f32 * (alpha as f32 / 255.0)) as u8 - }) - } - } - } else if !is_not_premultiplied { - for (_, _, mut pixel) in &mut image_out.pixels() { + // https://html.spec.whatwg.org/multipage/canvas.html#convert-from-premultiplied + + // 2. + PremultiplyAlpha::Premultiply => { + for (x, y, mut pixel) in image_out.clone().pixels() { let alpha = pixel[3]; - pixel.apply_without_alpha(|channel| { - (channel as f32 / (alpha as f32 / 255.0)) as u8 - }) + let normalized_alpha = alpha as f64 / u8::MAX as f64; + pixel.apply_without_alpha(|rgb| { + (rgb as f64 * normalized_alpha).round() as u8 + }); + // FIXME: Looking at the API, put_pixel doesn't seem to be necessary, + // but apply_without_alpha with DynamicImage doesn't seem to work as expected. + image_out.put_pixel(x, y, pixel); + } + } + // 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 args.image_bitmap_source == ImageBitmapSource::ImageData { + return Ok(image_out.into_bytes().into()); + } + + // 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 + let is_not_premultiplied = image_out.pixels().any(|(_, _, pixel)| { + let [r, g, b] = [pixel[0], pixel[1], pixel[2]]; + let alpha = pixel[3]; + (r.max(g).max(b)) > u8::MAX.saturating_mul(alpha) + }); + if is_not_premultiplied { + return Ok(image_out.into_bytes().into()); + } + + for (x, y, mut pixel) in image_out.clone().pixels() { + let alpha = pixel[3]; + pixel.apply_without_alpha(|rgb| { + (rgb as f64 / (alpha as f64 / u8::MAX as f64)).round() as u8 + }); + // FIXME: Looking at the API, put_pixel doesn't seem to be necessary, + // but apply_without_alpha with DynamicImage doesn't seem to work as expected. + image_out.put_pixel(x, y, pixel); } } } diff --git a/tests/testdata/image/2x2-transparent8.png b/tests/testdata/image/2x2-transparent8.png new file mode 100644 index 0000000000..153838d3e1 Binary files /dev/null and b/tests/testdata/image/2x2-transparent8.png differ diff --git a/tests/unit/image_bitmap_test.ts b/tests/unit/image_bitmap_test.ts index 38f54dbedb..faceb198fb 100644 --- a/tests/unit/image_bitmap_test.ts +++ b/tests/unit/image_bitmap_test.ts @@ -2,6 +2,8 @@ import { assertEquals, assertRejects } from "./test_util.ts"; +const prefix = "tests/testdata/image"; + function generateNumberedData(n: number): Uint8ClampedArray { return new Uint8ClampedArray( Array.from({ length: n }, (_, i) => [i + 1, 0, 0, 1]).flat(), @@ -91,8 +93,65 @@ Deno.test(async function imageBitmapFlipY() { ])); }); +Deno.test(async function imageBitmapPremultiplyAlpha() { + const imageData = new ImageData( + new Uint8ClampedArray([ + 255, + 255, + 0, + 153, + ]), + 1, + 1, + ); + { + 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, + ])); + } + { + 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 + ])); + } + { + 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, + ])); + } + { + 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(async function imageBitmapFromBlob() { - const prefix = "tests/testdata/image"; { const imageData = new Blob( [await Deno.readFile(`${prefix}/1x1-red8.png`)],