Skip to main content

oximedia_codec/apng/
mod.rs

1//! APNG (Animated Portable Network Graphics) top-level encoder and decoder.
2//!
3//! APNG extends PNG with animation chunks (`acTL`, `fcTL`, `fdAT`) as specified in
4//! the APNG specification (<https://wiki.mozilla.org/APNG_Spec>).
5//!
6//! # Container layout
7//!
8//! ```text
9//! PNG signature  (8 bytes)
10//! IHDR           canvas width, height, bit_depth=8, color_type=6 (RGBA)
11//! acTL           num_frames, num_plays (loop_count)
12//! — per frame —
13//!   fcTL         sequence_number, w, h, x_off, y_off, delay_num, delay_den, dispose, blend
14//!   IDAT / fdAT  compressed scanline data (IDAT for frame 0, fdAT for the rest)
15//! IEND
16//! ```
17//!
18//! # Example
19//!
20//! ```rust
21//! use oximedia_codec::apng::{ApngEncoder, ApngDecoder, ApngFrame, ApngConfig};
22//!
23//! let config = ApngConfig {
24//!     loop_count: 0,
25//!     default_delay_num: 1,
26//!     default_delay_den: 10,
27//! };
28//!
29//! let frame = ApngFrame {
30//!     pixels: vec![128u8; 4 * 4 * 4],
31//!     width: 4,
32//!     height: 4,
33//!     delay_num: 1,
34//!     delay_den: 10,
35//!     dispose_op: 0,
36//!     blend_op: 0,
37//!     x_offset: 0,
38//!     y_offset: 0,
39//! };
40//!
41//! let encoded = ApngEncoder::encode(&[frame], &config).expect("encode failed");
42//! assert!(encoded.starts_with(b"\x89PNG"));
43//! ```
44
45#![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
52// =============================================================================
53// CRC-32  (ISO 3309 — same polynomial as used in PNG spec)
54// =============================================================================
55
56fn 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// =============================================================================
74// Public types
75// =============================================================================
76
77/// Global configuration for an APNG animation.
78#[derive(Debug, Clone)]
79pub struct ApngConfig {
80    /// Number of times to loop the animation (0 = infinite).
81    pub loop_count: u32,
82    /// Default frame delay numerator used when constructing frames.
83    pub default_delay_num: u16,
84    /// Default frame delay denominator (e.g. 100 → centiseconds).
85    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/// A single frame of an APNG animation.
99#[derive(Debug, Clone)]
100pub struct ApngFrame {
101    /// Raw RGBA pixel data: `width × height × 4` bytes.
102    pub pixels: Vec<u8>,
103    /// Frame width in pixels.
104    pub width: u32,
105    /// Frame height in pixels.
106    pub height: u32,
107    /// Frame delay numerator.
108    pub delay_num: u16,
109    /// Frame delay denominator (fps = delay_den / delay_num; 0 treated as 100).
110    pub delay_den: u16,
111    /// Disposal operation: 0 = none, 1 = clear to background, 2 = revert to previous.
112    pub dispose_op: u8,
113    /// Blend operation: 0 = source (overwrite), 1 = over (alpha composite).
114    pub blend_op: u8,
115    /// X offset of this frame on the canvas.
116    pub x_offset: u32,
117    /// Y offset of this frame on the canvas.
118    pub y_offset: u32,
119}
120
121// =============================================================================
122// Encoder
123// =============================================================================
124
125/// APNG encoder — encodes a sequence of [`ApngFrame`]s into an APNG byte stream.
126pub struct ApngEncoder;
127
128impl ApngEncoder {
129    /// Encode `frames` into an APNG byte stream.
130    ///
131    /// The canvas dimensions are taken from the first frame.  All frames must
132    /// have a `pixels` buffer of exactly `width × height × 4` bytes.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`CodecError`] if `frames` is empty, any pixel buffer has the
137    /// wrong size, or DEFLATE compression fails.
138    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        // Canvas dimensions come from the first frame.
146        let canvas_w = frames[0].width;
147        let canvas_h = frames[0].height;
148
149        // Validate all pixel buffers up-front.
150        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        // PNG signature
165        out.extend_from_slice(b"\x89PNG\r\n\x1a\n");
166
167        // IHDR
168        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; // bit depth
172        ihdr[9] = 6; // colour type: RGBA
173                     // compression, filter, interlace all 0
174        write_chunk(&mut out, b"IHDR", &ihdr);
175
176        // acTL — animation control
177        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            // fcTL — frame control  (26 bytes of data)
186            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            // Compress pixel data.
200            let compressed =
201                compress_frame(&frame.pixels, frame.width as usize, frame.height as usize)?;
202
203            if frame_idx == 0 {
204                // First frame: standard IDAT so non-APNG decoders show it.
205                write_chunk(&mut out, b"IDAT", &compressed);
206            } else {
207                // Subsequent frames: fdAT prefixed with sequence number.
208                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        // IEND
217        write_chunk(&mut out, b"IEND", &[]);
218
219        Ok(out)
220    }
221}
222
223// =============================================================================
224// Decoder
225// =============================================================================
226
227/// APNG decoder — parses an APNG byte stream and reconstructs [`ApngFrame`]s.
228pub struct ApngDecoder;
229
230impl ApngDecoder {
231    /// Decode a raw APNG byte stream.
232    ///
233    /// Returns the list of frames (with decompressed RGBA pixels) and the
234    /// global [`ApngConfig`].
235    ///
236    /// # Errors
237    ///
238    /// Returns [`CodecError`] if the PNG signature is missing, any chunk is
239    /// truncated, or pixel decompression fails.
240    pub fn decode(data: &[u8]) -> Result<(Vec<ApngFrame>, ApngConfig), CodecError> {
241        check_signature(data)?;
242
243        // ── Pass 1: parse all chunks ─────────────────────────────────────────
244        let chunks = parse_chunks(data)?;
245
246        // ── Extract IHDR ─────────────────────────────────────────────────────
247        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        // We only handle 8-bit RGBA for now.
260        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        // ── Extract acTL ─────────────────────────────────────────────────────
267        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        // ── Collect fcTL + IDAT/fdAT pairs ───────────────────────────────────
282        // Walk the chunk list in order, keeping a pending fcTL and accumulating
283        // compressed data per frame.
284        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 there's an active frame being built, finalise it.
309                    if let Some(fctl) = current_fctl.take() {
310                        // The previous fcTL had no data yet (shouldn't happen in
311                        // well-formed APNG, but be defensive).
312                        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                        // Append to last frame's compressed data (split IDAT).
342                        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                        // IDAT before any fcTL — treat canvas as default frame 0.
357                        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                    // fdAT: 4-byte sequence number prefix then compressed data.
377                    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                        // Continuation fdAT for current frame.
392                        last.compressed.extend_from_slice(payload);
393                    }
394                }
395                _ => {}
396            }
397        }
398
399        // Flush any trailing pending fcTL with no data.
400        if let Some(fctl) = current_fctl.take() {
401            frames_raw.push(PendingFrame {
402                fctl,
403                compressed: Vec::new(),
404            });
405        }
406
407        // ── Decompress + defilter each frame ─────────────────────────────────
408        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                // No data: return transparent black.
414                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        // Sanity: warn-or-ignore mismatched declared_frame_count (not an error).
446        let _ = declared_frame_count;
447
448        Ok((out_frames, config))
449    }
450
451    /// Quick probe: returns the number of frames declared in the `acTL` chunk.
452    ///
453    /// Does not decompress pixel data.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`CodecError`] if the PNG signature is invalid or the file is
458    /// truncated.
459    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        // No acTL found — not animated; treat as 1-frame static PNG.
489        Ok(1)
490    }
491
492    /// Returns `true` if `data` is an APNG (valid PNG signature + `acTL` chunk).
493    #[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; // skip CRC
512        }
513        false
514    }
515}
516
517// =============================================================================
518// Internal helpers
519// =============================================================================
520
521fn 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
530/// Parsed chunk: (4-byte type, data bytes).
531type 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; // skip signature
536    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; // skip 4-byte CRC
553        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
567/// Apply PNG Sub filter + DEFLATE-compress an RGBA frame.
568fn 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); // Sub filter
573        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
585/// DEFLATE-decompress a zlib stream and PNG-defilter to get RGBA pixels.
586fn decompress_rgba(compressed: &[u8], width: usize, height: usize) -> Result<Vec<u8>, CodecError> {
587    // Inflate.
588    let row_stride = width * 4; // bytes per row (no filter byte here)
589    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    // Each row is: 1 filter byte + row_stride data bytes.
597    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        // Copy the previous row into a local buffer so we can hold a mutable
614        // borrow on `pixels[dst_start..]` while reading from the prior row.
615        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                // None
626                dst.copy_from_slice(src);
627            }
628            1 => {
629                // Sub: Recon(x) = Filt(x) + Recon(a)
630                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                // Up: Recon(x) = Filt(x) + Recon(b)
637                for i in 0..row_stride {
638                    dst[i] = src[i].wrapping_add(prev_row[i]);
639                }
640            }
641            3 => {
642                // Average: Recon(x) = Filt(x) + floor((Recon(a)+Recon(b))/2)
643                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                // Paeth
651                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/// PNG Paeth predictor function (spec §9.4).
670#[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
688/// Serialise one PNG chunk: `length(4) ++ type(4) ++ data ++ crc(4)`.
689fn 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// =============================================================================
700// Tests
701// =============================================================================
702
703#[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    // ── Encoder tests ──────────────────────────────────────────────────────────
726
727    #[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], // wrong
754            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        // IEND chunk = length(0) + "IEND" + crc
800        let iend_pos = data.len().saturating_sub(12);
801        assert_eq!(&data[iend_pos + 4..iend_pos + 8], b"IEND");
802    }
803
804    // ── is_apng ───────────────────────────────────────────────────────────────
805
806    #[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    // ── frame_count ───────────────────────────────────────────────────────────
819
820    #[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    // ── decode (full roundtrip) ───────────────────────────────────────────────
843
844    #[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        // CRC32 of b"IHDR" = 0x4E4D4C4B (not the real value, we just check consistency)
921        // Instead verify that our CRC matches what PNG spec requires for a known chunk.
922        // The CRC of "IEND" (type) + "" (no data) = 0xAE426082
923        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}