icon.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. // Copyright 2019-2024 Tauri Programme within The Commons Conservancy
  2. // SPDX-License-Identifier: Apache-2.0
  3. // SPDX-License-Identifier: MIT
  4. use crate::{helpers::app_paths::tauri_dir, Result};
  5. use std::{
  6. collections::HashMap,
  7. fs::{create_dir_all, File},
  8. io::{BufWriter, Write},
  9. path::{Path, PathBuf},
  10. str::FromStr,
  11. sync::Arc,
  12. };
  13. use anyhow::Context;
  14. use clap::Parser;
  15. use icns::{IconFamily, IconType};
  16. use image::{
  17. codecs::{
  18. ico::{IcoEncoder, IcoFrame},
  19. png::{CompressionType, FilterType as PngFilterType, PngEncoder},
  20. },
  21. imageops::FilterType,
  22. open, DynamicImage, ExtendedColorType, ImageBuffer, ImageEncoder, Rgba,
  23. };
  24. use resvg::{tiny_skia, usvg};
  25. use serde::Deserialize;
  26. #[derive(Debug, Deserialize)]
  27. struct IcnsEntry {
  28. size: u32,
  29. ostype: String,
  30. }
  31. #[derive(Debug)]
  32. struct PngEntry {
  33. name: String,
  34. size: u32,
  35. out_path: PathBuf,
  36. }
  37. #[derive(Debug, Parser)]
  38. #[clap(about = "Generate various icons for all major platforms")]
  39. pub struct Options {
  40. /// Path to the source icon (squared PNG or SVG file with transparency).
  41. #[clap(default_value = "./app-icon.png")]
  42. input: PathBuf,
  43. /// Output directory.
  44. /// Default: 'icons' directory next to the tauri.conf.json file.
  45. #[clap(short, long)]
  46. output: Option<PathBuf>,
  47. /// Custom PNG icon sizes to generate. When set, the default icons are not generated.
  48. #[clap(short, long, use_value_delimiter = true)]
  49. png: Option<Vec<u32>>,
  50. /// The background color of the iOS icon - string as defined in the W3C's CSS Color Module Level 4 <https://www.w3.org/TR/css-color-4/>.
  51. #[clap(long, default_value = "#fff")]
  52. ios_color: String,
  53. }
  54. enum Source {
  55. Svg(resvg::usvg::Tree),
  56. DynamicImage(DynamicImage),
  57. }
  58. impl Source {
  59. fn width(&self) -> u32 {
  60. match self {
  61. Self::Svg(svg) => svg.size().width() as u32,
  62. Self::DynamicImage(i) => i.width(),
  63. }
  64. }
  65. fn height(&self) -> u32 {
  66. match self {
  67. Self::Svg(svg) => svg.size().height() as u32,
  68. Self::DynamicImage(i) => i.height(),
  69. }
  70. }
  71. fn resize_exact(&self, size: u32) -> Result<DynamicImage> {
  72. match self {
  73. Self::Svg(svg) => {
  74. let mut pixmap = tiny_skia::Pixmap::new(size, size).unwrap();
  75. let scale = size as f32 / svg.size().height();
  76. resvg::render(
  77. svg,
  78. tiny_skia::Transform::from_scale(scale, scale),
  79. &mut pixmap.as_mut(),
  80. );
  81. let img_buffer = ImageBuffer::from_raw(size, size, pixmap.take()).unwrap();
  82. Ok(DynamicImage::ImageRgba8(img_buffer))
  83. }
  84. Self::DynamicImage(i) => Ok(i.resize_exact(size, size, FilterType::Lanczos3)),
  85. }
  86. }
  87. }
  88. pub fn command(options: Options) -> Result<()> {
  89. let input = options.input;
  90. let out_dir = options.output.unwrap_or_else(|| {
  91. crate::helpers::app_paths::resolve();
  92. tauri_dir().join("icons")
  93. });
  94. let png_icon_sizes = options.png.unwrap_or_default();
  95. let ios_color = css_color::Srgb::from_str(&options.ios_color)
  96. .map(|color| {
  97. Rgba([
  98. (color.red * 255.) as u8,
  99. (color.green * 255.) as u8,
  100. (color.blue * 255.) as u8,
  101. (color.alpha * 255.) as u8,
  102. ])
  103. })
  104. .map_err(|_| anyhow::anyhow!("failed to parse iOS color"))?;
  105. create_dir_all(&out_dir).context("Can't create output directory")?;
  106. let source = if let Some(extension) = input.extension() {
  107. if extension == "svg" {
  108. let rtree = {
  109. let mut fontdb = usvg::fontdb::Database::new();
  110. fontdb.load_system_fonts();
  111. let opt = usvg::Options {
  112. // Get file's absolute directory.
  113. resources_dir: std::fs::canonicalize(&input)
  114. .ok()
  115. .and_then(|p| p.parent().map(|p| p.to_path_buf())),
  116. fontdb: Arc::new(fontdb),
  117. ..Default::default()
  118. };
  119. let svg_data = std::fs::read(&input).unwrap();
  120. usvg::Tree::from_data(&svg_data, &opt).unwrap()
  121. };
  122. Source::Svg(rtree)
  123. } else {
  124. Source::DynamicImage(DynamicImage::ImageRgba8(
  125. open(&input)
  126. .context("Can't read and decode source image")?
  127. .into_rgba8(),
  128. ))
  129. }
  130. } else {
  131. anyhow::bail!("Error loading image");
  132. };
  133. if source.height() != source.width() {
  134. anyhow::bail!("Source image must be square");
  135. }
  136. if png_icon_sizes.is_empty() {
  137. appx(&source, &out_dir).context("Failed to generate appx icons")?;
  138. icns(&source, &out_dir).context("Failed to generate .icns file")?;
  139. ico(&source, &out_dir).context("Failed to generate .ico file")?;
  140. png(&source, &out_dir, ios_color).context("Failed to generate png icons")?;
  141. } else {
  142. for target in png_icon_sizes
  143. .into_iter()
  144. .map(|size| {
  145. let name = format!("{size}x{size}.png");
  146. let out_path = out_dir.join(&name);
  147. PngEntry {
  148. name,
  149. out_path,
  150. size,
  151. }
  152. })
  153. .collect::<Vec<PngEntry>>()
  154. {
  155. log::info!(action = "PNG"; "Creating {}", target.name);
  156. resize_and_save_png(&source, target.size, &target.out_path, None)?;
  157. }
  158. }
  159. Ok(())
  160. }
  161. fn appx(source: &Source, out_dir: &Path) -> Result<()> {
  162. log::info!(action = "Appx"; "Creating StoreLogo.png");
  163. resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None)?;
  164. for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] {
  165. let file_name = format!("Square{size}x{size}Logo.png");
  166. log::info!(action = "Appx"; "Creating {}", file_name);
  167. resize_and_save_png(source, size, &out_dir.join(&file_name), None)?;
  168. }
  169. Ok(())
  170. }
  171. // Main target: macOS
  172. fn icns(source: &Source, out_dir: &Path) -> Result<()> {
  173. log::info!(action = "ICNS"; "Creating icon.icns");
  174. let entries: HashMap<String, IcnsEntry> =
  175. serde_json::from_slice(include_bytes!("helpers/icns.json")).unwrap();
  176. let mut family = IconFamily::new();
  177. for (name, entry) in entries {
  178. let size = entry.size;
  179. let mut buf = Vec::new();
  180. let image = source.resize_exact(size)?;
  181. write_png(image.as_bytes(), &mut buf, size)?;
  182. let image = icns::Image::read_png(&buf[..])?;
  183. family
  184. .add_icon_with_type(
  185. &image,
  186. IconType::from_ostype(entry.ostype.parse().unwrap()).unwrap(),
  187. )
  188. .with_context(|| format!("Can't add {name} to Icns Family"))?;
  189. }
  190. let mut out_file = BufWriter::new(File::create(out_dir.join("icon.icns"))?);
  191. family.write(&mut out_file)?;
  192. out_file.flush()?;
  193. Ok(())
  194. }
  195. // Generate .ico file with layers for the most common sizes.
  196. // Main target: Windows
  197. fn ico(source: &Source, out_dir: &Path) -> Result<()> {
  198. log::info!(action = "ICO"; "Creating icon.ico");
  199. let mut frames = Vec::new();
  200. for size in [32, 16, 24, 48, 64, 256] {
  201. let image = source.resize_exact(size)?;
  202. // Only the 256px layer can be compressed according to the ico specs.
  203. if size == 256 {
  204. let mut buf = Vec::new();
  205. write_png(image.as_bytes(), &mut buf, size)?;
  206. frames.push(IcoFrame::with_encoded(
  207. buf,
  208. size,
  209. size,
  210. ExtendedColorType::Rgba8,
  211. )?)
  212. } else {
  213. frames.push(IcoFrame::as_png(
  214. image.as_bytes(),
  215. size,
  216. size,
  217. ExtendedColorType::Rgba8,
  218. )?);
  219. }
  220. }
  221. let mut out_file = BufWriter::new(File::create(out_dir.join("icon.ico"))?);
  222. let encoder = IcoEncoder::new(&mut out_file);
  223. encoder.encode_images(&frames)?;
  224. out_file.flush()?;
  225. Ok(())
  226. }
  227. // Generate .png files in 32x32, 128x128, 256x256, 512x512 (icon.png)
  228. // Main target: Linux
  229. fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
  230. fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
  231. let mut entries = Vec::new();
  232. for size in [32, 128, 256, 512] {
  233. let file_name = match size {
  234. 256 => "128x128@2x.png".to_string(),
  235. 512 => "icon.png".to_string(),
  236. _ => format!("{size}x{size}.png"),
  237. };
  238. entries.push(PngEntry {
  239. out_path: out_dir.join(&file_name),
  240. name: file_name,
  241. size,
  242. });
  243. }
  244. entries
  245. }
  246. fn android_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
  247. struct AndroidEntry {
  248. name: &'static str,
  249. size: u32,
  250. foreground_size: u32,
  251. }
  252. let mut entries = Vec::new();
  253. let targets = vec![
  254. AndroidEntry {
  255. name: "hdpi",
  256. size: 49,
  257. foreground_size: 162,
  258. },
  259. AndroidEntry {
  260. name: "mdpi",
  261. size: 48,
  262. foreground_size: 108,
  263. },
  264. AndroidEntry {
  265. name: "xhdpi",
  266. size: 96,
  267. foreground_size: 216,
  268. },
  269. AndroidEntry {
  270. name: "xxhdpi",
  271. size: 144,
  272. foreground_size: 324,
  273. },
  274. AndroidEntry {
  275. name: "xxxhdpi",
  276. size: 192,
  277. foreground_size: 432,
  278. },
  279. ];
  280. for target in targets {
  281. let folder_name = format!("mipmap-{}", target.name);
  282. let out_folder = out_dir.join(&folder_name);
  283. create_dir_all(&out_folder).context("Can't create Android mipmap output directory")?;
  284. entries.push(PngEntry {
  285. name: format!("{}/{}", folder_name, "ic_launcher_foreground.png"),
  286. out_path: out_folder.join("ic_launcher_foreground.png"),
  287. size: target.foreground_size,
  288. });
  289. entries.push(PngEntry {
  290. name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
  291. out_path: out_folder.join("ic_launcher_round.png"),
  292. size: target.size,
  293. });
  294. entries.push(PngEntry {
  295. name: format!("{}/{}", folder_name, "ic_launcher.png"),
  296. out_path: out_folder.join("ic_launcher.png"),
  297. size: target.size,
  298. });
  299. }
  300. Ok(entries)
  301. }
  302. fn ios_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
  303. struct IosEntry {
  304. size: f32,
  305. multipliers: Vec<u8>,
  306. has_extra: bool,
  307. }
  308. let mut entries = Vec::new();
  309. let targets = vec![
  310. IosEntry {
  311. size: 20.,
  312. multipliers: vec![1, 2, 3],
  313. has_extra: true,
  314. },
  315. IosEntry {
  316. size: 29.,
  317. multipliers: vec![1, 2, 3],
  318. has_extra: true,
  319. },
  320. IosEntry {
  321. size: 40.,
  322. multipliers: vec![1, 2, 3],
  323. has_extra: true,
  324. },
  325. IosEntry {
  326. size: 60.,
  327. multipliers: vec![2, 3],
  328. has_extra: false,
  329. },
  330. IosEntry {
  331. size: 76.,
  332. multipliers: vec![1, 2],
  333. has_extra: false,
  334. },
  335. IosEntry {
  336. size: 83.5,
  337. multipliers: vec![2],
  338. has_extra: false,
  339. },
  340. IosEntry {
  341. size: 512.,
  342. multipliers: vec![2],
  343. has_extra: false,
  344. },
  345. ];
  346. for target in targets {
  347. let size_str = if target.size == 512. {
  348. "512".to_string()
  349. } else {
  350. format!("{size}x{size}", size = target.size)
  351. };
  352. if target.has_extra {
  353. let name = format!("AppIcon-{size_str}@2x-1.png");
  354. entries.push(PngEntry {
  355. out_path: out_dir.join(&name),
  356. name,
  357. size: (target.size * 2.) as u32,
  358. });
  359. }
  360. for multiplier in target.multipliers {
  361. let name = format!("AppIcon-{size_str}@{multiplier}x.png");
  362. entries.push(PngEntry {
  363. out_path: out_dir.join(&name),
  364. name,
  365. size: (target.size * multiplier as f32) as u32,
  366. });
  367. }
  368. }
  369. Ok(entries)
  370. }
  371. let mut entries = desktop_entries(out_dir);
  372. let android_out = out_dir
  373. .parent()
  374. .unwrap()
  375. .join("gen/android/app/src/main/res/");
  376. let out = if android_out.exists() {
  377. android_out
  378. } else {
  379. let out = out_dir.join("android");
  380. create_dir_all(&out).context("Can't create Android output directory")?;
  381. out
  382. };
  383. entries.extend(android_entries(&out)?);
  384. let ios_out = out_dir
  385. .parent()
  386. .unwrap()
  387. .join("gen/apple/Assets.xcassets/AppIcon.appiconset");
  388. let out = if ios_out.exists() {
  389. ios_out
  390. } else {
  391. let out = out_dir.join("ios");
  392. create_dir_all(&out).context("Can't create iOS output directory")?;
  393. out
  394. };
  395. for entry in entries {
  396. log::info!(action = "PNG"; "Creating {}", entry.name);
  397. resize_and_save_png(source, entry.size, &entry.out_path, None)?;
  398. }
  399. for entry in ios_entries(&out)? {
  400. log::info!(action = "iOS"; "Creating {}", entry.name);
  401. resize_and_save_png(source, entry.size, &entry.out_path, Some(ios_color))?;
  402. }
  403. Ok(())
  404. }
  405. // Resize image and save it to disk.
  406. fn resize_and_save_png(
  407. source: &Source,
  408. size: u32,
  409. file_path: &Path,
  410. bg_color: Option<Rgba<u8>>,
  411. ) -> Result<()> {
  412. let mut image = source.resize_exact(size)?;
  413. if let Some(bg_color) = bg_color {
  414. let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color);
  415. image::imageops::overlay(&mut bg_img, &image, 0, 0);
  416. image = bg_img.into();
  417. }
  418. let mut out_file = BufWriter::new(File::create(file_path)?);
  419. write_png(image.as_bytes(), &mut out_file, size)?;
  420. Ok(out_file.flush()?)
  421. }
  422. // Encode image data as png with compression.
  423. fn write_png<W: Write>(image_data: &[u8], w: W, size: u32) -> Result<()> {
  424. let encoder = PngEncoder::new_with_quality(w, CompressionType::Best, PngFilterType::Adaptive);
  425. encoder.write_image(image_data, size, size, ExtendedColorType::Rgba8)?;
  426. Ok(())
  427. }