From 540a62b06bd8328f2890d709f3b4af7d9072c8c6 Mon Sep 17 00:00:00 2001 From: barrett Date: Thu, 26 Mar 2026 17:24:39 -0500 Subject: [PATCH 01/14] refactor(meta): move `png.rs` to `png/mod.rs` --- raves_metadata/src/providers/{png.rs => png/mod.rs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename raves_metadata/src/providers/{png.rs => png/mod.rs} (99%) diff --git a/raves_metadata/src/providers/png.rs b/raves_metadata/src/providers/png/mod.rs similarity index 99% rename from raves_metadata/src/providers/png.rs rename to raves_metadata/src/providers/png/mod.rs index 1a478ae..1af23dc 100644 --- a/raves_metadata/src/providers/png.rs +++ b/raves_metadata/src/providers/png/mod.rs @@ -478,7 +478,7 @@ mod tests { #[test] fn blank_sample_with_exif() { logger(); - const BLOB: &[u8] = include_bytes!("../../assets/providers/png/exif.png"); + const BLOB: &[u8] = include_bytes!("../../../assets/providers/png/exif.png"); let png: Png = Png::new(&BLOB).expect("parse PNG"); From 6883b15296928aebe48b9aac2c5890fcc922b8ee Mon Sep 17 00:00:00 2001 From: barrett Date: Thu, 26 Mar 2026 17:25:09 -0500 Subject: [PATCH 02/14] refactor(meta): create a `png/error.rs` --- raves_metadata/src/providers/png/error.rs | 39 ++++++++++++++++++++ raves_metadata/src/providers/png/mod.rs | 44 +++-------------------- 2 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 raves_metadata/src/providers/png/error.rs diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs new file mode 100644 index 0000000..4b9bb8f --- /dev/null +++ b/raves_metadata/src/providers/png/error.rs @@ -0,0 +1,39 @@ +/// An error that occurs when constructing a [`Png`] for its metadata. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub enum PngConstructionError { + /// The file ran out of bytes before we could check for a signature. + /// + /// It might be empty. + NoSignature, + + /// No PNG signature was detected. + NotAPng { + /// The signature that was found instead. + found: [u8; 8], + }, +} + +impl core::fmt::Display for PngConstructionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + const NOT_A_PNG_MSG: &str = "The given file's signature indicated it was not a PNG"; + + match self { + PngConstructionError::NoSignature => { + f.write_str("File didn't have enough bytes for a signature.") + } + + PngConstructionError::NotAPng { found } => match core::str::from_utf8(found) { + Ok(utf8_found) => write!( + f, + "{NOT_A_PNG_MSG}. Signature was: `{found:?}`. (UTF-8: `{utf8_found}`)" + ), + Err(_) => write!( + f, + "{NOT_A_PNG_MSG}. Signature was: `{found:?}`. (Not valid UTF-8.)`" + ), + }, + } + } +} + +impl core::error::Error for PngConstructionError {} diff --git a/raves_metadata/src/providers/png/mod.rs b/raves_metadata/src/providers/png/mod.rs index 1af23dc..c01d946 100644 --- a/raves_metadata/src/providers/png/mod.rs +++ b/raves_metadata/src/providers/png/mod.rs @@ -13,6 +13,10 @@ use winnow::{ token::{literal, rest, take}, }; +mod error; + +pub use error::PngConstructionError; + /// A signature indicating that a file is a PNG. pub const PNG_SIGNATURE: &[u8; 8] = &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; @@ -298,46 +302,6 @@ fn try_to_parse_xmp_from_itxt<'input>( }) } -/// An error that occurs when constructing a [`Png`] for its metadata. -#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] -pub enum PngConstructionError { - /// The file ran out of bytes before we could check for a signature. - /// - /// It might be empty. - NoSignature, - - /// No PNG signature was detected. - NotAPng { - /// The signature that was found instead. - found: [u8; 8], - }, -} - -impl core::fmt::Display for PngConstructionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - const NOT_A_PNG_MSG: &str = "The given file's signature indicated it was not a PNG"; - - match self { - PngConstructionError::NoSignature => { - f.write_str("File didn't have enough bytes for a signature.") - } - - PngConstructionError::NotAPng { found } => match core::str::from_utf8(found) { - Ok(utf8_found) => write!( - f, - "{NOT_A_PNG_MSG}. Signature was: `{found:?}`. (UTF-8: `{utf8_found}`)" - ), - Err(_) => write!( - f, - "{NOT_A_PNG_MSG}. Signature was: `{found:?}`. (Not valid UTF-8.)`" - ), - }, - } - } -} - -impl core::error::Error for PngConstructionError {} - #[cfg(test)] mod tests { From 5ac2242352b1d46be3d26194fec04e925a49cc7f Mon Sep 17 00:00:00 2001 From: barrett Date: Thu, 26 Mar 2026 17:25:44 -0500 Subject: [PATCH 03/14] feat(meta): add PNG chunks abstraction --- .../src/providers/png/chunks/mod.rs | 204 ++++++++++++++++++ raves_metadata/src/providers/png/error.rs | 35 +++ raves_metadata/src/providers/png/mod.rs | 1 + 3 files changed, 240 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/mod.rs diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs new file mode 100644 index 0000000..77f2295 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -0,0 +1,204 @@ +//! Includes support for all the different kinds of chunks in a PNG image. +//! +//! Each chunk here has the ability to be read and written. + +use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; + +use crate::providers::png::{PngConstructionError, error::PngWriteError}; + +pub trait Chunk: Sized { + /// The "type" identifier for a PNG chunk. + /// + /// Each chunk has a unique type, meaning that this value identifies each + /// chunk exactly. + const TYPE: [u8; 4]; + + /// A printable, stringy version of the chunk type. + /// + /// Used for error messages and logging. + const TYPE_STR: &str = const { + // convert the `TYPE` byte array into a string. + // + // note that we have to handle the option that it's not UTF-8, but we + // do so in `const`, meaning that a panic here can only happen at + // compile-time ;D + let Ok(type_str) = core::str::from_utf8(&Self::TYPE) else { + panic!() + }; + + type_str + }; + + /// Reads this `Chunk` from the `blob`, given a chunk header that's already + /// been parsed out of the blob. + fn read(blob: &mut &[u8], header: PngChunkHeader) -> Result; + + /// Writes this chunk into the given buffer, `buf`. + fn write(&self, buf: &mut W) -> Result<(), PngWriteError>; + + /// Reads a byte from the given `blob`. + fn read_u8(blob: &mut &[u8], field: &'static str) -> Result { + winnow::binary::u8 + .parse_next(blob) + .map_err(|_e: EmptyError| { + log::error!( + "Can't get `u8`! Outta bytes when attempting to read chunk: `{}` \ + for field: `{field}`.", + Self::TYPE_STR + ); + PngConstructionError::OuttaBytes { + chunk_type: Self::TYPE_STR, + expected: 1_u8, + remaining: 0_u8, + } + }) + } + + /// Writes a byte to the given `buf`. + fn write_u8( + value: u8, + buf: &mut W, + field: &'static str, + ) -> Result<(), PngWriteError> { + buf.write_all(&[value]).map_err(|_| { + log::error!( + "Failed to write `u8` value to buffer! \ + Buffer type: {}, \ + Chunk type: {}, \ + Field: {field}", + core::any::type_name_of_val(buf), + Self::TYPE_STR + ); + PngWriteError::CantWriteToBuf {} + }) + } + + /// Reads a `u16` from the given `blob`. + fn read_u16(blob: &mut &[u8], field: &'static str) -> Result { + winnow::binary::be_u16 + .parse_next(blob) + .map_err(|_e: EmptyError| { + log::error!( + "Can't get `u16`! Outta bytes when attempting to read chunk: `{}` \ + for field: `{field}`.", + Self::TYPE_STR + ); + PngConstructionError::OuttaBytes { + chunk_type: Self::TYPE_STR, + expected: 2_u8, + remaining: blob.len() as u8, + } + }) + } + + /// Writes a `u16` to the given `buf`. + fn write_u16( + value: u16, + buf: &mut W, + field: &'static str, + ) -> Result<(), PngWriteError> { + buf.write_all(&value.to_be_bytes()).map_err(|_| { + log::error!( + "Failed to write `u16` value to buffer! \ + Buffer type: {}, \ + Chunk type: {}, \ + Field: {field}", + core::any::type_name_of_val(buf), + Self::TYPE_STR + ); + PngWriteError::CantWriteToBuf {} + }) + } + + /// Reads a `u32` from the given `blob`. + fn read_u32(blob: &mut &[u8], field: &'static str) -> Result { + winnow::binary::be_u32 + .parse_next(blob) + .map_err(|_e: EmptyError| { + log::error!( + "Can't get `u32`! Outta bytes when attempting to read chunk: `{}` \ + for field: `{field}`.", + Self::TYPE_STR + ); + PngConstructionError::OuttaBytes { + chunk_type: Self::TYPE_STR, + expected: 4_u8, + remaining: blob.len() as u8, + } + }) + } + + /// Writes a `u32` to the given `buf`. + fn write_u32( + value: u32, + buf: &mut W, + field: &'static str, + ) -> Result<(), PngWriteError> { + buf.write_all(&value.to_be_bytes()).map_err(|_| { + log::error!( + "Failed to write `u32` value to buffer! \ + Buffer type: {}, \ + Chunk type: {}, \ + Field: {field}", + core::any::type_name_of_val(buf), + Self::TYPE_STR + ); + PngWriteError::CantWriteToBuf {} + }) + } +} + +/// A header for a PNG chunk. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct PngChunkHeader { + pub chunk_length: u32, // in bytes + pub chunk_ident: [u8; 4], // four ascii letters +} + +impl PngChunkHeader { + /// Reads the chunk header from the blob (byte slice). + pub fn read(blob: &mut &[u8]) -> Result { + let chunk_length: u32 = be_u32.parse_next(blob).map_err(|_e: EmptyError| { + log::error!( + "Chunk length couldn't be parsed from the blob. \ + Outta bytes to consume!" + ); + PngConstructionError::NotEnoughBytesForChunkHeader + })?; + + let chunk_ident: [u8; 4] = take(4_usize) + .parse_next(blob) + .map_err(|_e: EmptyError| { + log::error!( + "Chunk identifier couldn't be parsed from the blob. \ + Outta bytes to consume!" + ); + PngConstructionError::NotEnoughBytesForChunkHeader + })? + .try_into() + .unwrap_or_else(|e| { + unreachable!("winnow already said this must be 4 bytes. but err: {e}") + }); + + Ok(PngChunkHeader { + chunk_length, + chunk_ident, + }) + } + + /// Writes a chunk header to the buffer, `buf`. + pub fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + buf.write_all(&self.chunk_length.to_be_bytes()) + .map_err(|_| { + log::error!("Failed to write chunk length!"); + PngWriteError::CantWriteToBuf {} + })?; + + buf.write_all(&self.chunk_ident).map_err(|_| { + log::error!("Failed to write chunk length!"); + PngWriteError::CantWriteToBuf {} + })?; + + Ok(()) + } +} diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 4b9bb8f..37a2b47 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -11,6 +11,20 @@ pub enum PngConstructionError { /// The signature that was found instead. found: [u8; 8], }, + + /// Unexpectedly ran out of bytes when parsing a chunk. + OuttaBytes { + /// The chunk's name. (e.g., `IHDR`) + chunk_type: &'static str, + /// The number of bytes that we tried to get. + expected: u8, + /// The number of bytes that were actually remaining. + remaining: u8, + }, + + /// Failed to parse chunk header. + NotEnoughBytesForChunkHeader, + } impl core::fmt::Display for PngConstructionError { @@ -32,8 +46,29 @@ impl core::fmt::Display for PngConstructionError { "{NOT_A_PNG_MSG}. Signature was: `{found:?}`. (Not valid UTF-8.)`" ), }, + + Self::OuttaBytes { + chunk_type, + expected, + remaining, + } => write!( + f, + "Unexpectedly ran out of bytes when parsing chunk: `{chunk_type}`. \ + Expected `{expected}` more bytes, \ + but only found `{remaining}` bytes.", + ), + + other_TODO => todo!(), } } } impl core::error::Error for PngConstructionError {} + +/// An error that can occur when writing a PNG back to disk. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub enum PngWriteError { + CantWriteToBuf {}, +} + +fn TODO_impl_error_for_PngWriteError() {} diff --git a/raves_metadata/src/providers/png/mod.rs b/raves_metadata/src/providers/png/mod.rs index c01d946..42e50f3 100644 --- a/raves_metadata/src/providers/png/mod.rs +++ b/raves_metadata/src/providers/png/mod.rs @@ -13,6 +13,7 @@ use winnow::{ token::{literal, rest, take}, }; +pub mod chunks; mod error; pub use error::PngConstructionError; From 80c5f028045b7c4897ebdca39e9d712bef8eb079 Mon Sep 17 00:00:00 2001 From: barrett Date: Thu, 26 Mar 2026 17:26:04 -0500 Subject: [PATCH 04/14] feat(meta): support IHDR parsing in PNG --- .../src/providers/png/chunks/ihdr.rs | 220 ++++++++++++++++++ .../src/providers/png/chunks/mod.rs | 2 + raves_metadata/src/providers/png/error.rs | 41 ++++ 3 files changed, 263 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/ihdr.rs diff --git a/raves_metadata/src/providers/png/chunks/ihdr.rs b/raves_metadata/src/providers/png/chunks/ihdr.rs new file mode 100644 index 0000000..d5d6d08 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/ihdr.rs @@ -0,0 +1,220 @@ +use crate::providers::png::{PngConstructionError, chunks::PngChunkHeader, error::PngWriteError}; + +/// The first chunk in the PNG datastream. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Ihdr { + header: PngChunkHeader, + + width_px: u32, + height_px: u32, + + bit_depth: BitDepth, + color_type: ColorType, + + compression_method: CompressionMethod, + filter_method: FilterMethod, + interlace_method: InterlaceMethod, +} + +impl super::Chunk for Ihdr { + const TYPE: [u8; 4] = [0x49, 0x48, 0x44, 0x52]; + + fn read(blob: &mut &[u8], header: PngChunkHeader) -> Result { + let width_px: u32 = Self::read_u32(blob, "width")?; + let height_px: u32 = Self::read_u32(blob, "height")?; + + let bit_depth: BitDepth = BitDepth::new(Self::read_u8(blob, "bit_depth")?)?; + let color_type: ColorType = ColorType::new(Self::read_u8(blob, "color_type")?, bit_depth)?; + + let compression_method: CompressionMethod = + CompressionMethod::new(Self::read_u8(blob, "compression_method")?)?; + let filter_method: FilterMethod = FilterMethod::new(Self::read_u8(blob, "filter_method")?)?; + let interlace_method: InterlaceMethod = + InterlaceMethod::new(Self::read_u8(blob, "interlace_method")?)?; + + Ok(Self { + header, + + width_px, + height_px, + + bit_depth, + color_type, + + compression_method, + filter_method, + interlace_method, + }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + let Self { + header, + width_px, + height_px, + bit_depth, + color_type, + compression_method, + filter_method, + interlace_method, + } = self; + + // write the header + header.write(buf)?; + + // write the sizes + Self::write_u32(*width_px, buf, "width_px")?; + Self::write_u32(*height_px, buf, "height_px")?; + + // write the bit/color stuff + Self::write_u8(*bit_depth as u8, buf, "bit_depth")?; + Self::write_u8(*color_type as u8, buf, "color_type")?; + + // write *_methods + Self::write_u8(*compression_method as u8, buf, "compression_method")?; + Self::write_u8(*filter_method as u8, buf, "filter_method")?; + Self::write_u8(*interlace_method as u8, buf, "interlace_method")?; + + Ok(()) + } +} + +/// The bit depth of the image. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BitDepth { + One = 1, + Two = 2, + Four = 4, + Eight = 8, + Sixteen = 16, +} + +impl BitDepth { + /// Attempts to create a new `BitDepth` given a byte value. + pub fn new(byte: u8) -> Result { + Ok(match byte { + 1 => BitDepth::One, + 2 => BitDepth::Two, + 4 => BitDepth::Four, + 8 => BitDepth::Eight, + 16 => BitDepth::Sixteen, + + other => { + return Err(PngConstructionError::DisallowedBitDepth { found_value: other }) + .inspect_err(|_e| { + log::error!("Disallowed bit depth found in IHDR chunk: `{other}`") + }); + } + }) + } +} + +/// The color type of the image. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ColorType { + Greyscale = 0, + Truecolor = 2, + Indexed = 3, + GreyscaleAlpha = 4, + TruecolorAlpha = 6, +} + +impl ColorType { + /// Attempts to create a new `ColorType`. + pub fn new(byte: u8, bit_depth: BitDepth) -> Result { + let color_type: ColorType = match byte { + 0 => Self::Greyscale, + 2 => Self::Truecolor, + 3 => Self::Indexed, + 4 => Self::GreyscaleAlpha, + 6 => Self::TruecolorAlpha, + + other => { + return Err(PngConstructionError::DisallowedColorType { found_value: other }) + .inspect_err(|_e| { + log::error!("Disallowed color type found in IHDR chunk: `{other}`") + }); + } + }; + + let compatible: bool = match color_type { + Self::Greyscale => true, + Self::Truecolor => matches!(bit_depth, BitDepth::Eight | BitDepth::Sixteen), + Self::Indexed => matches!( + bit_depth, + BitDepth::One | BitDepth::Two | BitDepth::Four | BitDepth::Eight + ), + Self::GreyscaleAlpha | Self::TruecolorAlpha => { + matches!(bit_depth, BitDepth::Eight | BitDepth::Sixteen) + } + }; + + if !compatible { + return Err(PngConstructionError::BitDepthIncompatibleWithColorType { + bit_depth, + incompatible_color_type: color_type, + }); + } + + Ok(color_type) + } +} + +/// The compression method used to compress the image data. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CompressionMethod { + Deflate = 0, +} + +impl CompressionMethod { + /// Attempts to create a new `CompressionMethod`. + pub fn new(byte: u8) -> Result { + if byte == 0 { + Ok(Self::Deflate) + } else { + Err(PngConstructionError::DisallowedCompressionMethod { found_value: byte }) + } + } +} + +/// The filter method used to filter the image data before compression. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FilterMethod { + Adaptive = 0, +} + +impl FilterMethod { + /// Attempts to create a new `FilterMethod`. + pub fn new(byte: u8) -> Result { + if byte == 0 { + Ok(Self::Adaptive) + } else { + Err(PngConstructionError::DisallowedFilterMethod { found_value: byte }) + } + } +} + +/// The interlace method used to interlace the image data, if any. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InterlaceMethod { + None = 0, + Adam7 = 1, +} + +impl InterlaceMethod { + /// Attempts to create a new `InterlaceMethod`. + pub fn new(byte: u8) -> Result { + if byte == 0 { + Ok(Self::None) + } else if byte == 7 { + Ok(Self::Adam7) + } else { + Err(PngConstructionError::DisallowedInterlaceMethod { found_value: byte }) + } + } +} diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 77f2295..362168e 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -6,6 +6,8 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; +pub mod ihdr; + pub trait Chunk: Sized { /// The "type" identifier for a PNG chunk. /// diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 37a2b47..7445cf0 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -1,3 +1,5 @@ +use crate::providers::png::chunks::ihdr::{BitDepth, ColorType}; + /// An error that occurs when constructing a [`Png`] for its metadata. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] pub enum PngConstructionError { @@ -25,6 +27,45 @@ pub enum PngConstructionError { /// Failed to parse chunk header. NotEnoughBytesForChunkHeader, + /// In the IHDR chunk, a disallowed bit depth was found. + DisallowedBitDepth { + /// The weird bit depth value we found. + found_value: u8, + }, + + /// In the IHDR chunk, a disallowed bit depth and color type pair was + /// found. + BitDepthIncompatibleWithColorType { + /// The bit depth value provided. + bit_depth: BitDepth, + + /// The expected color type. + incompatible_color_type: ColorType, + }, + + /// In the IHDR chunk, a disallowed color type byte was found. + DisallowedColorType { + /// The weird color type byte we found. + found_value: u8, + }, + + /// In the IHDR chunk, a weird compression method was found. + DisallowedCompressionMethod { + /// The disallowed byte value. + found_value: u8, + }, + + /// In the IHDR chunk, a weird filter method was found. + DisallowedFilterMethod { + /// The disallowed byte value. + found_value: u8, + }, + + /// In the IHDR chunk, a weird interlace method was found. + DisallowedInterlaceMethod { + /// The disallowed byte value. + found_value: u8, + }, } impl core::fmt::Display for PngConstructionError { From 1ca54588f6e1ca2a679a0f2db92089f7c91272ad Mon Sep 17 00:00:00 2001 From: Barrett Date: Sat, 28 Mar 2026 17:01:29 -0500 Subject: [PATCH 05/14] feat(meta): support PLTE chunk in PNG --- .../src/providers/png/chunks/mod.rs | 1 + .../src/providers/png/chunks/plte.rs | 78 +++++++++++++++++++ raves_metadata/src/providers/png/error.rs | 18 +++++ 3 files changed, 97 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/plte.rs diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 362168e..59bba76 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -7,6 +7,7 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; pub mod ihdr; +pub mod plte; pub trait Chunk: Sized { /// The "type" identifier for a PNG chunk. diff --git a/raves_metadata/src/providers/png/chunks/plte.rs b/raves_metadata/src/providers/png/chunks/plte.rs new file mode 100644 index 0000000..4206c49 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/plte.rs @@ -0,0 +1,78 @@ +use crate::providers::png::{PngConstructionError, chunks::PngChunkHeader, error::PngWriteError}; + +/// A collection of palettes. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Plte { + header: PngChunkHeader, + entries: Vec, +} + +/// The maximum number of palettes a `PLTE` chunk may carry. +pub const MAX_PALETTES: u32 = 256_u32; + +impl super::Chunk for Plte { + const TYPE: [u8; 4] = *b"PLTE"; + + fn read(blob: &mut &[u8], header: super::PngChunkHeader) -> Result { + // check if chunk len is messed up + if !header.chunk_length.is_multiple_of(3) || blob.is_empty() { + log::error!( + "PLTE chunk length is not a multiple of 3! \ + Can't continue parsing it..." + ); + return Err(PngConstructionError::PlteNotMultipleOfThree { + found_chunk_length: header.chunk_length, + }); + } + + // check if we've got too many palettes + let palette_count: u32 = header.chunk_length / 3; + if palette_count > MAX_PALETTES { + log::error!("PLTE chunk had too many palettes! ({palette_count})"); + return Err(PngConstructionError::PlteTooManyPalettes { + palette_ct: palette_count, + }); + } + + // nope, all good! write each palette + let mut entries: Vec = Vec::with_capacity(palette_count as usize); + for _ in 0..palette_count { + entries.push(Palette { + red: Self::read_u8(blob, "red")?, + green: Self::read_u8(blob, "green")?, + blue: Self::read_u8(blob, "blue")?, + }); + } + + Ok(Self { header, entries }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // check palette ct before writing + if self.entries.is_empty() || self.entries.len() as u32 > MAX_PALETTES { + return Err(PngWriteError::PltePaletteCount { + palette_ct: self.entries.len() as u32, + }); + } + + // write header + self.header.write(buf)?; + + // write the palettes + for Palette { red, green, blue } in &self.entries { + Self::write_u8(*red, buf, "red")?; + Self::write_u8(*green, buf, "green")?; + Self::write_u8(*blue, buf, "blue")?; + } + + Ok(()) + } +} + +/// A color. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Palette { + pub red: u8, + pub green: u8, + pub blue: u8, +} diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 7445cf0..0c95a91 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -66,6 +66,18 @@ pub enum PngConstructionError { /// The disallowed byte value. found_value: u8, }, + + /// In the PLTE chunk, the chunk length was not a multiple of 3. + PlteNotMultipleOfThree { + /// The chunk length we got. + found_chunk_length: u32, + }, + + /// In the PLTE chunk, there were too many palettes (>256). + PlteTooManyPalettes { + /// The number of palettes found. + palette_ct: u32, + }, } impl core::fmt::Display for PngConstructionError { @@ -110,6 +122,12 @@ impl core::error::Error for PngConstructionError {} #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] pub enum PngWriteError { CantWriteToBuf {}, + + /// On the PLTE chunk, the number of palettes was either 0 or >256. + PltePaletteCount { + /// The number of palettes. + palette_ct: u32, + }, } fn TODO_impl_error_for_PngWriteError() {} From 84d625e086a3bf1ac1c55319eb5a203649f83c24 Mon Sep 17 00:00:00 2001 From: Barrett Date: Wed, 1 Apr 2026 11:40:59 -0500 Subject: [PATCH 06/14] refactor(meta): png OuttaBytes err should use u32 --- .../src/providers/png/chunks/mod.rs | 32 +++++++++++++++---- raves_metadata/src/providers/png/error.rs | 4 +-- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 59bba76..77849e1 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -6,6 +6,7 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; +pub mod idat; pub mod ihdr; pub mod plte; @@ -51,8 +52,8 @@ pub trait Chunk: Sized { ); PngConstructionError::OuttaBytes { chunk_type: Self::TYPE_STR, - expected: 1_u8, - remaining: 0_u8, + expected: 1_u32, + remaining: 0_u32, } }) } @@ -88,8 +89,8 @@ pub trait Chunk: Sized { ); PngConstructionError::OuttaBytes { chunk_type: Self::TYPE_STR, - expected: 2_u8, - remaining: blob.len() as u8, + expected: 2_u32, + remaining: blob.len() as u32, } }) } @@ -125,8 +126,8 @@ pub trait Chunk: Sized { ); PngConstructionError::OuttaBytes { chunk_type: Self::TYPE_STR, - expected: 4_u8, - remaining: blob.len() as u8, + expected: 4_u32, + remaining: blob.len() as u32, } }) } @@ -149,6 +150,25 @@ pub trait Chunk: Sized { PngWriteError::CantWriteToBuf {} }) } + + /// Writes a byte slice to the given buffer. + fn write_byte_slice( + value: &[u8], + buf: &mut W, + field: &'static str, + ) -> Result<(), PngWriteError> { + buf.write_all(value).map_err(|_| { + log::error!( + "Failed to write byte slice to buffer! \ + Buffer type: {}, \ + Chunk type: {}, \ + Field: {field}", + core::any::type_name_of_val(buf), + Self::TYPE_STR + ); + PngWriteError::CantWriteToBuf {} + }) + } } /// A header for a PNG chunk. diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 0c95a91..c9c33d8 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -19,9 +19,9 @@ pub enum PngConstructionError { /// The chunk's name. (e.g., `IHDR`) chunk_type: &'static str, /// The number of bytes that we tried to get. - expected: u8, + expected: u32, /// The number of bytes that were actually remaining. - remaining: u8, + remaining: u32, }, /// Failed to parse chunk header. From 514e211aa4bfcc1816f5bcfc405aa6bcdb2e120a Mon Sep 17 00:00:00 2001 From: Barrett Date: Wed, 1 Apr 2026 11:42:40 -0500 Subject: [PATCH 07/14] feat(meta): support IDAT chunk in PNG --- .../src/providers/png/chunks/idat.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/idat.rs diff --git a/raves_metadata/src/providers/png/chunks/idat.rs b/raves_metadata/src/providers/png/chunks/idat.rs new file mode 100644 index 0000000..9901573 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/idat.rs @@ -0,0 +1,56 @@ +use winnow::{Parser, error::EmptyError, token::take}; + +use crate::providers::png::{ + PngConstructionError, + chunks::{Chunk, PngChunkHeader}, + error::PngWriteError, +}; + +/// A chunk of image data. +/// +/// Compressed and unaltered -- this library isn't a decoder or encoder. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Idat { + header: PngChunkHeader, + data: Vec, +} + +impl Chunk for Idat { + const TYPE: [u8; 4] = *b"IDAT"; + + fn read(blob: &mut &[u8], header: super::PngChunkHeader) -> Result { + let mut entries: Vec = Vec::with_capacity(header.chunk_length as usize); + + let slice_len: usize = blob.len(); + let slice: &[u8] = + take(header.chunk_length) + .parse_next(blob) + .map_err(|_e: EmptyError| { + log::error!( + "When parsing `IDAT` chunk, failed to read all {} bytes.", + header.chunk_length + ); + PngConstructionError::OuttaBytes { + chunk_type: Idat::TYPE_STR, + expected: header.chunk_length, + remaining: slice_len as u32, + } + })?; + + entries.extend_from_slice(slice); + + Ok(Self { + header, + data: entries, + }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write header + self.header.write(buf)?; + + // write all the bytes + Self::write_byte_slice(&self.data, buf, "data")?; + Ok(()) + } +} From c8034d59a84f535a7de6174ae6b660c2b26f93fa Mon Sep 17 00:00:00 2001 From: Barrett Date: Wed, 1 Apr 2026 11:49:19 -0500 Subject: [PATCH 08/14] feat(meta): support IEND chunk in PNG --- .../src/providers/png/chunks/iend.rs | 38 +++++++++++++++++++ .../src/providers/png/chunks/mod.rs | 1 + raves_metadata/src/providers/png/error.rs | 6 +++ 3 files changed, 45 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/iend.rs diff --git a/raves_metadata/src/providers/png/chunks/iend.rs b/raves_metadata/src/providers/png/chunks/iend.rs new file mode 100644 index 0000000..9407b21 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/iend.rs @@ -0,0 +1,38 @@ +use crate::providers::png::{ + chunks::{Chunk, PngChunkHeader}, + error::{PngConstructionError, PngWriteError}, +}; + +/// A chunk stating that the PNG datastream has ended. +/// +/// It has no associated data -- just the header. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Iend { + header: PngChunkHeader, +} + +impl Chunk for Iend { + const TYPE: [u8; 4] = *b"IEND"; + + fn read( + _blob: &mut &[u8], + header: super::PngChunkHeader, + ) -> Result { + // this chunk must be zero length. so let's check that! + if header.chunk_length != 0_u32 { + log::error!("IEND chunk has associated data! That's not allowed."); + return Err(PngConstructionError::IendHadData { + chunk_length: header.chunk_length, + }); + } + + // all good! just store its header + Ok(Self { header }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write header + self.header.write(buf)?; + Ok(()) + } +} diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 77849e1..74f8999 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -7,6 +7,7 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; pub mod idat; +pub mod iend; pub mod ihdr; pub mod plte; diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index c9c33d8..371aa52 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -78,6 +78,12 @@ pub enum PngConstructionError { /// The number of palettes found. palette_ct: u32, }, + + /// The IEND chunk had a non-zero chunk length, but that's not allowed. + IendHadData { + /// The (non-zero) chunk length for this chunk. + chunk_length: u32, + }, } impl core::fmt::Display for PngConstructionError { From 5eaf2f6080ead017607bcfc980fbf6366bafa93c Mon Sep 17 00:00:00 2001 From: Barrett Date: Thu, 2 Apr 2026 15:40:22 -0500 Subject: [PATCH 09/14] refactor(meta): add optional ctx on PNG chunk read --- raves_metadata/src/providers/png/chunks/idat.rs | 8 ++++++-- raves_metadata/src/providers/png/chunks/iend.rs | 3 ++- raves_metadata/src/providers/png/chunks/ihdr.rs | 12 ++++++++++-- raves_metadata/src/providers/png/chunks/mod.rs | 17 ++++++++++++++++- raves_metadata/src/providers/png/chunks/plte.rs | 12 ++++++++++-- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/raves_metadata/src/providers/png/chunks/idat.rs b/raves_metadata/src/providers/png/chunks/idat.rs index 9901573..11b6477 100644 --- a/raves_metadata/src/providers/png/chunks/idat.rs +++ b/raves_metadata/src/providers/png/chunks/idat.rs @@ -2,7 +2,7 @@ use winnow::{Parser, error::EmptyError, token::take}; use crate::providers::png::{ PngConstructionError, - chunks::{Chunk, PngChunkHeader}, + chunks::{Chunk, ChunkContext, PngChunkHeader}, error::PngWriteError, }; @@ -18,7 +18,11 @@ pub struct Idat { impl Chunk for Idat { const TYPE: [u8; 4] = *b"IDAT"; - fn read(blob: &mut &[u8], header: super::PngChunkHeader) -> Result { + fn read( + blob: &mut &[u8], + header: super::PngChunkHeader, + _context: ChunkContext, + ) -> Result { let mut entries: Vec = Vec::with_capacity(header.chunk_length as usize); let slice_len: usize = blob.len(); diff --git a/raves_metadata/src/providers/png/chunks/iend.rs b/raves_metadata/src/providers/png/chunks/iend.rs index 9407b21..f41f6d9 100644 --- a/raves_metadata/src/providers/png/chunks/iend.rs +++ b/raves_metadata/src/providers/png/chunks/iend.rs @@ -1,5 +1,5 @@ use crate::providers::png::{ - chunks::{Chunk, PngChunkHeader}, + chunks::{Chunk, ChunkContext, PngChunkHeader}, error::{PngConstructionError, PngWriteError}, }; @@ -17,6 +17,7 @@ impl Chunk for Iend { fn read( _blob: &mut &[u8], header: super::PngChunkHeader, + _context: ChunkContext, ) -> Result { // this chunk must be zero length. so let's check that! if header.chunk_length != 0_u32 { diff --git a/raves_metadata/src/providers/png/chunks/ihdr.rs b/raves_metadata/src/providers/png/chunks/ihdr.rs index d5d6d08..12714a9 100644 --- a/raves_metadata/src/providers/png/chunks/ihdr.rs +++ b/raves_metadata/src/providers/png/chunks/ihdr.rs @@ -1,4 +1,8 @@ -use crate::providers::png::{PngConstructionError, chunks::PngChunkHeader, error::PngWriteError}; +use crate::providers::png::{ + PngConstructionError, + chunks::{ChunkContext, PngChunkHeader}, + error::PngWriteError, +}; /// The first chunk in the PNG datastream. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] @@ -19,7 +23,11 @@ pub struct Ihdr { impl super::Chunk for Ihdr { const TYPE: [u8; 4] = [0x49, 0x48, 0x44, 0x52]; - fn read(blob: &mut &[u8], header: PngChunkHeader) -> Result { + fn read( + blob: &mut &[u8], + header: PngChunkHeader, + _context: ChunkContext, + ) -> Result { let width_px: u32 = Self::read_u32(blob, "width")?; let height_px: u32 = Self::read_u32(blob, "height")?; diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 74f8999..4943d64 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -11,6 +11,7 @@ pub mod iend; pub mod ihdr; pub mod plte; +/// A "chunk" of PNG data. pub trait Chunk: Sized { /// The "type" identifier for a PNG chunk. /// @@ -36,7 +37,11 @@ pub trait Chunk: Sized { /// Reads this `Chunk` from the `blob`, given a chunk header that's already /// been parsed out of the blob. - fn read(blob: &mut &[u8], header: PngChunkHeader) -> Result; + fn read( + blob: &mut &[u8], + header: PngChunkHeader, + context: ChunkContext, + ) -> Result; /// Writes this chunk into the given buffer, `buf`. fn write(&self, buf: &mut W) -> Result<(), PngWriteError>; @@ -172,6 +177,16 @@ pub trait Chunk: Sized { } } +/// Context relating to parsing/writing a chunk. +/// +/// Note that the 'par lifetime is related to parser borrows -- meaning the +/// crate's internal parser provides the data. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub enum ChunkContext<'par> { + /// This chunk doesn't take context. + Other, +} + /// A header for a PNG chunk. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] pub struct PngChunkHeader { diff --git a/raves_metadata/src/providers/png/chunks/plte.rs b/raves_metadata/src/providers/png/chunks/plte.rs index 4206c49..4daadc3 100644 --- a/raves_metadata/src/providers/png/chunks/plte.rs +++ b/raves_metadata/src/providers/png/chunks/plte.rs @@ -1,4 +1,8 @@ -use crate::providers::png::{PngConstructionError, chunks::PngChunkHeader, error::PngWriteError}; +use crate::providers::png::{ + PngConstructionError, + chunks::{ChunkContext, PngChunkHeader}, + error::PngWriteError, +}; /// A collection of palettes. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] @@ -13,7 +17,11 @@ pub const MAX_PALETTES: u32 = 256_u32; impl super::Chunk for Plte { const TYPE: [u8; 4] = *b"PLTE"; - fn read(blob: &mut &[u8], header: super::PngChunkHeader) -> Result { + fn read( + blob: &mut &[u8], + header: super::PngChunkHeader, + _context: ChunkContext, + ) -> Result { // check if chunk len is messed up if !header.chunk_length.is_multiple_of(3) || blob.is_empty() { log::error!( From d0d65dfa7abd16a6e453321a9726cc7fe9cb8147 Mon Sep 17 00:00:00 2001 From: Barrett Date: Thu, 2 Apr 2026 15:40:48 -0500 Subject: [PATCH 10/14] feat(meta): support tRNS chunk in PNG --- .../src/providers/png/chunks/mod.rs | 4 + .../src/providers/png/chunks/trns.rs | 138 ++++++++++++++++++ raves_metadata/src/providers/png/error.rs | 9 +- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 raves_metadata/src/providers/png/chunks/trns.rs diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 4943d64..750da5c 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -10,6 +10,7 @@ pub mod idat; pub mod iend; pub mod ihdr; pub mod plte; +pub mod trns; /// A "chunk" of PNG data. pub trait Chunk: Sized { @@ -183,6 +184,9 @@ pub trait Chunk: Sized { /// crate's internal parser provides the data. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] pub enum ChunkContext<'par> { + /// The primary transparency info chunk. + Trns(&'par trns::TrnsContext), + /// This chunk doesn't take context. Other, } diff --git a/raves_metadata/src/providers/png/chunks/trns.rs b/raves_metadata/src/providers/png/chunks/trns.rs new file mode 100644 index 0000000..58c6ff1 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/trns.rs @@ -0,0 +1,138 @@ +use crate::providers::png::{ + chunks::{Chunk, ChunkContext, PngChunkHeader}, + error::{PngConstructionError, PngWriteError}, +}; + +/// Specifies colors relating to transparency. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Trns { + header: PngChunkHeader, + data: TrnsData, +} + +/// The data behind the `Trns` type. +/// +/// Separated to disallow access/mutation by crate users. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub enum TrnsData { + ColorType0 { + grey_sample_value: u16, + }, + + ColorType2 { + red_sample_value: u16, + green_sample_value: u16, + blue_sample_value: u16, + }, + + ColorType3 { + alpha_per_palette: Vec, + }, +} + +/// Context given to the tRNS reader. +/// +/// Based on the `color_type: ColorType` from the IHDR chunk. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub enum TrnsContext { + /// Type 0 doesn't need any info. + ColorType0, + + /// Neither does type 2. + ColorType2, + + /// Type 3 needs to know how many entries are in the PLTE chunk. + ColorType3 { number_of_plte_entries: u32 }, + + /// Other color types aren't permitted for tRNS -- error out! + NotPermitted, +} + +impl Chunk for Trns { + const TYPE: [u8; 4] = *b"tRNS"; + + fn read( + blob: &mut &[u8], + header: super::PngChunkHeader, + context: ChunkContext, + ) -> Result { + // ensure context is the right kind + let ChunkContext::Trns(trns_ctx) = context else { + log::error!( + "INTERNAL ERROR: `Trns::read` with wrong type of context! \ + Please create an issue on the `raves-project/raves_metadata` GitHub repo. \ + context: {context:?}" + ); + std::process::abort(); + }; + + // grab data (depending on color type) + let data: TrnsData = match trns_ctx { + TrnsContext::ColorType0 => TrnsData::ColorType0 { + grey_sample_value: Self::read_u16(blob, "grey_sample_value (type 0)")?, + }, + + TrnsContext::ColorType2 => TrnsData::ColorType2 { + red_sample_value: Self::read_u16(blob, "red_sample_value (type 2)")?, + green_sample_value: Self::read_u16(blob, "green_sample_value (type 2)")?, + blue_sample_value: Self::read_u16(blob, "blue_sample_value (type 2)")?, + }, + + TrnsContext::ColorType3 { + number_of_plte_entries, + } => { + let mut v: Vec<_> = Vec::with_capacity(*number_of_plte_entries as usize); + for _ in 0..*number_of_plte_entries { + v.push(Self::read_u8(blob, "alpha (type 3)")?); + } + TrnsData::ColorType3 { + alpha_per_palette: v, + } + } + + // error out on other types + TrnsContext::NotPermitted => { + log::error!( + "TRNS chunk was found, but the color type (from IHDR) \ + says that this chunk shouldn't be in the datastream. \ + Returning an error..." + ); + return Err(PngConstructionError::TrnsGivenDisallowedType); + } + }; + + // all good! just store its header + Ok(Self { header, data }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write header + self.header.write(buf)?; + + // write depending on the data... + match self.data { + TrnsData::ColorType0 { grey_sample_value } => { + Self::write_u16(grey_sample_value, buf, "grey_sample_value (type 0)")?; + } + TrnsData::ColorType2 { + red_sample_value, + green_sample_value, + blue_sample_value, + } => { + Self::write_u16(red_sample_value, buf, "red_sample_value (type 2)")?; + Self::write_u16(green_sample_value, buf, "green_sample_value (type 2)")?; + Self::write_u16(blue_sample_value, buf, "blue_sample_value (type 2)")?; + } + + TrnsData::ColorType3 { + ref alpha_per_palette, + } => { + for alpha_value in alpha_per_palette.iter() { + Self::write_u8(*alpha_value, buf, "alpha_value (type 3)")?; + } + } + } + + Ok(()) + } +} diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 371aa52..0a04aeb 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -1,4 +1,7 @@ -use crate::providers::png::chunks::ihdr::{BitDepth, ColorType}; +use crate::providers::png::chunks::{ + ihdr::{BitDepth, ColorType}, + trns::TrnsContext, +}; /// An error that occurs when constructing a [`Png`] for its metadata. #[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] @@ -84,6 +87,10 @@ pub enum PngConstructionError { /// The (non-zero) chunk length for this chunk. chunk_length: u32, }, + + /// The tRNS chunk should not exist if the color type is disallowed, but + /// the parser found a tRNS chunk in the datastream anyway. + TrnsGivenDisallowedType, } impl core::fmt::Display for PngConstructionError { From 4ac66a1f3ac8a8f7a38df9a8800700505f089b6f Mon Sep 17 00:00:00 2001 From: Barrett Date: Thu, 2 Apr 2026 15:52:06 -0500 Subject: [PATCH 11/14] feat(meta): support cHRM chunk in PNG --- .../src/providers/png/chunks/chrm.rs | 84 +++++++++++++++++++ .../src/providers/png/chunks/mod.rs | 1 + 2 files changed, 85 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/chrm.rs diff --git a/raves_metadata/src/providers/png/chunks/chrm.rs b/raves_metadata/src/providers/png/chunks/chrm.rs new file mode 100644 index 0000000..8d337c8 --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/chrm.rs @@ -0,0 +1,84 @@ +use crate::providers::png::{ + chunks::{Chunk, ChunkContext, PngChunkHeader}, + error::{PngConstructionError, PngWriteError}, +}; + +/// The cHRM chunk has info about chromaticities and the white point. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Chrm { + header: PngChunkHeader, + + // note: these are (x, y) pairs. saves some space/typing. + white_point: (u32, u32), + red: (u32, u32), + green: (u32, u32), + blue: (u32, u32), +} + +impl Chunk for Chrm { + const TYPE: [u8; 4] = *b"cHRM"; + + fn read( + blob: &mut &[u8], + header: PngChunkHeader, + context: ChunkContext, + ) -> Result { + // check that there's no context + debug_assert_eq!(context, ChunkContext::Other); + + // grab white point + let white_point: (u32, u32) = ( + Self::read_u32(blob, "white_point_x")?, + Self::read_u32(blob, "white_point_y")?, + ); + + // grab red + let red: (u32, u32) = ( + Self::read_u32(blob, "red_x")?, + Self::read_u32(blob, "red_y")?, + ); + + // green + let green: (u32, u32) = ( + Self::read_u32(blob, "green_x")?, + Self::read_u32(blob, "green_y")?, + ); + + // blue + let blue: (u32, u32) = ( + Self::read_u32(blob, "blue_x")?, + Self::read_u32(blob, "blue_y")?, + ); + + Ok(Self { + header, + white_point, + red, + green, + blue, + }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write the header + self.header.write(buf)?; + + // white point + Self::write_u32(self.white_point.0, buf, "white_point_x")?; + Self::write_u32(self.white_point.1, buf, "white_point_y")?; + + // red + Self::write_u32(self.red.0, buf, "red_x")?; + Self::write_u32(self.red.1, buf, "red_y")?; + + // green + Self::write_u32(self.green.0, buf, "green_x")?; + Self::write_u32(self.green.1, buf, "green_y")?; + + // blue + Self::write_u32(self.blue.0, buf, "blue_x")?; + Self::write_u32(self.blue.1, buf, "blue_y")?; + + Ok(()) + } +} diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 750da5c..a4bd8f2 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -6,6 +6,7 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; +pub mod chrm; pub mod idat; pub mod iend; pub mod ihdr; From f56f4baf1f8f1dda4bc4804631dd2a76bb1f14f2 Mon Sep 17 00:00:00 2001 From: Barrett Date: Thu, 2 Apr 2026 15:59:39 -0500 Subject: [PATCH 12/14] feat(meta): support gAMA chunk in PNG --- .../src/providers/png/chunks/gama.rs | 38 +++++++++++++++++++ .../src/providers/png/chunks/mod.rs | 1 + 2 files changed, 39 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/gama.rs diff --git a/raves_metadata/src/providers/png/chunks/gama.rs b/raves_metadata/src/providers/png/chunks/gama.rs new file mode 100644 index 0000000..c7c41ea --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/gama.rs @@ -0,0 +1,38 @@ +use crate::providers::png::{ + chunks::{Chunk, ChunkContext, PngChunkHeader}, + error::{PngConstructionError, PngWriteError}, +}; + +/// The gAMA chunk provides a basic gamma (brightness) value. +/// +/// Other chunks do this better -- this chunk is pretty basic. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Gama { + header: PngChunkHeader, + gamma: u32, +} + +impl Chunk for Gama { + const TYPE: [u8; 4] = *b"gAMA"; + + fn read( + blob: &mut &[u8], + header: PngChunkHeader, + context: ChunkContext, + ) -> Result { + // check that there's no context + debug_assert_eq!(context, ChunkContext::Other); + + // grab gamma value + let gamma: u32 = Self::read_u32(blob, "gamma")?; + Ok(Self { header, gamma }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write the header, then the gamma + self.header.write(buf)?; + Self::write_u32(self.gamma, buf, "gamma")?; + + Ok(()) + } +} diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index a4bd8f2..1ad4df0 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -7,6 +7,7 @@ use winnow::{Parser, binary::be_u32, error::EmptyError, token::take}; use crate::providers::png::{PngConstructionError, error::PngWriteError}; pub mod chrm; +pub mod gama; pub mod idat; pub mod iend; pub mod ihdr; From 27a14155d519d75d429540bd56fe1fde2ea84dfb Mon Sep 17 00:00:00 2001 From: Barrett Date: Mon, 6 Apr 2026 12:33:37 -0500 Subject: [PATCH 13/14] feat(meta): support iCCP chunk in PNG --- .../src/providers/png/chunks/iccp.rs | 103 ++++++++++++++++++ .../src/providers/png/chunks/mod.rs | 1 + raves_metadata/src/providers/png/error.rs | 14 +++ 3 files changed, 118 insertions(+) create mode 100644 raves_metadata/src/providers/png/chunks/iccp.rs diff --git a/raves_metadata/src/providers/png/chunks/iccp.rs b/raves_metadata/src/providers/png/chunks/iccp.rs new file mode 100644 index 0000000..29aeabc --- /dev/null +++ b/raves_metadata/src/providers/png/chunks/iccp.rs @@ -0,0 +1,103 @@ +use crate::providers::png::{ + chunks::{Chunk, ChunkContext, PngChunkHeader}, + error::{PngConstructionError, PngWriteError}, +}; + +/// The iCCP chunk provides an advanced color profile. +#[derive(Clone, Debug, PartialEq, PartialOrd, Hash)] +pub struct Iccp { + header: PngChunkHeader, + profile_name: String, // converting from the NUL-terminated grossness + compression_method: CompressionMethod, + compressed_profile: Vec, // not decompressing that. have fun nerds +} + +impl Chunk for Iccp { + const TYPE: [u8; 4] = *b"iCCP"; + + fn read( + blob: &mut &[u8], + header: PngChunkHeader, + context: ChunkContext, + ) -> Result { + // check that there's no context + debug_assert_eq!(context, ChunkContext::Other); + + // grab profile name + let mut profile_name: String = String::new(); + for _ in 0..79 { + // grab a byte + let byte: u8 = Self::read_u8(blob, "profile name (one byte)")?; + if byte == 0x00 { + break; + } + + // push it to the string + profile_name.push(byte as char); + } + + // grab compression method + let compression_method_raw: u8 = Self::read_u8(blob, "compression method")?; + let compression_method: CompressionMethod = if compression_method_raw != 0 { + return Err(PngConstructionError::IccpUnknownCompressionValue { + value: compression_method_raw, + }); + } else { + CompressionMethod::ZlibDeflate + }; + + // read the rest of the compressed profile + let profile_len: usize = profile_name.len().saturating_sub(2); + let mut compressed_profile: Vec = Vec::with_capacity(profile_len); + for _ in 0..profile_len { + let byte: u8 = Self::read_u8(blob, "compressed profile (one byte)")?; + compressed_profile.push(byte); + } + + Ok(Self { + header, + profile_name, + compression_method, + compressed_profile, + }) + } + + fn write(&self, buf: &mut W) -> Result<(), PngWriteError> { + // write the header + self.header.write(buf)?; + + // profile name + for c in self.profile_name.chars().map(|c: char| c as u8) { + if profile_name_char_is_allowed(c) { + Self::write_u8(c, buf, "profile name (one byte)")?; + } else { + log::error!("iCCP chunk: A character in the profile name was not Latin-1: `{c}`"); + return Err(PngWriteError::IccpProfileNameNotLatin1 { c }); + } + } + + // NUL terminator on profile name + Self::write_u8(0_u8, buf, "null separator")?; + + // compression method (will always be 0) + Self::write_u8(0_u8, buf, "compression method")?; + + // compressed profile + for byte in &self.compressed_profile { + Self::write_u8(*byte, buf, "compressed profile (one byte)")?; + } + + Ok(()) + } +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Hash)] +pub enum CompressionMethod { + ZlibDeflate = 0, +} + +/// Checks if a character in the profile name is allowed. +const fn profile_name_char_is_allowed(c: u8) -> bool { + (c >= 0x20 && c <= 0x7E) || (c >= 0xA1) +} diff --git a/raves_metadata/src/providers/png/chunks/mod.rs b/raves_metadata/src/providers/png/chunks/mod.rs index 1ad4df0..762bc0b 100644 --- a/raves_metadata/src/providers/png/chunks/mod.rs +++ b/raves_metadata/src/providers/png/chunks/mod.rs @@ -8,6 +8,7 @@ use crate::providers::png::{PngConstructionError, error::PngWriteError}; pub mod chrm; pub mod gama; +pub mod iccp; pub mod idat; pub mod iend; pub mod ihdr; diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index 0a04aeb..e095c04 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -91,6 +91,13 @@ pub enum PngConstructionError { /// The tRNS chunk should not exist if the color type is disallowed, but /// the parser found a tRNS chunk in the datastream anyway. TrnsGivenDisallowedType, + + /// The iCCP chunk's compression method should always be zero, but another + /// value was found. + IccpUnknownCompressionValue { + /// The non-zero value that was found. + value: u8, + }, } impl core::fmt::Display for PngConstructionError { @@ -141,6 +148,13 @@ pub enum PngWriteError { /// The number of palettes. palette_ct: u32, }, + + /// An iCCP chunk profile name should be fully Latin-1-encoded, but a + /// character wasn't in Latin-1 bounds. + IccpProfileNameNotLatin1 { + /// The violating character. + c: u8, + }, } fn TODO_impl_error_for_PngWriteError() {} From 858727b8144e986fcc96b1b529052bf3725afea8 Mon Sep 17 00:00:00 2001 From: Barrett Date: Wed, 15 Apr 2026 15:10:16 -0500 Subject: [PATCH 14/14] feat(meta): improve iccp chunk in PNG --- .../src/providers/png/chunks/iccp.rs | 121 ++++++++++++++---- raves_metadata/src/providers/png/error.rs | 19 +++ 2 files changed, 115 insertions(+), 25 deletions(-) diff --git a/raves_metadata/src/providers/png/chunks/iccp.rs b/raves_metadata/src/providers/png/chunks/iccp.rs index 29aeabc..6c542fc 100644 --- a/raves_metadata/src/providers/png/chunks/iccp.rs +++ b/raves_metadata/src/providers/png/chunks/iccp.rs @@ -23,19 +23,41 @@ impl Chunk for Iccp { // check that there's no context debug_assert_eq!(context, ChunkContext::Other); - // grab profile name - let mut profile_name: String = String::new(); + let mut profile_name_bytes: Vec = Vec::with_capacity(79); + let mut found_null_terminator: bool = false; + for _ in 0..79 { - // grab a byte let byte: u8 = Self::read_u8(blob, "profile name (one byte)")?; if byte == 0x00 { + found_null_terminator = true; break; } - // push it to the string - profile_name.push(byte as char); + profile_name_bytes.push(byte); + } + + if !found_null_terminator { + let null_separator: u8 = Self::read_u8(blob, "null separator")?; + if null_separator != 0x00 { + return Err(PngConstructionError::IccpInvalidProfileName { + reason: "profile name must be terminated by a NUL separator after 1-79 bytes", + }); + } } + // make sure the profile name is up to spec + validate_profile_name_bytes(&profile_name_bytes).map_err(|err| match err { + ProfileNameError::NotLatin1 { c } => { + PngConstructionError::IccpProfileNameNotLatin1 { c } + } + ProfileNameError::InvalidStructure { reason } => { + PngConstructionError::IccpInvalidProfileName { reason } + } + })?; + + // write the profile name into a string + let profile_name: String = profile_name_bytes.into_iter().map(char::from).collect(); + // grab compression method let compression_method_raw: u8 = Self::read_u8(blob, "compression method")?; let compression_method: CompressionMethod = if compression_method_raw != 0 { @@ -47,12 +69,8 @@ impl Chunk for Iccp { }; // read the rest of the compressed profile - let profile_len: usize = profile_name.len().saturating_sub(2); - let mut compressed_profile: Vec = Vec::with_capacity(profile_len); - for _ in 0..profile_len { - let byte: u8 = Self::read_u8(blob, "compressed profile (one byte)")?; - compressed_profile.push(byte); - } + let compressed_profile: Vec = blob.to_vec(); + *blob = &[]; Ok(Self { header, @@ -66,26 +84,35 @@ impl Chunk for Iccp { // write the header self.header.write(buf)?; - // profile name - for c in self.profile_name.chars().map(|c: char| c as u8) { - if profile_name_char_is_allowed(c) { - Self::write_u8(c, buf, "profile name (one byte)")?; - } else { - log::error!("iCCP chunk: A character in the profile name was not Latin-1: `{c}`"); - return Err(PngWriteError::IccpProfileNameNotLatin1 { c }); + let profile_name_bytes: Vec = self + .profile_name + .chars() + .map(|c: char| { + let code_point: u32 = c.into(); + u8::try_from(code_point).map_err(|_| PngWriteError::IccpInvalidProfileName { + reason: "profile name must contain only Latin-1 code points", + }) + }) + .collect::, PngWriteError>>()?; + + validate_profile_name_bytes(&profile_name_bytes).map_err(|err| match err { + ProfileNameError::NotLatin1 { c } => PngWriteError::IccpProfileNameNotLatin1 { c }, + ProfileNameError::InvalidStructure { reason } => { + PngWriteError::IccpInvalidProfileName { reason } } - } + })?; + + // profile name + Self::write_byte_slice(&profile_name_bytes, buf, "profile name")?; // NUL terminator on profile name Self::write_u8(0_u8, buf, "null separator")?; // compression method (will always be 0) - Self::write_u8(0_u8, buf, "compression method")?; + Self::write_u8(self.compression_method as u8, buf, "compression method")?; // compressed profile - for byte in &self.compressed_profile { - Self::write_u8(*byte, buf, "compressed profile (one byte)")?; - } + Self::write_byte_slice(&self.compressed_profile, buf, "compressed profile")?; Ok(()) } @@ -97,7 +124,51 @@ pub enum CompressionMethod { ZlibDeflate = 0, } -/// Checks if a character in the profile name is allowed. -const fn profile_name_char_is_allowed(c: u8) -> bool { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProfileNameError { + NotLatin1 { c: u8 }, + InvalidStructure { reason: &'static str }, +} + +fn validate_profile_name_bytes(bytes: &[u8]) -> Result<(), ProfileNameError> { + if bytes.is_empty() { + return Err(ProfileNameError::InvalidStructure { + reason: "profile name must contain at least one byte", + }); + } + + if bytes.len() > 79 { + return Err(ProfileNameError::InvalidStructure { + reason: "profile name must be 79 bytes or fewer", + }); + } + + let mut prev_was_space: bool = false; + for (idx, &byte) in bytes.iter().enumerate() { + if !profile_name_byte_is_allowed(byte) { + return Err(ProfileNameError::NotLatin1 { c: byte }); + } + + let is_space: bool = byte == b' '; + if (idx == 0 || idx + 1 == bytes.len()) && is_space { + return Err(ProfileNameError::InvalidStructure { + reason: "profile name may not have leading or trailing spaces", + }); + } + + if prev_was_space && is_space { + return Err(ProfileNameError::InvalidStructure { + reason: "profile name may not contain consecutive spaces", + }); + } + + prev_was_space = is_space; + } + + Ok(()) +} + +/// Checks if a byte in the profile name is allowed. +const fn profile_name_byte_is_allowed(c: u8) -> bool { (c >= 0x20 && c <= 0x7E) || (c >= 0xA1) } diff --git a/raves_metadata/src/providers/png/error.rs b/raves_metadata/src/providers/png/error.rs index e095c04..b146194 100644 --- a/raves_metadata/src/providers/png/error.rs +++ b/raves_metadata/src/providers/png/error.rs @@ -92,6 +92,19 @@ pub enum PngConstructionError { /// the parser found a tRNS chunk in the datastream anyway. TrnsGivenDisallowedType, + /// An iCCP chunk profile name should be fully Latin-1-encoded, but a + /// character wasn't in Latin-1 bounds. + IccpProfileNameNotLatin1 { + /// The violating character. + c: u8, + }, + + /// The iCCP chunk profile name violated PNG's structural requirements. + IccpInvalidProfileName { + /// The reason the profile name was invalid. + reason: &'static str, + }, + /// The iCCP chunk's compression method should always be zero, but another /// value was found. IccpUnknownCompressionValue { @@ -155,6 +168,12 @@ pub enum PngWriteError { /// The violating character. c: u8, }, + + /// The iCCP chunk profile name violated PNG's structural requirements. + IccpInvalidProfileName { + /// The reason the profile name was invalid. + reason: &'static str, + }, } fn TODO_impl_error_for_PngWriteError() {}