ico_builder/
lib.rs

1//! A crate for creating multi-size ICO files from separate images.
2//! The images are automatically resized to the specified sizes.
3//!
4//! ## Examples
5//! ### Basic
6//! In this example, the 16px, 24px, and 32px versions of this icon will
7//! be resized versions of `app-icon-32x32.png` while the 48px and 256px
8//! versions will be resized from `app-icon-256x256.png`.
9//!
10//! ```no_run
11//! # use ico_builder::IcoBuilder;
12//! IcoBuilder::default()
13//!     .add_source_file("app-icon-32x32.png")
14//!     .add_source_file("app-icon-256x256.png")
15//!     .build_file("app-icon.ico");
16//! ```
17//!
18//! ### Custom Icon Sizes
19//! If you want more fine grained control over which icon sizes are included,
20//! you can specify a custom list of icon sizes.
21//!
22//! ```no_run
23//! # use ico_builder::IcoBuilder;
24//! IcoBuilder::default()
25//!     .sizes(&[16, 32])
26//!     .add_source_file("app-icon-32x32.png")
27//!     .build_file("app-icon.ico");
28//! ```
29
30#[cfg(doctest)]
31#[doc = include_str!("../readme.md")]
32mod test_readme {}
33
34use image::codecs::ico::{IcoEncoder, IcoFrame};
35use image::codecs::png::PngEncoder;
36use image::imageops::resize;
37use image::io::Reader as ImageReader;
38use image::{DynamicImage, ExtendedColorType, ImageEncoder};
39use std::borrow::Cow;
40use std::ffi::OsStr;
41use std::fs::OpenOptions;
42use std::io::Cursor;
43use std::ops::Deref;
44use std::path::{Path, PathBuf};
45use std::{env, iter};
46
47mod error;
48pub use error::*;
49pub type Result<T> = std::result::Result<T, Error>;
50
51pub use image::imageops::FilterType;
52
53/// Builds an ICO file from individual files.
54/// For each size, the closest source image is scaled down to the appropriate size.
55#[derive(Debug)]
56pub struct IcoBuilder {
57    sizes: IconSizes,
58    source_files: Vec<PathBuf>,
59    filter_type: FilterType,
60}
61
62impl Default for IcoBuilder {
63    fn default() -> Self {
64        IcoBuilder {
65            sizes: Default::default(),
66            source_files: Default::default(),
67            filter_type: FilterType::Lanczos3,
68        }
69    }
70}
71
72impl IcoBuilder {
73    /// Customizes the sizes included in the ICO file. Defaults to [`IconSizes::MINIMAL`].
74    pub fn sizes(&mut self, sizes: impl Into<IconSizes>) -> &mut IcoBuilder {
75        self.sizes = sizes.into();
76        self
77    }
78
79    /// Adds a source file. These file can be PNG, BMP or any other format supported by the
80    /// [`image`] crate.
81    /// The icons are assumed to be a square.
82    ///
83    /// Note that you'll have to enable the necessary features on the [`image`] crate if you want
84    /// to use formats other than PNG or BMP:
85    /// ```toml
86    /// # ...
87    ///
88    /// [dependencies]
89    /// ico-builder = { version = "...", features = ["jpeg"] }
90    /// ```
91    pub fn add_source_file(&mut self, source_file: impl AsRef<Path>) -> &mut IcoBuilder {
92        self.add_source_files(iter::once(source_file))
93    }
94
95    /// Adds sources files. See: [`IcoBuilder::add_source_file`].
96    pub fn add_source_files(
97        &mut self,
98        source_files: impl IntoIterator<Item = impl AsRef<Path>>,
99    ) -> &mut IcoBuilder {
100        self.source_files
101            .extend(source_files.into_iter().map(|f| f.as_ref().to_owned()));
102        self
103    }
104
105    /// Customizes the filter type used when downscaling the images. Defaults to [`FilterType::Lanczos3`].
106    pub fn filter_type(&mut self, filter_type: FilterType) -> &mut IcoBuilder {
107        self.filter_type = filter_type;
108        self
109    }
110
111    /// Builds the ICO file and writes it to the specified `output_file_path`.
112    pub fn build_file(&self, output_file_path: impl AsRef<Path>) -> Result<()> {
113        let icons = decode_icons(&self.source_files)?;
114        let frames = create_ico_frames(&self.sizes, &icons, self.filter_type)?;
115
116        let file = OpenOptions::new()
117            .create(true)
118            .truncate(true)
119            .write(true)
120            .open(&output_file_path)?;
121        IcoEncoder::new(file).encode_images(&frames)?;
122
123        Ok(())
124    }
125
126    /// Builds the ICO file and writes it to `OUT_DIR`.
127    /// Tells Cargo to re-build when one of the specified sources changes.
128    /// ## Panics
129    /// This function panics if the path of one of the source files is not valid UTF-8.
130    pub fn build_file_cargo(&self, file_name: impl AsRef<OsStr>) -> Result<PathBuf> {
131        let out_dir = env::var_os("OUT_DIR").expect(
132            "OUT_DIR environment variable is required.\nHint: This function is intended to be used in Cargo build scripts.",
133        );
134        let output_path: PathBuf = [&out_dir, file_name.as_ref()].iter().collect();
135
136        for file in &self.source_files {
137            println!(
138                "cargo:rerun-if-changed={}",
139                file.to_str().expect("Path needs to be valid UTF-8")
140            )
141        }
142
143        self.build_file(&output_path)?;
144
145        Ok(output_path)
146    }
147}
148
149/// A list of icon sizes.
150#[derive(Debug)]
151pub struct IconSizes(Cow<'static, [u32]>);
152
153impl IconSizes {
154    /// The [bare minimum] recommended icon sizes: 16x16, 24x24, 32x32, 48x48, and 256x256.
155    ///
156    /// [bare minimum]: https://learn.microsoft.com/en-us/windows/apps/design/style/iconography/app-icon-construction#icon-scaling
157    pub const MINIMAL: Self = Self::new(&[16, 24, 32, 48, 256]);
158
159    pub const fn new(sizes: &'static [u32]) -> IconSizes {
160        Self(Cow::Borrowed(sizes))
161    }
162}
163
164impl Default for IconSizes {
165    fn default() -> Self {
166        IconSizes::MINIMAL
167    }
168}
169
170impl<'a, I> From<I> for IconSizes
171where
172    I: IntoIterator<Item = &'a u32>,
173{
174    fn from(value: I) -> Self {
175        IconSizes(value.into_iter().copied().collect::<Vec<_>>().into())
176    }
177}
178
179impl Deref for IconSizes {
180    type Target = [u32];
181
182    fn deref(&self) -> &Self::Target {
183        &self.0
184    }
185}
186
187fn decode_icons(
188    icon_sources: impl IntoIterator<Item = impl AsRef<Path>>,
189) -> Result<Vec<DynamicImage>> {
190    icon_sources
191        .into_iter()
192        .map(|path| decode_icon(path.as_ref()))
193        .collect()
194}
195
196fn decode_icon(path: &Path) -> Result<DynamicImage> {
197    let image = ImageReader::open(path)?.decode()?;
198
199    if is_square(&image) {
200        Ok(image)
201    } else {
202        Err(Error::NonSquareImage {
203            path: path.to_owned(),
204            width: image.width(),
205            height: image.height(),
206        })
207    }
208}
209
210fn is_square(image: &DynamicImage) -> bool {
211    image.width() == image.height()
212}
213
214fn find_next_bigger_icon(icons: &[DynamicImage], size: u32) -> Result<&DynamicImage> {
215    icons
216        .iter()
217        .filter(|icon| icon.width() >= size)
218        .min_by_key(|icon| icon.width())
219        .ok_or(Error::MissingIconSize(size))
220}
221
222fn create_ico_frames(
223    sizes: &IconSizes,
224    icons: &[DynamicImage],
225    filter_type: FilterType,
226) -> Result<Vec<IcoFrame<'static>>> {
227    sizes
228        .iter()
229        .copied()
230        .map(|size| create_ico_frame(icons, size, filter_type))
231        .collect()
232}
233
234fn create_ico_frame(
235    icons: &[DynamicImage],
236    size: u32,
237    filter_type: FilterType,
238) -> Result<IcoFrame<'static>> {
239    let next_bigger_icon = find_next_bigger_icon(icons, size)?;
240    let resized = resize(next_bigger_icon, size, size, filter_type);
241    encode_ico_frame(resized.as_raw(), size)
242}
243
244fn encode_ico_frame(buffer: &[u8], size: u32) -> Result<IcoFrame<'static>> {
245    let color_type = ExtendedColorType::Rgba8;
246    let mut encoded = Vec::new();
247    PngEncoder::new(Cursor::new(&mut encoded)).write_image(buffer, size, size, color_type)?;
248    Ok(IcoFrame::with_encoded(encoded, size, size, color_type)?)
249}