From 3e2d2105920d992da2c12b06e5c46f7da767dc71 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 13 Mar 2026 07:57:18 +0100 Subject: [PATCH] Implement PNG decoder (RFC 2083) Pure Rust PNG decoder supporting all color types (grayscale, RGB, indexed, grayscale+alpha, RGBA), bit depths 1-16, scanline filtering (None/Sub/Up/Average/Paeth), Adam7 interlacing, tRNS transparency, CRC-32 validation, and multiple IDAT chunk concatenation. 40+ unit tests covering all color types, bit depths, filter types, interlacing, transparency, error cases, and edge cases. Co-Authored-By: Claude Opus 4.6 --- crates/image/src/lib.rs | 1 + crates/image/src/png.rs | 1544 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1545 insertions(+) create mode 100644 crates/image/src/png.rs diff --git a/crates/image/src/lib.rs b/crates/image/src/lib.rs index 5aa97f7..ef3ea93 100644 --- a/crates/image/src/lib.rs +++ b/crates/image/src/lib.rs @@ -2,4 +2,5 @@ pub mod deflate; pub mod pixel; +pub mod png; pub mod zlib; diff --git a/crates/image/src/png.rs b/crates/image/src/png.rs new file mode 100644 index 0000000..1bdfc7a --- /dev/null +++ b/crates/image/src/png.rs @@ -0,0 +1,1544 @@ +//! PNG decoder (RFC 2083). +//! +//! Decodes PNG images into RGBA8 pixel data. Supports all standard color types +//! (grayscale, RGB, indexed, grayscale+alpha, RGBA), bit depths 1–16, +//! scanline filtering, and Adam7 interlacing. + +use crate::pixel::{self, Image, ImageError}; +use crate::zlib; + +// --------------------------------------------------------------------------- +// CRC-32 +// --------------------------------------------------------------------------- + +/// CRC-32 lookup table (polynomial 0xEDB88320, reflected). +const CRC32_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0u32; + while i < 256 { + let mut crc = i; + let mut j = 0; + while j < 8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0xEDB8_8320; + } else { + crc >>= 1; + } + j += 1; + } + table[i as usize] = crc; + i += 1; + } + table +}; + +/// Compute CRC-32 over a byte slice. +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + let idx = ((crc ^ b as u32) & 0xFF) as usize; + crc = CRC32_TABLE[idx] ^ (crc >> 8); + } + crc ^ 0xFFFF_FFFF +} + +// --------------------------------------------------------------------------- +// PNG constants +// --------------------------------------------------------------------------- + +/// 8-byte PNG file signature. +const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10]; + +// Chunk type tags (as big-endian u32). +const CHUNK_IHDR: u32 = u32::from_be_bytes(*b"IHDR"); +const CHUNK_PLTE: u32 = u32::from_be_bytes(*b"PLTE"); +const CHUNK_IDAT: u32 = u32::from_be_bytes(*b"IDAT"); +const CHUNK_IEND: u32 = u32::from_be_bytes(*b"IEND"); +const CHUNK_TRNS: u32 = u32::from_be_bytes(*b"tRNS"); + +// Color type flags. +const COLOR_GRAYSCALE: u8 = 0; +const COLOR_RGB: u8 = 2; +const COLOR_INDEXED: u8 = 3; +const COLOR_GRAYSCALE_ALPHA: u8 = 4; +const COLOR_RGBA: u8 = 6; + +// Filter types. +const FILTER_NONE: u8 = 0; +const FILTER_SUB: u8 = 1; +const FILTER_UP: u8 = 2; +const FILTER_AVERAGE: u8 = 3; +const FILTER_PAETH: u8 = 4; + +// Adam7 interlace pass parameters: (x_start, y_start, x_step, y_step) +const ADAM7_PASSES: [(usize, usize, usize, usize); 7] = [ + (0, 0, 8, 8), + (4, 0, 8, 8), + (0, 4, 4, 8), + (2, 0, 4, 4), + (0, 2, 2, 4), + (1, 0, 2, 2), + (0, 1, 1, 2), +]; + +// --------------------------------------------------------------------------- +// IHDR +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct Ihdr { + width: u32, + height: u32, + bit_depth: u8, + color_type: u8, + interlace: u8, +} + +impl Ihdr { + fn parse(data: &[u8]) -> Result { + if data.len() != 13 { + return Err(decode_err("IHDR chunk must be 13 bytes")); + } + let width = read_u32_be(data, 0); + let height = read_u32_be(data, 4); + let bit_depth = data[8]; + let color_type = data[9]; + let compression = data[10]; + let filter = data[11]; + let interlace = data[12]; + + if width == 0 || height == 0 { + return Err(ImageError::ZeroDimension { width, height }); + } + if compression != 0 { + return Err(decode_err("unsupported compression method")); + } + if filter != 0 { + return Err(decode_err("unsupported filter method")); + } + if interlace > 1 { + return Err(decode_err("unsupported interlace method")); + } + + // Validate bit depth for each color type per PNG spec. + let valid = match color_type { + COLOR_GRAYSCALE => matches!(bit_depth, 1 | 2 | 4 | 8 | 16), + COLOR_RGB => matches!(bit_depth, 8 | 16), + COLOR_INDEXED => matches!(bit_depth, 1 | 2 | 4 | 8), + COLOR_GRAYSCALE_ALPHA => matches!(bit_depth, 8 | 16), + COLOR_RGBA => matches!(bit_depth, 8 | 16), + _ => false, + }; + if !valid { + return Err(decode_err(&format!( + "invalid bit_depth={bit_depth} for color_type={color_type}" + ))); + } + + Ok(Self { + width, + height, + bit_depth, + color_type, + interlace, + }) + } + + /// Number of channels for this color type. + fn channels(&self) -> usize { + match self.color_type { + COLOR_GRAYSCALE => 1, + COLOR_RGB => 3, + COLOR_INDEXED => 1, + COLOR_GRAYSCALE_ALPHA => 2, + COLOR_RGBA => 4, + _ => 1, + } + } + + /// Bytes per pixel (for filter byte offset), minimum 1. + fn bytes_per_pixel(&self) -> usize { + let bits = self.channels() * self.bit_depth as usize; + std::cmp::max(1, bits / 8) + } + + /// Bytes per scanline row (excluding the filter byte), for given width. + fn row_bytes(&self, width: u32) -> usize { + let bits_per_pixel = self.channels() * self.bit_depth as usize; + let total_bits = bits_per_pixel * width as usize; + total_bits.div_ceil(8) + } +} + +// --------------------------------------------------------------------------- +// Transparency info +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum Transparency { + /// Grayscale transparent value (color type 0). + Gray(u16), + /// RGB transparent value (color type 2). + Rgb(u16, u16, u16), + /// Per-palette-entry alpha values (color type 3). + Palette(Vec), +} + +// --------------------------------------------------------------------------- +// Chunk reader +// --------------------------------------------------------------------------- + +struct ChunkReader<'a> { + data: &'a [u8], + pos: usize, +} + +struct Chunk<'a> { + chunk_type: u32, + data: &'a [u8], +} + +impl<'a> ChunkReader<'a> { + fn new(data: &'a [u8]) -> Result { + if data.len() < 8 || data[..8] != PNG_SIGNATURE { + return Err(decode_err("invalid PNG signature")); + } + Ok(Self { data, pos: 8 }) + } + + fn next_chunk(&mut self) -> Result, ImageError> { + if self.pos + 12 > self.data.len() { + return Err(decode_err("unexpected end of PNG data")); + } + let length = read_u32_be(self.data, self.pos) as usize; + let chunk_type_bytes = &self.data[self.pos + 4..self.pos + 8]; + let chunk_type = u32::from_be_bytes([ + chunk_type_bytes[0], + chunk_type_bytes[1], + chunk_type_bytes[2], + chunk_type_bytes[3], + ]); + + let data_start = self.pos + 8; + let data_end = data_start + length; + let crc_end = data_end + 4; + + if crc_end > self.data.len() { + return Err(decode_err("chunk extends beyond PNG data")); + } + + let chunk_data = &self.data[data_start..data_end]; + + // CRC covers chunk type + chunk data. + let crc_input = &self.data[self.pos + 4..data_end]; + let stored_crc = read_u32_be(self.data, data_end); + let computed_crc = crc32(crc_input); + if stored_crc != computed_crc { + return Err(decode_err(&format!( + "CRC mismatch in chunk {:?}: stored={stored_crc:#010x}, computed={computed_crc:#010x}", + std::str::from_utf8(chunk_type_bytes).unwrap_or("????") + ))); + } + + self.pos = crc_end; + + Ok(Chunk { + chunk_type, + data: chunk_data, + }) + } +} + +// --------------------------------------------------------------------------- +// Scanline filter reconstruction +// --------------------------------------------------------------------------- + +/// Paeth predictor function (PNG spec). +fn paeth_predictor(a: u8, b: u8, c: u8) -> u8 { + let a = a as i16; + let b = b as i16; + let c = c as i16; + let p = a + b - c; + let pa = (p - a).abs(); + let pb = (p - b).abs(); + let pc = (p - c).abs(); + if pa <= pb && pa <= pc { + a as u8 + } else if pb <= pc { + b as u8 + } else { + c as u8 + } +} + +/// Reconstruct filtered scanline data in-place. +/// +/// `data` contains all scanlines concatenated, each prefixed by a filter byte. +/// `row_bytes` is the number of data bytes per row (excluding filter byte). +/// `bpp` is the bytes-per-pixel (minimum 1). +fn unfilter( + data: &mut [u8], + row_bytes: usize, + height: usize, + bpp: usize, +) -> Result<(), ImageError> { + let stride = row_bytes + 1; // filter byte + row data + if data.len() != stride * height { + return Err(decode_err("decompressed data size mismatch")); + } + + for y in 0..height { + let row_start = y * stride; + let filter_type = data[row_start]; + // Work on a copy of the previous row for the Up/Average/Paeth filters. + // We reconstruct in-place, so we need prior row data before modification. + // Since rows are processed top-to-bottom, the previous row (y-1) is already + // reconstructed at this point. + + match filter_type { + FILTER_NONE => {} + FILTER_SUB => { + for i in bpp..row_bytes { + let cur = row_start + 1 + i; + let left = data[cur - bpp]; + data[cur] = data[cur].wrapping_add(left); + } + } + FILTER_UP => { + if y > 0 { + let prev_start = (y - 1) * stride + 1; + for i in 0..row_bytes { + let cur = row_start + 1 + i; + let up = data[prev_start + i]; + data[cur] = data[cur].wrapping_add(up); + } + } + } + FILTER_AVERAGE => { + for i in 0..row_bytes { + let cur = row_start + 1 + i; + let left = if i >= bpp { data[cur - bpp] } else { 0 }; + let up = if y > 0 { + data[(y - 1) * stride + 1 + i] + } else { + 0 + }; + let avg = ((left as u16 + up as u16) / 2) as u8; + data[cur] = data[cur].wrapping_add(avg); + } + } + FILTER_PAETH => { + for i in 0..row_bytes { + let cur = row_start + 1 + i; + let left = if i >= bpp { data[cur - bpp] } else { 0 }; + let up = if y > 0 { + data[(y - 1) * stride + 1 + i] + } else { + 0 + }; + let upper_left = if y > 0 && i >= bpp { + data[(y - 1) * stride + 1 + i - bpp] + } else { + 0 + }; + data[cur] = data[cur].wrapping_add(paeth_predictor(left, up, upper_left)); + } + } + _ => return Err(decode_err(&format!("unknown filter type: {filter_type}"))), + } + } + + Ok(()) +} + +/// Extract raw pixel bytes from unfiltered data (strip filter bytes). +fn strip_filter_bytes(data: &[u8], row_bytes: usize, height: usize) -> Vec { + let stride = row_bytes + 1; + let mut out = Vec::with_capacity(row_bytes * height); + for y in 0..height { + let row_start = y * stride + 1; // skip filter byte + out.extend_from_slice(&data[row_start..row_start + row_bytes]); + } + out +} + +// --------------------------------------------------------------------------- +// Bit depth expansion +// --------------------------------------------------------------------------- + +/// Expand sub-byte samples (1, 2, 4 bits) to 8-bit values for a row of `width` samples. +fn expand_sub_byte(row: &[u8], bit_depth: u8, width: usize) -> Vec { + let mut out = Vec::with_capacity(width); + let mask = (1u8 << bit_depth) - 1; + let scale = 255 / mask; + let samples_per_byte = 8 / bit_depth as usize; + + let mut sample_idx = 0; + for &byte in row { + for shift_idx in 0..samples_per_byte { + if sample_idx >= width { + break; + } + let shift = 8 - bit_depth * (shift_idx as u8 + 1); + let val = (byte >> shift) & mask; + out.push(val * scale); + sample_idx += 1; + } + } + out +} + +/// Expand 16-bit samples to 8-bit by taking the high byte. +fn downconvert_16_to_8(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() / 2); + for pair in data.chunks_exact(2) { + out.push(pair[0]); // high byte + } + out +} + +// --------------------------------------------------------------------------- +// Color conversion to RGBA8 +// --------------------------------------------------------------------------- + +/// Convert raw pixel data to RGBA8, handling all color types and bit depths. +fn to_rgba8( + ihdr: &Ihdr, + raw: &[u8], + width: u32, + height: u32, + palette: Option<&[u8]>, + transparency: Option<&Transparency>, +) -> Result, ImageError> { + let pixel_count = width as usize * height as usize; + + match ihdr.color_type { + COLOR_GRAYSCALE => { + let samples = if ihdr.bit_depth < 8 { + expand_sub_byte(raw, ihdr.bit_depth, pixel_count) + } else if ihdr.bit_depth == 16 { + downconvert_16_to_8(raw) + } else { + raw.to_vec() + }; + + // Apply transparency if present. + if let Some(Transparency::Gray(trans_val)) = transparency { + let tv = if ihdr.bit_depth == 16 { + (*trans_val >> 8) as u8 + } else if ihdr.bit_depth < 8 { + let mask = (1u16 << ihdr.bit_depth) - 1; + let scale = 255 / mask; + (*trans_val & (mask * scale)) as u8 + } else { + *trans_val as u8 + }; + let mut rgba = Vec::with_capacity(pixel_count * 4); + for &g in &samples { + rgba.push(g); + rgba.push(g); + rgba.push(g); + rgba.push(if g == tv { 0 } else { 255 }); + } + return Ok(rgba); + } + + let img = pixel::from_grayscale(width, height, &samples)?; + Ok(img.data) + } + + COLOR_RGB => { + let samples = if ihdr.bit_depth == 16 { + downconvert_16_to_8(raw) + } else { + raw.to_vec() + }; + + if let Some(Transparency::Rgb(tr, tg, tb)) = transparency { + let (tvr, tvg, tvb) = if ihdr.bit_depth == 16 { + ((*tr >> 8) as u8, (*tg >> 8) as u8, (*tb >> 8) as u8) + } else { + (*tr as u8, *tg as u8, *tb as u8) + }; + let mut rgba = Vec::with_capacity(pixel_count * 4); + for triple in samples.chunks_exact(3) { + let r = triple[0]; + let g = triple[1]; + let b = triple[2]; + rgba.push(r); + rgba.push(g); + rgba.push(b); + rgba.push(if r == tvr && g == tvg && b == tvb { + 0 + } else { + 255 + }); + } + return Ok(rgba); + } + + let img = pixel::from_rgb(width, height, &samples)?; + Ok(img.data) + } + + COLOR_INDEXED => { + let pal = palette.ok_or_else(|| decode_err("missing PLTE chunk for indexed image"))?; + + let indices = if ihdr.bit_depth < 8 { + // For indexed images, don't scale — we want raw index values. + let mask = (1u8 << ihdr.bit_depth) - 1; + let samples_per_byte = 8 / ihdr.bit_depth as usize; + let mut out = Vec::with_capacity(pixel_count); + let row_bytes = ihdr.row_bytes(width); + for y in 0..height as usize { + let row = &raw[y * row_bytes..(y + 1) * row_bytes]; + let mut sample_idx = 0; + for &byte in row { + for shift_idx in 0..samples_per_byte { + if sample_idx >= width as usize { + break; + } + let shift = 8 - ihdr.bit_depth * (shift_idx as u8 + 1); + let val = (byte >> shift) & mask; + out.push(val); + sample_idx += 1; + } + } + } + out + } else { + raw.to_vec() + }; + + if let Some(Transparency::Palette(alpha)) = transparency { + let img = pixel::from_indexed_alpha(width, height, pal, alpha, &indices)?; + Ok(img.data) + } else { + let img = pixel::from_indexed(width, height, pal, &indices)?; + Ok(img.data) + } + } + + COLOR_GRAYSCALE_ALPHA => { + let samples = if ihdr.bit_depth == 16 { + downconvert_16_to_8(raw) + } else { + raw.to_vec() + }; + let img = pixel::from_grayscale_alpha(width, height, &samples)?; + Ok(img.data) + } + + COLOR_RGBA => { + let samples = if ihdr.bit_depth == 16 { + downconvert_16_to_8(raw) + } else { + raw.to_vec() + }; + let img = pixel::from_rgba(width, height, samples)?; + Ok(img.data) + } + + _ => Err(decode_err(&format!( + "unsupported color type: {}", + ihdr.color_type + ))), + } +} + +// --------------------------------------------------------------------------- +// Adam7 interlacing +// --------------------------------------------------------------------------- + +/// Decode Adam7-interlaced image data. +fn decode_adam7( + decompressed: &[u8], + ihdr: &Ihdr, + palette: Option<&[u8]>, + transparency: Option<&Transparency>, +) -> Result, ImageError> { + let full_width = ihdr.width as usize; + let full_height = ihdr.height as usize; + let mut final_rgba = vec![0u8; full_width * full_height * 4]; + + let mut offset = 0; + + for &(x_start, y_start, x_step, y_step) in &ADAM7_PASSES { + // Calculate pass dimensions. + let pass_width = if x_start >= full_width { + 0 + } else { + (full_width - x_start).div_ceil(x_step) + }; + let pass_height = if y_start >= full_height { + 0 + } else { + (full_height - y_start).div_ceil(y_step) + }; + + if pass_width == 0 || pass_height == 0 { + continue; + } + + let row_bytes = ihdr.row_bytes(pass_width as u32); + let stride = row_bytes + 1; // filter byte + data + let pass_data_len = stride * pass_height; + + if offset + pass_data_len > decompressed.len() { + return Err(decode_err("interlaced data too short")); + } + + let mut pass_data = decompressed[offset..offset + pass_data_len].to_vec(); + offset += pass_data_len; + + unfilter( + &mut pass_data, + row_bytes, + pass_height, + ihdr.bytes_per_pixel(), + )?; + let raw = strip_filter_bytes(&pass_data, row_bytes, pass_height); + + let pass_rgba = to_rgba8( + ihdr, + &raw, + pass_width as u32, + pass_height as u32, + palette, + transparency, + )?; + + // Place pass pixels into the final image. + for py in 0..pass_height { + for px in 0..pass_width { + let fx = x_start + px * x_step; + let fy = y_start + py * y_step; + if fx < full_width && fy < full_height { + let src = (py * pass_width + px) * 4; + let dst = (fy * full_width + fx) * 4; + final_rgba[dst..dst + 4].copy_from_slice(&pass_rgba[src..src + 4]); + } + } + } + } + + Ok(final_rgba) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Decode a PNG image from raw bytes. +/// +/// Returns an `Image` with RGBA8 pixel data. +pub fn decode_png(data: &[u8]) -> Result { + let mut reader = ChunkReader::new(data)?; + + // First chunk must be IHDR. + let ihdr_chunk = reader.next_chunk()?; + if ihdr_chunk.chunk_type != CHUNK_IHDR { + return Err(decode_err("first chunk must be IHDR")); + } + let ihdr = Ihdr::parse(ihdr_chunk.data)?; + + let mut palette: Option> = None; + let mut transparency: Option = None; + let mut idat_data: Vec = Vec::new(); + + // Read remaining chunks. + loop { + let chunk = reader.next_chunk()?; + + match chunk.chunk_type { + CHUNK_PLTE => { + if chunk.data.len() % 3 != 0 || chunk.data.is_empty() { + return Err(decode_err("invalid PLTE chunk length")); + } + palette = Some(chunk.data.to_vec()); + } + CHUNK_TRNS => { + let trans = match ihdr.color_type { + COLOR_GRAYSCALE => { + if chunk.data.len() != 2 { + return Err(decode_err("invalid tRNS length for grayscale")); + } + Transparency::Gray(read_u16_be(chunk.data, 0)) + } + COLOR_RGB => { + if chunk.data.len() != 6 { + return Err(decode_err("invalid tRNS length for RGB")); + } + Transparency::Rgb( + read_u16_be(chunk.data, 0), + read_u16_be(chunk.data, 2), + read_u16_be(chunk.data, 4), + ) + } + COLOR_INDEXED => Transparency::Palette(chunk.data.to_vec()), + _ => { + return Err(decode_err("tRNS not allowed for this color type")); + } + }; + transparency = Some(trans); + } + CHUNK_IDAT => { + idat_data.extend_from_slice(chunk.data); + } + CHUNK_IEND => break, + _ => { + // Unknown or ancillary chunk — skip. + // If it's critical (uppercase first letter), that's an error. + let first_byte = (chunk.chunk_type >> 24) as u8; + if first_byte.is_ascii_uppercase() { + return Err(decode_err(&format!( + "unknown critical chunk: {:?}", + std::str::from_utf8(&chunk.chunk_type.to_be_bytes()).unwrap_or("????") + ))); + } + } + } + } + + if idat_data.is_empty() { + return Err(decode_err("no IDAT chunks found")); + } + + // Validate palette requirement for indexed images. + if ihdr.color_type == COLOR_INDEXED && palette.is_none() { + return Err(decode_err("missing PLTE chunk for indexed image")); + } + + // Decompress IDAT data (zlib). + let decompressed = + zlib::zlib_decompress(&idat_data).map_err(|e| ImageError::Decode(format!("zlib: {e}")))?; + + let pal_ref = palette.as_deref(); + let trans_ref = transparency.as_ref(); + + if ihdr.interlace == 1 { + // Adam7 interlaced. + let rgba = decode_adam7(&decompressed, &ihdr, pal_ref, trans_ref)?; + Image::new(ihdr.width, ihdr.height, rgba) + } else { + // Non-interlaced. + let row_bytes = ihdr.row_bytes(ihdr.width); + let mut data_buf = decompressed; + unfilter( + &mut data_buf, + row_bytes, + ihdr.height as usize, + ihdr.bytes_per_pixel(), + )?; + let raw = strip_filter_bytes(&data_buf, row_bytes, ihdr.height as usize); + let rgba = to_rgba8(&ihdr, &raw, ihdr.width, ihdr.height, pal_ref, trans_ref)?; + Image::new(ihdr.width, ihdr.height, rgba) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn read_u32_be(data: &[u8], offset: usize) -> u32 { + u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) +} + +fn read_u16_be(data: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([data[offset], data[offset + 1]]) +} + +fn decode_err(msg: &str) -> ImageError { + ImageError::Decode(msg.to_string()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- CRC-32 tests -- + + #[test] + fn crc32_empty() { + assert_eq!(crc32(&[]), 0x0000_0000); + } + + #[test] + fn crc32_known_value() { + // CRC-32 of "IEND" (the IEND chunk type bytes) + assert_eq!(crc32(b"IEND"), 0xAE42_6082); + } + + #[test] + fn crc32_abc() { + // Known CRC-32 of "abc" = 0x352441C2 + assert_eq!(crc32(b"abc"), 0x352441C2); + } + + #[test] + fn crc32_check_value() { + // The CRC-32 check value: CRC of "123456789" = 0xCBF43926 + assert_eq!(crc32(b"123456789"), 0xCBF43926); + } + + // -- PNG signature tests -- + + #[test] + fn invalid_signature() { + let data = [0u8; 8]; + assert!(decode_png(&data).is_err()); + } + + #[test] + fn too_short() { + assert!(decode_png(&[]).is_err()); + assert!(decode_png(&[137, 80, 78, 71]).is_err()); + } + + // -- Paeth predictor tests -- + + #[test] + fn paeth_predictor_basic() { + // When a=b=c=0, should return 0 + assert_eq!(paeth_predictor(0, 0, 0), 0); + // When a=1, b=0, c=0: p=1, pa=0, pb=1, pc=1 → a=1 + assert_eq!(paeth_predictor(1, 0, 0), 1); + // When a=0, b=1, c=0: p=1, pa=1, pb=0, pc=1 → b=1 + assert_eq!(paeth_predictor(0, 1, 0), 1); + // When a=0, b=0, c=1: p=-1, pa=1, pb=1, pc=2 → a=0 + assert_eq!(paeth_predictor(0, 0, 1), 0); + } + + // -- Sub-byte expansion tests -- + + #[test] + fn expand_1bit() { + // 0b10110000 with 4 samples → 255, 0, 255, 255 + let row = [0b1011_0000]; + let expanded = expand_sub_byte(&row, 1, 4); + assert_eq!(expanded, vec![255, 0, 255, 255]); + } + + #[test] + fn expand_2bit() { + // 0b11_10_01_00 → 255, 170, 85, 0 + let row = [0b1110_0100]; + let expanded = expand_sub_byte(&row, 2, 4); + assert_eq!(expanded, vec![255, 170, 85, 0]); + } + + #[test] + fn expand_4bit() { + // 0xF0 → high nibble=15→255, low nibble=0→0 + let row = [0xF0]; + let expanded = expand_sub_byte(&row, 4, 2); + assert_eq!(expanded, vec![255, 0]); + } + + // -- IHDR validation tests -- + + #[test] + fn ihdr_valid_rgb8() { + let mut data = [0u8; 13]; + // width=1, height=1 + data[0..4].copy_from_slice(&1u32.to_be_bytes()); + data[4..8].copy_from_slice(&1u32.to_be_bytes()); + data[8] = 8; // bit depth + data[9] = 2; // color type RGB + let ihdr = Ihdr::parse(&data).unwrap(); + assert_eq!(ihdr.channels(), 3); + assert_eq!(ihdr.bytes_per_pixel(), 3); + assert_eq!(ihdr.row_bytes(1), 3); + } + + #[test] + fn ihdr_invalid_bit_depth() { + let mut data = [0u8; 13]; + data[0..4].copy_from_slice(&1u32.to_be_bytes()); + data[4..8].copy_from_slice(&1u32.to_be_bytes()); + data[8] = 3; // invalid bit depth for RGB + data[9] = 2; // color type RGB + assert!(Ihdr::parse(&data).is_err()); + } + + #[test] + fn ihdr_zero_dimensions() { + let mut data = [0u8; 13]; + data[0..4].copy_from_slice(&0u32.to_be_bytes()); + data[4..8].copy_from_slice(&1u32.to_be_bytes()); + data[8] = 8; + data[9] = 2; + assert!(matches!( + Ihdr::parse(&data), + Err(ImageError::ZeroDimension { .. }) + )); + } + + // -- Unfilter tests -- + + #[test] + fn unfilter_none() { + // 2x1 image, 3 bytes per pixel (RGB), filter=0 + let mut data = vec![FILTER_NONE, 10, 20, 30, 40, 50, 60]; + unfilter(&mut data, 6, 1, 3).unwrap(); + assert_eq!(data, vec![FILTER_NONE, 10, 20, 30, 40, 50, 60]); + } + + #[test] + fn unfilter_sub() { + // 2x1 RGB: filter=1, first pixel=(10,20,30), second pixel delta=(5,5,5) + let mut data = vec![FILTER_SUB, 10, 20, 30, 5, 5, 5]; + unfilter(&mut data, 6, 1, 3).unwrap(); + // After Sub: second pixel = (5+10, 5+20, 5+30) = (15, 25, 35) + assert_eq!(data, vec![FILTER_SUB, 10, 20, 30, 15, 25, 35]); + } + + #[test] + fn unfilter_up() { + // 1x2 RGB: row 0 filter=0 (10,20,30), row 1 filter=2 (5,5,5) + let mut data = vec![FILTER_NONE, 10, 20, 30, FILTER_UP, 5, 5, 5]; + unfilter(&mut data, 3, 2, 3).unwrap(); + // After Up: row1 = (5+10, 5+20, 5+30) = (15, 25, 35) + assert_eq!(data, vec![FILTER_NONE, 10, 20, 30, FILTER_UP, 15, 25, 35,]); + } + + #[test] + fn unfilter_average() { + // 2x1 grayscale: filter=3, values [100, 80] + // bpp=1, left(0)=0, up(0)=0 → avg=0 → 100+0=100 + // left(1)=100, up(1)=0 → avg=50 → 80+50=130 + let mut data = vec![FILTER_AVERAGE, 100, 80]; + unfilter(&mut data, 2, 1, 1).unwrap(); + assert_eq!(data, vec![FILTER_AVERAGE, 100, 130]); + } + + #[test] + fn unfilter_paeth() { + // 2x1 grayscale: filter=4, values [100, 10] + // bpp=1 + // Pixel 0: a=0, b=0, c=0, paeth=0 → 100+0=100 + // Pixel 1: a=100, b=0, c=0, paeth=paeth(100,0,0) + // p=100, pa=0, pb=100, pc=100 → a=100 → 10+100=110 + let mut data = vec![FILTER_PAETH, 100, 10]; + unfilter(&mut data, 2, 1, 1).unwrap(); + assert_eq!(data, vec![FILTER_PAETH, 100, 110]); + } + + // -- Minimal valid PNG construction helpers -- + + /// Build a minimal valid PNG file from components. + fn build_png( + ihdr: &Ihdr, + palette: Option<&[u8]>, + trns: Option<&[u8]>, + image_data: &[u8], + ) -> Vec { + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + + // IHDR chunk + let mut ihdr_data = Vec::with_capacity(13); + ihdr_data.extend_from_slice(&ihdr.width.to_be_bytes()); + ihdr_data.extend_from_slice(&ihdr.height.to_be_bytes()); + ihdr_data.push(ihdr.bit_depth); + ihdr_data.push(ihdr.color_type); + ihdr_data.push(0); // compression + ihdr_data.push(0); // filter + ihdr_data.push(ihdr.interlace); + write_chunk(&mut png, b"IHDR", &ihdr_data); + + // PLTE + if let Some(pal) = palette { + write_chunk(&mut png, b"PLTE", pal); + } + + // tRNS + if let Some(t) = trns { + write_chunk(&mut png, b"tRNS", t); + } + + // IDAT: compress image_data with zlib + let compressed = zlib_compress(image_data); + write_chunk(&mut png, b"IDAT", &compressed); + + // IEND + write_chunk(&mut png, b"IEND", &[]); + + png + } + + fn write_chunk(out: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(chunk_type); + out.extend_from_slice(data); + // CRC over type + data + let mut crc_buf = Vec::with_capacity(4 + data.len()); + crc_buf.extend_from_slice(chunk_type); + crc_buf.extend_from_slice(data); + let crc = crc32(&crc_buf); + out.extend_from_slice(&crc.to_be_bytes()); + } + + /// Minimal zlib compression: store block (no compression). + fn zlib_compress(data: &[u8]) -> Vec { + let mut out = Vec::new(); + // zlib header: CMF=0x78 (deflate, window=32KB), FLG=0x01 + out.push(0x78); + out.push(0x01); + + // DEFLATE: split into non-compressed blocks of max 65535 bytes. + let chunks: Vec<&[u8]> = if data.is_empty() { + vec![&[]] + } else { + data.chunks(65535).collect() + }; + for (i, chunk) in chunks.iter().enumerate() { + let is_final = i == chunks.len() - 1; + out.push(if is_final { 0x01 } else { 0x00 }); // BFINAL + BTYPE=00 + let len = chunk.len() as u16; + out.push(len as u8); + out.push((len >> 8) as u8); + let nlen = !len; + out.push(nlen as u8); + out.push((nlen >> 8) as u8); + out.extend_from_slice(chunk); + } + + // Adler-32 trailer + let adler = adler32_compute(data); + out.push((adler >> 24) as u8); + out.push((adler >> 16) as u8); + out.push((adler >> 8) as u8); + out.push(adler as u8); + out + } + + fn adler32_compute(data: &[u8]) -> u32 { + const MOD: u32 = 65521; + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + byte as u32) % MOD; + b = (b + a) % MOD; + } + (b << 16) | a + } + + /// Build raw scanline data with filter byte prepended to each row. + fn make_filtered_rows(rows: &[Vec]) -> Vec { + let mut out = Vec::new(); + for row in rows { + out.push(FILTER_NONE); // no filter + out.extend_from_slice(row); + } + out + } + + fn make_ihdr(width: u32, height: u32, bit_depth: u8, color_type: u8) -> Ihdr { + Ihdr { + width, + height, + bit_depth, + color_type, + interlace: 0, + } + } + + // -- Decoding tests -- + + #[test] + fn decode_1x1_rgb() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGB); + let raw = make_filtered_rows(&[vec![255, 0, 0]]); // red pixel + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.width, 1); + assert_eq!(img.height, 1); + assert_eq!(img.data, vec![255, 0, 0, 255]); + } + + #[test] + fn decode_1x1_rgba() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGBA); + let raw = make_filtered_rows(&[vec![10, 20, 30, 128]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![10, 20, 30, 128]); + } + + #[test] + fn decode_1x1_grayscale() { + let ihdr = make_ihdr(1, 1, 8, COLOR_GRAYSCALE); + let raw = make_filtered_rows(&[vec![128]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![128, 128, 128, 255]); + } + + #[test] + fn decode_1x1_grayscale_alpha() { + let ihdr = make_ihdr(1, 1, 8, COLOR_GRAYSCALE_ALPHA); + let raw = make_filtered_rows(&[vec![200, 100]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![200, 200, 200, 100]); + } + + #[test] + fn decode_indexed() { + let ihdr = make_ihdr(2, 1, 8, COLOR_INDEXED); + let palette = vec![255, 0, 0, 0, 255, 0]; // red, green + let raw = make_filtered_rows(&[vec![0, 1]]); // index 0, 1 + let png = build_png(&ihdr, Some(&palette), None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![255, 0, 0, 255, 0, 255, 0, 255]); + } + + #[test] + fn decode_indexed_with_trns() { + let ihdr = make_ihdr(2, 1, 8, COLOR_INDEXED); + let palette = vec![255, 0, 0, 0, 255, 0]; + let trns = vec![128, 64]; // alpha for entries 0 and 1 + let raw = make_filtered_rows(&[vec![0, 1]]); + let png = build_png(&ihdr, Some(&palette), Some(&trns), &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![255, 0, 0, 128, 0, 255, 0, 64]); + } + + #[test] + fn decode_2x2_rgb() { + let ihdr = make_ihdr(2, 2, 8, COLOR_RGB); + let raw = make_filtered_rows(&[ + vec![255, 0, 0, 0, 255, 0], // red, green + vec![0, 0, 255, 255, 255, 0], // blue, yellow + ]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.width, 2); + assert_eq!(img.height, 2); + assert_eq!( + img.data, + vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 0, 255, // yellow + ] + ); + } + + #[test] + fn decode_grayscale_with_trns() { + let ihdr = make_ihdr(2, 1, 8, COLOR_GRAYSCALE); + let raw = make_filtered_rows(&[vec![100, 200]]); + let trns = 100u16.to_be_bytes().to_vec(); // gray value 100 is transparent + let png = build_png(&ihdr, None, Some(&trns), &raw); + let img = decode_png(&png).unwrap(); + // pixel 0: gray=100 matches trns → alpha=0 + // pixel 1: gray=200 no match → alpha=255 + assert_eq!(img.data, vec![100, 100, 100, 0, 200, 200, 200, 255]); + } + + #[test] + fn decode_rgb_with_trns() { + let ihdr = make_ihdr(2, 1, 8, COLOR_RGB); + let raw = make_filtered_rows(&[vec![255, 0, 0, 0, 255, 0]]); + // tRNS: transparent color is (255, 0, 0) = red + let mut trns = Vec::new(); + trns.extend_from_slice(&255u16.to_be_bytes()); + trns.extend_from_slice(&0u16.to_be_bytes()); + trns.extend_from_slice(&0u16.to_be_bytes()); + let png = build_png(&ihdr, None, Some(&trns), &raw); + let img = decode_png(&png).unwrap(); + assert_eq!( + img.data, + vec![ + 255, 0, 0, 0, // red → transparent + 0, 255, 0, 255, // green → opaque + ] + ); + } + + #[test] + fn decode_16bit_rgb() { + let ihdr = make_ihdr(1, 1, 16, COLOR_RGB); + // 16-bit RGB: (0xFF00, 0x8000, 0x4000) + let raw = make_filtered_rows(&[vec![0xFF, 0x00, 0x80, 0x00, 0x40, 0x00]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + // Downconvert takes high byte: R=0xFF, G=0x80, B=0x40 + assert_eq!(img.data, vec![0xFF, 0x80, 0x40, 255]); + } + + #[test] + fn decode_16bit_grayscale() { + let ihdr = make_ihdr(1, 1, 16, COLOR_GRAYSCALE); + let raw = make_filtered_rows(&[vec![0xAB, 0xCD]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![0xAB, 0xAB, 0xAB, 255]); + } + + #[test] + fn decode_1bit_grayscale() { + let ihdr = make_ihdr(8, 1, 1, COLOR_GRAYSCALE); + // 8 pixels in 1 byte: 0b10101010 → 255,0,255,0,255,0,255,0 + let raw = make_filtered_rows(&[vec![0b1010_1010]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.width, 8); + let expected_gray = [255, 0, 255, 0, 255, 0, 255, 0]; + for (i, &g) in expected_gray.iter().enumerate() { + assert_eq!(img.data[i * 4], g, "pixel {i} R"); + assert_eq!(img.data[i * 4 + 1], g, "pixel {i} G"); + assert_eq!(img.data[i * 4 + 2], g, "pixel {i} B"); + assert_eq!(img.data[i * 4 + 3], 255, "pixel {i} A"); + } + } + + #[test] + fn decode_4bit_grayscale() { + let ihdr = make_ihdr(2, 1, 4, COLOR_GRAYSCALE); + // 2 pixels in 1 byte: 0xF0 → 255, 0 + let raw = make_filtered_rows(&[vec![0xF0]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![255, 255, 255, 255, 0, 0, 0, 255]); + } + + #[test] + fn decode_2bit_indexed() { + let ihdr = make_ihdr(4, 1, 2, COLOR_INDEXED); + let palette = vec![ + 255, 0, 0, // 0: red + 0, 255, 0, // 1: green + 0, 0, 255, // 2: blue + 255, 255, 0, // 3: yellow + ]; + // 4 pixels at 2 bits each = 1 byte: indices 0,1,2,3 → 0b00_01_10_11 + let raw = make_filtered_rows(&[vec![0b00_01_10_11]]); + let png = build_png(&ihdr, Some(&palette), None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!( + img.data, + vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 0, 255, // yellow + ] + ); + } + + #[test] + fn decode_sub_filter() { + let ihdr = make_ihdr(3, 1, 8, COLOR_GRAYSCALE); + // Sub filter: each byte = current - left + // Desired output: [100, 110, 120] + // Encoded: [100, 10, 10] (first is raw, rest are deltas from left) + let image_data = vec![FILTER_SUB, 100, 10, 10]; + let png = build_png(&ihdr, None, None, &image_data); + let img = decode_png(&png).unwrap(); + assert_eq!( + img.data, + vec![100, 100, 100, 255, 110, 110, 110, 255, 120, 120, 120, 255,] + ); + } + + #[test] + fn decode_up_filter() { + let ihdr = make_ihdr(2, 2, 8, COLOR_GRAYSCALE); + // Row 0: no filter [50, 60] + // Row 1: up filter, deltas [10, 10] → [60, 70] + let image_data = vec![FILTER_NONE, 50, 60, FILTER_UP, 10, 10]; + let png = build_png(&ihdr, None, None, &image_data); + let img = decode_png(&png).unwrap(); + assert_eq!( + img.data, + vec![50, 50, 50, 255, 60, 60, 60, 255, 60, 60, 60, 255, 70, 70, 70, 255,] + ); + } + + #[test] + fn decode_average_filter() { + let ihdr = make_ihdr(2, 1, 8, COLOR_GRAYSCALE); + // Average filter: each byte = current - floor((left + up) / 2) + // With no prior row, up=0 for all. + // Desired: [100, 80] + // Encoded: [100, 30] → pixel 0: 100+avg(0,0)=100, pixel 1: 30+avg(100,0)=30+50=80 + let image_data = vec![FILTER_AVERAGE, 100, 30]; + let png = build_png(&ihdr, None, None, &image_data); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![100, 100, 100, 255, 80, 80, 80, 255,]); + } + + #[test] + fn decode_paeth_filter() { + let ihdr = make_ihdr(2, 1, 8, COLOR_GRAYSCALE); + // Paeth filter, single row: up=0, upper_left=0 for all. + // pixel 0: a=0, b=0, c=0 → paeth=0 → 100+0=100 + // pixel 1: a=100, b=0, c=0 → p=100, pa=0, pb=100, pc=100 → a → 10+100=110 + let image_data = vec![FILTER_PAETH, 100, 10]; + let png = build_png(&ihdr, None, None, &image_data); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![100, 100, 100, 255, 110, 110, 110, 255,]); + } + + // -- Adam7 interlacing tests -- + + #[test] + fn decode_adam7_2x2() { + // A 2x2 Adam7 interlaced image. + // Pass 1: (0,0) step (8,8) → pixel (0,0) → 1 pixel + // Pass 6: (1,0) step (2,2) → pixel (1,0) → 1 pixel + // Pass 7: (0,1) step (1,2) → pixels (0,1),(1,1) → 2 pixels + // Other passes have 0 pixels for a 2x2 image. + let ihdr = Ihdr { + width: 2, + height: 2, + bit_depth: 8, + color_type: COLOR_GRAYSCALE, + interlace: 1, + }; + + // Pass 1: 1x1 image, 1 byte per row + // Pass 6: 1x1 image, 1 byte per row + // Pass 7: 2x1 image, 2 bytes per row + let mut image_data = Vec::new(); + // Pass 1: pixel (0,0) = gray 10 + image_data.push(FILTER_NONE); + image_data.push(10); + // Pass 6: pixel (1,0) = gray 20 + image_data.push(FILTER_NONE); + image_data.push(20); + // Pass 7: pixels (0,1)=gray 30, (1,1)=gray 40 + image_data.push(FILTER_NONE); + image_data.push(30); + image_data.push(40); + + let png = build_png(&ihdr, None, None, &image_data); + let img = decode_png(&png).unwrap(); + + // Expected layout: + // (0,0)=10 (1,0)=20 + // (0,1)=30 (1,1)=40 + assert_eq!( + img.data, + vec![10, 10, 10, 255, 20, 20, 20, 255, 30, 30, 30, 255, 40, 40, 40, 255,] + ); + } + + // -- Error cases -- + + #[test] + fn missing_ihdr() { + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + write_chunk(&mut png, b"IEND", &[]); + assert!(decode_png(&png).is_err()); + } + + #[test] + fn missing_idat() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGB); + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + + let mut ihdr_data = Vec::new(); + ihdr_data.extend_from_slice(&ihdr.width.to_be_bytes()); + ihdr_data.extend_from_slice(&ihdr.height.to_be_bytes()); + ihdr_data.push(ihdr.bit_depth); + ihdr_data.push(ihdr.color_type); + ihdr_data.push(0); + ihdr_data.push(0); + ihdr_data.push(0); + write_chunk(&mut png, b"IHDR", &ihdr_data); + write_chunk(&mut png, b"IEND", &[]); + assert!(decode_png(&png).is_err()); + } + + #[test] + fn invalid_plte_length() { + // PLTE must be a multiple of 3 + let ihdr = make_ihdr(1, 1, 8, COLOR_INDEXED); + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + + let mut ihdr_data = Vec::new(); + ihdr_data.extend_from_slice(&ihdr.width.to_be_bytes()); + ihdr_data.extend_from_slice(&ihdr.height.to_be_bytes()); + ihdr_data.push(ihdr.bit_depth); + ihdr_data.push(ihdr.color_type); + ihdr_data.push(0); + ihdr_data.push(0); + ihdr_data.push(0); + write_chunk(&mut png, b"IHDR", &ihdr_data); + write_chunk(&mut png, b"PLTE", &[1, 2]); // invalid: 2 bytes, not multiple of 3 + write_chunk(&mut png, b"IEND", &[]); + assert!(decode_png(&png).is_err()); + } + + #[test] + fn crc_mismatch_rejected() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGB); + let raw = make_filtered_rows(&[vec![255, 0, 0]]); + let mut png = build_png(&ihdr, None, None, &raw); + // Corrupt CRC of the IHDR chunk (bytes 16-19 after signature+length+type+data) + // IHDR chunk starts at offset 8, length=4 bytes, type=4 bytes, data=13 bytes, then CRC=4 bytes + // CRC is at offset 8 + 4 + 4 + 13 = 29 + if png.len() > 32 { + png[29] ^= 0xFF; + } + assert!(decode_png(&png).is_err()); + } + + // -- Larger image test -- + + #[test] + fn decode_10x10_rgb_gradient() { + let ihdr = make_ihdr(10, 10, 8, COLOR_RGB); + let mut rows = Vec::new(); + for y in 0..10u8 { + let mut row = Vec::new(); + for x in 0..10u8 { + row.push(x * 25); // R + row.push(y * 25); // G + row.push(128); // B + } + rows.push(row); + } + let raw = make_filtered_rows(&rows); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.width, 10); + assert_eq!(img.height, 10); + assert_eq!(img.data.len(), 10 * 10 * 4); + // Spot-check pixel (0,0) + assert_eq!(&img.data[0..4], &[0, 0, 128, 255]); + // Spot-check pixel (9,9) + let offset = (9 * 10 + 9) * 4; + assert_eq!(&img.data[offset..offset + 4], &[225, 225, 128, 255]); + } + + // -- 16-bit RGBA -- + + #[test] + fn decode_16bit_rgba() { + let ihdr = make_ihdr(1, 1, 16, COLOR_RGBA); + // R=0xFF00, G=0x8000, B=0x4000, A=0xC000 + let raw = make_filtered_rows(&[vec![0xFF, 0x00, 0x80, 0x00, 0x40, 0x00, 0xC0, 0x00]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![0xFF, 0x80, 0x40, 0xC0]); + } + + // -- 16-bit grayscale+alpha -- + + #[test] + fn decode_16bit_grayscale_alpha() { + let ihdr = make_ihdr(1, 1, 16, COLOR_GRAYSCALE_ALPHA); + let raw = make_filtered_rows(&[vec![0xAB, 0xCD, 0x80, 0x00]]); + let png = build_png(&ihdr, None, None, &raw); + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![0xAB, 0xAB, 0xAB, 0x80]); + } + + // -- Multiple IDAT chunks -- + + #[test] + fn decode_multiple_idat() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGB); + let raw = make_filtered_rows(&[vec![42, 84, 126]]); + let compressed = zlib_compress(&raw); + + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + + let mut ihdr_data = Vec::new(); + ihdr_data.extend_from_slice(&ihdr.width.to_be_bytes()); + ihdr_data.extend_from_slice(&ihdr.height.to_be_bytes()); + ihdr_data.push(ihdr.bit_depth); + ihdr_data.push(ihdr.color_type); + ihdr_data.push(0); + ihdr_data.push(0); + ihdr_data.push(0); + write_chunk(&mut png, b"IHDR", &ihdr_data); + + // Split compressed data into two IDAT chunks. + let mid = compressed.len() / 2; + write_chunk(&mut png, b"IDAT", &compressed[..mid]); + write_chunk(&mut png, b"IDAT", &compressed[mid..]); + write_chunk(&mut png, b"IEND", &[]); + + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![42, 84, 126, 255]); + } + + // -- Ancillary chunks are skipped -- + + #[test] + fn ancillary_chunks_skipped() { + let ihdr = make_ihdr(1, 1, 8, COLOR_RGB); + let raw = make_filtered_rows(&[vec![1, 2, 3]]); + + let mut png = Vec::new(); + png.extend_from_slice(&PNG_SIGNATURE); + + let mut ihdr_data = Vec::new(); + ihdr_data.extend_from_slice(&ihdr.width.to_be_bytes()); + ihdr_data.extend_from_slice(&ihdr.height.to_be_bytes()); + ihdr_data.push(ihdr.bit_depth); + ihdr_data.push(ihdr.color_type); + ihdr_data.push(0); + ihdr_data.push(0); + ihdr_data.push(0); + write_chunk(&mut png, b"IHDR", &ihdr_data); + // Add an ancillary chunk (lowercase first letter = ancillary) + write_chunk(&mut png, b"tEXt", b"Comment\x00Hello"); + let compressed = zlib_compress(&raw); + write_chunk(&mut png, b"IDAT", &compressed); + write_chunk(&mut png, b"IEND", &[]); + + let img = decode_png(&png).unwrap(); + assert_eq!(img.data, vec![1, 2, 3, 255]); + } + + // -- 1-bit indexed (palette) -- + + #[test] + fn decode_1bit_indexed() { + let ihdr = make_ihdr(8, 1, 1, COLOR_INDEXED); + let palette = vec![ + 0, 0, 0, // index 0: black + 255, 255, 255, // index 1: white + ]; + // 8 pixels in 1 byte: 0b10101010 → indices: 1,0,1,0,1,0,1,0 + let raw = make_filtered_rows(&[vec![0b1010_1010]]); + let png = build_png(&ihdr, Some(&palette), None, &raw); + let img = decode_png(&png).unwrap(); + for i in 0..8 { + let expected = if i % 2 == 0 { 255 } else { 0 }; + assert_eq!(img.data[i * 4], expected, "pixel {i} R"); + assert_eq!(img.data[i * 4 + 1], expected, "pixel {i} G"); + assert_eq!(img.data[i * 4 + 2], expected, "pixel {i} B"); + assert_eq!(img.data[i * 4 + 3], 255, "pixel {i} A"); + } + } + + // -- Error Display test -- + + #[test] + fn error_display_decode() { + let err = ImageError::Decode("test error".to_string()); + assert_eq!(err.to_string(), "decode error: test error"); + } +} -- 2.51.2