From d51b385cb522e9691553df2e6683d119344b4b40 Mon Sep 17 00:00:00 2001 From: Tony Date: Wed, 5 Nov 2025 08:28:36 +0800 Subject: [PATCH 1/4] fix(cli): demultiply tiny skia pixels --- .changes/image-premultiply-fix.md | 6 ++++++ crates/tauri-cli/src/icon.rs | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changes/image-premultiply-fix.md diff --git a/.changes/image-premultiply-fix.md b/.changes/image-premultiply-fix.md new file mode 100644 index 000000000000..0ee098c834f2 --- /dev/null +++ b/.changes/image-premultiply-fix.md @@ -0,0 +1,6 @@ +--- +'tauri-cli': 'patch:bug' +'@tauri-apps/cli': 'patch:bug' +--- + +Premultiply Alpha before Resizing which gets rid of the gray fringe around the icons for svg images. diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index 474fb8ad65c9..fe3a240e92fb 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -134,7 +134,12 @@ impl Source { tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut(), ); - let img_buffer = ImageBuffer::from_raw(size, size, pixmap.take()).unwrap(); + // Switch to use `Pixmap::take_demultiplied` in the future when it's published + // https://github.com/linebender/tiny-skia/blob/624257c0feb394bf6c4d0d688f8ea8030aae320f/src/pixmap.rs#L266 + let img_buffer = ImageBuffer::from_par_fn(size, size, |x, y| { + let pixel = pixmap.pixel(x, y).unwrap().demultiply(); + Rgba([pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()]) + }); Ok(DynamicImage::ImageRgba8(img_buffer)) } Self::DynamicImage(image) => { @@ -155,7 +160,7 @@ impl Source { let mut resized = image::imageops::resize(&premultiplied_image, size, size, FilterType::Lanczos3); - // Unmultiply alpha + // Demultiply alpha resized.par_pixels_mut().for_each(|pixel| { let alpha = pixel.0[3] as f32 / u8::MAX as f32; pixel.apply_without_alpha(|channel_value| (channel_value as f32 / alpha) as u8); @@ -183,7 +188,7 @@ fn read_source(path: PathBuf) -> Result { ..Default::default() }; - let svg_data = std::fs::read(&path).unwrap(); + let svg_data = std::fs::read(&path).fs_context("Failed to read source icon", &path)?; usvg::Tree::from_data(&svg_data, &opt).unwrap() }; From b30e61eb0ddc4649395ca1677bbfed9397194c42 Mon Sep 17 00:00:00 2001 From: Tony Date: Wed, 5 Nov 2025 08:59:53 +0800 Subject: [PATCH 2/4] Pull resize out to a function `resize_image` --- crates/tauri-cli/src/icon.rs | 64 ++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index fe3a240e92fb..b484cc2c9d3b 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -124,7 +124,7 @@ impl Source { } } - fn resize_exact(&self, size: u32) -> Result { + fn resize_exact(&self, size: u32) -> DynamicImage { match self { Self::Svg(svg) => { let mut pixmap = tiny_skia::Pixmap::new(size, size).unwrap(); @@ -140,38 +140,44 @@ impl Source { let pixel = pixmap.pixel(x, y).unwrap().demultiply(); Rgba([pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()]) }); - Ok(DynamicImage::ImageRgba8(img_buffer)) + DynamicImage::ImageRgba8(img_buffer) } Self::DynamicImage(image) => { // `image` does not use premultiplied alpha in resize, so we do it manually here, // see https://github.com/image-rs/image/issues/1655 // // image.resize_exact(size, size, FilterType::Lanczos3) - - // Premultiply alpha - let premultiplied_image = - ImageBuffer::from_par_fn(image.width(), image.height(), |x, y| { - let mut pixel = image.get_pixel(x, y); - let alpha = pixel.0[3] as f32 / u8::MAX as f32; - pixel.apply_without_alpha(|channel_value| (channel_value as f32 * alpha) as u8); - pixel - }); - - let mut resized = - image::imageops::resize(&premultiplied_image, size, size, FilterType::Lanczos3); - - // Demultiply alpha - resized.par_pixels_mut().for_each(|pixel| { - let alpha = pixel.0[3] as f32 / u8::MAX as f32; - pixel.apply_without_alpha(|channel_value| (channel_value as f32 / alpha) as u8); - }); - - Ok(DynamicImage::ImageRgba8(resized)) + resize_image(image, size, size) } } } } +fn resize_image(image: &DynamicImage, new_width: u32, new_height: u32) -> DynamicImage { + // Premultiply alpha + let premultiplied_image = ImageBuffer::from_par_fn(image.width(), image.height(), |x, y| { + let mut pixel = image.get_pixel(x, y); + let alpha = pixel.0[3] as f32 / u8::MAX as f32; + pixel.apply_without_alpha(|channel_value| (channel_value as f32 * alpha) as u8); + pixel + }); + + let mut resized = image::imageops::resize( + &premultiplied_image, + new_width, + new_height, + FilterType::Lanczos3, + ); + + // Demultiply alpha + resized.par_pixels_mut().for_each(|pixel| { + let alpha = pixel.0[3] as f32 / u8::MAX as f32; + pixel.apply_without_alpha(|channel_value| (channel_value as f32 / alpha) as u8); + }); + + DynamicImage::ImageRgba8(resized) +} + fn read_source(path: PathBuf) -> Result { if let Some(extension) = path.extension() { if extension == "svg" { @@ -334,7 +340,7 @@ fn icns(source: &Source, out_dir: &Path) -> Result<()> { let size = entry.size; let mut buf = Vec::new(); - let image = source.resize_exact(size)?; + let image = source.resize_exact(size); write_png(image.as_bytes(), &mut buf, size).context("failed to write output file")?; @@ -369,7 +375,7 @@ fn ico(source: &Source, out_dir: &Path) -> Result<()> { let mut frames = Vec::new(); for size in [32, 16, 24, 48, 64, 256] { - let image = source.resize_exact(size)?; + let image = source.resize_exact(size); // Only the 256px layer can be compressed according to the ico specs. if size == 256 { @@ -800,7 +806,7 @@ fn resize_png( bg: Option, scale_percent: Option, ) -> Result { - let mut image = source.resize_exact(size)?; + let mut image = source.resize_exact(size); match bg { Some(Background::Color(bg_color)) => { @@ -814,7 +820,7 @@ fn resize_png( image = bg_img.into(); } Some(Background::Image(bg_source)) => { - let mut bg = bg_source.resize_exact(size)?; + let mut bg = bg_source.resize_exact(size); let fg = scale_percent .map(|scale| resize_asset(&image, size, scale)) @@ -894,9 +900,9 @@ fn content_bounds(img: &DynamicImage) -> Option<(u32, u32, u32, u32)> { fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> DynamicImage { let cropped = if let Some((x, y, cw, ch)) = content_bounds(img) { - img.crop_imm(x, y, cw, ch) + &img.crop_imm(x, y, cw, ch) } else { - img.clone() + img }; let (cw, ch) = cropped.dimensions(); @@ -906,7 +912,7 @@ fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> Dyn let new_w = (cw as f32 * scale).round() as u32; let new_h = (ch as f32 * scale).round() as u32; - let resized = image::imageops::resize(&cropped, new_w, new_h, image::imageops::Lanczos3); + let resized = resize_image(cropped, new_w, new_h); // Place on transparent square canvas let mut canvas = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0])); From ecd5612b95a48d9597429d7f1fcbacd906809498 Mon Sep 17 00:00:00 2001 From: Tony Date: Wed, 5 Nov 2025 09:01:49 +0800 Subject: [PATCH 3/4] Move comments as well --- crates/tauri-cli/src/icon.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index b484cc2c9d3b..86cf7180ffa9 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -143,9 +143,6 @@ impl Source { DynamicImage::ImageRgba8(img_buffer) } Self::DynamicImage(image) => { - // `image` does not use premultiplied alpha in resize, so we do it manually here, - // see https://github.com/image-rs/image/issues/1655 - // // image.resize_exact(size, size, FilterType::Lanczos3) resize_image(image, size, size) } @@ -153,6 +150,8 @@ impl Source { } } +// `image` does not use premultiplied alpha in resize, so we do it manually here, +// see https://github.com/image-rs/image/issues/1655 fn resize_image(image: &DynamicImage, new_width: u32, new_height: u32) -> DynamicImage { // Premultiply alpha let premultiplied_image = ImageBuffer::from_par_fn(image.width(), image.height(), |x, y| { From d3d498d349c4fd99e7c69bb77a7c429c25a8ef4a Mon Sep 17 00:00:00 2001 From: Tony Date: Wed, 5 Nov 2025 09:16:23 +0800 Subject: [PATCH 4/4] Use cow for older rust versions --- crates/tauri-cli/src/icon.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index 86cf7180ffa9..ca1783d5d647 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -9,6 +9,7 @@ use crate::{ }; use std::{ + borrow::Cow, collections::HashMap, fs::{create_dir_all, File}, io::{BufWriter, Write}, @@ -899,9 +900,10 @@ fn content_bounds(img: &DynamicImage) -> Option<(u32, u32, u32, u32)> { fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> DynamicImage { let cropped = if let Some((x, y, cw, ch)) = content_bounds(img) { - &img.crop_imm(x, y, cw, ch) + // TODO: Use `&` here instead when we raise MSRV to above 1.79 + Cow::Owned(img.crop_imm(x, y, cw, ch)) } else { - img + Cow::Borrowed(img) }; let (cw, ch) = cropped.dimensions(); @@ -911,7 +913,7 @@ fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> Dyn let new_w = (cw as f32 * scale).round() as u32; let new_h = (ch as f32 * scale).round() as u32; - let resized = resize_image(cropped, new_w, new_h); + let resized = resize_image(&cropped, new_w, new_h); // Place on transparent square canvas let mut canvas = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0]));