//! PXE boot-menu background compositor. //! //! The brief (v0.4.69): match iVentoy's polished graphical PXE screen. //! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to //! the framebuffer via `console --picture`, then draws the text menu on //! top (the console's default background colour is rendered transparent //! so the picture shows through the menu's blank cells). So what we //! produce here is a **full-screen 1024×768 background**, not just a //! floating logo: //! //! - a solid dark field (matches the WebUI dark theme so the product //! feels consistent from browser to bare metal), with //! - the operator's uploaded logo composited across the top, leaving //! the lower ~two-thirds clear for the iPXE menu text. //! //! When no custom logo is uploaded we still return a designed //! background — a dark field with a centered "rainbow-horizon" disc //! echoing the bundled OpenPXE mark — so the boot screen is graphical //! out of the box. This replaces the old ASCII wordmark entirely. //! //! iPXE does **not** scale pictures (confirmed against the decoder //! source): the image is painted at native pixel size and the firmware //! picks the smallest video mode that fits. 1024×768 is the universal //! safe mode, so we pin the canvas there. Operators uploading a 4K logo //! get it downscaled to fit the top band; tiny icons paint at native //! size, centered. //! //! Input formats: anything the `image` crate decodes with our enabled //! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so //! we always *emit* PNG regardless of what the operator uploaded; a //! WebP logo is transcoded here transparently. use image::imageops::FilterType; use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage}; use std::io::Cursor; /// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer /// mode every BIOS/UEFI console supports, and iPXE doesn't scale. pub const CANVAS_W: u32 = 1024; pub const CANVAS_H: u32 = 768; /// Bounding box for the operator's logo across the top band. Wider than /// the old floating-logo box because the logo now anchors a full /// background rather than sitting alone on transparency. const LOGO_MAX_W: u32 = 760; const LOGO_MAX_H: u32 = 200; /// Top margin from the canvas top to the logo's top edge. const LOGO_TOP_MARGIN: u32 = 72; /// Background fill — a near-black with a faint blue cast, matching the /// WebUI's dark theme surface so the product reads as one piece from /// browser to PXE screen. const BG: Rgba = Rgba([11, 14, 22, 255]); /// Compose the operator's uploaded raster (`Some`) — or the default /// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background /// and return the encoded bytes. /// /// Errors only when a provided `src_bytes` can't be decoded; the /// `None` path and the PNG encode are infallible for our fixed canvas. pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result, ImageError> { let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG); match src_bytes { Some(bytes) => { let logo = image::load_from_memory(bytes)?; let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H); let logo_rgba = logo.to_rgba8(); let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2; let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height())); // `overlay` alpha-composites, so a transparent-background // logo blends onto the dark field exactly as designed. image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into()); } None => draw_default_mark(&mut canvas), } let mut out = Vec::with_capacity(128 * 1024); DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?; Ok(out) } /// Back-compat shim for the old name — callers that pass a raw logo and /// want it composited get the same result as `compose_pxe_background` /// with `Some`. pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result, ImageError> { compose_pxe_background(Some(src_bytes)) } /// Paint a centered "rainbow-horizon" disc onto the dark canvas as the /// default brand mark when no operator logo is set. Pure pixel math — /// no font, no SVG rasterizer, no extra deps. A filled circle with a /// left-to-right hue sweep echoes the bundled `logo.svg` motif. // Casts here are all bounded small-range geometry (radius ≤ 90, canvas // ≤ 1024) — precision loss / wrap is structurally impossible. #[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)] fn draw_default_mark(canvas: &mut RgbaImage) { let radius: i32 = 90; let cx = (CANVAS_W / 2) as i32; let cy = (LOGO_TOP_MARGIN + 100) as i32; // Four-stop horizontal sweep across the disc (teal → blue → violet // → magenta) — the OpenPXE palette. let stops = [ [0x22u8, 0xd3, 0xaa], [0x3b, 0x82, 0xf6], [0x8b, 0x5c, 0xf6], [0xec, 0x48, 0x99], ]; let r2 = radius * radius; for dy in -radius..=radius { for dx in -radius..=radius { if dx * dx + dy * dy > r2 { continue; } // Position across the disc in [0,1] left→right. let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0))) / (f32::from(i16::try_from(2 * radius).unwrap_or(1))); let color = gradient_at(&stops, t); // Soft edge: fade alpha in the outer 3px ring. let dist = ((dx * dx + dy * dy) as f32).sqrt(); let alpha = if dist > (radius as f32 - 3.0) { let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0; (edge * 255.0) as u8 } else { 255 }; let px = cx + dx; let py = cy + dy; if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H { blend_pixel(canvas, px as u32, py as u32, color, alpha); } } } } /// Linear interpolate across an N-stop palette at position `t` in [0,1]. // `segments`/`idx` are ≤ palette length (4) — f32 cast is exact. #[allow(clippy::cast_precision_loss)] fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] { let t = t.clamp(0.0, 1.0); let segments = stops.len() - 1; let scaled = t * segments as f32; let idx = (scaled.floor() as usize).min(segments - 1); let frac = scaled - idx as f32; let a = stops[idx]; let b = stops[idx + 1]; [ lerp(a[0], b[0], frac), lerp(a[1], b[1], frac), lerp(a[2], b[2], frac), ] } fn lerp(a: u8, b: u8, t: f32) -> u8 { (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8 } /// Alpha-blend `color` at `alpha` over the existing canvas pixel. fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) { let bg = canvas.get_pixel(x, y).0; let a = f32::from(alpha) / 255.0; let out = Rgba([ lerp(bg[0], color[0], a), lerp(bg[1], color[1], a), lerp(bg[2], color[2], a), 255, ]); canvas.put_pixel(x, y, out); } fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage { let (w, h) = (img.width(), img.height()); if w <= max_w && h <= max_h { return img; } img.resize(max_w, max_h, FilterType::Lanczos3) } #[cfg(test)] mod tests { use super::*; use image::{ImageBuffer, Rgb}; fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(w, h, Rgb(rgb)); let mut out = Vec::with_capacity(4096); DynamicImage::ImageRgb8(img) .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) .unwrap(); out } #[test] fn custom_logo_emits_canvas_sized_png_with_dark_field() { let src = solid_png(120, 60, [200, 50, 50]); let out = compose_pxe_background(Some(&src)).unwrap(); let img = image::load_from_memory(&out).unwrap().to_rgba8(); assert_eq!(img.width(), CANVAS_W); assert_eq!(img.height(), CANVAS_H); // A far corner should be the opaque dark background fill, not // transparent — this is a full background now, not a floating // logo on transparency. let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1); assert_eq!(corner.0, BG.0, "corner should be the dark fill"); } #[test] fn custom_logo_painted_in_top_band() { let src = solid_png(100, 40, [10, 200, 10]); let out = compose_pxe_background(Some(&src)).unwrap(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); let cx = (CANVAS_W - 100) / 2; let cy = LOGO_TOP_MARGIN; let inside = canvas.get_pixel(cx + 10, cy + 10); assert!( inside.0[1] > 100 && inside.0[0] < 100, "logo pixel color mismatch: {inside:?}" ); } #[test] fn default_background_is_dark_with_a_painted_mark() { let out = compose_pxe_background(None).unwrap(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); assert_eq!(canvas.width(), CANVAS_W); assert_eq!(canvas.height(), CANVAS_H); // Corner is dark fill. assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0); // Center of the disc is not the background fill (something was // painted there). let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100); assert_ne!(center.0, BG.0, "default mark should paint over the field"); } #[test] fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() { // Encode a JPEG and confirm the compositor decodes it and emits // a valid PNG (iPXE only eats PNG, so transcoding is the point). let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90])); let mut jpeg = Vec::new(); DynamicImage::ImageRgb8(img) .write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg) .unwrap(); let out = compose_pxe_background(Some(&jpeg)).unwrap(); // Output must be a PNG (magic bytes) of canvas size. assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n"); let img = image::load_from_memory(&out).unwrap(); assert_eq!(img.width(), CANVAS_W); } #[test] fn unsupported_bytes_returns_error_not_panic() { let r = compose_pxe_background(Some(b"\xde\xad\xbe\xef not an image")); assert!(r.is_err()); } #[test] fn gradient_endpoints_match_stops() { let stops = [[0, 0, 0], [255, 255, 255]]; assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]); assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]); } }