1use 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
31pub const SOURCE_PNG_SIZE: u32 = 512;
37
38pub struct DerivedIcon {
40 pub file_name: &'static str,
41 pub size: u32,
42 format: IconFormat,
43}
44
45enum IconFormat {
46 Png,
47 Ico,
50}
51
52pub 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
76pub 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
92pub enum IconSource {
94 Svg(PathBuf),
97 Png(PathBuf),
99}
100
101impl IconSource {
102 #[must_use]
104 pub const fn is_vector(&self) -> bool {
105 matches!(self, Self::Svg(_))
106 }
107}
108
109pub fn resolve_source(manifest: &Manifest) -> Result<IconSource, CacheBusterError> {
117 resolve_source_in(Path::new(""), manifest)
118}
119
120pub(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
147pub fn generate_missing_icons(manifest: &Manifest) -> Result<(), CacheBusterError> {
158 generate_missing_icons_in(Path::new(""), manifest)
159}
160
161pub(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
200enum 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 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
272fn 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
287pub 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
304fn wrap_png_in_ico(png: &[u8], size: u32) -> Vec<u8> {
309 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()); ico.extend_from_slice(&1u16.to_le_bytes()); ico.extend_from_slice(&1u16.to_le_bytes()); ico.push(dimension); ico.push(dimension); ico.push(0); ico.push(0); ico.extend_from_slice(&1u16.to_le_bytes()); ico.extend_from_slice(&32u16.to_le_bytes()); ico.extend_from_slice(&length.to_le_bytes()); ico.extend_from_slice(&22u32.to_le_bytes()); 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 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}