//! Pixel format types and RGBA8 conversion. //! //! Provides a common `Image` type (always RGBA8) and functions to convert //! from various source formats: grayscale, grayscale+alpha, RGB, RGBA, //! and indexed (palette) color. use std::fmt; // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- /// Errors that can occur during image operations. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ImageError { /// Image dimensions are zero. ZeroDimension { width: u32, height: u32 }, /// Pixel data length does not match expected dimensions. DataLengthMismatch { expected: usize, actual: usize }, /// Palette index is out of bounds. PaletteIndexOutOfBounds { index: u8, palette_len: usize }, /// Palette is empty. EmptyPalette, /// Generic decoding error. Decode(String), } impl fmt::Display for ImageError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ZeroDimension { width, height } => { write!(f, "zero dimension: {width}x{height}") } Self::DataLengthMismatch { expected, actual } => { write!(f, "data length mismatch: expected {expected}, got {actual}") } Self::PaletteIndexOutOfBounds { index, palette_len } => { write!( f, "palette index {index} out of bounds (palette has {palette_len} entries)" ) } Self::EmptyPalette => write!(f, "empty palette"), Self::Decode(msg) => write!(f, "decode error: {msg}"), } } } pub type Result = std::result::Result; // --------------------------------------------------------------------------- // Image // --------------------------------------------------------------------------- /// An image stored as RGBA8 pixel data (4 bytes per pixel). /// /// Pixels are stored in row-major order, top-to-bottom, left-to-right. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Image { /// Width in pixels. pub width: u32, /// Height in pixels. pub height: u32, /// RGBA8 pixel data: `4 * width * height` bytes. pub data: Vec, } impl Image { /// Create an image from pre-validated RGBA8 data. pub fn new(width: u32, height: u32, data: Vec) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } let expected = (width as usize) * (height as usize) * 4; if data.len() != expected { return Err(ImageError::DataLengthMismatch { expected, actual: data.len(), }); } Ok(Self { width, height, data, }) } /// Total number of pixels. pub fn pixel_count(&self) -> usize { self.width as usize * self.height as usize } } // --------------------------------------------------------------------------- // Conversion functions // --------------------------------------------------------------------------- /// Convert grayscale (1 channel) pixel data to an RGBA8 `Image`. /// /// Each grayscale byte maps to (G, G, G, 255). pub fn from_grayscale(width: u32, height: u32, gray: &[u8]) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } let pixel_count = width as usize * height as usize; if gray.len() != pixel_count { return Err(ImageError::DataLengthMismatch { expected: pixel_count, actual: gray.len(), }); } let mut data = Vec::with_capacity(pixel_count * 4); for &g in gray { data.push(g); data.push(g); data.push(g); data.push(255); } Ok(Image { width, height, data, }) } /// Convert grayscale+alpha (2 channels) pixel data to an RGBA8 `Image`. /// /// Each pair of bytes (G, A) maps to (G, G, G, A). pub fn from_grayscale_alpha(width: u32, height: u32, ga: &[u8]) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } let pixel_count = width as usize * height as usize; if ga.len() != pixel_count * 2 { return Err(ImageError::DataLengthMismatch { expected: pixel_count * 2, actual: ga.len(), }); } let mut data = Vec::with_capacity(pixel_count * 4); for pair in ga.chunks_exact(2) { let g = pair[0]; let a = pair[1]; data.push(g); data.push(g); data.push(g); data.push(a); } Ok(Image { width, height, data, }) } /// Convert RGB (3 channels) pixel data to an RGBA8 `Image`. /// /// Each triple (R, G, B) maps to (R, G, B, 255). pub fn from_rgb(width: u32, height: u32, rgb: &[u8]) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } let pixel_count = width as usize * height as usize; if rgb.len() != pixel_count * 3 { return Err(ImageError::DataLengthMismatch { expected: pixel_count * 3, actual: rgb.len(), }); } let mut data = Vec::with_capacity(pixel_count * 4); for triple in rgb.chunks_exact(3) { data.push(triple[0]); data.push(triple[1]); data.push(triple[2]); data.push(255); } Ok(Image { width, height, data, }) } /// Convert RGBA (4 channels) pixel data to an RGBA8 `Image`. /// /// This is the identity conversion — validates dimensions and length. pub fn from_rgba(width: u32, height: u32, rgba: Vec) -> Result { Image::new(width, height, rgba) } /// Convert indexed-color pixel data to an RGBA8 `Image`. /// /// `palette` is a flat array of RGB triples (3 bytes per entry). /// `indices` contains one palette index per pixel. pub fn from_indexed(width: u32, height: u32, palette: &[u8], indices: &[u8]) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } if palette.is_empty() { return Err(ImageError::EmptyPalette); } let palette_len = palette.len() / 3; let pixel_count = width as usize * height as usize; if indices.len() != pixel_count { return Err(ImageError::DataLengthMismatch { expected: pixel_count, actual: indices.len(), }); } let mut data = Vec::with_capacity(pixel_count * 4); for &idx in indices { if (idx as usize) >= palette_len { return Err(ImageError::PaletteIndexOutOfBounds { index: idx, palette_len, }); } let offset = idx as usize * 3; data.push(palette[offset]); data.push(palette[offset + 1]); data.push(palette[offset + 2]); data.push(255); } Ok(Image { width, height, data, }) } /// Convert indexed-color pixel data with per-entry alpha to an RGBA8 `Image`. /// /// `palette` is a flat array of RGB triples (3 bytes per entry). /// `alpha` provides alpha values for palette entries (may be shorter than palette; /// missing entries default to 255). /// `indices` contains one palette index per pixel. pub fn from_indexed_alpha( width: u32, height: u32, palette: &[u8], alpha: &[u8], indices: &[u8], ) -> Result { if width == 0 || height == 0 { return Err(ImageError::ZeroDimension { width, height }); } if palette.is_empty() { return Err(ImageError::EmptyPalette); } let palette_len = palette.len() / 3; let pixel_count = width as usize * height as usize; if indices.len() != pixel_count { return Err(ImageError::DataLengthMismatch { expected: pixel_count, actual: indices.len(), }); } let mut data = Vec::with_capacity(pixel_count * 4); for &idx in indices { if (idx as usize) >= palette_len { return Err(ImageError::PaletteIndexOutOfBounds { index: idx, palette_len, }); } let offset = idx as usize * 3; let a = alpha.get(idx as usize).copied().unwrap_or(255); data.push(palette[offset]); data.push(palette[offset + 1]); data.push(palette[offset + 2]); data.push(a); } Ok(Image { width, height, data, }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // -- Image::new tests -- #[test] fn image_new_valid() { let data = vec![0; 2 * 3 * 4]; // 2x3, 4 bytes per pixel let img = Image::new(2, 3, data).unwrap(); assert_eq!(img.width, 2); assert_eq!(img.height, 3); assert_eq!(img.pixel_count(), 6); assert_eq!(img.data.len(), 24); } #[test] fn image_new_zero_width() { assert!(matches!( Image::new(0, 5, vec![]), Err(ImageError::ZeroDimension { width: 0, height: 5 }) )); } #[test] fn image_new_zero_height() { assert!(matches!( Image::new(5, 0, vec![]), Err(ImageError::ZeroDimension { width: 5, height: 0 }) )); } #[test] fn image_new_data_length_mismatch() { let err = Image::new(2, 2, vec![0; 10]).unwrap_err(); assert!(matches!( err, ImageError::DataLengthMismatch { expected: 16, actual: 10 } )); } // -- Grayscale tests -- #[test] fn grayscale_basic() { let img = from_grayscale(2, 1, &[0, 128]).unwrap(); assert_eq!(img.width, 2); assert_eq!(img.height, 1); assert_eq!(img.data, vec![0, 0, 0, 255, 128, 128, 128, 255]); } #[test] fn grayscale_white() { let img = from_grayscale(1, 1, &[255]).unwrap(); assert_eq!(img.data, vec![255, 255, 255, 255]); } #[test] fn grayscale_zero_dimension() { assert!(matches!( from_grayscale(0, 1, &[]), Err(ImageError::ZeroDimension { .. }) )); } #[test] fn grayscale_data_mismatch() { assert!(matches!( from_grayscale(2, 2, &[0, 1, 2]), Err(ImageError::DataLengthMismatch { expected: 4, actual: 3 }) )); } // -- Grayscale+Alpha tests -- #[test] fn grayscale_alpha_basic() { let img = from_grayscale_alpha(1, 2, &[100, 200, 50, 128]).unwrap(); assert_eq!(img.data, vec![100, 100, 100, 200, 50, 50, 50, 128]); } #[test] fn grayscale_alpha_zero_dimension() { assert!(matches!( from_grayscale_alpha(1, 0, &[]), Err(ImageError::ZeroDimension { .. }) )); } #[test] fn grayscale_alpha_data_mismatch() { assert!(matches!( from_grayscale_alpha(2, 1, &[0, 1, 2]), Err(ImageError::DataLengthMismatch { expected: 4, actual: 3 }) )); } // -- RGB tests -- #[test] fn rgb_basic() { let img = from_rgb(1, 2, &[255, 0, 0, 0, 255, 0]).unwrap(); assert_eq!(img.data, vec![255, 0, 0, 255, 0, 255, 0, 255]); } #[test] fn rgb_zero_dimension() { assert!(matches!( from_rgb(0, 0, &[]), Err(ImageError::ZeroDimension { .. }) )); } #[test] fn rgb_data_mismatch() { assert!(matches!( from_rgb(2, 1, &[0, 1, 2, 3, 4]), Err(ImageError::DataLengthMismatch { expected: 6, actual: 5 }) )); } // -- RGBA tests -- #[test] fn rgba_basic() { let data = vec![10, 20, 30, 40, 50, 60, 70, 80]; let img = from_rgba(2, 1, data.clone()).unwrap(); assert_eq!(img.data, data); } #[test] fn rgba_zero_dimension() { assert!(matches!( from_rgba(0, 1, vec![]), Err(ImageError::ZeroDimension { .. }) )); } #[test] fn rgba_data_mismatch() { assert!(matches!( from_rgba(1, 1, vec![0, 1, 2]), Err(ImageError::DataLengthMismatch { expected: 4, actual: 3 }) )); } // -- Indexed tests -- #[test] fn indexed_basic() { let palette = [255, 0, 0, 0, 255, 0, 0, 0, 255]; // red, green, blue let indices = [0, 1, 2, 0]; let img = from_indexed(2, 2, &palette, &indices).unwrap(); assert_eq!( img.data, vec![ 255, 0, 0, 255, // red 0, 255, 0, 255, // green 0, 0, 255, 255, // blue 255, 0, 0, 255, // red ] ); } #[test] fn indexed_zero_dimension() { assert!(matches!( from_indexed(0, 1, &[0, 0, 0], &[]), Err(ImageError::ZeroDimension { .. }) )); } #[test] fn indexed_empty_palette() { assert!(matches!( from_indexed(1, 1, &[], &[0]), Err(ImageError::EmptyPalette) )); } #[test] fn indexed_out_of_bounds() { let palette = [255, 0, 0]; // 1 entry assert!(matches!( from_indexed(1, 1, &palette, &[1]), Err(ImageError::PaletteIndexOutOfBounds { index: 1, palette_len: 1 }) )); } #[test] fn indexed_data_mismatch() { let palette = [0, 0, 0]; assert!(matches!( from_indexed(2, 2, &palette, &[0, 0]), Err(ImageError::DataLengthMismatch { expected: 4, actual: 2 }) )); } // -- Indexed+Alpha tests -- #[test] fn indexed_alpha_basic() { let palette = [255, 0, 0, 0, 255, 0]; // red, green let alpha = [128, 64]; let indices = [0, 1]; let img = from_indexed_alpha(2, 1, &palette, &alpha, &indices).unwrap(); assert_eq!(img.data, vec![255, 0, 0, 128, 0, 255, 0, 64]); } #[test] fn indexed_alpha_missing_defaults_to_255() { let palette = [255, 0, 0, 0, 255, 0]; // red, green let alpha = [128]; // only first entry has alpha let indices = [0, 1]; let img = from_indexed_alpha(2, 1, &palette, &alpha, &indices).unwrap(); assert_eq!(img.data, vec![255, 0, 0, 128, 0, 255, 0, 255]); } #[test] fn indexed_alpha_empty_alpha() { let palette = [10, 20, 30]; let alpha: &[u8] = &[]; let indices = [0]; let img = from_indexed_alpha(1, 1, &palette, alpha, &indices).unwrap(); assert_eq!(img.data, vec![10, 20, 30, 255]); } // -- Error Display tests -- #[test] fn error_display() { assert_eq!( ImageError::ZeroDimension { width: 0, height: 5 } .to_string(), "zero dimension: 0x5" ); assert_eq!( ImageError::DataLengthMismatch { expected: 100, actual: 50 } .to_string(), "data length mismatch: expected 100, got 50" ); assert_eq!( ImageError::PaletteIndexOutOfBounds { index: 10, palette_len: 5 } .to_string(), "palette index 10 out of bounds (palette has 5 entries)" ); assert_eq!(ImageError::EmptyPalette.to_string(), "empty palette"); assert_eq!( ImageError::Decode("bad header".to_string()).to_string(), "decode error: bad header" ); } // -- Larger images -- #[test] fn grayscale_larger_image() { let width = 10; let height = 10; let gray: Vec = (0..100).collect(); let img = from_grayscale(width, height, &gray).unwrap(); assert_eq!(img.data.len(), 400); // Spot-check first and last pixel assert_eq!(&img.data[0..4], &[0, 0, 0, 255]); assert_eq!(&img.data[396..400], &[99, 99, 99, 255]); } #[test] fn rgb_larger_image() { let width = 4; let height = 4; let rgb: Vec = (0..48).collect(); let img = from_rgb(width, height, &rgb).unwrap(); assert_eq!(img.data.len(), 64); // First pixel: R=0, G=1, B=2, A=255 assert_eq!(&img.data[0..4], &[0, 1, 2, 255]); // Second pixel: R=3, G=4, B=5, A=255 assert_eq!(&img.data[4..8], &[3, 4, 5, 255]); } // -- 1x1 minimum images -- #[test] fn single_pixel_all_formats() { // Grayscale let img = from_grayscale(1, 1, &[42]).unwrap(); assert_eq!(img.data, vec![42, 42, 42, 255]); // Grayscale+Alpha let img = from_grayscale_alpha(1, 1, &[42, 100]).unwrap(); assert_eq!(img.data, vec![42, 42, 42, 100]); // RGB let img = from_rgb(1, 1, &[10, 20, 30]).unwrap(); assert_eq!(img.data, vec![10, 20, 30, 255]); // RGBA let img = from_rgba(1, 1, vec![10, 20, 30, 40]).unwrap(); assert_eq!(img.data, vec![10, 20, 30, 40]); // Indexed let img = from_indexed(1, 1, &[10, 20, 30], &[0]).unwrap(); assert_eq!(img.data, vec![10, 20, 30, 255]); } }