From f4152cff45500adaeb5c33880bbe67eafed28d91 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 13 Mar 2026 17:45:56 +0100 Subject: [PATCH] Implement GIF decoder (GIF87a/GIF89a) Pure Rust GIF decoder in the image crate supporting: - GIF87a and GIF89a format parsing - LZW decompression with variable-width codes, clear/EOI codes - Global and local color tables - Graphic Control Extension (transparency, delay, disposal methods) - Multi-frame animated GIFs with canvas compositing - Interlaced image support (4-pass Adam7-style deinterlacing) - Sub-frame positioning (frames smaller than logical screen) - Application, comment, and plain text extension skipping - API: decode_gif() for first frame, decode_gif_frames() for all frames 31 unit tests covering LZW decompression, deinterlacing, frame decoding, transparency, animation, local color tables, extensions, and error cases. No external dependencies, no unsafe. Co-Authored-By: Claude Opus 4.6 --- crates/image/src/gif.rs | 1879 +++++++++++++++++++++++++++++++++++++++ crates/image/src/lib.rs | 1 + 2 files changed, 1880 insertions(+) create mode 100644 crates/image/src/gif.rs diff --git a/crates/image/src/gif.rs b/crates/image/src/gif.rs new file mode 100644 index 0000000..bcd97a5 --- /dev/null +++ b/crates/image/src/gif.rs @@ -0,0 +1,1879 @@ +//! GIF decoder (GIF87a / GIF89a) — pure Rust. +//! +//! Supports single-frame and animated GIFs, global and local color tables, +//! LZW decompression, transparency via Graphic Control Extension, interlacing, +//! and frame disposal methods. + +use crate::pixel::{self, Image, ImageError}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const GIF87A: &[u8; 6] = b"GIF87a"; +const GIF89A: &[u8; 6] = b"GIF89a"; + +const IMAGE_DESCRIPTOR: u8 = 0x2C; +const EXTENSION_INTRODUCER: u8 = 0x21; +const TRAILER: u8 = 0x3B; + +const GRAPHIC_CONTROL_EXT: u8 = 0xF9; +const APPLICATION_EXT: u8 = 0xFF; +const COMMENT_EXT: u8 = 0xFE; +const PLAIN_TEXT_EXT: u8 = 0x01; + +// --------------------------------------------------------------------------- +// Disposal method +// --------------------------------------------------------------------------- + +/// How the decoder should handle the frame's area before drawing the next frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposalMethod { + /// No disposal specified — leave the frame in place. + None, + /// Do not dispose — leave the frame in place (same as None). + DoNotDispose, + /// Restore the area to the background color. + RestoreBackground, + /// Restore the area to the previous frame's content. + RestorePrevious, +} + +impl DisposalMethod { + fn from_byte(b: u8) -> Self { + match b { + 0 => Self::None, + 1 => Self::DoNotDispose, + 2 => Self::RestoreBackground, + 3 => Self::RestorePrevious, + _ => Self::None, + } + } +} + +// --------------------------------------------------------------------------- +// GifFrame +// --------------------------------------------------------------------------- + +/// A single frame from an animated GIF. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GifFrame { + /// The frame image in RGBA8. + pub image: Image, + /// Delay time in hundredths of a second (centiseconds). + pub delay_cs: u16, + /// Disposal method for this frame. + pub disposal: DisposalMethod, +} + +// --------------------------------------------------------------------------- +// LZW decompressor +// --------------------------------------------------------------------------- + +/// LZW decompressor for GIF image data. +/// +/// GIF uses a variable-width LZW scheme with clear and EOI codes. The minimum +/// code size is specified per sub-image. Codes are packed LSB-first. +struct LzwDecoder { + min_code_size: u8, + clear_code: u16, + eoi_code: u16, + /// Current entries in the code table. Each entry is stored as + /// (prefix_code, suffix_byte). Single-byte entries have prefix = u16::MAX. + table: Vec<(u16, u8)>, + code_size: u8, + next_code: u16, +} + +impl LzwDecoder { + fn new(min_code_size: u8) -> Self { + let clear_code = 1u16 << min_code_size; + let eoi_code = clear_code + 1; + Self { + min_code_size, + clear_code, + eoi_code, + table: Vec::new(), + code_size: min_code_size + 1, + next_code: 0, + } + } + + fn reset(&mut self) { + self.table.clear(); + let initial_entries = (1u16 << self.min_code_size) + 2; + self.table.reserve(initial_entries as usize); + for i in 0..self.clear_code { + self.table.push((u16::MAX, i as u8)); + } + // Clear code and EOI code entries (not used for output, but occupy slots) + self.table.push((u16::MAX, 0)); // clear code + self.table.push((u16::MAX, 0)); // eoi code + self.code_size = self.min_code_size + 1; + self.next_code = self.eoi_code + 1; + } + + /// Emit the string for a code into `output`. Returns the first byte of the string. + fn emit(&self, code: u16, output: &mut Vec) -> u8 { + // Walk the chain to find the string length, then write in reverse. + let start = output.len(); + let mut c = code; + loop { + let (prefix, suffix) = self.table[c as usize]; + output.push(suffix); + if prefix == u16::MAX { + break; + } + c = prefix; + } + // Reverse the appended portion + output[start..].reverse(); + output[start] + } + + /// Get the first byte of the string for a code. + fn first_byte(&self, code: u16) -> u8 { + let mut c = code; + loop { + let (prefix, suffix) = self.table[c as usize]; + if prefix == u16::MAX { + return suffix; + } + c = prefix; + } + } + + fn add_entry(&mut self, prefix: u16, suffix: u8) { + if self.next_code < 4096 { + self.table.push((prefix, suffix)); + self.next_code += 1; + // Increase code size when we've filled the current capacity + if self.next_code > (1 << self.code_size) && self.code_size < 12 { + self.code_size += 1; + } + } + } + + /// Decompress LZW data from packed sub-blocks. + fn decompress(&mut self, data: &[u8]) -> Result, ImageError> { + self.reset(); + let mut reader = LzwBitReader::new(data); + let mut output = Vec::new(); + + // First code must be a clear code + let first = reader.read_bits(self.code_size)?; + if first != self.clear_code { + return Err(ImageError::Decode( + "GIF LZW: expected clear code at start".into(), + )); + } + + // Read the first data code after clear + let mut prev_code = reader.read_bits(self.code_size)?; + if prev_code == self.eoi_code { + return Ok(output); + } + if prev_code >= self.next_code { + return Err(ImageError::Decode("GIF LZW: invalid first code".into())); + } + self.emit(prev_code, &mut output); + + while let Ok(code) = reader.read_bits(self.code_size) { + if code == self.eoi_code { + break; + } + + if code == self.clear_code { + self.reset(); + prev_code = match reader.read_bits(self.code_size) { + Ok(c) => c, + Err(_) => break, + }; + if prev_code == self.eoi_code { + break; + } + if prev_code >= self.next_code { + return Err(ImageError::Decode( + "GIF LZW: invalid code after clear".into(), + )); + } + self.emit(prev_code, &mut output); + continue; + } + + if code < self.next_code { + // Code is in the table + let first = self.first_byte(code); + self.add_entry(prev_code, first); + self.emit(code, &mut output); + } else if code == self.next_code { + // Special case: code is not yet in table + let first = self.first_byte(prev_code); + self.add_entry(prev_code, first); + self.emit(code, &mut output); + } else { + return Err(ImageError::Decode(format!( + "GIF LZW: code {code} out of range (next={next})", + next = self.next_code + ))); + } + + prev_code = code; + } + + Ok(output) + } +} + +// --------------------------------------------------------------------------- +// Bit reader for LZW (LSB-first, packed) +// --------------------------------------------------------------------------- + +struct LzwBitReader<'a> { + data: &'a [u8], + pos: usize, + bit_buf: u32, + bits_in_buf: u8, +} + +impl<'a> LzwBitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + pos: 0, + bit_buf: 0, + bits_in_buf: 0, + } + } + + fn read_bits(&mut self, count: u8) -> Result { + while self.bits_in_buf < count { + if self.pos >= self.data.len() { + return Err(ImageError::Decode("GIF LZW: unexpected end of data".into())); + } + self.bit_buf |= (self.data[self.pos] as u32) << self.bits_in_buf; + self.pos += 1; + self.bits_in_buf += 8; + } + let mask = (1u32 << count) - 1; + let value = (self.bit_buf & mask) as u16; + self.bit_buf >>= count; + self.bits_in_buf -= count; + Ok(value) + } +} + +// --------------------------------------------------------------------------- +// GIF parser internals +// --------------------------------------------------------------------------- + +struct GifReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> GifReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn read_byte(&mut self) -> Result { + if self.pos >= self.data.len() { + return Err(ImageError::Decode("GIF: unexpected end of data".into())); + } + let b = self.data[self.pos]; + self.pos += 1; + Ok(b) + } + + fn read_u16_le(&mut self) -> Result { + if self.pos + 2 > self.data.len() { + return Err(ImageError::Decode("GIF: unexpected end of data".into())); + } + let lo = self.data[self.pos] as u16; + let hi = self.data[self.pos + 1] as u16; + self.pos += 2; + Ok(lo | (hi << 8)) + } + + fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], ImageError> { + if self.pos + n > self.data.len() { + return Err(ImageError::Decode("GIF: unexpected end of data".into())); + } + let slice = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(slice) + } + + /// Read concatenated sub-blocks (size-prefixed, terminated by a zero-length block). + fn read_sub_blocks(&mut self) -> Result, ImageError> { + let mut result = Vec::new(); + loop { + let block_size = self.read_byte()? as usize; + if block_size == 0 { + break; + } + let block = self.read_bytes(block_size)?; + result.extend_from_slice(block); + } + Ok(result) + } + + /// Skip sub-blocks without collecting data. + fn skip_sub_blocks(&mut self) -> Result<(), ImageError> { + loop { + let block_size = self.read_byte()? as usize; + if block_size == 0 { + break; + } + if self.pos + block_size > self.data.len() { + return Err(ImageError::Decode("GIF: unexpected end of data".into())); + } + self.pos += block_size; + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Graphic Control Extension +// --------------------------------------------------------------------------- + +struct GraphicControl { + disposal: DisposalMethod, + transparent_color: Option, + delay_cs: u16, +} + +// --------------------------------------------------------------------------- +// Logical Screen Descriptor +// --------------------------------------------------------------------------- + +struct ScreenDescriptor { + width: u16, + height: u16, + global_color_table: Option>, + background_index: u8, +} + +fn parse_header_and_screen(reader: &mut GifReader<'_>) -> Result { + // Validate signature + if reader.remaining() < 6 { + return Err(ImageError::Decode("GIF: data too short for header".into())); + } + let sig = reader.read_bytes(6)?; + if sig != GIF87A.as_slice() && sig != GIF89A.as_slice() { + return Err(ImageError::Decode("GIF: invalid signature".into())); + } + + // Logical screen descriptor (7 bytes) + let width = reader.read_u16_le()?; + let height = reader.read_u16_le()?; + let packed = reader.read_byte()?; + let background_index = reader.read_byte()?; + let _pixel_aspect_ratio = reader.read_byte()?; + + let has_gct = (packed & 0x80) != 0; + let gct_size_field = packed & 0x07; + + let global_color_table = if has_gct { + let num_entries = 1usize << (gct_size_field as usize + 1); + let table_bytes = num_entries * 3; + let table = reader.read_bytes(table_bytes)?; + Some(table.to_vec()) + } else { + None + }; + + Ok(ScreenDescriptor { + width, + height, + global_color_table, + background_index, + }) +} + +// --------------------------------------------------------------------------- +// GIF interlace pass reordering +// --------------------------------------------------------------------------- + +/// GIF interlaced images use 4 passes with these parameters: +/// Pass 1: start row 0, step 8 +/// Pass 2: start row 4, step 8 +/// Pass 3: start row 2, step 4 +/// Pass 4: start row 1, step 2 +const INTERLACE_PASSES: [(usize, usize); 4] = [(0, 8), (4, 8), (2, 4), (1, 2)]; + +fn deinterlace(data: &[u8], width: usize, height: usize) -> Vec { + let row_bytes = width; + let mut output = vec![0u8; width * height]; + let mut src_row = 0usize; + + for &(start, step) in &INTERLACE_PASSES { + let mut y = start; + while y < height { + let src_offset = src_row * row_bytes; + let dst_offset = y * row_bytes; + if src_offset + row_bytes <= data.len() { + output[dst_offset..dst_offset + row_bytes] + .copy_from_slice(&data[src_offset..src_offset + row_bytes]); + } + src_row += 1; + y += step; + } + } + + output +} + +// --------------------------------------------------------------------------- +// Frame decoding +// --------------------------------------------------------------------------- + +struct RawFrame { + left: u16, + top: u16, + width: u16, + height: u16, + color_table: Vec, + transparent_color: Option, + disposal: DisposalMethod, + delay_cs: u16, + /// Decompressed indexed pixel data. + indices: Vec, +} + +fn decode_frame( + reader: &mut GifReader<'_>, + global_ct: &Option>, + gce: Option, +) -> Result { + // Image descriptor + let left = reader.read_u16_le()?; + let top = reader.read_u16_le()?; + let width = reader.read_u16_le()?; + let height = reader.read_u16_le()?; + let packed = reader.read_byte()?; + + let has_lct = (packed & 0x80) != 0; + let interlaced = (packed & 0x40) != 0; + let lct_size_field = packed & 0x07; + + let local_color_table = if has_lct { + let num_entries = 1usize << (lct_size_field as usize + 1); + let table_bytes = num_entries * 3; + let table = reader.read_bytes(table_bytes)?; + table.to_vec() + } else { + Vec::new() + }; + + // Determine which color table to use + let color_table = if !local_color_table.is_empty() { + local_color_table + } else if let Some(ref gct) = global_ct { + gct.clone() + } else { + return Err(ImageError::Decode("GIF: no color table available".into())); + }; + + // LZW minimum code size + let min_code_size = reader.read_byte()?; + if min_code_size > 11 { + return Err(ImageError::Decode(format!( + "GIF: invalid LZW minimum code size {min_code_size}" + ))); + } + + // Read LZW sub-blocks + let lzw_data = reader.read_sub_blocks()?; + + // Decompress + let mut decoder = LzwDecoder::new(min_code_size); + let mut indices = decoder.decompress(&lzw_data)?; + + // Deinterlace if needed + if interlaced && width > 0 && height > 0 { + indices = deinterlace(&indices, width as usize, height as usize); + } + + let (disposal, transparent_color, delay_cs) = match gce { + Some(gc) => (gc.disposal, gc.transparent_color, gc.delay_cs), + None => (DisposalMethod::None, None, 0), + }; + + Ok(RawFrame { + left, + top, + width, + height, + color_table, + transparent_color, + disposal, + delay_cs, + indices, + }) +} + +/// Render a raw frame's indices into an RGBA8 image. +fn render_frame(frame: &RawFrame) -> Result { + let w = frame.width as u32; + let h = frame.height as u32; + if w == 0 || h == 0 { + return Err(ImageError::Decode("GIF: frame has zero dimension".into())); + } + + let pixel_count = w as usize * h as usize; + let palette = &frame.color_table; + let palette_len = palette.len() / 3; + + // Truncate indices to expected pixel count (some GIFs have extra data) + let indices = if frame.indices.len() >= pixel_count { + &frame.indices[..pixel_count] + } else { + // Pad with zeros if data is short + return render_frame_padded(frame, pixel_count, palette, palette_len); + }; + + match frame.transparent_color { + Some(tc) => { + let mut data = Vec::with_capacity(pixel_count * 4); + for &idx in indices { + if idx == tc { + data.extend_from_slice(&[0, 0, 0, 0]); + } else { + let i = idx as usize; + if i >= palette_len { + return Err(ImageError::PaletteIndexOutOfBounds { + index: idx, + palette_len, + }); + } + let offset = i * 3; + data.push(palette[offset]); + data.push(palette[offset + 1]); + data.push(palette[offset + 2]); + data.push(255); + } + } + Image::new(w, h, data) + } + None => pixel::from_indexed(w, h, palette, indices), + } +} + +fn render_frame_padded( + frame: &RawFrame, + pixel_count: usize, + palette: &[u8], + palette_len: usize, +) -> Result { + let w = frame.width as u32; + let h = frame.height as u32; + let mut data = Vec::with_capacity(pixel_count * 4); + for i in 0..pixel_count { + let idx = if i < frame.indices.len() { + frame.indices[i] + } else { + 0 + }; + if let Some(tc) = frame.transparent_color { + if idx == tc { + data.extend_from_slice(&[0, 0, 0, 0]); + continue; + } + } + let ci = idx as usize; + if ci >= palette_len { + return Err(ImageError::PaletteIndexOutOfBounds { + index: idx, + palette_len, + }); + } + let offset = ci * 3; + data.push(palette[offset]); + data.push(palette[offset + 1]); + data.push(palette[offset + 2]); + data.push(255); + } + Image::new(w, h, data) +} + +// --------------------------------------------------------------------------- +// Canvas compositing for animated GIFs +// --------------------------------------------------------------------------- + +fn composite_frame( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + frame: &RawFrame, + frame_image: &Image, +) { + let cx = frame.left as usize; + let cy = frame.top as usize; + let fw = frame.width as usize; + let fh = frame.height as usize; + let cw = canvas_width as usize; + let ch = canvas_height as usize; + + for row in 0..fh { + let dy = cy + row; + if dy >= ch { + break; + } + for col in 0..fw { + let dx = cx + col; + if dx >= cw { + break; + } + let src_off = (row * fw + col) * 4; + let dst_off = (dy * cw + dx) * 4; + let a = frame_image.data[src_off + 3]; + if a > 0 { + canvas[dst_off] = frame_image.data[src_off]; + canvas[dst_off + 1] = frame_image.data[src_off + 1]; + canvas[dst_off + 2] = frame_image.data[src_off + 2]; + canvas[dst_off + 3] = frame_image.data[src_off + 3]; + } + } + } +} + +fn apply_disposal( + canvas: &mut [u8], + prev_canvas: &[u8], + frame: &RawFrame, + canvas_width: u32, + canvas_height: u32, + bg_index: u8, + color_table: &Option>, +) { + match frame.disposal { + DisposalMethod::RestoreBackground => { + let cx = frame.left as usize; + let cy = frame.top as usize; + let fw = frame.width as usize; + let fh = frame.height as usize; + let cw = canvas_width as usize; + let ch = canvas_height as usize; + + // Background color from the global color table + let (br, bg, bb) = if let Some(ref gct) = color_table { + let i = bg_index as usize; + let palette_len = gct.len() / 3; + if i < palette_len { + let off = i * 3; + (gct[off], gct[off + 1], gct[off + 2]) + } else { + (0, 0, 0) + } + } else { + (0, 0, 0) + }; + + for row in 0..fh { + let dy = cy + row; + if dy >= ch { + break; + } + for col in 0..fw { + let dx = cx + col; + if dx >= cw { + break; + } + let off = (dy * cw + dx) * 4; + canvas[off] = br; + canvas[off + 1] = bg; + canvas[off + 2] = bb; + canvas[off + 3] = 0; // transparent background + } + } + } + DisposalMethod::RestorePrevious => { + canvas.copy_from_slice(prev_canvas); + } + DisposalMethod::None | DisposalMethod::DoNotDispose => { + // Leave canvas as-is + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Decode a GIF image, returning the first frame as an RGBA8 image. +/// +/// For single-frame GIFs this returns the only frame. For animated GIFs this +/// returns the composited first frame at the logical screen dimensions. +pub fn decode_gif(data: &[u8]) -> Result { + let frames = decode_gif_frames(data)?; + if frames.is_empty() { + return Err(ImageError::Decode("GIF: no frames found".into())); + } + Ok(frames.into_iter().next().unwrap().image) +} + +/// Decode all frames from a GIF image. +/// +/// Each frame is composited onto the logical screen canvas according to the +/// frame's position, disposal method, and transparency settings. The returned +/// `Image` in each `GifFrame` has the logical screen dimensions. +pub fn decode_gif_frames(data: &[u8]) -> Result, ImageError> { + let mut reader = GifReader::new(data); + let screen = parse_header_and_screen(&mut reader)?; + + let sw = screen.width as u32; + let sh = screen.height as u32; + if sw == 0 || sh == 0 { + return Err(ImageError::Decode( + "GIF: logical screen has zero dimension".into(), + )); + } + + let canvas_size = sw as usize * sh as usize * 4; + let mut canvas = vec![0u8; canvas_size]; + let mut prev_canvas = vec![0u8; canvas_size]; + let mut frames = Vec::new(); + let mut current_gce: Option = None; + + loop { + if reader.remaining() == 0 { + break; + } + let block_type = reader.read_byte()?; + + match block_type { + TRAILER => break, + + EXTENSION_INTRODUCER => { + let label = reader.read_byte()?; + match label { + GRAPHIC_CONTROL_EXT => { + let block_size = reader.read_byte()?; + if block_size != 4 { + return Err(ImageError::Decode( + "GIF: invalid graphic control extension size".into(), + )); + } + let packed = reader.read_byte()?; + let delay_cs = reader.read_u16_le()?; + let transparent_index = reader.read_byte()?; + let _terminator = reader.read_byte()?; + + let disposal = DisposalMethod::from_byte((packed >> 2) & 0x07); + let has_transparency = (packed & 0x01) != 0; + + current_gce = Some(GraphicControl { + disposal, + transparent_color: if has_transparency { + Some(transparent_index) + } else { + None + }, + delay_cs, + }); + } + APPLICATION_EXT | COMMENT_EXT | PLAIN_TEXT_EXT => { + // Skip the block size and sub-blocks + reader.skip_sub_blocks()?; + } + _ => { + // Unknown extension — skip sub-blocks + reader.skip_sub_blocks()?; + } + } + } + + IMAGE_DESCRIPTOR => { + let gce = current_gce.take(); + let raw = decode_frame(&mut reader, &screen.global_color_table, gce)?; + let frame_image = render_frame(&raw)?; + + // Save canvas state before compositing (for RestorePrevious) + prev_canvas.copy_from_slice(&canvas); + + // Composite frame onto canvas + composite_frame(&mut canvas, sw, sh, &raw, &frame_image); + + // Output the composited canvas as this frame + let output_image = Image::new(sw, sh, canvas.clone())?; + frames.push(GifFrame { + image: output_image, + delay_cs: raw.delay_cs, + disposal: raw.disposal, + }); + + // Apply disposal for next frame + apply_disposal( + &mut canvas, + &prev_canvas, + &raw, + sw, + sh, + screen.background_index, + &screen.global_color_table, + ); + } + + _ => { + // Unknown block type — try to skip. In practice this shouldn't happen + // in well-formed GIFs but we don't want to fail hard. + break; + } + } + } + + Ok(frames) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // LZW decompressor tests + // ----------------------------------------------------------------------- + + #[test] + fn lzw_simple_sequence() { + // LZW with min code size 2 (codes 0-3 are literal, 4=clear, 5=eoi) + // Encode a simple sequence: clear, 0, 1, 0, 1, eoi + // Using 3-bit codes (min_code_size=2, initial code_size=3) + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (4, 3), // clear code + (0, 3), // literal 0 + (1, 3), // literal 1 + (0, 3), // literal 0 + (1, 3), // literal 1 + (5, 3), // eoi + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(2); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![0, 1, 0, 1]); + } + + #[test] + fn lzw_repeated_pattern() { + // Encode: clear, 1, 1, 1, 1, eoi with min_code_size=2 + // After clear: table has 0,1,2,3, clear=4, eoi=5, next=6 + // Code size = 3 + // Read 1 -> output [1], prev=1 + // Read 1 -> in table, first_byte(1)=1, add (1,1) as code 6 -> output [1,1] + // next=7 + // Read 6 -> in table, first_byte(6)=1, add (1,1) as code 7 -> output [1,1,1,1] + // next=8 -> code_size becomes 4 + // Read 5 (eoi, now 4 bits) -> done + + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (4, 3), // clear + (1, 3), // literal 1 + (1, 3), // literal 1 + (6, 3), // code 6 = {1,1} + (5, 4), // eoi (now 4 bits since next_code=8 bumped code_size) + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(2); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![1, 1, 1, 1]); + } + + #[test] + fn lzw_code_not_in_table() { + // Test the special case where code == next_code + // min_code_size=2, clear=4, eoi=5, next=6 + // Sequence: clear, 0, 6(=next), eoi + // After 0: prev=0 + // Read 6: code == next_code, first_byte(prev=0)=0, add(0,0) as 6 + // emit(6) = [0,0] + // Total output: [0, 0, 0] + + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (4, 3), // clear + (0, 3), // literal 0 + (6, 3), // code 6 = next_code (special case) + (5, 3), // eoi + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(2); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![0, 0, 0]); + } + + #[test] + fn lzw_clear_code_reset() { + // Test that clear code resets the table + // min_code_size=2, clear=4, eoi=5 + // Sequence: clear, 0, 1, clear, 2, 3, eoi + + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (4, 3), // clear + (0, 3), // literal 0 + (1, 3), // literal 1 + (4, 3), // clear (reset) + (2, 3), // literal 2 + (3, 3), // literal 3 + (5, 3), // eoi + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(2); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![0, 1, 2, 3]); + } + + #[test] + fn lzw_empty_after_clear_eoi() { + // clear then immediate eoi + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (4, 3), // clear + (5, 3), // eoi + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(2); + let result = decoder.decompress(&data).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn lzw_min_code_size_1() { + // min_code_size=1: literals 0,1; clear=2, eoi=3 + // code_size starts at 2 + // After reading 0, 1: add_entry(0,1)=code4, next=5 > 4=1<<2 → code_size=3 + let mut bits: u64 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (2, 2), // clear (code_size=2) + (0, 2), // literal 0 + (1, 2), // literal 1 → after this, code_size bumps to 3 + (0, 3), // literal 0 (now 3 bits) + (3, 3), // eoi (3 bits) + ]; + + for &(code, nbits) in codes { + bits |= (code as u64) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(1); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![0, 1, 0]); + } + + #[test] + fn lzw_min_code_size_8() { + // min_code_size=8: literals 0-255; clear=256, eoi=257 + // code_size starts at 9 + let mut bits: u128 = 0; + let mut bit_pos = 0u32; + + let codes: &[(u16, u8)] = &[ + (256, 9), // clear + (0, 9), // literal 0 + (255, 9), // literal 255 + (128, 9), // literal 128 + (257, 9), // eoi + ]; + + for &(code, nbits) in codes { + bits |= (code as u128) << bit_pos; + bit_pos += nbits as u32; + } + + let byte_count = (bit_pos + 7) / 8; + let data: Vec = (0..byte_count) + .map(|i| ((bits >> (i * 8)) & 0xFF) as u8) + .collect(); + + let mut decoder = LzwDecoder::new(8); + let result = decoder.decompress(&data).unwrap(); + assert_eq!(result, vec![0, 255, 128]); + } + + // ----------------------------------------------------------------------- + // Deinterlace tests + // ----------------------------------------------------------------------- + + #[test] + fn deinterlace_8_rows() { + // With 8 rows and width=1: + // Pass 1 (start=0, step=8): row 0 + // Pass 2 (start=4, step=8): row 4 + // Pass 3 (start=2, step=4): rows 2, 6 + // Pass 4 (start=1, step=2): rows 1, 3, 5, 7 + // Interlaced order: [0, 4, 2, 6, 1, 3, 5, 7] + let interlaced: Vec = vec![0, 4, 2, 6, 1, 3, 5, 7]; + let result = deinterlace(&interlaced, 1, 8); + assert_eq!(result, vec![0, 1, 2, 3, 4, 5, 6, 7]); + } + + #[test] + fn deinterlace_4_rows() { + // 4 rows: + // Pass 1 (start=0, step=8): row 0 + // Pass 2 (start=4, step=8): (none) + // Pass 3 (start=2, step=4): row 2 + // Pass 4 (start=1, step=2): rows 1, 3 + // Interlaced order: [0, 2, 1, 3] + let interlaced: Vec = vec![0, 2, 1, 3]; + let result = deinterlace(&interlaced, 1, 4); + assert_eq!(result, vec![0, 1, 2, 3]); + } + + #[test] + fn deinterlace_1_row() { + let interlaced: Vec = vec![42]; + let result = deinterlace(&interlaced, 1, 1); + assert_eq!(result, vec![42]); + } + + #[test] + fn deinterlace_wider() { + // 4 rows, width=2 + // Interlaced row order: 0, 2, 1, 3 + let interlaced: Vec = vec![ + 10, 11, // row 0 + 30, 31, // row 2 + 20, 21, // row 1 + 40, 41, // row 3 + ]; + let result = deinterlace(&interlaced, 2, 4); + assert_eq!( + result, + vec![ + 10, 11, // row 0 + 20, 21, // row 1 + 30, 31, // row 2 + 40, 41, // row 3 + ] + ); + } + + // ----------------------------------------------------------------------- + // Disposal method tests + // ----------------------------------------------------------------------- + + #[test] + fn disposal_from_byte() { + assert_eq!(DisposalMethod::from_byte(0), DisposalMethod::None); + assert_eq!(DisposalMethod::from_byte(1), DisposalMethod::DoNotDispose); + assert_eq!( + DisposalMethod::from_byte(2), + DisposalMethod::RestoreBackground + ); + assert_eq!( + DisposalMethod::from_byte(3), + DisposalMethod::RestorePrevious + ); + assert_eq!(DisposalMethod::from_byte(4), DisposalMethod::None); + assert_eq!(DisposalMethod::from_byte(255), DisposalMethod::None); + } + + // ----------------------------------------------------------------------- + // Full GIF decode tests (hand-crafted minimal GIFs) + // ----------------------------------------------------------------------- + + /// Build a minimal valid GIF87a with a single 1x1 red pixel. + fn build_1x1_red_gif() -> Vec { + let mut gif = Vec::new(); + + // Header + gif.extend_from_slice(b"GIF87a"); + + // Logical screen descriptor: 1x1, GCT flag set, 1 bit color resolution + // packed: 0x80 (has GCT) | 0x00 (color res = 0+1=1) | 0x00 (sort=0) | 0x00 (GCT size=0 -> 2 entries) + gif.push(1); + gif.push(0); // width=1 + gif.push(1); + gif.push(0); // height=1 + gif.push(0x80); // packed + gif.push(0); // background index + gif.push(0); // pixel aspect ratio + + // GCT: 2 entries (2^(0+1) = 2) + gif.extend_from_slice(&[255, 0, 0]); // entry 0: red + gif.extend_from_slice(&[0, 0, 0]); // entry 1: black + + // Image descriptor + gif.push(0x2C); + gif.extend_from_slice(&[0, 0]); // left + gif.extend_from_slice(&[0, 0]); // top + gif.push(1); + gif.push(0); // width=1 + gif.push(1); + gif.push(0); // height=1 + gif.push(0); // packed (no LCT, no interlace) + + // LZW min code size = 2 (must be >= 2 for GIF) + gif.push(2); + + // LZW data: clear(4), 0, eoi(5) — all in 3-bit codes + // Bits: 100 000 101 = 0b101_000_100 packed LSB first + // Byte 0: bits 0-7 = 00100 000 = 0b00000100 (reversed reading) + // Let me construct this properly: + // code 4 (clear) = 100 (3 bits) + // code 0 = 000 (3 bits) + // code 5 (eoi) = 101 (3 bits) + // Total: 9 bits packed LSB first + // bits[0..3] = 100 = 4 (clear) + // bits[3..6] = 000 = 0 (literal 0) + // bits[6..9] = 101 = 5 (eoi) + // Byte 0 (bits 0-7): bit0=0, bit1=0, bit2=1, bit3=0, bit4=0, bit5=0, bit6=1, bit7=0 + // = 0b01000100 = 0x44 + // Byte 1 (bits 8): bit8=1 + // = 0b00000001 = 0x01 + gif.push(2); // sub-block size = 2 bytes + gif.push(0x44); + gif.push(0x01); + gif.push(0); // sub-block terminator + + // Trailer + gif.push(0x3B); + + gif + } + + #[test] + fn decode_1x1_red() { + let data = build_1x1_red_gif(); + let img = decode_gif(&data).unwrap(); + assert_eq!(img.width, 1); + assert_eq!(img.height, 1); + assert_eq!(img.data, vec![255, 0, 0, 255]); + } + + /// Build a 2x2 GIF with 4 colors. + fn build_2x2_gif() -> Vec { + let mut gif = Vec::new(); + + // Header + gif.extend_from_slice(b"GIF89a"); + + // Screen: 2x2, GCT with 4 entries (size field = 1 -> 2^(1+1) = 4) + gif.push(2); + gif.push(0); + gif.push(2); + gif.push(0); + gif.push(0x81); // has GCT, GCT size=1 (4 entries) + gif.push(0); // bg index + gif.push(0); // aspect + + // GCT: 4 entries + gif.extend_from_slice(&[255, 0, 0]); // 0: red + gif.extend_from_slice(&[0, 255, 0]); // 1: green + gif.extend_from_slice(&[0, 0, 255]); // 2: blue + gif.extend_from_slice(&[255, 255, 0]); // 3: yellow + + // Image descriptor + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0]); // left=0, top=0 + gif.push(2); + gif.push(0); + gif.push(2); + gif.push(0); // 2x2 + gif.push(0); // no LCT + + // LZW min code size = 2 (4 literal codes: 0,1,2,3; clear=4, eoi=5) + gif.push(2); + + // Encode: clear(4), 0, 1, 2, 3, eoi(5) — all 3-bit codes + // 9 codes * 3 bits = 18 bits = 3 bytes (with padding) + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (1, 3), (2, 3), (3, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); // terminator + + gif.push(0x3B); // trailer + gif + } + + #[test] + fn decode_2x2_four_colors() { + let data = build_2x2_gif(); + let img = decode_gif(&data).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_frames_single() { + let data = build_2x2_gif(); + let frames = decode_gif_frames(&data).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].delay_cs, 0); + assert_eq!(frames[0].disposal, DisposalMethod::None); + } + + /// Build a GIF89a with transparency. + fn build_transparent_gif() -> Vec { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF89a"); + + // Screen: 2x1 + gif.push(2); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); // has GCT, size=0 (2 entries) + gif.push(0); + gif.push(0); + + // GCT: 2 entries + gif.extend_from_slice(&[255, 0, 0]); // 0: red + gif.extend_from_slice(&[0, 255, 0]); // 1: green + + // Graphic Control Extension + gif.push(0x21); // extension introducer + gif.push(0xF9); // GCE label + gif.push(4); // block size + gif.push(0x01); // packed: disposal=0, transparent=true + gif.push(10); + gif.push(0); // delay = 10cs + gif.push(1); // transparent color index = 1 + gif.push(0); // block terminator + + // Image descriptor + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0]); + gif.push(2); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0); + + // LZW min code size = 2 + gif.push(2); + + // Encode: clear(4), 0, 1, eoi(5) + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (1, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + + gif.push(0x3B); + gif + } + + #[test] + fn decode_with_transparency() { + let data = build_transparent_gif(); + let img = decode_gif(&data).unwrap(); + assert_eq!(img.width, 2); + assert_eq!(img.height, 1); + // Pixel 0: index 0 = red (opaque) + // Pixel 1: index 1 = transparent + assert_eq!( + img.data, + vec![ + 255, 0, 0, 255, // red, opaque + 0, 0, 0, 0, // transparent + ] + ); + } + + #[test] + fn decode_transparency_delay() { + let data = build_transparent_gif(); + let frames = decode_gif_frames(&data).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].delay_cs, 10); + } + + /// Build a 2-frame animated GIF. + fn build_animated_gif() -> Vec { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF89a"); + + // Screen: 2x1 + gif.push(2); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); // GCT, size=0 (2 entries) + gif.push(0); + gif.push(0); + + // GCT + gif.extend_from_slice(&[255, 0, 0]); // 0: red + gif.extend_from_slice(&[0, 0, 255]); // 1: blue + + // --- Frame 1 --- + // GCE: disposal=none, delay=5 + gif.push(0x21); + gif.push(0xF9); + gif.push(4); + gif.push(0x00); // disposal=0(none), no transparency + gif.push(5); + gif.push(0); // delay=5 + gif.push(0); // no transparent + gif.push(0); + + // Image descriptor: full 2x1 + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0]); + gif.push(2); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0); + + gif.push(2); // LZW min code size + + // Encode: clear(4), 0, 1, eoi(5) + { + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (1, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + } + + // --- Frame 2 --- + // GCE: disposal=restore_bg, delay=10 + gif.push(0x21); + gif.push(0xF9); + gif.push(4); + gif.push(0x08); // disposal=2(restore bg), no transparency + gif.push(10); + gif.push(0); // delay=10 + gif.push(0); + gif.push(0); + + // Image descriptor: full 2x1 + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0]); + gif.push(2); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0); + + gif.push(2); + + // Encode: clear(4), 1, 0, eoi(5) + { + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (1, 3), (0, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + } + + gif.push(0x3B); + gif + } + + #[test] + fn decode_animated_two_frames() { + let data = build_animated_gif(); + let frames = decode_gif_frames(&data).unwrap(); + assert_eq!(frames.len(), 2); + + // Frame 1: [red, blue] + assert_eq!(frames[0].delay_cs, 5); + assert_eq!(frames[0].disposal, DisposalMethod::None); + assert_eq!(frames[0].image.data, vec![255, 0, 0, 255, 0, 0, 255, 255]); + + // Frame 2: [blue, red] (composited on canvas) + assert_eq!(frames[1].delay_cs, 10); + assert_eq!(frames[1].disposal, DisposalMethod::RestoreBackground); + assert_eq!(frames[1].image.data, vec![0, 0, 255, 255, 255, 0, 0, 255]); + } + + #[test] + fn decode_animated_first_frame_only() { + let data = build_animated_gif(); + let img = decode_gif(&data).unwrap(); + // decode_gif returns the first frame + assert_eq!(img.data, vec![255, 0, 0, 255, 0, 0, 255, 255]); + } + + /// Build a GIF with a local color table. + fn build_local_ct_gif() -> Vec { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF87a"); + + // Screen: 1x1, NO GCT + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x00); // no GCT + gif.push(0); + gif.push(0); + + // Image descriptor with LCT + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0]); + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); // has LCT, size=0 (2 entries) + + // LCT + gif.extend_from_slice(&[0, 128, 255]); // 0: teal-ish + gif.extend_from_slice(&[0, 0, 0]); // 1: black + + gif.push(2); // LZW min code size + + // Encode: clear(4), 0, eoi(5) + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + + gif.push(0x3B); + gif + } + + #[test] + fn decode_local_color_table() { + let data = build_local_ct_gif(); + let img = decode_gif(&data).unwrap(); + assert_eq!(img.width, 1); + assert_eq!(img.height, 1); + assert_eq!(img.data, vec![0, 128, 255, 255]); + } + + // ----------------------------------------------------------------------- + // Error case tests + // ----------------------------------------------------------------------- + + #[test] + fn invalid_signature() { + let data = b"NOT_GIF"; + assert!(matches!( + decode_gif(data), + Err(ImageError::Decode(ref msg)) if msg.contains("invalid signature") + )); + } + + #[test] + fn too_short() { + let data = b"GIF8"; + assert!(matches!(decode_gif(data), Err(ImageError::Decode(_)))); + } + + #[test] + fn no_frames() { + let mut gif = Vec::new(); + gif.extend_from_slice(b"GIF87a"); + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x00); + gif.push(0); + gif.push(0); + gif.push(0x3B); + assert!(matches!( + decode_gif(&gif), + Err(ImageError::Decode(ref msg)) if msg.contains("no frames") + )); + } + + #[test] + fn zero_dimension_screen() { + let mut gif = Vec::new(); + gif.extend_from_slice(b"GIF87a"); + gif.push(0); + gif.push(0); // width=0 + gif.push(1); + gif.push(0); // height=1 + gif.push(0x00); + gif.push(0); + gif.push(0); + assert!(matches!( + decode_gif(&gif), + Err(ImageError::Decode(ref msg)) if msg.contains("zero dimension") + )); + } + + #[test] + fn invalid_lzw_min_code_size() { + let mut gif = Vec::new(); + gif.extend_from_slice(b"GIF87a"); + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); + gif.push(0); + gif.push(0); + gif.extend_from_slice(&[0; 6]); // GCT (2 entries) + + gif.push(0x2C); // image descriptor + gif.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0]); + gif.push(12); // invalid LZW min code size (>11) + + assert!(matches!( + decode_gif(&gif), + Err(ImageError::Decode(ref msg)) if msg.contains("LZW minimum code size") + )); + } + + // ----------------------------------------------------------------------- + // Sub-frame positioning test + // ----------------------------------------------------------------------- + + /// Build a GIF where the frame is smaller than the logical screen. + fn build_subframe_gif() -> Vec { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF89a"); + + // Screen: 3x3 + gif.push(3); + gif.push(0); + gif.push(3); + gif.push(0); + gif.push(0x80); // GCT, size=0 (2 entries) + gif.push(0); + gif.push(0); + + // GCT + gif.extend_from_slice(&[0, 0, 0]); // 0: black + gif.extend_from_slice(&[255, 255, 255]); // 1: white + + // Image descriptor: 1x1 at position (1,1) + gif.push(0x2C); + gif.push(1); + gif.push(0); // left=1 + gif.push(1); + gif.push(0); // top=1 + gif.push(1); + gif.push(0); // width=1 + gif.push(1); + gif.push(0); // height=1 + gif.push(0); + + gif.push(2); // LZW min + + // Encode: clear(4), 1, eoi(5) + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (1, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + + gif.push(0x3B); + gif + } + + #[test] + fn decode_subframe_positioning() { + let data = build_subframe_gif(); + let img = decode_gif(&data).unwrap(); + assert_eq!(img.width, 3); + assert_eq!(img.height, 3); + // Canvas starts all black (0,0,0,0 = transparent) + // The 1x1 white pixel is composited at position (1,1) + let pixel = |x: usize, y: usize| -> &[u8] { + let off = (y * 3 + x) * 4; + &img.data[off..off + 4] + }; + // All pixels except (1,1) should be transparent black + assert_eq!(pixel(0, 0), &[0, 0, 0, 0]); + assert_eq!(pixel(1, 0), &[0, 0, 0, 0]); + assert_eq!(pixel(2, 0), &[0, 0, 0, 0]); + assert_eq!(pixel(0, 1), &[0, 0, 0, 0]); + assert_eq!(pixel(1, 1), &[255, 255, 255, 255]); // white + assert_eq!(pixel(2, 1), &[0, 0, 0, 0]); + assert_eq!(pixel(0, 2), &[0, 0, 0, 0]); + assert_eq!(pixel(1, 2), &[0, 0, 0, 0]); + assert_eq!(pixel(2, 2), &[0, 0, 0, 0]); + } + + // ----------------------------------------------------------------------- + // Application extension skip test + // ----------------------------------------------------------------------- + + #[test] + fn skip_application_extension() { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF89a"); + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); + gif.push(0); + gif.push(0); + gif.extend_from_slice(&[255, 0, 0, 0, 0, 0]); // GCT + + // Application extension (NETSCAPE2.0 for looping) + gif.push(0x21); + gif.push(0xFF); + // Sub-blocks for the extension + gif.push(11); // sub-block size + gif.extend_from_slice(b"NETSCAPE2.0"); + gif.push(3); // sub-block size + gif.push(1); // sub-block id + gif.push(0); + gif.push(0); // loop count + gif.push(0); // terminator + + // Image + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0]); + gif.push(2); + + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + + gif.push(0x3B); + + let img = decode_gif(&gif).unwrap(); + assert_eq!(img.width, 1); + assert_eq!(img.height, 1); + assert_eq!(img.data, vec![255, 0, 0, 255]); + } + + // ----------------------------------------------------------------------- + // Comment extension skip test + // ----------------------------------------------------------------------- + + #[test] + fn skip_comment_extension() { + let mut gif = Vec::new(); + + gif.extend_from_slice(b"GIF89a"); + gif.push(1); + gif.push(0); + gif.push(1); + gif.push(0); + gif.push(0x80); + gif.push(0); + gif.push(0); + gif.extend_from_slice(&[0, 255, 0, 0, 0, 0]); // GCT + + // Comment extension + gif.push(0x21); + gif.push(0xFE); + gif.push(5); + gif.extend_from_slice(b"hello"); + gif.push(0); + + // Image + gif.push(0x2C); + gif.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0]); + gif.push(2); + + let mut bits: u64 = 0; + let mut bp = 0u32; + for &(code, nbits) in &[(4u16, 3u8), (0, 3), (5, 3)] { + bits |= (code as u64) << bp; + bp += nbits as u32; + } + let byte_count = ((bp + 7) / 8) as usize; + let lzw_bytes: Vec = (0..byte_count) + .map(|i| ((bits >> (i as u32 * 8)) & 0xFF) as u8) + .collect(); + gif.push(lzw_bytes.len() as u8); + gif.extend_from_slice(&lzw_bytes); + gif.push(0); + + gif.push(0x3B); + + let img = decode_gif(&gif).unwrap(); + assert_eq!(img.data, vec![0, 255, 0, 255]); + } + + // ----------------------------------------------------------------------- + // GIF87a compatibility test + // ----------------------------------------------------------------------- + + #[test] + fn gif87a_is_accepted() { + let data = build_1x1_red_gif(); + // Already uses GIF87a + assert!(data.starts_with(b"GIF87a")); + let img = decode_gif(&data).unwrap(); + assert_eq!(img.data, vec![255, 0, 0, 255]); + } + + // ----------------------------------------------------------------------- + // Bit reader tests + // ----------------------------------------------------------------------- + + #[test] + fn bit_reader_basic() { + // Byte 0: 0b10110100 — bits[0..8] = 0,0,1,0,1,1,0,1 + // Byte 1: 0b00000001 — bits[8..16] = 1,0,0,0,0,0,0,0 + let data = [0b10110100u8, 0b00000001]; + let mut reader = LzwBitReader::new(&data); + + // Read 3 bits [0,1,2]: 0*1+0*2+1*4 = 4 + assert_eq!(reader.read_bits(3).unwrap(), 4); + // Read 3 bits [3,4,5]: 0*1+1*2+1*4 = 6 + assert_eq!(reader.read_bits(3).unwrap(), 6); + // Read 3 bits [6,7,8]: 0*1+1*2+1*4 = 6 + assert_eq!(reader.read_bits(3).unwrap(), 6); + } + + #[test] + fn bit_reader_eof() { + let data = [0xFF]; + let mut reader = LzwBitReader::new(&data); + assert!(reader.read_bits(8).is_ok()); + assert!(reader.read_bits(1).is_err()); + } +} diff --git a/crates/image/src/lib.rs b/crates/image/src/lib.rs index ef3ea93..a8c26a8 100644 --- a/crates/image/src/lib.rs +++ b/crates/image/src/lib.rs @@ -1,6 +1,7 @@ //! Image decoders — PNG (DEFLATE), JPEG, GIF, WebP — pure Rust. pub mod deflate; +pub mod gif; pub mod pixel; pub mod png; pub mod zlib; -- 2.51.2