Skip to main content

tauri_codegen/
image.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use crate::{
6  Cached,
7  embedded_assets::{EmbeddedAssetsError, EmbeddedAssetsResult},
8};
9use proc_macro2::TokenStream;
10use quote::{ToTokens, TokenStreamExt, quote};
11use std::{ffi::OsStr, io::Cursor, path::Path};
12
13/// The format the Icon is consumed as.
14pub(crate) enum IconFormat {
15  /// The image, completely unmodified.
16  Raw,
17
18  /// RGBA raw data, meant to be consumed by [`tauri::image::Image`].
19  Image { width: u32, height: u32 },
20}
21
22pub struct CachedIcon {
23  cache: Cached,
24  format: IconFormat,
25  root: TokenStream,
26}
27
28impl CachedIcon {
29  pub fn new(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
30    match icon.extension().map(OsStr::to_string_lossy).as_deref() {
31      Some("png") => Self::new_png(root, icon),
32      Some("ico") => Self::new_ico(root, icon),
33      unknown => Err(EmbeddedAssetsError::InvalidImageExtension {
34        extension: unknown.unwrap_or_default().into(),
35        path: icon.to_path_buf(),
36      }),
37    }
38  }
39
40  /// Cache the icon without any manipulation.
41  pub fn new_raw(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
42    let buf = Self::open(icon);
43    Cached::try_from(buf).map(|cache| Self {
44      cache,
45      root: root.clone(),
46      format: IconFormat::Raw,
47    })
48  }
49
50  /// Cache an ICO icon as RGBA data, see [`ImageFormat::Image`].
51  pub fn new_ico(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
52    let buf = Self::open(icon);
53
54    let icon_dir = ico::IconDir::read(Cursor::new(&buf))
55      .unwrap_or_else(|e| panic!("failed to parse icon {}: {}", icon.display(), e));
56
57    let entry = largest_ico_entry(&icon_dir)
58      .unwrap_or_else(|| panic!("icon {} has no entries", icon.display()));
59    let rgba = entry
60      .decode()
61      .unwrap_or_else(|e| panic!("failed to decode icon {}: {}", icon.display(), e))
62      .rgba_data()
63      .to_vec();
64
65    Cached::try_from(rgba).map(|cache| Self {
66      cache,
67      root: root.clone(),
68      format: IconFormat::Image {
69        width: entry.width(),
70        height: entry.height(),
71      },
72    })
73  }
74
75  /// Cache a PNG icon as RGBA data, see [`ImageFormat::Image`].
76  pub fn new_png(root: &TokenStream, icon: &Path) -> EmbeddedAssetsResult<Self> {
77    let buf = Self::open(icon);
78    let decoder = png::Decoder::new(Cursor::new(&buf));
79    let mut reader = decoder
80      .read_info()
81      .unwrap_or_else(|e| panic!("failed to read icon {}: {}", icon.display(), e));
82
83    if reader.output_color_type().0 != png::ColorType::Rgba {
84      panic!("icon {} is not RGBA", icon.display());
85    }
86
87    let mut rgba = Vec::with_capacity(reader.output_buffer_size().unwrap());
88    while let Ok(Some(row)) = reader.next_row() {
89      rgba.extend(row.data());
90    }
91
92    Cached::try_from(rgba).map(|cache| Self {
93      cache,
94      root: root.clone(),
95      format: IconFormat::Image {
96        width: reader.info().width,
97        height: reader.info().height,
98      },
99    })
100  }
101
102  fn open(path: &Path) -> Vec<u8> {
103    std::fs::read(path).unwrap_or_else(|e| panic!("failed to open icon {}: {}", path.display(), e))
104  }
105}
106
107/// Picks the entry of an ICO file to embed: the largest one (and, for equal sizes, the deepest).
108///
109/// ICO files conventionally store entries smallest-first, so taking the first entry would give
110/// the OS a 16x16 image to upscale; the largest entry lets it downscale a high-resolution source instead.
111fn largest_ico_entry(icon_dir: &ico::IconDir) -> Option<&ico::IconDirEntry> {
112  icon_dir
113    .entries()
114    .iter()
115    .max_by_key(|e| (e.width() * e.height(), e.bits_per_pixel()))
116}
117
118impl ToTokens for CachedIcon {
119  fn to_tokens(&self, tokens: &mut TokenStream) {
120    let root = &self.root;
121    let cache = &self.cache;
122    let raw = quote!(::std::include_bytes!(#cache));
123    tokens.append_all(match self.format {
124      IconFormat::Raw => raw,
125      IconFormat::Image { width, height } => {
126        quote!(#root::image::Image::new(#raw, #width, #height))
127      }
128    })
129  }
130}
131
132#[cfg(test)]
133mod tests {
134  use super::largest_ico_entry;
135  use ico::{IconDir, IconDirEntry, IconImage, ResourceType};
136  use std::io::Cursor;
137
138  fn image(size: u32, alpha: u8) -> IconImage {
139    let rgba = [255, 0, 0, alpha].repeat((size * size) as usize);
140    IconImage::from_rgba_data(size, size, rgba)
141  }
142
143  /// Builds an ICO with an opaque entry for each size, in the given order, and reads it back
144  /// through the same parser `CachedIcon::new_ico` uses.
145  fn icon_dir(sizes: &[u32]) -> IconDir {
146    let mut dir = IconDir::new(ResourceType::Icon);
147    for &size in sizes {
148      let entry = if size == 256 {
149        IconDirEntry::encode_as_png(&image(size, 255)).unwrap()
150      } else {
151        IconDirEntry::encode_as_bmp(&image(size, 255)).unwrap()
152      };
153      dir.add_entry(entry);
154    }
155    let mut buf = Vec::new();
156    dir.write(&mut buf).unwrap();
157    IconDir::read(Cursor::new(buf)).unwrap()
158  }
159
160  #[test]
161  fn picks_largest_entry_regardless_of_order() {
162    // the entry order `tauri icon` used to generate, with the PNG-compressed 256 entry last
163    let dir = icon_dir(&[32, 16, 24, 48, 64, 256]);
164    let entry = largest_ico_entry(&dir).unwrap();
165    assert_eq!((entry.width(), entry.height()), (256, 256));
166    assert!(entry.is_png());
167
168    // largest first, in the middle and alone
169    for sizes in [&[64, 48, 16][..], &[16, 64, 48], &[64]] {
170      let dir = icon_dir(sizes);
171      let entry = largest_ico_entry(&dir).unwrap();
172      assert_eq!((entry.width(), entry.height()), (64, 64), "{sizes:?}");
173    }
174  }
175
176  #[test]
177  fn picks_deepest_entry_for_equal_sizes() {
178    // a single opaque color encodes as a low-depth BMP, a translucent one needs 32bpp
179    let shallow = IconDirEntry::encode_as_bmp(&image(32, 255)).unwrap();
180    let deep = IconDirEntry::encode_as_bmp(&image(32, 128)).unwrap();
181    assert!(shallow.bits_per_pixel() < deep.bits_per_pixel());
182    let deep_bpp = deep.bits_per_pixel();
183
184    for entries in [[shallow.clone(), deep.clone()], [deep, shallow]] {
185      let mut dir = IconDir::new(ResourceType::Icon);
186      for entry in entries {
187        dir.add_entry(entry);
188      }
189      let entry = largest_ico_entry(&dir).unwrap();
190      assert_eq!((entry.width(), entry.height()), (32, 32));
191      assert_eq!(entry.bits_per_pixel(), deep_bpp);
192    }
193  }
194
195  #[test]
196  fn selected_entry_decodes_at_its_own_size() {
197    let dir = icon_dir(&[16, 256]);
198    let entry = largest_ico_entry(&dir).unwrap();
199    let decoded = entry.decode().unwrap();
200    assert_eq!((decoded.width(), decoded.height()), (256, 256));
201    assert_eq!(decoded.rgba_data().len(), 256 * 256 * 4);
202  }
203
204  #[test]
205  fn no_entries() {
206    assert!(largest_ico_entry(&IconDir::new(ResourceType::Icon)).is_none());
207  }
208}