1#![forbid(unsafe_code)]
46#![allow(clippy::cast_possible_truncation)]
47
48use crate::error::CodecError;
49use flate2::{read::ZlibDecoder, write::ZlibEncoder, Compression};
50use std::io::{Read, Write};
51
52fn crc32(data: &[u8]) -> u32 {
57 const POLY: u32 = 0xEDB8_8320;
58 let mut crc: u32 = 0xFFFF_FFFF;
59 for &byte in data {
60 let mut b = u32::from(byte);
61 for _ in 0..8 {
62 if (crc ^ b) & 1 != 0 {
63 crc = (crc >> 1) ^ POLY;
64 } else {
65 crc >>= 1;
66 }
67 b >>= 1;
68 }
69 }
70 !crc
71}
72
73#[derive(Debug, Clone)]
79pub struct ApngConfig {
80 pub loop_count: u32,
82 pub default_delay_num: u16,
84 pub default_delay_den: u16,
86}
87
88impl Default for ApngConfig {
89 fn default() -> Self {
90 Self {
91 loop_count: 0,
92 default_delay_num: 1,
93 default_delay_den: 10,
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct ApngFrame {
101 pub pixels: Vec<u8>,
103 pub width: u32,
105 pub height: u32,
107 pub delay_num: u16,
109 pub delay_den: u16,
111 pub dispose_op: u8,
113 pub blend_op: u8,
115 pub x_offset: u32,
117 pub y_offset: u32,
119}
120
121pub struct ApngEncoder;
127
128impl ApngEncoder {
129 pub fn encode(frames: &[ApngFrame], config: &ApngConfig) -> Result<Vec<u8>, CodecError> {
139 if frames.is_empty() {
140 return Err(CodecError::InvalidParameter(
141 "APNG requires at least one frame".to_string(),
142 ));
143 }
144
145 let canvas_w = frames[0].width;
147 let canvas_h = frames[0].height;
148
149 for (i, frame) in frames.iter().enumerate() {
151 let expected = (frame.width as usize) * (frame.height as usize) * 4;
152 if frame.pixels.len() != expected {
153 return Err(CodecError::InvalidParameter(format!(
154 "frame {i}: expected {expected} bytes ({w}×{h}×4), got {}",
155 frame.pixels.len(),
156 w = frame.width,
157 h = frame.height,
158 )));
159 }
160 }
161
162 let mut out: Vec<u8> = Vec::new();
163
164 out.extend_from_slice(b"\x89PNG\r\n\x1a\n");
166
167 let mut ihdr = [0u8; 13];
169 ihdr[..4].copy_from_slice(&canvas_w.to_be_bytes());
170 ihdr[4..8].copy_from_slice(&canvas_h.to_be_bytes());
171 ihdr[8] = 8; ihdr[9] = 6; write_chunk(&mut out, b"IHDR", &ihdr);
175
176 let mut actl = [0u8; 8];
178 actl[..4].copy_from_slice(&(frames.len() as u32).to_be_bytes());
179 actl[4..].copy_from_slice(&config.loop_count.to_be_bytes());
180 write_chunk(&mut out, b"acTL", &actl);
181
182 let mut seq_num: u32 = 0;
183
184 for (frame_idx, frame) in frames.iter().enumerate() {
185 let mut fctl = [0u8; 26];
187 fctl[..4].copy_from_slice(&seq_num.to_be_bytes());
188 seq_num += 1;
189 fctl[4..8].copy_from_slice(&frame.width.to_be_bytes());
190 fctl[8..12].copy_from_slice(&frame.height.to_be_bytes());
191 fctl[12..16].copy_from_slice(&frame.x_offset.to_be_bytes());
192 fctl[16..20].copy_from_slice(&frame.y_offset.to_be_bytes());
193 fctl[20..22].copy_from_slice(&frame.delay_num.to_be_bytes());
194 fctl[22..24].copy_from_slice(&frame.delay_den.to_be_bytes());
195 fctl[24] = frame.dispose_op;
196 fctl[25] = frame.blend_op;
197 write_chunk(&mut out, b"fcTL", &fctl);
198
199 let compressed =
201 compress_frame(&frame.pixels, frame.width as usize, frame.height as usize)?;
202
203 if frame_idx == 0 {
204 write_chunk(&mut out, b"IDAT", &compressed);
206 } else {
207 let mut fdat = Vec::with_capacity(4 + compressed.len());
209 fdat.extend_from_slice(&seq_num.to_be_bytes());
210 seq_num += 1;
211 fdat.extend_from_slice(&compressed);
212 write_chunk(&mut out, b"fdAT", &fdat);
213 }
214 }
215
216 write_chunk(&mut out, b"IEND", &[]);
218
219 Ok(out)
220 }
221}
222
223pub struct ApngDecoder;
229
230impl ApngDecoder {
231 pub fn decode(data: &[u8]) -> Result<(Vec<ApngFrame>, ApngConfig), CodecError> {
241 check_signature(data)?;
242
243 let chunks = parse_chunks(data)?;
245
246 let ihdr_data = find_chunk_data(&chunks, b"IHDR")
248 .ok_or_else(|| CodecError::InvalidBitstream("APNG: missing IHDR chunk".to_string()))?;
249 if ihdr_data.len() < 13 {
250 return Err(CodecError::InvalidBitstream(
251 "APNG: IHDR too short".to_string(),
252 ));
253 }
254 let canvas_w = u32::from_be_bytes([ihdr_data[0], ihdr_data[1], ihdr_data[2], ihdr_data[3]]);
255 let canvas_h = u32::from_be_bytes([ihdr_data[4], ihdr_data[5], ihdr_data[6], ihdr_data[7]]);
256 let bit_depth = ihdr_data[8];
257 let color_type = ihdr_data[9];
258
259 if bit_depth != 8 || color_type != 6 {
261 return Err(CodecError::UnsupportedFeature(format!(
262 "APNG decoder supports only 8-bit RGBA (got bit_depth={bit_depth}, color_type={color_type})"
263 )));
264 }
265
266 let (loop_count, declared_frame_count) =
268 if let Some(actl) = find_chunk_data(&chunks, b"acTL") {
269 if actl.len() < 8 {
270 return Err(CodecError::InvalidBitstream(
271 "APNG: acTL too short".to_string(),
272 ));
273 }
274 let nf = u32::from_be_bytes([actl[0], actl[1], actl[2], actl[3]]);
275 let lc = u32::from_be_bytes([actl[4], actl[5], actl[6], actl[7]]);
276 (lc, nf)
277 } else {
278 (0u32, 0u32)
279 };
280
281 struct PendingFrame {
285 fctl: FctlInfo,
286 compressed: Vec<u8>,
287 }
288
289 #[derive(Clone)]
290 struct FctlInfo {
291 width: u32,
292 height: u32,
293 x_offset: u32,
294 y_offset: u32,
295 delay_num: u16,
296 delay_den: u16,
297 dispose_op: u8,
298 blend_op: u8,
299 }
300
301 let mut frames_raw: Vec<PendingFrame> = Vec::new();
302 let mut current_fctl: Option<FctlInfo> = None;
303 let mut idat_consumed = false;
304
305 for (ctype, cdata) in &chunks {
306 match ctype.as_slice() {
307 b"fcTL" => {
308 if let Some(fctl) = current_fctl.take() {
310 frames_raw.push(PendingFrame {
313 fctl,
314 compressed: Vec::new(),
315 });
316 }
317 if cdata.len() < 26 {
318 return Err(CodecError::InvalidBitstream(
319 "APNG: fcTL too short".to_string(),
320 ));
321 }
322 let fw = u32::from_be_bytes([cdata[4], cdata[5], cdata[6], cdata[7]]);
323 let fh = u32::from_be_bytes([cdata[8], cdata[9], cdata[10], cdata[11]]);
324 let fx = u32::from_be_bytes([cdata[12], cdata[13], cdata[14], cdata[15]]);
325 let fy = u32::from_be_bytes([cdata[16], cdata[17], cdata[18], cdata[19]]);
326 let dn = u16::from_be_bytes([cdata[20], cdata[21]]);
327 let dd = u16::from_be_bytes([cdata[22], cdata[23]]);
328 current_fctl = Some(FctlInfo {
329 width: fw,
330 height: fh,
331 x_offset: fx,
332 y_offset: fy,
333 delay_num: dn,
334 delay_den: dd,
335 dispose_op: cdata[24],
336 blend_op: cdata[25],
337 });
338 }
339 b"IDAT" => {
340 if idat_consumed {
341 if let Some(last) = frames_raw.last_mut() {
343 last.compressed.extend_from_slice(cdata);
344 }
345 continue;
346 }
347 idat_consumed = true;
348 if let Some(fctl) = current_fctl.take() {
349 let mut pending = PendingFrame {
350 fctl,
351 compressed: Vec::new(),
352 };
353 pending.compressed.extend_from_slice(cdata);
354 frames_raw.push(pending);
355 } else {
356 let fctl = FctlInfo {
358 width: canvas_w,
359 height: canvas_h,
360 x_offset: 0,
361 y_offset: 0,
362 delay_num: 1,
363 delay_den: 10,
364 dispose_op: 0,
365 blend_op: 0,
366 };
367 let mut pending = PendingFrame {
368 fctl,
369 compressed: Vec::new(),
370 };
371 pending.compressed.extend_from_slice(cdata);
372 frames_raw.push(pending);
373 }
374 }
375 b"fdAT" => {
376 if cdata.len() < 4 {
378 return Err(CodecError::InvalidBitstream(
379 "APNG: fdAT too short".to_string(),
380 ));
381 }
382 let payload = &cdata[4..];
383 if let Some(fctl) = current_fctl.take() {
384 let mut pending = PendingFrame {
385 fctl,
386 compressed: Vec::new(),
387 };
388 pending.compressed.extend_from_slice(payload);
389 frames_raw.push(pending);
390 } else if let Some(last) = frames_raw.last_mut() {
391 last.compressed.extend_from_slice(payload);
393 }
394 }
395 _ => {}
396 }
397 }
398
399 if let Some(fctl) = current_fctl.take() {
401 frames_raw.push(PendingFrame {
402 fctl,
403 compressed: Vec::new(),
404 });
405 }
406
407 let mut out_frames: Vec<ApngFrame> = Vec::with_capacity(frames_raw.len());
409 for pf in frames_raw {
410 let w = pf.fctl.width as usize;
411 let h = pf.fctl.height as usize;
412 let pixels = if pf.compressed.is_empty() {
413 vec![0u8; w * h * 4]
415 } else {
416 decompress_rgba(&pf.compressed, w, h)?
417 };
418 out_frames.push(ApngFrame {
419 pixels,
420 width: pf.fctl.width,
421 height: pf.fctl.height,
422 delay_num: pf.fctl.delay_num,
423 delay_den: pf.fctl.delay_den,
424 dispose_op: pf.fctl.dispose_op,
425 blend_op: pf.fctl.blend_op,
426 x_offset: pf.fctl.x_offset,
427 y_offset: pf.fctl.y_offset,
428 });
429 }
430
431 let config = ApngConfig {
432 loop_count,
433 default_delay_num: if out_frames.is_empty() {
434 1
435 } else {
436 out_frames[0].delay_num
437 },
438 default_delay_den: if out_frames.is_empty() {
439 10
440 } else {
441 out_frames[0].delay_den
442 },
443 };
444
445 let _ = declared_frame_count;
447
448 Ok((out_frames, config))
449 }
450
451 pub fn frame_count(data: &[u8]) -> Result<u32, CodecError> {
460 check_signature(data)?;
461 let mut pos = 8usize;
462 while pos + 8 <= data.len() {
463 let chunk_len =
464 u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
465 as usize;
466 let chunk_type = &data[pos + 4..pos + 8];
467 let data_start = pos + 8;
468 let data_end = data_start + chunk_len;
469 if data_end + 4 > data.len() {
470 return Err(CodecError::InvalidBitstream(
471 "APNG: truncated chunk while scanning for acTL".to_string(),
472 ));
473 }
474 if chunk_type == b"acTL" && chunk_len >= 8 {
475 let fc = u32::from_be_bytes([
476 data[data_start],
477 data[data_start + 1],
478 data[data_start + 2],
479 data[data_start + 3],
480 ]);
481 return Ok(fc);
482 }
483 if chunk_type == b"IEND" {
484 break;
485 }
486 pos = data_end + 4;
487 }
488 Ok(1)
490 }
491
492 #[must_use]
494 pub fn is_apng(data: &[u8]) -> bool {
495 if data.len() < 8 || &data[..8] != b"\x89PNG\r\n\x1a\n" {
496 return false;
497 }
498 let mut pos = 8usize;
499 while pos + 8 <= data.len() {
500 let chunk_len =
501 u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
502 as usize;
503 let chunk_type = &data[pos + 4..pos + 8];
504 if chunk_type == b"acTL" {
505 return true;
506 }
507 if chunk_type == b"IEND" {
508 break;
509 }
510 let data_end = pos + 8 + chunk_len;
511 pos = data_end + 4; }
513 false
514 }
515}
516
517fn check_signature(data: &[u8]) -> Result<(), CodecError> {
522 if data.len() < 8 || &data[..8] != b"\x89PNG\r\n\x1a\n" {
523 return Err(CodecError::InvalidBitstream(
524 "Not a PNG file (bad signature)".to_string(),
525 ));
526 }
527 Ok(())
528}
529
530type Chunk = ([u8; 4], Vec<u8>);
532
533fn parse_chunks(data: &[u8]) -> Result<Vec<Chunk>, CodecError> {
534 let mut chunks = Vec::new();
535 let mut pos = 8usize; while pos + 8 <= data.len() {
537 let chunk_len =
538 u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
539 let mut ctype = [0u8; 4];
540 ctype.copy_from_slice(&data[pos + 4..pos + 8]);
541 let data_start = pos + 8;
542 let data_end = data_start + chunk_len;
543 if data_end + 4 > data.len() {
544 return Err(CodecError::InvalidBitstream(format!(
545 "APNG: chunk '{}' is truncated",
546 String::from_utf8_lossy(&ctype)
547 )));
548 }
549 let cdata = data[data_start..data_end].to_vec();
550 let is_iend = &ctype == b"IEND";
551 chunks.push((ctype, cdata));
552 pos = data_end + 4; if is_iend {
554 break;
555 }
556 }
557 Ok(chunks)
558}
559
560fn find_chunk_data<'a>(chunks: &'a [Chunk], ctype: &[u8; 4]) -> Option<&'a [u8]> {
561 chunks
562 .iter()
563 .find(|(t, _)| t == ctype)
564 .map(|(_, d)| d.as_slice())
565}
566
567fn compress_frame(rgba: &[u8], width: usize, height: usize) -> Result<Vec<u8>, CodecError> {
569 let row_bytes = width * 4;
570 let mut filtered: Vec<u8> = Vec::with_capacity((row_bytes + 1) * height);
571 for row in 0..height {
572 filtered.push(1); let base = row * row_bytes;
574 for col in 0..row_bytes {
575 let pixel = rgba[base + col];
576 let prev = if col >= 4 { rgba[base + col - 4] } else { 0 };
577 filtered.push(pixel.wrapping_sub(prev));
578 }
579 }
580 let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
581 enc.write_all(&filtered).map_err(CodecError::Io)?;
582 enc.finish().map_err(CodecError::Io)
583}
584
585fn decompress_rgba(compressed: &[u8], width: usize, height: usize) -> Result<Vec<u8>, CodecError> {
587 let row_stride = width * 4; let expected_filtered = (row_stride + 1) * height;
590 let mut filtered = Vec::with_capacity(expected_filtered);
591 let mut decoder = ZlibDecoder::new(compressed);
592 decoder
593 .read_to_end(&mut filtered)
594 .map_err(|e| CodecError::InvalidBitstream(format!("APNG inflate error: {e}")))?;
595
596 if filtered.len() < (row_stride + 1) * height {
598 return Err(CodecError::InvalidBitstream(format!(
599 "APNG: decompressed data too short: got {} bytes, need {}",
600 filtered.len(),
601 (row_stride + 1) * height
602 )));
603 }
604
605 let mut pixels = vec![0u8; width * height * 4];
606
607 for row in 0..height {
608 let src_row_start = row * (row_stride + 1);
609 let filter_type = filtered[src_row_start];
610 let src = &filtered[src_row_start + 1..src_row_start + 1 + row_stride];
611 let dst_start = row * row_stride;
612
613 let prev_row: Vec<u8> = if row > 0 {
616 pixels[(row - 1) * row_stride..row * row_stride].to_vec()
617 } else {
618 vec![0u8; row_stride]
619 };
620
621 let dst = &mut pixels[dst_start..dst_start + row_stride];
622
623 match filter_type {
624 0 => {
625 dst.copy_from_slice(src);
627 }
628 1 => {
629 for i in 0..row_stride {
631 let a = if i >= 4 { dst[i - 4] } else { 0 };
632 dst[i] = src[i].wrapping_add(a);
633 }
634 }
635 2 => {
636 for i in 0..row_stride {
638 dst[i] = src[i].wrapping_add(prev_row[i]);
639 }
640 }
641 3 => {
642 for i in 0..row_stride {
644 let a = if i >= 4 { dst[i - 4] } else { 0 };
645 let b = prev_row[i];
646 dst[i] = src[i].wrapping_add(((u16::from(a) + u16::from(b)) / 2) as u8);
647 }
648 }
649 4 => {
650 for i in 0..row_stride {
652 let a = if i >= 4 { dst[i - 4] } else { 0 };
653 let b = prev_row[i];
654 let c = if i >= 4 { prev_row[i - 4] } else { 0 };
655 dst[i] = src[i].wrapping_add(paeth_predictor(a, b, c));
656 }
657 }
658 ft => {
659 return Err(CodecError::InvalidBitstream(format!(
660 "APNG: unknown PNG filter type {ft} on row {row}"
661 )));
662 }
663 }
664 }
665
666 Ok(pixels)
667}
668
669#[inline]
671fn paeth_predictor(a: u8, b: u8, c: u8) -> u8 {
672 let ia = i32::from(a);
673 let ib = i32::from(b);
674 let ic = i32::from(c);
675 let p = ia + ib - ic;
676 let pa = (p - ia).abs();
677 let pb = (p - ib).abs();
678 let pc = (p - ic).abs();
679 if pa <= pb && pa <= pc {
680 a
681 } else if pb <= pc {
682 b
683 } else {
684 c
685 }
686}
687
688fn write_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
690 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
691 out.extend_from_slice(chunk_type);
692 out.extend_from_slice(data);
693 let mut crc_input = Vec::with_capacity(4 + data.len());
694 crc_input.extend_from_slice(chunk_type);
695 crc_input.extend_from_slice(data);
696 out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
697}
698
699#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn rgba_frame(w: u32, h: u32, fill: u8) -> ApngFrame {
708 ApngFrame {
709 pixels: vec![fill; (w * h * 4) as usize],
710 width: w,
711 height: h,
712 delay_num: 1,
713 delay_den: 10,
714 dispose_op: 0,
715 blend_op: 0,
716 x_offset: 0,
717 y_offset: 0,
718 }
719 }
720
721 fn default_config() -> ApngConfig {
722 ApngConfig::default()
723 }
724
725 #[test]
728 fn test_encode_png_signature() {
729 let frame = rgba_frame(4, 4, 128);
730 let data = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
731 assert!(
732 data.starts_with(b"\x89PNG\r\n\x1a\n"),
733 "Must start with PNG signature"
734 );
735 }
736
737 #[test]
738 fn test_encode_contains_actl() {
739 let frames: Vec<_> = (0..3).map(|i| rgba_frame(8, 8, i * 50)).collect();
740 let data = ApngEncoder::encode(&frames, &default_config()).expect("encode");
741 assert!(data.windows(4).any(|w| w == b"acTL"), "Must contain acTL");
742 }
743
744 #[test]
745 fn test_encode_empty_frames_errors() {
746 let result = ApngEncoder::encode(&[], &default_config());
747 assert!(result.is_err());
748 }
749
750 #[test]
751 fn test_encode_wrong_pixel_size_errors() {
752 let bad = ApngFrame {
753 pixels: vec![0u8; 10], width: 4,
755 height: 4,
756 delay_num: 1,
757 delay_den: 10,
758 dispose_op: 0,
759 blend_op: 0,
760 x_offset: 0,
761 y_offset: 0,
762 };
763 let result = ApngEncoder::encode(&[bad], &default_config());
764 assert!(result.is_err());
765 }
766
767 #[test]
768 fn test_encode_first_frame_idat() {
769 let frame = rgba_frame(4, 4, 200);
770 let data = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
771 assert!(
772 data.windows(4).any(|w| w == b"IDAT"),
773 "First frame must use IDAT"
774 );
775 }
776
777 #[test]
778 fn test_encode_second_frame_fdat() {
779 let frames = vec![rgba_frame(4, 4, 100), rgba_frame(4, 4, 200)];
780 let data = ApngEncoder::encode(&frames, &default_config()).expect("encode");
781 assert!(
782 data.windows(4).any(|w| w == b"fdAT"),
783 "Frame 2+ must use fdAT"
784 );
785 }
786
787 #[test]
788 fn test_encode_fctl_count_matches_frame_count() {
789 let frames: Vec<_> = (0..5).map(|i| rgba_frame(4, 4, i * 40)).collect();
790 let data = ApngEncoder::encode(&frames, &default_config()).expect("encode");
791 let fctl_count = data.windows(4).filter(|w| *w == b"fcTL").count();
792 assert_eq!(fctl_count, 5, "One fcTL per frame");
793 }
794
795 #[test]
796 fn test_encode_ends_with_iend() {
797 let frame = rgba_frame(4, 4, 0);
798 let data = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
799 let iend_pos = data.len().saturating_sub(12);
801 assert_eq!(&data[iend_pos + 4..iend_pos + 8], b"IEND");
802 }
803
804 #[test]
807 fn test_is_apng_true_for_encoded() {
808 let frame = rgba_frame(4, 4, 0);
809 let data = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
810 assert!(ApngDecoder::is_apng(&data));
811 }
812
813 #[test]
814 fn test_is_apng_false_for_random() {
815 assert!(!ApngDecoder::is_apng(b"this is not a PNG"));
816 }
817
818 #[test]
821 fn test_frame_count_single() {
822 let frame = rgba_frame(4, 4, 50);
823 let data = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
824 let count = ApngDecoder::frame_count(&data).expect("frame_count");
825 assert_eq!(count, 1);
826 }
827
828 #[test]
829 fn test_frame_count_multi() {
830 let frames: Vec<_> = (0..7).map(|i| rgba_frame(4, 4, i * 30)).collect();
831 let data = ApngEncoder::encode(&frames, &default_config()).expect("encode");
832 let count = ApngDecoder::frame_count(&data).expect("frame_count");
833 assert_eq!(count, 7);
834 }
835
836 #[test]
837 fn test_frame_count_bad_signature_errors() {
838 let result = ApngDecoder::frame_count(b"not a png");
839 assert!(result.is_err());
840 }
841
842 #[test]
845 fn test_decode_single_frame_roundtrip() {
846 let original = rgba_frame(4, 4, 123);
847 let encoded = ApngEncoder::encode(&[original.clone()], &default_config()).expect("encode");
848 let (frames, _config) = ApngDecoder::decode(&encoded).expect("decode");
849 assert_eq!(frames.len(), 1);
850 assert_eq!(frames[0].width, 4);
851 assert_eq!(frames[0].height, 4);
852 assert_eq!(frames[0].pixels, original.pixels);
853 }
854
855 #[test]
856 fn test_decode_multi_frame_roundtrip() {
857 let originals: Vec<_> = (0..3).map(|i| rgba_frame(8, 6, i * 80)).collect();
858 let encoded = ApngEncoder::encode(&originals, &default_config()).expect("encode");
859 let (frames, _config) = ApngDecoder::decode(&encoded).expect("decode");
860 assert_eq!(frames.len(), 3);
861 for (i, (original, decoded)) in originals.iter().zip(frames.iter()).enumerate() {
862 assert_eq!(decoded.pixels, original.pixels, "frame {i} pixel mismatch");
863 }
864 }
865
866 #[test]
867 fn test_decode_loop_count_preserved() {
868 let config = ApngConfig {
869 loop_count: 5,
870 default_delay_num: 1,
871 default_delay_den: 25,
872 };
873 let frame = rgba_frame(4, 4, 0);
874 let encoded = ApngEncoder::encode(&[frame], &config).expect("encode");
875 let (_frames, out_config) = ApngDecoder::decode(&encoded).expect("decode");
876 assert_eq!(out_config.loop_count, 5);
877 }
878
879 #[test]
880 fn test_decode_frame_timing_preserved() {
881 let mut frame = rgba_frame(4, 4, 0);
882 frame.delay_num = 3;
883 frame.delay_den = 25;
884 let encoded = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
885 let (frames, _config) = ApngDecoder::decode(&encoded).expect("decode");
886 assert_eq!(frames[0].delay_num, 3);
887 assert_eq!(frames[0].delay_den, 25);
888 }
889
890 #[test]
891 fn test_decode_frame_offsets_preserved() {
892 let mut frame = rgba_frame(4, 4, 0);
893 frame.x_offset = 10;
894 frame.y_offset = 20;
895 let encoded = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
896 let (frames, _config) = ApngDecoder::decode(&encoded).expect("decode");
897 assert_eq!(frames[0].x_offset, 10);
898 assert_eq!(frames[0].y_offset, 20);
899 }
900
901 #[test]
902 fn test_decode_bad_signature_errors() {
903 let result = ApngDecoder::decode(b"garbage data");
904 assert!(result.is_err());
905 }
906
907 #[test]
908 fn test_decode_dispose_blend_ops_preserved() {
909 let mut frame = rgba_frame(4, 4, 0);
910 frame.dispose_op = 1;
911 frame.blend_op = 1;
912 let encoded = ApngEncoder::encode(&[frame], &default_config()).expect("encode");
913 let (frames, _config) = ApngDecoder::decode(&encoded).expect("decode");
914 assert_eq!(frames[0].dispose_op, 1);
915 assert_eq!(frames[0].blend_op, 1);
916 }
917
918 #[test]
919 fn test_crc32_known_value() {
920 let crc = crc32(b"IEND");
924 assert_eq!(crc, 0xAE42_6082, "CRC of 'IEND' must match PNG spec");
925 }
926
927 #[test]
928 fn test_large_frame_roundtrip() {
929 let frame = rgba_frame(64, 48, 200);
930 let encoded = ApngEncoder::encode(&[frame.clone()], &default_config()).expect("encode");
931 let (frames, _) = ApngDecoder::decode(&encoded).expect("decode");
932 assert_eq!(frames[0].pixels, frame.pixels);
933 }
934}