Skip to main content

webserver_base/assets/
icons.rs

1//! The icon set, rendered from the one icon a project authors.
2//!
3//! A project ships exactly one source — `favicon.svg`, or a 512×512
4//! `favicon.png` — and the `.ico`, the apple-touch icon and the two web-manifest
5//! icons are rasterised from it at build time. Five files of setup collapse to
6//! one, and they cannot drift from the source.
7//!
8//! Two formats because vector is not always available. SVG is preferred: it
9//! scales exactly, and it can carry its own `prefers-color-scheme` rules, which
10//! gives a dark-mode-adaptive tab icon for free. But a photographic mark cannot
11//! be vectorised well — autotracing a face yields either a posterised caricature
12//! or a multi-megabyte pile of paths — so a raster source is the honest answer
13//! there. Downscaling 512 → 192 → 180 → 32 is faithful; upscaling never is.
14//!
15//! Exactly one of the two must exist. Both is an error rather than a preference,
16//! because two sources silently drift the moment someone updates one of them.
17//!
18//! **An SVG must not rely on system fonts.** `resvg` is built without them, so
19//! any `<text>` should be converted to paths.
20
21use std::path::{Path, PathBuf};
22
23use resvg::tiny_skia::{FilterQuality, Pixmap, PixmapPaint, Transform};
24use resvg::usvg::{Options, Size, Tree};
25use tracing::info;
26
27use super::error::CacheBusterError;
28use super::generate::{FAVICON_DIRECTORY, FAVICON_PNG_SOURCE, FAVICON_SVG_SOURCE};
29use super::manifest::Manifest;
30
31/// The exact dimensions a raster source must have.
32///
33/// Not a minimum. Every size the set needs is a downscale from 512, so this is
34/// the natural source size, it makes `icon-512.png` a pixel-perfect copy, and
35/// one exact number is a clearer instruction than a range.
36pub const SOURCE_PNG_SIZE: u32 = 512;
37
38/// One icon derived from the source.
39pub struct DerivedIcon {
40    pub file_name: &'static str,
41    pub size: u32,
42    format: IconFormat,
43}
44
45enum IconFormat {
46    Png,
47    /// A single-image ICO wrapping a PNG, which every browser since IE 11
48    /// understands and which avoids hand-rolling a BMP encoder.
49    Ico,
50}
51
52/// The full derived set, in the order they are generated.
53pub const DERIVED: [DerivedIcon; 4] = [
54    DerivedIcon {
55        file_name: "favicon.ico",
56        size: 32,
57        format: IconFormat::Ico,
58    },
59    DerivedIcon {
60        file_name: "apple-touch-icon.png",
61        size: 180,
62        format: IconFormat::Png,
63    },
64    DerivedIcon {
65        file_name: "icon-192.png",
66        size: 192,
67        format: IconFormat::Png,
68    },
69    DerivedIcon {
70        file_name: "icon-512.png",
71        size: 512,
72        format: IconFormat::Png,
73    },
74];
75
76/// Every file the icon directory may contain: one source, and the four derived
77/// from it.
78///
79/// A closed set rather than a minimum. Anything else in there is either a stale
80/// icon from a previous design or a size somebody expected to be picked up and
81/// which never will be — both of which are silent until someone notices the
82/// wrong picture in a browser tab.
83pub const ALLOWED_FAVICON_FILES: [&str; 6] = [
84    "favicon.svg",
85    "favicon-512.png",
86    "favicon.ico",
87    "apple-touch-icon.png",
88    "icon-192.png",
89    "icon-512.png",
90];
91
92/// Which source a project authored.
93pub enum IconSource {
94    /// Vector. Also earns the `<link rel="icon" type="image/svg+xml">` and the
95    /// `/favicon.svg` route.
96    Svg(PathBuf),
97    /// Raster, exactly [`SOURCE_PNG_SIZE`] square.
98    Png(PathBuf),
99}
100
101impl IconSource {
102    /// Whether the layout should link an SVG icon.
103    #[must_use]
104    pub const fn is_vector(&self) -> bool {
105        matches!(self, Self::Svg(_))
106    }
107}
108
109/// Finds the project's single icon source.
110///
111/// # Errors
112///
113/// [`CacheBusterError::MissingFavicon`] if neither exists,
114/// [`CacheBusterError::AmbiguousFavicon`] if both do, or
115/// [`CacheBusterError::FaviconDimensions`] if a PNG source is the wrong size.
116pub fn resolve_source(manifest: &Manifest) -> Result<IconSource, CacheBusterError> {
117    resolve_source_in(Path::new(""), manifest)
118}
119
120/// [`resolve_source`], under `root`.
121pub(crate) fn resolve_source_in(
122    root: &Path,
123    manifest: &Manifest,
124) -> Result<IconSource, CacheBusterError> {
125    let svg: Option<PathBuf> = existing(root, manifest, FAVICON_SVG_SOURCE);
126    let png: Option<PathBuf> = existing(root, manifest, FAVICON_PNG_SOURCE);
127
128    match (svg, png) {
129        (Some(_), Some(_)) => Err(CacheBusterError::AmbiguousFavicon),
130        (Some(svg), None) => Ok(IconSource::Svg(svg)),
131        (None, Some(png)) => {
132            let (width, height) = dimensions(&png)?;
133            if width != SOURCE_PNG_SIZE || height != SOURCE_PNG_SIZE {
134                return Err(CacheBusterError::FaviconDimensions {
135                    path: PathBuf::from(FAVICON_PNG_SOURCE),
136                    expected: SOURCE_PNG_SIZE,
137                    width,
138                    height,
139                });
140            }
141            Ok(IconSource::Png(png))
142        }
143        (None, None) => Err(CacheBusterError::MissingFavicon),
144    }
145}
146
147/// Renders every derived icon that does not already exist.
148///
149/// A file already present is left alone, so a hand-tuned small icon — the one
150/// size where a naive downscale of a detailed mark really does look muddy —
151/// still wins.
152///
153/// # Errors
154///
155/// [`CacheBusterError`] if the source cannot be resolved, parsed, rasterised, or
156/// an output cannot be written.
157pub fn generate_missing_icons(manifest: &Manifest) -> Result<(), CacheBusterError> {
158    generate_missing_icons_in(Path::new(""), manifest)
159}
160
161/// [`generate_missing_icons`], under `root`.
162pub(crate) fn generate_missing_icons_in(
163    root: &Path,
164    manifest: &Manifest,
165) -> Result<(), CacheBusterError> {
166    let source: IconSource = resolve_source_in(root, manifest)?;
167
168    let outstanding: Vec<&DerivedIcon> = DERIVED
169        .iter()
170        .filter(|icon| {
171            existing(
172                root,
173                manifest,
174                &format!("{FAVICON_DIRECTORY}/{}", icon.file_name),
175            )
176            .is_none()
177        })
178        .collect();
179    if outstanding.is_empty() {
180        return Ok(());
181    }
182
183    let renderer: Renderer = Renderer::open(&source)?;
184    for icon in outstanding {
185        let png: Vec<u8> = renderer.rasterize(icon.size)?;
186        let bytes: Vec<u8> = match icon.format {
187            IconFormat::Png => png,
188            IconFormat::Ico => wrap_png_in_ico(&png, icon.size),
189        };
190
191        let path: PathBuf = root.join(FAVICON_DIRECTORY).join(icon.file_name);
192        std::fs::write(&path, bytes)
193            .map_err(|source| CacheBusterError::WriteIcon { path, source })?;
194        info!("generated `{FAVICON_DIRECTORY}/{}`", icon.file_name);
195    }
196
197    Ok(())
198}
199
200/// Whatever the source is, reduced to "give me a square PNG of size N".
201enum Renderer {
202    Svg(Box<Tree>),
203    Png(Box<Pixmap>),
204}
205
206impl Renderer {
207    fn open(source: &IconSource) -> Result<Self, CacheBusterError> {
208        match source {
209            IconSource::Svg(path) => {
210                let svg: String =
211                    std::fs::read_to_string(path).map_err(|error| CacheBusterError::ReadFile {
212                        path: path.clone(),
213                        source: error,
214                    })?;
215                let tree: Tree = Tree::from_str(&svg, &Options::default())
216                    .map_err(|source| CacheBusterError::RenderIcon { source })?;
217                Ok(Self::Svg(Box::new(tree)))
218            }
219            IconSource::Png(path) => {
220                let bytes: Vec<u8> =
221                    std::fs::read(path).map_err(|error| CacheBusterError::ReadFile {
222                        path: path.clone(),
223                        source: error,
224                    })?;
225                let pixmap: Pixmap =
226                    Pixmap::decode_png(&bytes).map_err(|error| CacheBusterError::EncodeIcon {
227                        reason: error.to_string(),
228                    })?;
229                Ok(Self::Png(Box::new(pixmap)))
230            }
231        }
232    }
233
234    fn rasterize(&self, size: u32) -> Result<Vec<u8>, CacheBusterError> {
235        let mut target: Pixmap =
236            Pixmap::new(size, size).ok_or(CacheBusterError::IconDimensions { size })?;
237        let requested: f32 = f32::from(u16::try_from(size).unwrap_or(u16::MAX));
238
239        match self {
240            Self::Svg(tree) => {
241                let source: Size = tree.size();
242                let transform: Transform =
243                    Transform::from_scale(requested / source.width(), requested / source.height());
244                resvg::render(tree, transform, &mut target.as_mut());
245            }
246            Self::Png(pixmap) => {
247                let source: f32 = f32::from(u16::try_from(pixmap.width()).unwrap_or(1));
248                target.draw_pixmap(
249                    0,
250                    0,
251                    pixmap.as_ref().as_ref(),
252                    &PixmapPaint {
253                        // Every derived size is a downscale from 512, which is
254                        // where a good filter earns its keep.
255                        quality: FilterQuality::Bicubic,
256                        ..PixmapPaint::default()
257                    },
258                    Transform::from_scale(requested / source, requested / source),
259                    None,
260                );
261            }
262        }
263
264        target
265            .encode_png()
266            .map_err(|error| CacheBusterError::EncodeIcon {
267                reason: error.to_string(),
268            })
269    }
270}
271
272/// Finds an asset by its hashed name, falling back to its logical one.
273///
274/// The fallback covers only the pre-hash state: during the build the source is
275/// still `favicon.svg`, and after it the manifest knows it as
276/// `favicon.<hash>.svg`. It is not a fallback for a *missing* asset — that is
277/// caught at boot, loudly.
278fn existing(root: &Path, manifest: &Manifest, logical: &str) -> Option<PathBuf> {
279    let hashed: PathBuf = root.join(manifest.resolve(logical));
280    if hashed.is_file() {
281        return Some(hashed);
282    }
283    let plain: PathBuf = root.join(logical);
284    plain.is_file().then_some(plain)
285}
286
287/// Reads an image's real dimensions.
288///
289/// # Errors
290///
291/// [`CacheBusterError::ReadImage`] if the file is absent or not an image.
292pub fn dimensions(path: &Path) -> Result<(u32, u32), CacheBusterError> {
293    let size: imagesize::ImageSize =
294        imagesize::size(path).map_err(|error| CacheBusterError::ReadImage {
295            path: path.to_path_buf(),
296            reason: error.to_string(),
297        })?;
298    Ok((
299        u32::try_from(size.width).unwrap_or(0),
300        u32::try_from(size.height).unwrap_or(0),
301    ))
302}
303
304/// Wraps PNG bytes in a single-image ICO container.
305///
306/// The format is a 6-byte directory header plus one 16-byte entry, and modern
307/// ICOs may carry PNG payloads verbatim — so this is a header, not an encoder.
308fn wrap_png_in_ico(png: &[u8], size: u32) -> Vec<u8> {
309    // 0 means 256 in this field, which is exactly what we want at that size.
310    let dimension: u8 = u8::try_from(size).unwrap_or(0);
311    let length: u32 = u32::try_from(png.len()).unwrap_or(u32::MAX);
312
313    let mut ico: Vec<u8> = Vec::with_capacity(22 + png.len());
314    ico.extend_from_slice(&0u16.to_le_bytes()); // reserved
315    ico.extend_from_slice(&1u16.to_le_bytes()); // type: icon
316    ico.extend_from_slice(&1u16.to_le_bytes()); // image count
317    ico.push(dimension); // width
318    ico.push(dimension); // height
319    ico.push(0); // palette size
320    ico.push(0); // reserved
321    ico.extend_from_slice(&1u16.to_le_bytes()); // colour planes
322    ico.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
323    ico.extend_from_slice(&length.to_le_bytes()); // payload size
324    ico.extend_from_slice(&22u32.to_le_bytes()); // payload offset
325    ico.extend_from_slice(png);
326    ico
327}
328
329#[cfg(test)]
330mod tests {
331    use super::{DERIVED, SOURCE_PNG_SIZE, wrap_png_in_ico};
332
333    #[test]
334    fn an_ico_carries_the_png_verbatim_after_a_22_byte_header() {
335        let png: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
336
337        let ico: Vec<u8> = wrap_png_in_ico(&png, 32);
338
339        let expected_len: usize = 22 + png.len();
340        let actual_len: usize = ico.len();
341        assert_eq!(expected_len, actual_len);
342
343        assert_eq!(&png[..], &ico[22..]);
344        assert_eq!(1u16, u16::from_le_bytes([ico[2], ico[3]]));
345        assert_eq!(1u16, u16::from_le_bytes([ico[4], ico[5]]));
346        assert_eq!(32u8, ico[6]);
347        assert_eq!(32u8, ico[7]);
348    }
349
350    #[test]
351    fn a_256_pixel_icon_records_its_dimension_as_zero_per_the_format() {
352        let ico: Vec<u8> = wrap_png_in_ico(&[0u8; 4], 256);
353
354        let expected: u8 = 0;
355        let actual: u8 = ico[6];
356        assert_eq!(expected, actual);
357    }
358
359    #[test]
360    fn every_derived_size_is_a_downscale_from_the_source() {
361        // Upscaling is what makes a generated icon look soft, so the source
362        // must be at least as large as everything derived from it.
363        for icon in &DERIVED {
364            assert!(
365                icon.size <= SOURCE_PNG_SIZE,
366                "`{}` is {}px, larger than the {SOURCE_PNG_SIZE}px source",
367                icon.file_name,
368                icon.size
369            );
370        }
371    }
372}