From cc6b3b1a2bb2ce5478ee530a01ea3d26ca1b33de Mon Sep 17 00:00:00 2001 From: Chase Knowlden Date: Wed, 22 Oct 2025 22:46:20 -0400 Subject: [PATCH 1/5] fix: Premultiply alpha before resizing --- crates/tauri-cli/src/icon.rs | 30 ++++++++++++++++++- ..._tests__platform-specific-permissions.snap | 1 + 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index e072d2c5aae6..127880a3c638 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -136,7 +136,35 @@ impl Source { let img_buffer = ImageBuffer::from_raw(size, size, pixmap.take()).unwrap(); Ok(DynamicImage::ImageRgba8(img_buffer)) } - Self::DynamicImage(i) => Ok(i.resize_exact(size, size, FilterType::Lanczos3)), + Self::DynamicImage(i) => { + // Step 1: Premultiply alpha + let mut buf = i.to_rgba8(); + for pixel in buf.pixels_mut() { + let a = pixel[3] as u32; + pixel[0] = ((pixel[0] as u32 * a + 127) / 255) as u8; + pixel[1] = ((pixel[1] as u32 * a + 127) / 255) as u8; + pixel[2] = ((pixel[2] as u32 * a + 127) / 255) as u8; + } + let premultiplied = DynamicImage::ImageRgba8(buf); + + // Step 2: Resize + let mut resized = premultiplied.resize_exact(size, size, FilterType::Lanczos3).to_rgba8(); + + // Step 3: Unpremultiply alpha and zero RGB for fully transparent + for pixel in resized.pixels_mut() { + let a = pixel[3] as u32; + if a == 0 { + pixel[0] = 0; + pixel[1] = 0; + pixel[2] = 0; + } else { + pixel[0] = ((pixel[0] as u32 * 255 + a / 2) / a).min(255) as u8; + pixel[1] = ((pixel[1] as u32 * 255 + a / 2) / a).min(255) as u8; + pixel[2] = ((pixel[2] as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + Ok(DynamicImage::ImageRgba8(resized)) + } } } } diff --git a/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap b/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap index b8b4d0b32e2f..73f393b23331 100644 --- a/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap +++ b/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap @@ -3,6 +3,7 @@ source: crates/tests/acl/src/lib.rs expression: resolved --- Resolved { + has_app_acl: false, allowed_commands: { "plugin:os|spawn": [ ResolvedCommand { From 2ae133429532358e0f8903a597097cad51c3d801 Mon Sep 17 00:00:00 2001 From: Chase Knowlden Date: Mon, 3 Nov 2025 16:48:02 -0500 Subject: [PATCH 2/5] feat: Use rayon for process speedup --- .changes/image-premultiply-fix.md | 5 ++ Cargo.lock | 1 + crates/tauri-cli/Cargo.toml | 1 + crates/tauri-cli/src/icon.rs | 57 +++++++++---------- ..._tests__platform-specific-permissions.snap | 1 - 5 files changed, 35 insertions(+), 30 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..4eb89007e389 --- /dev/null +++ b/.changes/image-premultiply-fix.md @@ -0,0 +1,5 @@ +--- +'tauri-cli': 'patch:fix' +--- + +Premultiply Alpha before Resizing which gets rid of the gray fringe around the icons. diff --git a/Cargo.lock b/Cargo.lock index 671ddc123587..f8a15ee2d173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8667,6 +8667,7 @@ dependencies = [ "plist", "pretty_assertions", "rand 0.9.1", + "rayon", "regex", "resvg", "semver", diff --git a/crates/tauri-cli/Cargo.toml b/crates/tauri-cli/Cargo.toml index d7c11d0fa542..b13c4d679534 100644 --- a/crates/tauri-cli/Cargo.toml +++ b/crates/tauri-cli/Cargo.toml @@ -113,6 +113,7 @@ uuid = { version = "1", features = ["v5"] } rand = "0.9" zip = { version = "4", default-features = false, features = ["deflate"] } which = "8" +rayon = "1.10" [dev-dependencies] insta = "1" diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index 127880a3c638..ffe21df2de35 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -25,10 +25,11 @@ use image::{ png::{CompressionType, FilterType as PngFilterType, PngEncoder}, }, imageops::FilterType, - open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Rgba, + open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Pixel, Rgba, }; use resvg::{tiny_skia, usvg}; use serde::Deserialize; +use rayon::iter::ParallelIterator; #[derive(Debug, Deserialize)] struct IcnsEntry { @@ -136,34 +137,32 @@ impl Source { let img_buffer = ImageBuffer::from_raw(size, size, pixmap.take()).unwrap(); Ok(DynamicImage::ImageRgba8(img_buffer)) } - Self::DynamicImage(i) => { - // Step 1: Premultiply alpha - let mut buf = i.to_rgba8(); - for pixel in buf.pixels_mut() { - let a = pixel[3] as u32; - pixel[0] = ((pixel[0] as u32 * a + 127) / 255) as u8; - pixel[1] = ((pixel[1] as u32 * a + 127) / 255) as u8; - pixel[2] = ((pixel[2] as u32 * a + 127) / 255) as u8; - } - let premultiplied = DynamicImage::ImageRgba8(buf); - - // Step 2: Resize - let mut resized = premultiplied.resize_exact(size, size, FilterType::Lanczos3).to_rgba8(); - - // Step 3: Unpremultiply alpha and zero RGB for fully transparent - for pixel in resized.pixels_mut() { - let a = pixel[3] as u32; - if a == 0 { - pixel[0] = 0; - pixel[1] = 0; - pixel[2] = 0; - } else { - pixel[0] = ((pixel[0] as u32 * 255 + a / 2) / a).min(255) as u8; - pixel[1] = ((pixel[1] as u32 * 255 + a / 2) / a).min(255) as u8; - pixel[2] = ((pixel[2] as u32 * 255 + a / 2) / a).min(255) as u8; - } - } - Ok(DynamicImage::ImageRgba8(resized)) + Self::DynamicImage(image) => { + // Premultiply alpha + let premultiplied_image = DynamicImage::ImageRgba8(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 = premultiplied_image.resize_exact(size, size, FilterType::Lanczos3); + + // Unmultiply alpha + resized + .as_mut_rgba8() + .unwrap() + .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(resized) } } } diff --git a/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap b/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap index 73f393b23331..b8b4d0b32e2f 100644 --- a/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap +++ b/crates/tests/acl/fixtures/snapshots/macOS/acl_tests__tests__platform-specific-permissions.snap @@ -3,7 +3,6 @@ source: crates/tests/acl/src/lib.rs expression: resolved --- Resolved { - has_app_acl: false, allowed_commands: { "plugin:os|spawn": [ ResolvedCommand { From 72704db50f71f30ce99e3c0df48c1e1a2bbb7256 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 4 Nov 2025 10:34:40 +0800 Subject: [PATCH 3/5] Fix change tag --- .changes/image-premultiply-fix.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changes/image-premultiply-fix.md b/.changes/image-premultiply-fix.md index 4eb89007e389..4abe9b867f56 100644 --- a/.changes/image-premultiply-fix.md +++ b/.changes/image-premultiply-fix.md @@ -1,5 +1,6 @@ --- -'tauri-cli': 'patch:fix' +'tauri-cli': 'patch:bug' +'@tauri-apps/cli': 'patch:bug' --- Premultiply Alpha before Resizing which gets rid of the gray fringe around the icons. From 6aac6d2912c86e2184138b06ec63ca7dbde70058 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 4 Nov 2025 10:35:02 +0800 Subject: [PATCH 4/5] `cargo fmt` --- crates/tauri-cli/src/icon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index ffe21df2de35..6fff69985afa 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -27,9 +27,9 @@ use image::{ imageops::FilterType, open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Pixel, Rgba, }; +use rayon::iter::ParallelIterator; use resvg::{tiny_skia, usvg}; use serde::Deserialize; -use rayon::iter::ParallelIterator; #[derive(Debug, Deserialize)] struct IcnsEntry { From 7b8bae4eebdacc30b196d801567806d348e9ea94 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 4 Nov 2025 10:48:26 +0800 Subject: [PATCH 5/5] Document reasoning & use imageops::resize directly --- crates/tauri-cli/src/icon.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index 6fff69985afa..474fb8ad65c9 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -138,31 +138,30 @@ impl Source { Ok(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 = DynamicImage::ImageRgba8(ImageBuffer::from_par_fn( - image.width(), - image.height(), - |x, y| { + 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 = premultiplied_image.resize_exact(size, size, FilterType::Lanczos3); + let mut resized = + image::imageops::resize(&premultiplied_image, size, size, FilterType::Lanczos3); // Unmultiply alpha - resized - .as_mut_rgba8() - .unwrap() - .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); - }); + 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(resized) + Ok(DynamicImage::ImageRgba8(resized)) } } }