diff --git a/src/apng.rs b/src/apng.rs new file mode 100644 index 00000000..8b99d0f1 --- /dev/null +++ b/src/apng.rs @@ -0,0 +1,75 @@ +use std::io::Write; + +use crate::{ + error::PngError, + headers::{read_be_u16, read_be_u32}, + PngResult, +}; + +#[derive(Debug, Clone)] +/// Animated PNG frame +pub struct Frame { + /// Width of the frame + pub width: u32, + /// Height of the frame + pub height: u32, + /// X offset of the frame + pub x_offset: u32, + /// Y offset of the frame + pub y_offset: u32, + /// Frame delay numerator + pub delay_num: u16, + /// Frame delay denominator + pub delay_den: u16, + /// Frame disposal operation + pub dispose_op: u8, + /// Frame blend operation + pub blend_op: u8, + /// Frame data, from fdAT chunks + pub data: Vec, +} + +impl Frame { + /// Construct a new Frame from the data in a fcTL chunk + pub fn from_fctl_data(byte_data: &[u8]) -> PngResult { + if byte_data.len() < 26 { + return Err(PngError::TruncatedData); + } + Ok(Frame { + width: read_be_u32(&byte_data[4..8]), + height: read_be_u32(&byte_data[8..12]), + x_offset: read_be_u32(&byte_data[12..16]), + y_offset: read_be_u32(&byte_data[16..20]), + delay_num: read_be_u16(&byte_data[20..22]), + delay_den: read_be_u16(&byte_data[22..24]), + dispose_op: byte_data[24], + blend_op: byte_data[25], + data: vec![], + }) + } + + /// Construct the data for a fcTL chunk using the given sequence number + #[must_use] + pub fn fctl_data(&self, sequence_number: u32) -> Vec { + let mut byte_data = Vec::with_capacity(26); + byte_data.write_all(&sequence_number.to_be_bytes()).unwrap(); + byte_data.write_all(&self.width.to_be_bytes()).unwrap(); + byte_data.write_all(&self.height.to_be_bytes()).unwrap(); + byte_data.write_all(&self.x_offset.to_be_bytes()).unwrap(); + byte_data.write_all(&self.y_offset.to_be_bytes()).unwrap(); + byte_data.write_all(&self.delay_num.to_be_bytes()).unwrap(); + byte_data.write_all(&self.delay_den.to_be_bytes()).unwrap(); + byte_data.push(self.dispose_op); + byte_data.push(self.blend_op); + byte_data + } + + /// Construct the data for a fdAT chunk using the given sequence number + #[must_use] + pub fn fdat_data(&self, sequence_number: u32) -> Vec { + let mut byte_data = Vec::with_capacity(4 + self.data.len()); + byte_data.write_all(&sequence_number.to_be_bytes()).unwrap(); + byte_data.write_all(&self.data).unwrap(); + byte_data + } +} diff --git a/src/error.rs b/src/error.rs index b2079d1b..2c7d88a9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,7 @@ pub enum PngError { TimedOut, NotPNG, APNGNotSupported, + APNGOutOfOrder, InvalidData, TruncatedData, ChunkMissing(&'static str), @@ -33,6 +34,7 @@ impl fmt::Display for PngError { f.write_str("Missing data in the file; the file is truncated") } PngError::APNGNotSupported => f.write_str("APNG files are not (yet) supported"), + PngError::APNGOutOfOrder => f.write_str("APNG chunks are out of order"), PngError::ChunkMissing(s) => write!(f, "Chunk {s} missing or empty"), PngError::InvalidDepthForType(d, ref c) => { write!(f, "Invalid bit depth {d} for color type {c}") diff --git a/src/lib.rs b/src/lib.rs index 90944618..1efc8251 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,7 @@ pub use crate::{ options::{InFile, Options, OutFile}, }; +mod apng; mod atomicmin; mod colors; mod deflate; @@ -530,6 +531,7 @@ fn optimize_raw( raw: png, idat_data, aux_chunks: Vec::new(), + frames: Vec::new(), }; if image.estimated_output_size() < max_size.unwrap_or(usize::MAX) { debug!("Found better combination:"); @@ -549,6 +551,7 @@ fn optimize_raw( raw: result.image, idat_data: result.idat_data, aux_chunks: Vec::new(), + frames: Vec::new(), }; if image.estimated_output_size() < max_size.unwrap_or(usize::MAX) { debug!("Found better combination:"); diff --git a/src/png/mod.rs b/src/png/mod.rs index 9d98c63a..9f856f94 100644 --- a/src/png/mod.rs +++ b/src/png/mod.rs @@ -12,6 +12,7 @@ use rgb::ComponentSlice; use rustc_hash::FxHashMap; use crate::{ + apng::*, colors::{BitDepth, ColorType}, deflate, error::PngError, @@ -47,6 +48,8 @@ pub struct PngData { pub idat_data: Vec, /// All non-critical chunks from the PNG are stored here pub aux_chunks: Vec, + /// APNG frames + pub frames: Vec, } impl PngData { @@ -97,6 +100,8 @@ impl PngData { let mut idat_data: Vec = Vec::new(); let mut key_chunks: FxHashMap<[u8; 4], Vec> = FxHashMap::default(); let mut aux_chunks: Vec = Vec::new(); + let mut frames: Vec = Vec::new(); + let mut sequence_number = 0; while let Some(chunk) = parse_next_chunk(byte_data, &mut byte_offset, opts.fix_errors)? { match &chunk.name { b"IDAT" => { @@ -112,27 +117,46 @@ impl PngData { b"IHDR" | b"PLTE" | b"tRNS" => { key_chunks.insert(chunk.name, chunk.data.to_owned()); } - _ => { - if opts.strip.keep(&chunk.name) { - if chunk.is_c2pa() { - // StripChunks::None is the default value, so to keep optimizing by default, - // interpret it as stripping the C2PA metadata. - // The C2PA metadata is invalidated if the file changes, so it shouldn't be kept. - if opts.strip == StripChunks::None { - continue; - } - return Err(PngError::C2PAMetadataPreventsChanges); + _ if opts.strip.keep(&chunk.name) => { + if chunk.is_c2pa() { + // StripChunks::None is the default value, so to keep optimizing by default, + // interpret it as stripping the C2PA metadata. + // The C2PA metadata is invalidated if the file changes, so it shouldn't be kept. + if opts.strip == StripChunks::None { + continue; } - aux_chunks.push(Chunk { - name: chunk.name, - data: chunk.data.to_owned(), - }); - } else if chunk.name == *b"acTL" { - warn!( - "Stripping animation data from APNG - image will become standard PNG" - ); + return Err(PngError::C2PAMetadataPreventsChanges); } + if chunk.name == *b"fcTL" || chunk.name == *b"fdAT" { + // Validate the sequence number + if read_be_u32(&chunk.data[0..4]) != sequence_number { + return Err(PngError::APNGOutOfOrder); + } + sequence_number += 1; + if chunk.name == *b"fcTL" && !idat_data.is_empty() { + // Only create a Frame if it's after the IDAT (else store it as an aux chunk) + frames.push(Frame::from_fctl_data(chunk.data)?); + continue; + } else if chunk.name == *b"fdAT" { + // Append the data to the last frame + frames + .last_mut() + .ok_or(PngError::APNGOutOfOrder)? + .data + .extend_from_slice(&chunk.data[4..]); + continue; + } + } + // Regular ancillary chunk + aux_chunks.push(Chunk { + name: chunk.name, + data: chunk.data.to_owned(), + }); } + b"acTL" => { + warn!("Stripping animation data from APNG - image will become standard PNG") + } + _ => (), } } @@ -166,6 +190,7 @@ impl PngData { idat_data, raw: Arc::new(raw), aux_chunks, + frames, }) } @@ -235,14 +260,24 @@ impl PngData { _ => {} } // Special ancillary chunks that need to come after PLTE but before IDAT + let mut sequence_number = 0; for chunk in aux_pre .iter() .filter(|c| matches!(&c.name, b"bKGD" | b"hIST" | b"tRNS" | b"fcTL")) { write_png_block(&chunk.name, &chunk.data, &mut output); + if &chunk.name == b"fcTL" { + sequence_number += 1; + } } // IDAT data write_png_block(b"IDAT", &self.idat_data, &mut output); + // APNG frames + for frame in self.frames.iter() { + write_png_block(b"fcTL", &frame.fctl_data(sequence_number), &mut output); + write_png_block(b"fdAT", &frame.fdat_data(sequence_number + 1), &mut output); + sequence_number += 2; + } // Ancillary chunks that come after IDAT for aux_post in aux_split { for chunk in aux_post {