fix(cli): demultiply tiny skia pixels (#14416)

* fix(cli): demultiply tiny skia pixels

* Pull resize out to a function `resize_image`

* Move comments as well

* Use cow for older rust versions
This commit is contained in:
Tony 2025-11-06 10:12:10 +08:00 committed by GitHub
parent dd70d213cd
commit b586ecf1f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 52 additions and 34 deletions

View File

@ -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.

View File

@ -9,6 +9,7 @@ use crate::{
};
use std::{
borrow::Cow,
collections::HashMap,
fs::{create_dir_all, File},
io::{BufWriter, Write},
@ -124,7 +125,7 @@ impl Source {
}
}
fn resize_exact(&self, size: u32) -> Result<DynamicImage> {
fn resize_exact(&self, size: u32) -> DynamicImage {
match self {
Self::Svg(svg) => {
let mut pixmap = tiny_skia::Pixmap::new(size, size).unwrap();
@ -134,39 +135,49 @@ impl Source {
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let img_buffer = ImageBuffer::from_raw(size, size, pixmap.take()).unwrap();
Ok(DynamicImage::ImageRgba8(img_buffer))
// 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()])
});
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);
// Unmultiply 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)
}
}
}
}
// `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| {
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<Source> {
if let Some(extension) = path.extension() {
if extension == "svg" {
@ -183,7 +194,7 @@ fn read_source(path: PathBuf) -> Result<Source> {
..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()
};
@ -329,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")?;
@ -364,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 {
@ -795,7 +806,7 @@ fn resize_png(
bg: Option<Background>,
scale_percent: Option<f32>,
) -> Result<DynamicImage> {
let mut image = source.resize_exact(size)?;
let mut image = source.resize_exact(size);
match bg {
Some(Background::Color(bg_color)) => {
@ -809,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))
@ -889,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.clone()
Cow::Borrowed(img)
};
let (cw, ch) = cropped.dimensions();
@ -901,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 = 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]));