diff --git a/Cargo.lock b/Cargo.lock index c189a9e..b43c1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,12 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -704,6 +710,16 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + [[package]] name = "ignore" version = "0.4.23" @@ -1563,6 +1579,7 @@ dependencies = [ "dotenvy", "envy", "futures", + "ico", "lazy_static", "phf 0.12.1", "phf_codegen 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 92d50fb..1598924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ phf = { version = "0.12.1", features = ["macros"] } phf_codegen = "0.12.1" chrono = "0.4.41" regex = "1.11.1" +ico = "0.4.0" [build-dependencies] chrono = "0.4.41" diff --git a/src/render.rs b/src/render.rs index e818f04..f14719c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,6 +5,7 @@ use axum::body::Bytes; use axum::http::{header, StatusCode}; use axum::response::IntoResponse; use chrono::{DateTime, Utc}; +use std::io::Cursor; /// Output format for rendered time banners. #[derive(Debug, Clone)] @@ -41,7 +42,7 @@ impl OutputFormat { } /// Converts SVG to the requested format. PNG requires rasterization. -fn handle_rasterize(data: String, format: &OutputFormat) -> Result { +pub fn handle_rasterize(data: String, format: &OutputFormat) -> Result { match format { OutputFormat::Svg => Ok(Bytes::from(data)), OutputFormat::Png => { @@ -103,3 +104,50 @@ pub fn render_time_response( Err(e) => get_error_response(e).into_response(), } } + +/// Generates PNG bytes for the favicon clock. +pub fn generate_favicon_png_bytes(time: DateTime) -> Result, TimeBannerError> { + // Build context for rendering + let context = RenderContext { + value: time, + output_form: OutputForm::Clock, + output_format: OutputFormat::Png, + timezone: None, + format: None, + now: None, + }; + + // Render template to SVG + let rendered_template = render_template(context) + .map_err(|e| TimeBannerError::RenderError(format!("Template rendering failed: {}", e)))?; + + // Convert SVG to PNG + let png_bytes = handle_rasterize(rendered_template, &OutputFormat::Png)?; + + Ok(png_bytes.to_vec()) +} + +/// Converts PNG bytes to ICO format using the ico crate. +pub fn convert_png_to_ico(png_bytes: &[u8]) -> Result { + // Create a new, empty icon collection + let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); + + // Read PNG data from bytes + let cursor = Cursor::new(png_bytes); + let image = + ico::IconImage::read_png(cursor).map_err(|e| format!("Failed to read PNG data: {}", e))?; + + // Add the image to the icon collection + icon_dir.add_entry( + ico::IconDirEntry::encode(&image) + .map_err(|e| format!("Failed to encode icon entry: {}", e))?, + ); + + // Write ICO data to a buffer + let mut ico_buffer = Vec::new(); + icon_dir + .write(&mut ico_buffer) + .map_err(|e| format!("Failed to write ICO data: {}", e))?; + + Ok(Bytes::from(ico_buffer)) +} diff --git a/src/routes.rs b/src/routes.rs index e4e5fe4..92dbe63 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,10 +1,11 @@ use crate::duration::parse_time_value; use crate::error::{get_error_response, TimeBannerError}; -use crate::render::render_time_response; +use crate::render::{convert_png_to_ico, generate_favicon_png_bytes, render_time_response}; use crate::template::OutputForm; use crate::utils::parse_path; use axum::extract::ConnectInfo; use axum::extract::Path; +use axum::http::{header, StatusCode}; use axum::response::IntoResponse; use std::net::SocketAddr; @@ -53,15 +54,33 @@ pub async fn implicit_handler(Path(path): Path) -> impl IntoResponse { /// Handles `/favicon.ico` - generates a dynamic clock favicon showing the current time. /// -/// Logs the client IP address and returns a PNG image of an analog clock. +/// Logs the client IP address and returns an ICO image of an analog clock. pub async fn favicon_handler(ConnectInfo(addr): ConnectInfo) -> impl IntoResponse { let now = chrono::Utc::now(); // Log the IP address for the favicon request tracing::info!("Favicon request from IP: {}", addr.ip()); - // Generate clock favicon showing current time - render_time_response(now, OutputForm::Clock, "png").into_response() + // Generate PNG bytes directly for conversion + let png_bytes = match generate_favicon_png_bytes(now) { + Ok(bytes) => bytes, + Err(e) => return get_error_response(e).into_response(), + }; + + // Convert PNG to ICO using the ico crate + match convert_png_to_ico(&png_bytes) { + Ok(ico_bytes) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/x-icon")], + ico_bytes, + ) + .into_response(), + Err(e) => get_error_response(TimeBannerError::RenderError(format!( + "Failed to convert PNG to ICO: {}", + e + ))) + .into_response(), + } } /// Fallback handler for unmatched routes.