//! 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"); } }