mirror of
https://github.com/tauri-apps/tauri.git
synced 2026-02-06 13:57:16 +00:00
Add support for adaptive and themed icons on android (#14223)
* add support for adaptive icons * fix * small cleanup * combine android_bg and android_fg when specified * Update crates/tauri-cli/src/icon.rs Co-authored-by: Fabian-Lars <github@fabianlars.de> * add scale option * properly generated rounded icons * covector, clippy --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app> Co-authored-by: Fabian-Lars <github@fabianlars.de>
This commit is contained in:
parent
673867aa0e
commit
94cbd40fc7
5
.changes/adaptive_icons.md
Normal file
5
.changes/adaptive_icons.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'tauri-cli': 'patch:enhance'
|
||||
---
|
||||
|
||||
Add support for Android's adaptive and themed icons.
|
||||
@ -22,7 +22,7 @@ use image::{
|
||||
png::{CompressionType, FilterType as PngFilterType, PngEncoder},
|
||||
},
|
||||
imageops::FilterType,
|
||||
open, DynamicImage, ExtendedColorType, ImageBuffer, ImageEncoder, Rgba,
|
||||
open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Rgba,
|
||||
};
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use serde::Deserialize;
|
||||
@ -40,10 +40,48 @@ struct PngEntry {
|
||||
out_path: PathBuf,
|
||||
}
|
||||
|
||||
enum AndroidIconKind {
|
||||
Regular,
|
||||
Rounded,
|
||||
}
|
||||
|
||||
struct AndroidEntries {
|
||||
icon: Vec<(PngEntry, AndroidIconKind)>,
|
||||
foreground: Vec<PngEntry>,
|
||||
background: Vec<PngEntry>,
|
||||
monochrome: Vec<PngEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
default: String,
|
||||
bg_color: Option<String>,
|
||||
android_bg: Option<String>,
|
||||
android_fg: Option<String>,
|
||||
android_monochrome: Option<String>,
|
||||
android_fg_scale: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(about = "Generate various icons for all major platforms")]
|
||||
pub struct Options {
|
||||
/// Path to the source icon (squared PNG or SVG file with transparency).
|
||||
/// Path to the source icon (squared PNG or SVG file with transparency) or a manifest file.
|
||||
///
|
||||
/// The manifest file is a JSON file with the following structure:
|
||||
/// {
|
||||
/// "default": "app-icon.png",
|
||||
/// "bg_color": "#fff",
|
||||
/// "android_bg": "app-icon-bg.png",
|
||||
/// "android_fg": "app-icon-fg.png",
|
||||
/// "android_fg_scale": 85,
|
||||
/// "android_monochrome": "app-icon-monochrome.png"
|
||||
/// }
|
||||
///
|
||||
/// All file paths defined in the manifest JSON are relative to the manifest file path.
|
||||
///
|
||||
/// Only the `default` manifest property is required.
|
||||
///
|
||||
/// The `bg_color` manifest value overwrites the `--ios-color` option if set.
|
||||
#[clap(default_value = "./app-icon.png")]
|
||||
input: PathBuf,
|
||||
/// Output directory.
|
||||
@ -60,6 +98,7 @@ pub struct Options {
|
||||
ios_color: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Source {
|
||||
Svg(resvg::usvg::Tree),
|
||||
@ -99,14 +138,41 @@ impl Source {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(options: Options) -> Result<()> {
|
||||
let input = options.input;
|
||||
let out_dir = options.output.unwrap_or_else(|| {
|
||||
crate::helpers::app_paths::resolve();
|
||||
tauri_dir().join("icons")
|
||||
});
|
||||
let png_icon_sizes = options.png.unwrap_or_default();
|
||||
let ios_color = css_color::Srgb::from_str(&options.ios_color)
|
||||
fn read_source(path: PathBuf) -> Result<Source> {
|
||||
if let Some(extension) = path.extension() {
|
||||
if extension == "svg" {
|
||||
let rtree = {
|
||||
let mut fontdb = usvg::fontdb::Database::new();
|
||||
fontdb.load_system_fonts();
|
||||
|
||||
let opt = usvg::Options {
|
||||
// Get file's absolute directory.
|
||||
resources_dir: std::fs::canonicalize(&path)
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf())),
|
||||
fontdb: Arc::new(fontdb),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let svg_data = std::fs::read(&path).unwrap();
|
||||
usvg::Tree::from_data(&svg_data, &opt).unwrap()
|
||||
};
|
||||
|
||||
Ok(Source::Svg(rtree))
|
||||
} else {
|
||||
Ok(Source::DynamicImage(DynamicImage::ImageRgba8(
|
||||
open(&path)
|
||||
.context(format!("Can't read and decode source image: {:?}", path))?
|
||||
.into_rgba8(),
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("Error loading image");
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bg_color(bg_color_string: &String) -> Result<Rgba<u8>> {
|
||||
let bg_color = css_color::Srgb::from_str(bg_color_string)
|
||||
.map(|color| {
|
||||
Rgba([
|
||||
(color.red * 255.) as u8,
|
||||
@ -115,41 +181,44 @@ pub fn command(options: Options) -> Result<()> {
|
||||
(color.alpha * 255.) as u8,
|
||||
])
|
||||
})
|
||||
.map_err(|_| anyhow::anyhow!("failed to parse iOS color"))?;
|
||||
.map_err(|_| anyhow::anyhow!("failed to parse color {}", bg_color_string))?;
|
||||
|
||||
Ok(bg_color)
|
||||
}
|
||||
|
||||
pub fn command(options: Options) -> Result<()> {
|
||||
let input = options.input;
|
||||
let out_dir = options.output.unwrap_or_else(|| {
|
||||
crate::helpers::app_paths::resolve();
|
||||
tauri_dir().join("icons")
|
||||
});
|
||||
let png_icon_sizes = options.png.unwrap_or_default();
|
||||
|
||||
create_dir_all(&out_dir).context("Can't create output directory")?;
|
||||
|
||||
let source = if let Some(extension) = input.extension() {
|
||||
if extension == "svg" {
|
||||
let rtree = {
|
||||
let mut fontdb = usvg::fontdb::Database::new();
|
||||
fontdb.load_system_fonts();
|
||||
|
||||
let opt = usvg::Options {
|
||||
// Get file's absolute directory.
|
||||
resources_dir: std::fs::canonicalize(&input)
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf())),
|
||||
fontdb: Arc::new(fontdb),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let svg_data = std::fs::read(&input).unwrap();
|
||||
usvg::Tree::from_data(&svg_data, &opt).unwrap()
|
||||
};
|
||||
|
||||
Source::Svg(rtree)
|
||||
} else {
|
||||
Source::DynamicImage(DynamicImage::ImageRgba8(
|
||||
open(&input)
|
||||
.context("Can't read and decode source image")?
|
||||
.into_rgba8(),
|
||||
))
|
||||
}
|
||||
let manifest = if input.extension().is_some_and(|ext| ext == "json") {
|
||||
parse_manifest(&input).map(Some)?
|
||||
} else {
|
||||
anyhow::bail!("Error loading image");
|
||||
None
|
||||
};
|
||||
|
||||
let bg_color_string = match manifest {
|
||||
Some(ref manifest) => manifest
|
||||
.bg_color
|
||||
.as_ref()
|
||||
.unwrap_or(&options.ios_color)
|
||||
.clone(),
|
||||
None => options.ios_color,
|
||||
};
|
||||
let bg_color = parse_bg_color(&bg_color_string)?;
|
||||
|
||||
let default_icon = match manifest {
|
||||
Some(ref manifest) => input.parent().unwrap().join(manifest.default.clone()),
|
||||
None => input.clone(),
|
||||
};
|
||||
|
||||
let source = read_source(default_icon)?;
|
||||
|
||||
if source.height() != source.width() {
|
||||
anyhow::bail!("Source image must be square");
|
||||
}
|
||||
@ -159,7 +228,9 @@ pub fn command(options: Options) -> Result<()> {
|
||||
icns(&source, &out_dir).context("Failed to generate .icns file")?;
|
||||
ico(&source, &out_dir).context("Failed to generate .ico file")?;
|
||||
|
||||
png(&source, &out_dir, ios_color).context("Failed to generate png icons")?;
|
||||
png(&source, &out_dir, bg_color).context("Failed to generate png icons")?;
|
||||
android(&source, &input, manifest, &bg_color_string, &out_dir)
|
||||
.context("Failed to generate android icons")?;
|
||||
} else {
|
||||
for target in png_icon_sizes
|
||||
.into_iter()
|
||||
@ -175,22 +246,32 @@ pub fn command(options: Options) -> Result<()> {
|
||||
.collect::<Vec<PngEntry>>()
|
||||
{
|
||||
log::info!(action = "PNG"; "Creating {}", target.name);
|
||||
resize_and_save_png(&source, target.size, &target.out_path, None)?;
|
||||
resize_and_save_png(&source, target.size, &target.out_path, None, None)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_manifest(manifest_path: &Path) -> Result<Manifest> {
|
||||
let manifest: Manifest = serde_json::from_str(
|
||||
&std::fs::read_to_string(manifest_path)
|
||||
.with_context(|| format!("cannot read manifest file {}", manifest_path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("failed to parse manifest file {}", manifest_path.display()))?;
|
||||
log::debug!("Read manifest file from {}", manifest_path.display());
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn appx(source: &Source, out_dir: &Path) -> Result<()> {
|
||||
log::info!(action = "Appx"; "Creating StoreLogo.png");
|
||||
resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None)?;
|
||||
resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None, None)?;
|
||||
|
||||
for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] {
|
||||
let file_name = format!("Square{size}x{size}Logo.png");
|
||||
log::info!(action = "Appx"; "Creating {}", file_name);
|
||||
|
||||
resize_and_save_png(source, size, &out_dir.join(&file_name), None)?;
|
||||
resize_and_save_png(source, size, &out_dir.join(&file_name), None, None)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -268,38 +349,20 @@ fn ico(source: &Source, out_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png)
|
||||
// Main target: Linux
|
||||
fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for size in [32, 64, 128, 256, 512] {
|
||||
let file_name = match size {
|
||||
256 => "128x128@2x.png".to_string(),
|
||||
512 => "icon.png".to_string(),
|
||||
_ => format!("{size}x{size}.png"),
|
||||
};
|
||||
|
||||
entries.push(PngEntry {
|
||||
out_path: out_dir.join(&file_name),
|
||||
name: file_name,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn android_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
|
||||
fn android(
|
||||
source: &Source,
|
||||
input: &Path,
|
||||
manifest: Option<Manifest>,
|
||||
bg_color: &String,
|
||||
out_dir: &Path,
|
||||
) -> Result<()> {
|
||||
fn android_entries(out_dir: &Path) -> Result<AndroidEntries> {
|
||||
struct AndroidEntry {
|
||||
name: &'static str,
|
||||
size: u32,
|
||||
foreground_size: u32,
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let targets = vec![
|
||||
AndroidEntry {
|
||||
name: "hdpi",
|
||||
@ -327,6 +390,10 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
foreground_size: 432,
|
||||
},
|
||||
];
|
||||
let mut icon_entries = Vec::new();
|
||||
let mut fg_entries = Vec::new();
|
||||
let mut bg_entries = Vec::new();
|
||||
let mut monochrome_entries = Vec::new();
|
||||
|
||||
for target in targets {
|
||||
let folder_name = format!("mipmap-{}", target.name);
|
||||
@ -334,24 +401,204 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
|
||||
create_dir_all(&out_folder).context("Can't create Android mipmap output directory")?;
|
||||
|
||||
entries.push(PngEntry {
|
||||
fg_entries.push(PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher_foreground.png"),
|
||||
out_path: out_folder.join("ic_launcher_foreground.png"),
|
||||
size: target.foreground_size,
|
||||
});
|
||||
entries.push(PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
|
||||
out_path: out_folder.join("ic_launcher_round.png"),
|
||||
size: target.size,
|
||||
icon_entries.push((
|
||||
PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
|
||||
out_path: out_folder.join("ic_launcher_round.png"),
|
||||
size: target.size,
|
||||
},
|
||||
AndroidIconKind::Rounded,
|
||||
));
|
||||
icon_entries.push((
|
||||
PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher.png"),
|
||||
out_path: out_folder.join("ic_launcher.png"),
|
||||
size: target.size,
|
||||
},
|
||||
AndroidIconKind::Regular,
|
||||
));
|
||||
|
||||
bg_entries.push(PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher_background.png"),
|
||||
out_path: out_folder.join("ic_launcher_background.png"),
|
||||
size: target.foreground_size,
|
||||
});
|
||||
entries.push(PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher.png"),
|
||||
out_path: out_folder.join("ic_launcher.png"),
|
||||
size: target.size,
|
||||
|
||||
monochrome_entries.push(PngEntry {
|
||||
name: format!("{}/{}", folder_name, "ic_launcher_monochrome.png"),
|
||||
out_path: out_folder.join("ic_launcher_monochrome.png"),
|
||||
size: target.foreground_size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
Ok(AndroidEntries {
|
||||
icon: icon_entries,
|
||||
foreground: fg_entries,
|
||||
background: bg_entries,
|
||||
monochrome: monochrome_entries,
|
||||
})
|
||||
}
|
||||
fn create_color_file(out_dir: &Path, color: &String) -> Result<()> {
|
||||
let values_folder = out_dir.join("values");
|
||||
create_dir_all(&values_folder).context("Can't create Android values output directory")?;
|
||||
let mut color_file = File::create(values_folder.join("ic_launcher_background.xml"))?;
|
||||
color_file.write_all(
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">{}</color>
|
||||
</resources>"#,
|
||||
color
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let android_out = out_dir
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("gen/android/app/src/main/res/");
|
||||
let out = if android_out.exists() {
|
||||
android_out
|
||||
} else {
|
||||
let out = out_dir.join("android");
|
||||
create_dir_all(&out).context("Can't create Android output directory")?;
|
||||
out
|
||||
};
|
||||
let entries = android_entries(&out)?;
|
||||
|
||||
let fg_source = match manifest {
|
||||
Some(ref manifest) => {
|
||||
Some(read_source(input.parent().unwrap().join(
|
||||
manifest.android_fg.as_ref().unwrap_or(&manifest.default),
|
||||
))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
for entry in entries.foreground {
|
||||
log::info!(action = "Android"; "Creating {}", entry.name);
|
||||
resize_and_save_png(
|
||||
fg_source.as_ref().unwrap_or(source),
|
||||
entry.size,
|
||||
&entry.out_path,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut bg_source = None;
|
||||
let mut has_monochrome_image = false;
|
||||
if let Some(ref manifest) = manifest {
|
||||
if let Some(ref background_path) = manifest.android_bg {
|
||||
let bg = read_source(input.parent().unwrap().join(background_path))?;
|
||||
for entry in entries.background {
|
||||
log::info!(action = "Android"; "Creating {}", entry.name);
|
||||
resize_and_save_png(&bg, entry.size, &entry.out_path, None, None)?;
|
||||
}
|
||||
bg_source.replace(bg);
|
||||
}
|
||||
if let Some(ref monochrome_path) = manifest.android_monochrome {
|
||||
has_monochrome_image = true;
|
||||
let mc = read_source(input.parent().unwrap().join(monochrome_path))?;
|
||||
for entry in entries.monochrome {
|
||||
log::info!(action = "Android"; "Creating {}", entry.name);
|
||||
resize_and_save_png(&mc, entry.size, &entry.out_path, None, None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (entry, kind) in entries.icon {
|
||||
log::info!(action = "Android"; "Creating {}", entry.name);
|
||||
|
||||
let (margin, radius) = match kind {
|
||||
AndroidIconKind::Regular => {
|
||||
let radius = ((entry.size as f32) * 0.0833).round() as u32;
|
||||
(radius, radius)
|
||||
}
|
||||
AndroidIconKind::Rounded => {
|
||||
let margin = ((entry.size as f32) * 0.04).round() as u32;
|
||||
let radius = ((entry.size as f32) * 0.5).round() as u32;
|
||||
(margin, radius)
|
||||
}
|
||||
};
|
||||
|
||||
let image = if let (Some(bg_source), Some(fg_source)) = (bg_source.as_ref(), fg_source.as_ref())
|
||||
{
|
||||
resize_png(
|
||||
fg_source,
|
||||
entry.size,
|
||||
Some(Background::Image(bg_source)),
|
||||
manifest
|
||||
.as_ref()
|
||||
.and_then(|manifest| manifest.android_fg_scale),
|
||||
)?
|
||||
} else {
|
||||
resize_png(source, entry.size, None, None)?
|
||||
};
|
||||
|
||||
let image = apply_round_mask(&image, entry.size, margin, radius);
|
||||
|
||||
let mut out_file = BufWriter::new(File::create(entry.out_path)?);
|
||||
write_png(image.as_bytes(), &mut out_file, entry.size)?;
|
||||
out_file.flush()?;
|
||||
}
|
||||
|
||||
let mut launcher_content = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>"#
|
||||
.to_owned();
|
||||
|
||||
if bg_source.is_some() {
|
||||
launcher_content
|
||||
.push_str("\n <background android:drawable=\"@mipmap/ic_launcher_background\"/>");
|
||||
} else {
|
||||
create_color_file(&out, bg_color)?;
|
||||
launcher_content
|
||||
.push_str("\n <background android:drawable=\"@color/ic_launcher_background\"/>");
|
||||
}
|
||||
if has_monochrome_image {
|
||||
launcher_content
|
||||
.push_str("\n <monochrome android:drawable=\"@mipmap/ic_launcher_monochrome\"/>");
|
||||
}
|
||||
launcher_content.push_str("\n</adaptive-icon>");
|
||||
|
||||
let any_dpi_folder = out.join("mipmap-anydpi-v26");
|
||||
create_dir_all(&any_dpi_folder)
|
||||
.context("Can't create Android mipmap-anydpi-v26 output directory")?;
|
||||
let mut launcher_file = File::create(any_dpi_folder.join("ic_launcher.xml"))?;
|
||||
launcher_file.write_all(launcher_content.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png)
|
||||
// Main target: Linux
|
||||
fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for size in [32, 64, 128, 256, 512] {
|
||||
let file_name = match size {
|
||||
256 => "128x128@2x.png".to_string(),
|
||||
512 => "icon.png".to_string(),
|
||||
_ => format!("{size}x{size}.png"),
|
||||
};
|
||||
|
||||
entries.push(PngEntry {
|
||||
out_path: out_dir.join(&file_name),
|
||||
name: file_name,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn ios_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
|
||||
@ -428,20 +675,7 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
let mut entries = desktop_entries(out_dir);
|
||||
|
||||
let android_out = out_dir
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("gen/android/app/src/main/res/");
|
||||
let out = if android_out.exists() {
|
||||
android_out
|
||||
} else {
|
||||
let out = out_dir.join("android");
|
||||
create_dir_all(&out).context("Can't create Android output directory")?;
|
||||
out
|
||||
};
|
||||
entries.extend(android_entries(&out)?);
|
||||
let entries = desktop_entries(out_dir);
|
||||
|
||||
let ios_out = out_dir
|
||||
.parent()
|
||||
@ -457,32 +691,73 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
|
||||
|
||||
for entry in entries {
|
||||
log::info!(action = "PNG"; "Creating {}", entry.name);
|
||||
resize_and_save_png(source, entry.size, &entry.out_path, None)?;
|
||||
resize_and_save_png(source, entry.size, &entry.out_path, None, None)?;
|
||||
}
|
||||
|
||||
for entry in ios_entries(&out)? {
|
||||
log::info!(action = "iOS"; "Creating {}", entry.name);
|
||||
resize_and_save_png(source, entry.size, &entry.out_path, Some(ios_color))?;
|
||||
resize_and_save_png(
|
||||
source,
|
||||
entry.size,
|
||||
&entry.out_path,
|
||||
Some(Background::Color(ios_color)),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum Background<'a> {
|
||||
Color(Rgba<u8>),
|
||||
Image(&'a Source),
|
||||
}
|
||||
|
||||
// Resize image.
|
||||
fn resize_png(
|
||||
source: &Source,
|
||||
size: u32,
|
||||
bg: Option<Background>,
|
||||
scale_percent: Option<f32>,
|
||||
) -> Result<DynamicImage> {
|
||||
let mut image = source.resize_exact(size)?;
|
||||
|
||||
match bg {
|
||||
Some(Background::Color(bg_color)) => {
|
||||
let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color);
|
||||
|
||||
let fg = scale_percent
|
||||
.map(|scale| resize_asset(&image, size, scale))
|
||||
.unwrap_or(image);
|
||||
|
||||
image::imageops::overlay(&mut bg_img, &fg, 0, 0);
|
||||
image = bg_img.into();
|
||||
}
|
||||
Some(Background::Image(bg_source)) => {
|
||||
let mut bg = bg_source.resize_exact(size)?;
|
||||
|
||||
let fg = scale_percent
|
||||
.map(|scale| resize_asset(&image, size, scale))
|
||||
.unwrap_or(image);
|
||||
|
||||
image::imageops::overlay(&mut bg, &fg, 0, 0);
|
||||
image = bg;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
// Resize image and save it to disk.
|
||||
fn resize_and_save_png(
|
||||
source: &Source,
|
||||
size: u32,
|
||||
file_path: &Path,
|
||||
bg_color: Option<Rgba<u8>>,
|
||||
bg: Option<Background>,
|
||||
scale_percent: Option<f32>,
|
||||
) -> Result<()> {
|
||||
let mut image = source.resize_exact(size)?;
|
||||
|
||||
if let Some(bg_color) = bg_color {
|
||||
let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color);
|
||||
image::imageops::overlay(&mut bg_img, &image, 0, 0);
|
||||
image = bg_img.into();
|
||||
}
|
||||
|
||||
let image = resize_png(source, size, bg, scale_percent)?;
|
||||
let mut out_file = BufWriter::new(File::create(file_path)?);
|
||||
write_png(image.as_bytes(), &mut out_file, size)?;
|
||||
Ok(out_file.flush()?)
|
||||
@ -494,3 +769,133 @@ fn write_png<W: Write>(image_data: &[u8], w: W, size: u32) -> Result<()> {
|
||||
encoder.write_image(image_data, size, size, ExtendedColorType::Rgba8)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// finds the bounding box of non-transparent pixels in an RGBA image.
|
||||
fn content_bounds(img: &DynamicImage) -> Option<(u32, u32, u32, u32)> {
|
||||
let rgba = img.to_rgba8();
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
let mut min_x = width;
|
||||
let mut min_y = height;
|
||||
let mut max_x = 0;
|
||||
let mut max_y = 0;
|
||||
let mut found = false;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let a = rgba.get_pixel(x, y)[3];
|
||||
if a > 0 {
|
||||
found = true;
|
||||
if x < min_x {
|
||||
min_x = x;
|
||||
}
|
||||
if y < min_y {
|
||||
min_y = y;
|
||||
}
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
Some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
img.clone()
|
||||
};
|
||||
|
||||
let (cw, ch) = cropped.dimensions();
|
||||
let max_dim = cw.max(ch) as f32;
|
||||
let scale = (target_size as f32 * (scale_percent / 100.0)) / max_dim;
|
||||
|
||||
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);
|
||||
|
||||
// Place on transparent square canvas
|
||||
let mut canvas = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0]));
|
||||
let offset_x = if new_w > target_size {
|
||||
// Image wider than canvas → start at negative offset
|
||||
-((new_w - target_size) as i32 / 2)
|
||||
} else {
|
||||
(target_size - new_w) as i32 / 2
|
||||
};
|
||||
|
||||
let offset_y = if new_h > target_size {
|
||||
-((new_h - target_size) as i32 / 2)
|
||||
} else {
|
||||
(target_size - new_h) as i32 / 2
|
||||
};
|
||||
|
||||
image::imageops::overlay(&mut canvas, &resized, offset_x.into(), offset_y.into());
|
||||
|
||||
DynamicImage::ImageRgba8(canvas)
|
||||
}
|
||||
|
||||
fn apply_round_mask(
|
||||
img: &DynamicImage,
|
||||
target_size: u32,
|
||||
margin: u32,
|
||||
radius: u32,
|
||||
) -> DynamicImage {
|
||||
// Clamp radius to half of inner size
|
||||
let inner_size = target_size.saturating_sub(2 * margin);
|
||||
let radius = radius.min(inner_size / 2);
|
||||
|
||||
// Resize inner image to fit inside margins
|
||||
let resized = img.resize_exact(inner_size, inner_size, image::imageops::Lanczos3);
|
||||
|
||||
// Prepare output canvas
|
||||
let mut out = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0]));
|
||||
|
||||
// Draw the resized image at (margin, margin)
|
||||
image::imageops::overlay(&mut out, &resized, margin as i64, margin as i64);
|
||||
|
||||
// Apply rounded corners
|
||||
for y in 0..target_size {
|
||||
for x in 0..target_size {
|
||||
let inside = if x >= margin + radius
|
||||
&& x < target_size - margin - radius
|
||||
&& y >= margin + radius
|
||||
&& y < target_size - margin - radius
|
||||
{
|
||||
true // inside central rectangle
|
||||
} else {
|
||||
// Determine corner centers
|
||||
let (cx, cy) = if x < margin + radius && y < margin + radius {
|
||||
(margin + radius, margin + radius) // top-left
|
||||
} else if x >= target_size - margin - radius && y < margin + radius {
|
||||
(target_size - margin - radius, margin + radius) // top-right
|
||||
} else if x < margin + radius && y >= target_size - margin - radius {
|
||||
(margin + radius, target_size - margin - radius) // bottom-left
|
||||
} else if x >= target_size - margin - radius && y >= target_size - margin - radius {
|
||||
(target_size - margin - radius, target_size - margin - radius) // bottom-right
|
||||
} else {
|
||||
continue; // edges that are not corners are inside
|
||||
};
|
||||
let dx = x as i32 - cx as i32;
|
||||
let dy = y as i32 - cy as i32;
|
||||
dx * dx + dy * dy <= (radius as i32 * radius as i32)
|
||||
};
|
||||
|
||||
if !inside {
|
||||
out.put_pixel(x, y, Rgba([0, 0, 0, 0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DynamicImage::ImageRgba8(out)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user