From d5276950ef86e3ca2c34ce43c486813abb65f3e0 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 13 Mar 2026 07:48:30 +0100 Subject: [PATCH] Add pixel format types and RGBA8 conversion utilities Image struct (always RGBA8) with conversions from grayscale, grayscale+alpha, RGB, RGBA, indexed color, and indexed+alpha. 29 unit tests covering all conversion paths and edge cases. Co-Authored-By: Claude Opus 4.6 --- crates/image/src/lib.rs | 1 + crates/image/src/pixel.rs | 624 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 crates/image/src/pixel.rs diff --git a/crates/image/src/lib.rs b/crates/image/src/lib.rs index eb476af..5aa97f7 100644 --- a/crates/image/src/lib.rs +++ b/crates/image/src/lib.rs @@ -1,4 +1,5 @@ //! Image decoders — PNG (DEFLATE), JPEG, GIF, WebP — pure Rust. pub mod deflate; +pub mod pixel; pub mod zlib; diff --git a/crates/image/src/pixel.rs b/crates/image/src/pixel.rs new file mode 100644 index 0000000..a046524 --- /dev/null +++ b/crates/image/src/pixel.rs @@ -0,0 +1,624 @@ +//! 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]); + } +} -- 2.51.2