//! Image helpers: PNG encode/decode and small list thumbnails. use base64::Engine; use std::borrow::Cow; pub const THUMB_MAX_EDGE: u32 = 96; pub fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result, png::EncodingError> { let mut buf = Vec::new(); { let mut encoder = png::Encoder::new(&mut buf, width, height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header()?; writer.write_image_data(rgba)?; } Ok(buf) } pub fn decode_png_rgba(png_data: &[u8]) -> Result<(u32, u32, Vec), String> { let decoder = png::Decoder::new(std::io::Cursor::new(png_data)); let mut reader = decoder.read_info().map_err(|e| e.to_string())?; let mut buf = vec![0; reader.output_buffer_size()]; let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?; buf.truncate(info.buffer_size()); // Expand to RGBA if needed let rgba = match info.color_type { png::ColorType::Rgba => buf, png::ColorType::Rgb => { let mut out = Vec::with_capacity((buf.len() / 3) * 4); for chunk in buf.chunks_exact(3) { out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); } out } png::ColorType::Grayscale => { let mut out = Vec::with_capacity(buf.len() * 4); for &g in &buf { out.extend_from_slice(&[g, g, g, 255]); } out } png::ColorType::GrayscaleAlpha => { let mut out = Vec::with_capacity((buf.len() / 2) * 4); for chunk in buf.chunks_exact(2) { out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]); } out } other => return Err(format!("unsupported PNG color type: {:?}", other)), }; Ok((info.width, info.height, rgba)) } /// Nearest-neighbor downscale so the longest edge is ≤ `max_edge`. pub fn downscale_rgba(width: u32, height: u32, rgba: &[u8], max_edge: u32) -> (u32, u32, Vec) { if width == 0 || height == 0 || rgba.len() < (width as usize * height as usize * 4) { return (width.max(1), height.max(1), vec![0, 0, 0, 0]); } let longest = width.max(height); if longest <= max_edge { return (width, height, rgba.to_vec()); } let scale = max_edge as f32 / longest as f32; let tw = ((width as f32) * scale).round().max(1.0) as u32; let th = ((height as f32) * scale).round().max(1.0) as u32; let mut out = vec![0u8; (tw as usize) * (th as usize) * 4]; for y in 0..th { let sy = ((y as f32 / th as f32) * height as f32) as u32; for x in 0..tw { let sx = ((x as f32 / tw as f32) * width as f32) as u32; let si = ((sy * width + sx) as usize) * 4; let di = ((y * tw + x) as usize) * 4; out[di..di + 4].copy_from_slice(&rgba[si..si + 4]); } } (tw, th, out) } pub fn rgba_to_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result { let png = encode_rgba_to_png(width, height, rgba).map_err(|e| e.to_string())?; let b64 = base64::engine::general_purpose::STANDARD.encode(&png); Ok(format!("data:image/png;base64,{}", b64)) } pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result { let (tw, th, thumb) = downscale_rgba(width, height, rgba, THUMB_MAX_EDGE); rgba_to_data_uri(tw, th, &thumb) } /// Shrink RGBA so `width * height * 4 <= max_bytes`, preserving aspect ratio. /// Returns the original buffer unchanged when already under the limit. pub fn fit_rgba_under_bytes( width: u32, height: u32, rgba: &[u8], max_bytes: usize, ) -> (u32, u32, Vec) { let need = (width as usize) .saturating_mul(height as usize) .saturating_mul(4); if need == 0 || need <= max_bytes { return (width.max(1), height.max(1), rgba.to_vec()); } let max_pixels = (max_bytes / 4).max(1) as f64; let pixels = (width as f64) * (height as f64); let scale = (max_pixels / pixels).sqrt(); let max_edge = ((width.max(height) as f64) * scale).floor().max(1.0) as u32; downscale_rgba(width, height, rgba, max_edge) } pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec), String> { let b64 = data_uri .split_once(',') .map(|(_, b)| b) .ok_or_else(|| "invalid data URI".to_string())?; let png = base64::engine::general_purpose::STANDARD .decode(b64) .map_err(|e| e.to_string())?; decode_png_rgba(&png) } pub fn to_arboard_image(width: u32, height: u32, rgba: Vec) -> arboard::ImageData<'static> { arboard::ImageData { width: width as usize, height: height as usize, bytes: Cow::Owned(rgba), } } #[cfg(test)] mod tests { use super::*; #[test] fn thumbnail_shrinks_large_image() { let w = 400u32; let h = 200u32; let rgba = vec![128u8; (w * h * 4) as usize]; let (tw, th, out) = downscale_rgba(w, h, &rgba, 96); assert!(tw <= 96 && th <= 96); assert_eq!(out.len(), (tw * th * 4) as usize); assert!(tw >= th); // landscape stays wider } #[test] fn roundtrip_png_small() { let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255]; let png = encode_rgba_to_png(2, 2, &rgba).unwrap(); let (w, h, out) = decode_png_rgba(&png).unwrap(); assert_eq!((w, h), (2, 2)); assert_eq!(out.len(), 16); } #[test] fn make_thumbnail_data_uri_ok() { let rgba = vec![10u8; 50 * 50 * 4]; let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap(); assert!(uri.starts_with("data:image/png;base64,")); } #[test] fn fit_rgba_leaves_small_images_alone() { let rgba = vec![1u8; 10 * 10 * 4]; let (w, h, out) = fit_rgba_under_bytes(10, 10, &rgba, 16 * 1024 * 1024); assert_eq!((w, h), (10, 10)); assert_eq!(out.len(), rgba.len()); } #[test] fn fit_rgba_shrinks_oversized_buffer() { let w = 2500u32; let h = 1700u32; let rgba = vec![40u8; (w * h * 4) as usize]; let max = 16 * 1024 * 1024; assert!(rgba.len() > max); let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max); assert!(out.len() <= max); assert!(tw < w || th < h); assert_eq!(out.len(), (tw * th * 4) as usize); } #[test] fn fit_rgba_uses_production_max_image_bytes_cap() { // Lock the constant the clipboard poller uses — if someone raises the // skip threshold again without downscale, this fixture still exceeds it. let max = crate::clipboard::MAX_IMAGE_BYTES; let w = 3024u32; let h = 1964u32; let rgba = vec![0u8; (w * h * 4) as usize]; assert!(rgba.len() > max); let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max); assert!(out.len() <= max); assert!(tw > 0 && th > 0); } #[test] fn downscale_handles_zero_dimensions() { let (w, h, out) = downscale_rgba(0, 0, &[], 96); assert!(w >= 1 && h >= 1); assert_eq!(out.len(), 4); // single transparent pixel path } }