use image::{DynamicImage, GenericImageView};
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::info;
#[derive(Error, Debug)]
pub enum FaviconError {
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("PNG optimization error: {0}")]
PngOptimize(String),
#[error("Input image too small: {0}x{1} (need at least 16x16)")]
TooSmall(u32, u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaviconOptions {
pub png_level: u8,
}
impl Default for FaviconOptions {
fn default() -> Self {
Self { png_level: 4 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconSpec {
pub filename: String,
pub width: u32,
pub height: u32,
pub purpose: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FaviconResult {
pub generated_files: Vec<PathBuf>,
pub html_snippet: String,
pub total_size: u64,
pub icons: Vec<IconSpec>,
}
const FAVICON_SPECS: &[(&str, u32, u32, &str)] = &[
("favicon-16x16.png", 16, 16, "favicon"),
("favicon-32x32.png", 32, 32, "favicon"),
("apple-touch-icon.png", 180, 180, "apple-touch-icon"),
("android-chrome-192x192.png", 192, 192, "android-chrome"),
("android-chrome-512x512.png", 512, 512, "android-chrome"),
];
const ICO_SIZES: &[u32] = &[16, 32, 48];
pub fn generate_favicon_set(
input: &Path,
output_dir: &Path,
opts: &FaviconOptions,
) -> Result<FaviconResult, FaviconError> {
let img = image::open(input)?;
generate_favicon_set_from_image(&img, output_dir, opts)
}
pub fn generate_favicon_set_from_image(
img: &DynamicImage,
output_dir: &Path,
opts: &FaviconOptions,
) -> Result<FaviconResult, FaviconError> {
let (w, h) = img.dimensions();
if w < 16 || h < 16 {
return Err(FaviconError::TooSmall(w, h));
}
std::fs::create_dir_all(output_dir)?;
let square = crop_to_square(img);
let mut generated_files = Vec::new();
let mut icons = Vec::new();
let mut total_size = 0u64;
let ico_path = output_dir.join("favicon.ico");
let ico_bytes = build_multi_resolution_ico(&square, ICO_SIZES, opts.png_level)?;
std::fs::write(&ico_path, &ico_bytes)?;
let ico_size = ico_bytes.len() as u64;
total_size += ico_size;
info!(
"Generated: favicon.ico ({} bytes, {} sizes)",
ico_size,
ICO_SIZES.len()
);
generated_files.push(ico_path);
icons.push(IconSpec {
filename: "favicon.ico".to_string(),
width: 48,
height: 48,
purpose: "favicon".to_string(),
});
for &(filename, width, height, purpose) in FAVICON_SPECS {
let resized = square.resize_exact(width, height, image::imageops::FilterType::Lanczos3);
let png_bytes = encode_and_optimize_png(&resized, opts.png_level)?;
let path = output_dir.join(filename);
std::fs::write(&path, &png_bytes)?;
let size = png_bytes.len() as u64;
total_size += size;
info!(
"Generated: {} ({}x{}, {} bytes)",
filename, width, height, size
);
generated_files.push(path);
icons.push(IconSpec {
filename: filename.to_string(),
width,
height,
purpose: purpose.to_string(),
});
}
let html_snippet = build_html_snippet();
Ok(FaviconResult {
generated_files,
html_snippet,
total_size,
icons,
})
}
fn crop_to_square(img: &DynamicImage) -> DynamicImage {
let (w, h) = img.dimensions();
if w == h {
return img.clone();
}
let side = w.min(h);
let x = (w - side) / 2;
let y = (h - side) / 2;
img.crop_imm(x, y, side, side)
}
fn encode_and_optimize_png(img: &DynamicImage, level: u8) -> Result<Vec<u8>, FaviconError> {
let mut raw_png = Vec::new();
img.write_to(&mut Cursor::new(&mut raw_png), image::ImageFormat::Png)?;
let mut opts = oxipng::Options::from_preset(level);
opts.strip = oxipng::StripChunks::Safe;
let optimized = oxipng::optimize_from_memory(&raw_png, &opts)
.map_err(|e| FaviconError::PngOptimize(e.to_string()))?;
Ok(optimized)
}
fn build_multi_resolution_ico(
source: &DynamicImage,
sizes: &[u32],
png_level: u8,
) -> Result<Vec<u8>, FaviconError> {
let count = sizes.len() as u16;
let mut png_entries: Vec<(u32, Vec<u8>)> = Vec::new();
for &size in sizes {
let resized = source.resize_exact(size, size, image::imageops::FilterType::Lanczos3);
let png_bytes = encode_and_optimize_png(&resized, png_level)?;
png_entries.push((size, png_bytes));
}
let header_size: u32 = 6;
let dir_size: u32 = 16 * count as u32;
let data_start = header_size + dir_size;
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&count.to_le_bytes());
let mut current_offset = data_start;
for (size, png_data) in &png_entries {
let dim = if *size >= 256 { 0u8 } else { *size as u8 };
buf.push(dim); buf.push(dim); buf.push(0); buf.push(0); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&32u16.to_le_bytes()); buf.extend_from_slice(&(png_data.len() as u32).to_le_bytes()); buf.extend_from_slice(¤t_offset.to_le_bytes()); current_offset += png_data.len() as u32;
}
for (_, png_data) in &png_entries {
buf.extend_from_slice(png_data);
}
Ok(buf)
}
fn build_html_snippet() -> String {
r#"<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png">"#
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_image(w: u32, h: u32) -> DynamicImage {
DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
image::Rgba([(x % 256) as u8, (y % 256) as u8, 128u8, 255u8])
}))
}
#[test]
fn test_crop_to_square_already_square() {
let img = test_image(100, 100);
let cropped = crop_to_square(&img);
assert_eq!(cropped.dimensions(), (100, 100));
}
#[test]
fn test_crop_to_square_landscape() {
let img = test_image(200, 100);
let cropped = crop_to_square(&img);
assert_eq!(cropped.dimensions(), (100, 100));
}
#[test]
fn test_crop_to_square_portrait() {
let img = test_image(100, 200);
let cropped = crop_to_square(&img);
assert_eq!(cropped.dimensions(), (100, 100));
}
#[test]
fn test_encode_and_optimize_png() {
let img = test_image(64, 64);
let bytes = encode_and_optimize_png(&img, 2).unwrap();
assert_eq!(&bytes[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
}
#[test]
fn test_build_multi_resolution_ico() {
let img = test_image(64, 64);
let ico = build_multi_resolution_ico(&img, &[16, 32, 48], 2).unwrap();
assert_eq!(&ico[0..2], &[0, 0]); assert_eq!(&ico[2..4], &[1, 0]); assert_eq!(&ico[4..6], &[3, 0]); assert_eq!(ico[6], 16);
assert_eq!(ico[6 + 16], 32);
assert_eq!(ico[6 + 32], 48);
}
#[test]
fn test_generate_favicon_set_creates_all_files() {
let img = test_image(512, 512);
let dir = tempfile::tempdir().unwrap();
let result =
generate_favicon_set_from_image(&img, dir.path(), &FaviconOptions::default()).unwrap();
assert_eq!(result.generated_files.len(), 6);
assert!(result.html_snippet.contains("favicon.ico"));
assert!(result.html_snippet.contains("apple-touch-icon"));
for path in &result.generated_files {
assert!(path.exists());
}
assert_eq!(result.icons.len(), 6);
}
#[test]
fn test_generate_favicon_set_non_square() {
let img = test_image(800, 400);
let dir = tempfile::tempdir().unwrap();
let result =
generate_favicon_set_from_image(&img, dir.path(), &FaviconOptions::default()).unwrap();
assert_eq!(result.generated_files.len(), 6);
}
#[test]
fn test_too_small_image_rejected() {
let img = test_image(8, 8);
let dir = tempfile::tempdir().unwrap();
let result = generate_favicon_set_from_image(&img, dir.path(), &FaviconOptions::default());
assert!(matches!(result, Err(FaviconError::TooSmall(8, 8))));
}
#[test]
fn test_html_snippet_contains_all_links() {
let snippet = build_html_snippet();
assert!(snippet.contains("favicon.ico"));
assert!(snippet.contains("favicon-16x16.png"));
assert!(snippet.contains("favicon-32x32.png"));
assert!(snippet.contains("apple-touch-icon.png"));
assert!(snippet.contains("android-chrome-192x192.png"));
assert!(snippet.contains("android-chrome-512x512.png"));
}
}