1use super::image_codec::RawDecodeResult;
13use std::collections::HashMap;
14
15#[derive(Debug, thiserror::Error)]
21pub enum GifError {
22 #[error("Invalid GIF: {0}")]
24 Invalid(String),
25 #[error("Truncated GIF data")]
27 Truncated,
28 #[error("LZW error: {0}")]
30 LzwError(String),
31}
32
33struct Bucket {
39 pixels: Vec<[u8; 3]>,
41}
42
43impl Bucket {
44 fn new(pixels: Vec<[u8; 3]>) -> Self {
45 Self { pixels }
46 }
47
48 fn channel_ranges(&self) -> ([u8; 3], [u8; 3]) {
50 let mut min = [255u8; 3];
51 let mut max = [0u8; 3];
52 for p in &self.pixels {
53 for c in 0..3 {
54 if p[c] < min[c] {
55 min[c] = p[c];
56 }
57 if p[c] > max[c] {
58 max[c] = p[c];
59 }
60 }
61 }
62 (min, max)
63 }
64
65 fn widest_channel(&self) -> usize {
67 let (min, max) = self.channel_ranges();
68 let ranges = [
69 max[0].saturating_sub(min[0]),
70 max[1].saturating_sub(min[1]),
71 max[2].saturating_sub(min[2]),
72 ];
73 if ranges[0] >= ranges[1] && ranges[0] >= ranges[2] {
74 0
75 } else if ranges[1] >= ranges[2] {
76 1
77 } else {
78 2
79 }
80 }
81
82 fn split(mut self) -> Option<(Bucket, Bucket)> {
85 if self.pixels.len() < 2 {
86 return None;
87 }
88 let ch = self.widest_channel();
89 self.pixels.sort_unstable_by_key(|p| p[ch]);
90 let mid = self.pixels.len() / 2;
91 if self.pixels[0][ch] == self.pixels[self.pixels.len() - 1][ch] {
94 return None;
95 }
96 let right = self.pixels.split_off(mid);
97 Some((Bucket::new(self.pixels), Bucket::new(right)))
98 }
99
100 fn representative(&self) -> [u8; 3] {
102 if self.pixels.is_empty() {
103 return [0, 0, 0];
104 }
105 let mut sum = [0u64; 3];
106 for p in &self.pixels {
107 sum[0] += p[0] as u64;
108 sum[1] += p[1] as u64;
109 sum[2] += p[2] as u64;
110 }
111 let n = self.pixels.len() as u64;
112 [(sum[0] / n) as u8, (sum[1] / n) as u8, (sum[2] / n) as u8]
113 }
114}
115
116fn median_cut_quantize(pixels: &[[u8; 3]], max_colors: usize) -> (Vec<[u8; 3]>, Vec<u8>) {
121 if pixels.is_empty() {
122 return (vec![[0, 0, 0]], vec![]);
123 }
124
125 let mut buckets: Vec<Bucket> = vec![Bucket::new(pixels.to_vec())];
127
128 while buckets.len() < max_colors {
130 let pick = buckets
132 .iter()
133 .enumerate()
134 .map(|(i, b)| {
135 let (mn, mx) = b.channel_ranges();
136 let spread = (mx[0].saturating_sub(mn[0]) as u32)
137 + (mx[1].saturating_sub(mn[1]) as u32)
138 + (mx[2].saturating_sub(mn[2]) as u32);
139 (spread, i)
140 })
141 .max_by_key(|&(s, _)| s);
142
143 let idx = match pick {
144 Some((0, _)) | None => break, Some((_, i)) => i,
146 };
147
148 let bucket = buckets.remove(idx);
149 match bucket.split() {
150 Some((a, b)) => {
151 buckets.push(a);
152 buckets.push(b);
153 }
154 None => {
155 buckets.insert(idx, Bucket::new(vec![]));
157 break;
158 }
159 }
160 }
161
162 let palette: Vec<[u8; 3]> = buckets.iter().map(|b| b.representative()).collect();
164
165 let index_map: Vec<u8> = pixels
167 .iter()
168 .map(|p| nearest_palette_index(p, &palette))
169 .collect();
170
171 (palette, index_map)
172}
173
174fn nearest_palette_index(pixel: &[u8; 3], palette: &[[u8; 3]]) -> u8 {
176 let mut best_idx = 0usize;
177 let mut best_dist = u32::MAX;
178 for (i, entry) in palette.iter().enumerate() {
179 let dr = pixel[0] as i32 - entry[0] as i32;
180 let dg = pixel[1] as i32 - entry[1] as i32;
181 let db = pixel[2] as i32 - entry[2] as i32;
182 let dist = (dr * dr + dg * dg + db * db) as u32;
183 if dist < best_dist {
184 best_dist = dist;
185 best_idx = i;
186 if dist == 0 {
187 break;
188 }
189 }
190 }
191 best_idx as u8
192}
193
194fn lzw_compress(indices: &[u8], min_code_size: u8) -> Vec<u8> {
202 let clear_code = 1u32 << min_code_size;
203 let end_code = clear_code + 1;
204 let initial_next = end_code + 1;
205
206 let mut table: HashMap<(u32, u8), u32> = HashMap::new();
208 let mut next_code = initial_next;
209 let mut code_width = min_code_size as u32 + 1;
210 let mut next_threshold = 1u32 << code_width;
212
213 let mut bit_buf = 0u64; let mut bit_len = 0u32; let mut out: Vec<u8> = Vec::new();
216
217 fn emit(code: u32, bits: u32, buf: &mut u64, len: &mut u32, out: &mut Vec<u8>) {
219 *buf |= (code as u64) << *len;
220 *len += bits;
221 while *len >= 8 {
222 out.push((*buf & 0xFF) as u8);
223 *buf >>= 8;
224 *len -= 8;
225 }
226 }
227
228 macro_rules! reset_table {
230 () => {{
231 table.clear();
232 next_code = initial_next;
233 code_width = min_code_size as u32 + 1;
234 next_threshold = 1u32 << code_width;
235 }};
236 }
237
238 emit(clear_code, code_width, &mut bit_buf, &mut bit_len, &mut out);
240
241 if indices.is_empty() {
242 emit(end_code, code_width, &mut bit_buf, &mut bit_len, &mut out);
243 if bit_len > 0 {
245 out.push((bit_buf & 0xFF) as u8);
246 }
247 return out;
248 }
249
250 let mut string_code = indices[0] as u32;
251
252 for &byte in &indices[1..] {
253 let key = (string_code, byte);
254 match table.get(&key) {
255 Some(&existing) => {
256 string_code = existing;
257 }
258 None => {
259 emit(
261 string_code,
262 code_width,
263 &mut bit_buf,
264 &mut bit_len,
265 &mut out,
266 );
267
268 if next_code < 4096 {
269 table.insert(key, next_code);
270 next_code += 1;
271 if next_code > next_threshold && code_width < 12 {
272 code_width += 1;
273 next_threshold = 1u32 << code_width;
274 }
275 } else {
276 emit(clear_code, code_width, &mut bit_buf, &mut bit_len, &mut out);
278 reset_table!();
279 }
280
281 string_code = byte as u32;
282 }
283 }
284 }
285
286 emit(
288 string_code,
289 code_width,
290 &mut bit_buf,
291 &mut bit_len,
292 &mut out,
293 );
294 emit(end_code, code_width, &mut bit_buf, &mut bit_len, &mut out);
296 if bit_len > 0 {
298 out.push((bit_buf & 0xFF) as u8);
299 }
300
301 out
302}
303
304fn wrap_in_sub_blocks(lzw_data: &[u8]) -> Vec<u8> {
306 let mut out = Vec::new();
307 for chunk in lzw_data.chunks(255) {
308 out.push(chunk.len() as u8);
309 out.extend_from_slice(chunk);
310 }
311 out.push(0); out
313}
314
315pub fn gif_encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Result<Vec<u8>, GifError> {
324 let pixel_count = (width as usize) * (height as usize);
325 if pixels.len() < pixel_count * 3 {
326 return Err(GifError::Invalid(format!(
327 "pixel buffer too short: expected {} bytes, got {}",
328 pixel_count * 3,
329 pixels.len()
330 )));
331 }
332 if width == 0 || height == 0 {
333 return Err(GifError::Invalid("width and height must be > 0".into()));
334 }
335
336 let rgb_pixels: Vec<[u8; 3]> = pixels
338 .chunks_exact(3)
339 .take(pixel_count)
340 .map(|c| [c[0], c[1], c[2]])
341 .collect();
342
343 let max_colors = 256usize;
345 let (mut palette, index_map) = median_cut_quantize(&rgb_pixels, max_colors);
346
347 let palette_len = palette.len().max(2);
350 let depth = {
351 let mut d = 1u8;
352 while (1usize << d) < palette_len {
353 d += 1;
354 }
355 d };
357 while palette.len() < (1 << depth) {
359 palette.push([0, 0, 0]);
360 }
361
362 let gct_size_field = depth - 1; let packed_byte: u8 = 0x80 | ((depth - 1) << 4) | gct_size_field; let min_code_size = depth.max(2); let mut out: Vec<u8> = Vec::new();
370
371 out.extend_from_slice(b"GIF89a");
373
374 out.extend_from_slice(&(width as u16).to_le_bytes());
376 out.extend_from_slice(&(height as u16).to_le_bytes());
377 out.push(packed_byte);
378 out.push(0); out.push(0); for entry in &palette {
383 out.push(entry[0]);
384 out.push(entry[1]);
385 out.push(entry[2]);
386 }
387
388 out.push(0x2C); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&(width as u16).to_le_bytes());
393 out.extend_from_slice(&(height as u16).to_le_bytes());
394 out.push(0x00); out.push(min_code_size);
398
399 let lzw_bytes = lzw_compress(&index_map, min_code_size);
401 let sub_blocks = wrap_in_sub_blocks(&lzw_bytes);
402 out.extend_from_slice(&sub_blocks);
403
404 out.push(0x3B);
406
407 Ok(out)
408}
409
410struct BitReader<'a> {
417 data: &'a [u8],
418 pos: usize, bit_buf: u32,
420 bit_len: u32,
421}
422
423impl<'a> BitReader<'a> {
424 fn new(data: &'a [u8]) -> Self {
425 Self {
426 data,
427 pos: 0,
428 bit_buf: 0,
429 bit_len: 0,
430 }
431 }
432
433 fn read_bits(&mut self, n: u32) -> Option<u32> {
435 while self.bit_len < n {
436 if self.pos >= self.data.len() {
437 return None;
438 }
439 self.bit_buf |= (self.data[self.pos] as u32) << self.bit_len;
440 self.pos += 1;
441 self.bit_len += 8;
442 }
443 let mask = (1u32 << n) - 1;
444 let val = self.bit_buf & mask;
445 self.bit_buf >>= n;
446 self.bit_len -= n;
447 Some(val)
448 }
449}
450
451fn read_sub_blocks(bytes: &[u8]) -> Result<(Vec<u8>, usize), GifError> {
457 let mut out: Vec<u8> = Vec::new();
458 let mut pos = 0usize;
459 loop {
460 if pos >= bytes.len() {
461 return Err(GifError::Truncated);
462 }
463 let block_len = bytes[pos] as usize;
464 pos += 1;
465 if block_len == 0 {
466 break;
467 }
468 if pos + block_len > bytes.len() {
469 return Err(GifError::Truncated);
470 }
471 out.extend_from_slice(&bytes[pos..pos + block_len]);
472 pos += block_len;
473 }
474 Ok((out, pos))
475}
476
477fn lzw_decompress(data: &[u8], min_code_size: u8) -> Result<Vec<u8>, GifError> {
491 let clear_code = 1u16 << min_code_size;
492 let end_code = clear_code + 1;
493 let table_initial_size = (end_code + 1) as usize;
494
495 let mut prefix: Vec<u16> = Vec::with_capacity(4096);
497 let mut suffix: Vec<u8> = Vec::with_capacity(4096);
498
499 for i in 0..(clear_code as usize) {
501 prefix.push(u16::MAX);
502 suffix.push(i as u8);
503 }
504 prefix.push(u16::MAX);
506 suffix.push(0);
507 prefix.push(u16::MAX);
509 suffix.push(0);
510
511 let mut code_width = min_code_size as u32 + 1;
512
513 let mut reader = BitReader::new(data);
514 let mut output: Vec<u8> = Vec::new();
515
516 let mut scratch: Vec<u8> = Vec::new();
518
519 let walk_chain =
521 |code: u16, pfx: &[u16], sfx: &[u8], scratch: &mut Vec<u8>| -> Result<(), GifError> {
522 scratch.clear();
523 let mut c = code;
524 let mut depth = 0usize;
525 loop {
526 if (c as usize) >= pfx.len() {
527 return Err(GifError::LzwError(format!("code {} not in table", c)));
528 }
529 scratch.push(sfx[c as usize]);
530 let p = pfx[c as usize];
531 if p == u16::MAX {
532 break;
533 }
534 c = p;
535 depth += 1;
536 if depth > 4096 {
537 return Err(GifError::LzwError("cycle in LZW chain".into()));
538 }
539 }
540 Ok(())
541 };
542
543 let first_byte = |code: u16, pfx: &[u16], sfx: &[u8]| -> u8 {
545 let mut c = code;
546 loop {
547 let p = pfx[c as usize];
548 if p == u16::MAX {
549 return sfx[c as usize];
550 }
551 c = p;
552 }
553 };
554
555 let mut prev_code: Option<u16> = None;
556 let mut after_clear = true;
558
559 loop {
560 let raw = reader.read_bits(code_width).ok_or(GifError::Truncated)?;
561 let code = raw as u16;
562
563 if code == end_code {
564 break;
565 }
566
567 if code == clear_code {
568 prefix.truncate(table_initial_size);
569 suffix.truncate(table_initial_size);
570 code_width = min_code_size as u32 + 1;
571 prev_code = None;
572 after_clear = true;
573 continue;
574 }
575
576 if after_clear {
578 if (code as usize) >= table_initial_size {
580 return Err(GifError::LzwError(format!(
581 "first code after clear ({}) exceeds initial table size ({})",
582 code, table_initial_size
583 )));
584 }
585 output.push(suffix[code as usize]);
586 prev_code = Some(code);
587 after_clear = false;
588 continue;
590 }
591
592 let prev = prev_code.ok_or_else(|| GifError::LzwError("no prev code".into()))?;
593 let next_code = prefix.len() as u16;
594
595 if (code as usize) < prefix.len() {
596 walk_chain(code, &prefix, &suffix, &mut scratch)?;
599 scratch.reverse();
600
601 let fb = scratch[0];
603 if next_code < 4096 {
604 prefix.push(prev);
605 suffix.push(fb);
606 }
607
608 output.extend_from_slice(&scratch);
609 } else if code == next_code {
610 let fb = first_byte(prev, &prefix, &suffix);
613
614 if next_code < 4096 {
615 prefix.push(prev);
616 suffix.push(fb);
617 }
618
619 walk_chain(prev, &prefix, &suffix, &mut scratch)?;
621 scratch.reverse();
622 scratch.push(fb);
623
624 output.extend_from_slice(&scratch);
625 } else {
626 return Err(GifError::LzwError(format!(
627 "code {} is beyond next expected entry {}",
628 code, next_code
629 )));
630 }
631
632 prev_code = Some(code);
633
634 if code_width < 12 && prefix.len() == (1 << code_width) {
638 code_width += 1;
639 }
640 }
641
642 Ok(output)
643}
644
645pub fn gif_decode(bytes: &[u8]) -> Result<RawDecodeResult, GifError> {
654 if bytes.len() < 6 {
655 return Err(GifError::Truncated);
656 }
657
658 if &bytes[..6] != b"GIF89a" && &bytes[..6] != b"GIF87a" {
660 return Err(GifError::Invalid(format!(
661 "bad GIF signature: {:?}",
662 &bytes[..6.min(bytes.len())]
663 )));
664 }
665
666 if bytes.len() < 13 {
667 return Err(GifError::Truncated);
668 }
669
670 let canvas_width = u16::from_le_bytes([bytes[6], bytes[7]]) as usize;
672 let canvas_height = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
673 let lsd_packed = bytes[10];
674 let has_gct = (lsd_packed & 0x80) != 0;
675 let gct_size_field = lsd_packed & 0x07;
676 let gct_entries = 1usize << (gct_size_field as usize + 1);
677
678 let mut pos = 13usize; let mut global_palette: Vec<[u8; 3]> = Vec::new();
682 if has_gct {
683 let gct_bytes = gct_entries * 3;
684 if pos + gct_bytes > bytes.len() {
685 return Err(GifError::Truncated);
686 }
687 for i in 0..gct_entries {
688 global_palette.push([
689 bytes[pos + i * 3],
690 bytes[pos + i * 3 + 1],
691 bytes[pos + i * 3 + 2],
692 ]);
693 }
694 pos += gct_bytes;
695 }
696
697 loop {
699 if pos >= bytes.len() {
700 return Err(GifError::Truncated);
701 }
702 match bytes[pos] {
703 0x3B => {
704 return Err(GifError::Invalid("GIF contains no image frames".into()));
706 }
707 0x21 => {
708 pos += 1;
710 if pos >= bytes.len() {
711 return Err(GifError::Truncated);
712 }
713 pos += 1; loop {
716 if pos >= bytes.len() {
717 return Err(GifError::Truncated);
718 }
719 let block_len = bytes[pos] as usize;
720 pos += 1;
721 if block_len == 0 {
722 break;
723 }
724 pos += block_len;
725 if pos > bytes.len() {
726 return Err(GifError::Truncated);
727 }
728 }
729 }
730 0x2C => {
731 break;
733 }
734 other => {
735 return Err(GifError::Invalid(format!(
736 "unexpected block byte 0x{:02X} at offset {}",
737 other, pos
738 )));
739 }
740 }
741 }
742
743 if pos + 10 > bytes.len() {
746 return Err(GifError::Truncated);
747 }
748 let img_width = u16::from_le_bytes([bytes[pos + 5], bytes[pos + 6]]) as usize;
749 let img_height = u16::from_le_bytes([bytes[pos + 7], bytes[pos + 8]]) as usize;
750 let img_packed = bytes[pos + 9];
751 let has_lct = (img_packed & 0x80) != 0;
752 let lct_size_field = img_packed & 0x07;
753 pos += 10; let active_palette: Vec<[u8; 3]> = if has_lct {
757 let lct_entries = 1usize << (lct_size_field as usize + 1);
758 let lct_bytes = lct_entries * 3;
759 if pos + lct_bytes > bytes.len() {
760 return Err(GifError::Truncated);
761 }
762 let mut lct = Vec::with_capacity(lct_entries);
763 for i in 0..lct_entries {
764 lct.push([
765 bytes[pos + i * 3],
766 bytes[pos + i * 3 + 1],
767 bytes[pos + i * 3 + 2],
768 ]);
769 }
770 pos += lct_bytes;
771 lct
772 } else {
773 global_palette
774 };
775
776 if active_palette.is_empty() {
777 return Err(GifError::Invalid("no colour table available".into()));
778 }
779
780 if pos >= bytes.len() {
782 return Err(GifError::Truncated);
783 }
784 let min_code_size = bytes[pos];
785 pos += 1;
786
787 if !(2..=11).contains(&min_code_size) {
788 return Err(GifError::Invalid(format!(
789 "invalid LZW minimum code size: {}",
790 min_code_size
791 )));
792 }
793
794 if pos >= bytes.len() {
796 return Err(GifError::Truncated);
797 }
798 let (lzw_data, _consumed) = read_sub_blocks(&bytes[pos..])?;
799
800 let indices = lzw_decompress(&lzw_data, min_code_size)?;
802
803 let out_width = if img_width == 0 {
805 canvas_width
806 } else {
807 img_width
808 };
809 let out_height = if img_height == 0 {
810 canvas_height
811 } else {
812 img_height
813 };
814 let pixel_count = out_width * out_height;
815
816 let mut pixels: Vec<u8> = Vec::with_capacity(pixel_count * 3);
818 for i in 0..pixel_count {
819 let idx = if i < indices.len() {
820 indices[i] as usize
821 } else {
822 0
823 };
824 let color = if idx < active_palette.len() {
825 active_palette[idx]
826 } else {
827 [0, 0, 0]
828 };
829 pixels.push(color[0]);
830 pixels.push(color[1]);
831 pixels.push(color[2]);
832 }
833
834 Ok(RawDecodeResult {
835 width: out_width,
836 height: out_height,
837 pixels,
838 })
839}
840
841#[cfg(test)]
846mod tests {
847 use super::*;
848
849 fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
851 let n = (width * height) as usize;
852 let mut buf = Vec::with_capacity(n * 3);
853 for _ in 0..n {
854 buf.push(r);
855 buf.push(g);
856 buf.push(b);
857 }
858 buf
859 }
860
861 fn gradient_rgb(width: u32, height: u32) -> Vec<u8> {
863 let mut buf = Vec::new();
864 for y in 0..height {
865 for x in 0..width {
866 buf.push(((x * 255) / width.max(1)) as u8);
867 buf.push(((y * 255) / height.max(1)) as u8);
868 buf.push(128u8);
869 }
870 }
871 buf
872 }
873
874 #[test]
875 fn test_gif_header() {
876 let pixels = solid_rgb(4, 4, 200, 10, 10);
877 let gif = gif_encode_rgb(4, 4, &pixels).expect("encode");
878 assert!(
879 gif.starts_with(b"GIF89a"),
880 "GIF must start with GIF89a signature"
881 );
882 }
883
884 #[test]
885 fn test_gif_trailer() {
886 let pixels = solid_rgb(4, 4, 10, 200, 10);
887 let gif = gif_encode_rgb(4, 4, &pixels).expect("encode");
888 assert_eq!(
889 *gif.last().expect("non-empty"),
890 0x3Bu8,
891 "GIF must end with 0x3B trailer"
892 );
893 }
894
895 #[test]
896 fn test_gif_roundtrip_solid_color() {
897 let pixels = solid_rgb(4, 4, 220, 20, 20);
898 let gif = gif_encode_rgb(4, 4, &pixels).expect("encode");
899 let result = gif_decode(&gif).expect("decode");
900 let reddish = result
902 .pixels
903 .chunks_exact(3)
904 .filter(|p| p[0] >= 200 && p[1] <= 50 && p[2] <= 50)
905 .count();
906 let total = result.width * result.height;
907 assert!(
908 reddish * 2 >= total,
909 "expected reddish pixels: got {}/{} reddish",
910 reddish,
911 total
912 );
913 }
914
915 #[test]
916 fn test_gif_roundtrip_size() {
917 let pixels = gradient_rgb(8, 8);
918 let gif = gif_encode_rgb(8, 8, &pixels).expect("encode");
919 let result = gif_decode(&gif).expect("decode");
920 assert_eq!(result.width, 8);
921 assert_eq!(result.height, 8);
922 }
923
924 #[test]
925 fn test_gif_lzw_clear_code() {
926 let pixels = solid_rgb(2, 2, 100, 100, 100);
932 let gif = gif_encode_rgb(2, 2, &pixels).expect("encode");
933
934 let sep_pos = gif
938 .iter()
939 .position(|&b| b == 0x2C)
940 .expect("image separator");
941 let min_code_size_pos = sep_pos + 10;
942 assert!(
943 min_code_size_pos + 2 < gif.len(),
944 "GIF too short for LZW data"
945 );
946 let min_code_size = gif[min_code_size_pos];
947 let clear_code = 1u32 << min_code_size;
948
949 let sub_block_start = min_code_size_pos + 1;
951 assert!(
952 sub_block_start < gif.len(),
953 "No sub-block after min_code_size"
954 );
955 let sub_block_len = gif[sub_block_start] as usize;
956 assert!(sub_block_len > 0, "First sub-block must be non-empty");
957
958 let data_start = sub_block_start + 1;
960 assert!(
961 data_start + sub_block_len <= gif.len(),
962 "Sub-block data truncated"
963 );
964 let lzw_data = &gif[data_start..data_start + sub_block_len];
965
966 let code_width = min_code_size as u32 + 1;
969 let mut bit_buf = 0u32;
970 for (i, &byte) in lzw_data.iter().take(4).enumerate() {
971 bit_buf |= (byte as u32) << (i * 8);
972 }
973 let mask = (1u32 << code_width) - 1;
974 let first_code = bit_buf & mask;
975 assert_eq!(
976 first_code, clear_code,
977 "First LZW code must be the clear code ({}); got {}",
978 clear_code, first_code
979 );
980 }
981
982 #[test]
983 fn test_gif_decode_invalid_returns_error() {
984 let bad = b"NOT_GIF";
985 let result = gif_decode(bad);
986 assert!(result.is_err(), "Expected error for invalid GIF magic");
987 }
988
989 #[test]
990 fn test_gif_decode_empty_returns_error() {
991 let result = gif_decode(&[]);
992 assert!(result.is_err(), "Expected error for empty input");
993 }
994
995 #[test]
996 fn test_gif_encode_dimensions_preserved() {
997 let pixels = gradient_rgb(10, 6);
998 let gif = gif_encode_rgb(10, 6, &pixels).expect("encode");
999 let w = u16::from_le_bytes([gif[6], gif[7]]) as u32;
1001 let h = u16::from_le_bytes([gif[8], gif[9]]) as u32;
1002 assert_eq!(w, 10, "encoded width mismatch");
1003 assert_eq!(h, 6, "encoded height mismatch");
1004 }
1005}