use alloc::string::String;
use alloc::vec::Vec;
#[derive(Debug, Clone)]
pub struct PngProbe {
pub width: u32,
pub height: u32,
pub color_type: ColorType,
pub bit_depth: u8,
pub has_alpha: bool,
pub interlaced: bool,
pub sequence: zencodec::ImageSequence,
pub palette_size: u16,
pub creating_tool: Option<String>,
pub compressed_data_size: u64,
pub raw_data_size: u64,
pub compression_ratio: f32,
pub compression_assessment: CompressionAssessment,
pub recommendations: Vec<Recommendation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorType {
Grayscale,
Rgb,
Indexed,
GrayscaleAlpha,
Rgba,
}
impl ColorType {
fn from_png(ct: u8) -> Self {
match ct {
0 => Self::Grayscale,
2 => Self::Rgb,
3 => Self::Indexed,
4 => Self::GrayscaleAlpha,
6 => Self::Rgba,
_ => Self::Rgb, }
}
fn channels(self) -> u8 {
match self {
Self::Grayscale => 1,
Self::Rgb => 3,
Self::Indexed => 1,
Self::GrayscaleAlpha => 2,
Self::Rgba => 4,
}
}
}
#[derive(Debug, Clone)]
pub enum CompressionAssessment {
Optimal,
Improvable {
estimated_saving_pct: f32,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Recommendation {
RemoveInterlacing,
DropUnusedAlpha,
ReduceBitDepth,
ConvertToIndexed {
estimated_colors: u32,
},
AlreadyOptimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProbeError {
TooShort,
NotPng,
Truncated,
}
impl core::fmt::Display for ProbeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::TooShort => write!(f, "data too short to be a PNG file"),
Self::NotPng => write!(f, "not a PNG file (missing signature)"),
Self::Truncated => write!(f, "truncated PNG file"),
}
}
}
impl std::error::Error for ProbeError {}
const PNG_SIG: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
pub fn probe(data: &[u8]) -> Result<PngProbe, ProbeError> {
if data.len() < 8 {
return Err(ProbeError::TooShort);
}
if data[..8] != PNG_SIG {
return Err(ProbeError::NotPng);
}
if data.len() < 8 + 8 + 13 {
return Err(ProbeError::Truncated);
}
let ihdr_len = u32::from_be_bytes(data[8..12].try_into().unwrap()) as usize;
if &data[12..16] != b"IHDR" || ihdr_len != 13 || data.len() < 33 {
return Err(ProbeError::Truncated);
}
let ihdr_data = &data[16..29];
let width = u32::from_be_bytes(ihdr_data[0..4].try_into().unwrap());
let height = u32::from_be_bytes(ihdr_data[4..8].try_into().unwrap());
let bit_depth = ihdr_data[8];
let color_type_raw = ihdr_data[9];
let interlace = ihdr_data[12];
let color_type = ColorType::from_png(color_type_raw);
let interlaced = interlace == 1;
let mut creating_tool: Option<String> = None;
let mut idat_total: u64 = 0;
let mut has_trns = false;
let mut palette_size: u16 = 0;
let mut sequence = zencodec::ImageSequence::Single;
let mut pos = 8; while pos + 12 <= data.len() {
let chunk_len = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
let chunk_type = &data[pos + 4..pos + 8];
let chunk_data_start = pos + 8;
let chunk_data_end = (chunk_data_start + chunk_len).min(data.len());
match chunk_type {
b"IDAT" | b"fdAT" => {
idat_total += chunk_len as u64;
}
b"PLTE" => {
palette_size = (chunk_len / 3) as u16;
}
b"tRNS" => {
has_trns = true;
}
b"acTL" => {
let frame_count = if chunk_data_end - chunk_data_start >= 4 {
Some(u32::from_be_bytes(
data[chunk_data_start..chunk_data_start + 4]
.try_into()
.unwrap(),
))
} else {
None
};
sequence = zencodec::ImageSequence::Animation {
frame_count,
loop_count: None,
random_access: false,
};
}
b"tEXt" => {
if chunk_data_end > chunk_data_start {
let chunk_bytes = &data[chunk_data_start..chunk_data_end];
if let Some(null_pos) = chunk_bytes.iter().position(|&b| b == 0) {
let keyword = core::str::from_utf8(&chunk_bytes[..null_pos]).ok();
let value_bytes = &chunk_bytes[null_pos + 1..];
let value = core::str::from_utf8(value_bytes).ok();
if let (Some(kw), Some(val)) = (keyword, value)
&& (kw == "Software" || kw == "Creator" || kw == "Comment")
&& creating_tool.is_none()
{
creating_tool = Some(String::from(val));
}
}
}
}
b"iTXt" => {
if chunk_data_end > chunk_data_start {
let chunk_bytes = &data[chunk_data_start..chunk_data_end];
if let Some(null_pos) = chunk_bytes.iter().position(|&b| b == 0) {
let keyword = core::str::from_utf8(&chunk_bytes[..null_pos]).ok();
if let Some(kw) = keyword
&& (kw == "Software" || kw == "Creator")
&& creating_tool.is_none()
{
let rest = &chunk_bytes[null_pos + 1..];
if rest.len() >= 2 {
let after_method = &rest[2..];
if let Some(p1) = after_method.iter().position(|&b| b == 0) {
let after_lang = &after_method[p1 + 1..];
if let Some(p2) = after_lang.iter().position(|&b| b == 0) {
let text = &after_lang[p2 + 1..];
if let Ok(s) = core::str::from_utf8(text) {
creating_tool = Some(String::from(s));
}
}
}
}
}
}
}
}
_ => {}
}
pos = chunk_data_start + chunk_len + 4;
}
let has_alpha =
color_type == ColorType::GrayscaleAlpha || color_type == ColorType::Rgba || has_trns;
let channels = if has_trns && color_type == ColorType::Indexed {
1 } else {
color_type.channels()
};
let raw_data_size =
width as u64 * height as u64 * channels as u64 * (bit_depth.max(8) as u64 / 8);
let compression_ratio = if raw_data_size > 0 {
idat_total as f32 / raw_data_size as f32
} else {
1.0
};
let compression_assessment = if compression_ratio < 0.15 {
CompressionAssessment::Optimal
} else {
let estimated_saving = match compression_ratio {
r if r > 0.8 => 25.0, r if r > 0.5 => 15.0, r if r > 0.3 => 8.0, _ => 3.0, };
CompressionAssessment::Improvable {
estimated_saving_pct: estimated_saving,
}
};
let mut recommendations = Vec::new();
if interlaced {
recommendations.push(Recommendation::RemoveInterlacing);
}
if color_type == ColorType::Rgba && !has_trns {
recommendations.push(Recommendation::DropUnusedAlpha);
}
if bit_depth == 16 {
recommendations.push(Recommendation::ReduceBitDepth);
}
if recommendations.is_empty()
&& matches!(compression_assessment, CompressionAssessment::Optimal)
{
recommendations.push(Recommendation::AlreadyOptimal);
}
Ok(PngProbe {
width,
height,
color_type,
bit_depth,
has_alpha,
interlaced,
sequence,
palette_size,
creating_tool,
compressed_data_size: idat_total,
raw_data_size,
compression_ratio,
compression_assessment,
recommendations,
})
}
impl PngProbe {
pub fn is_improvable(&self) -> bool {
matches!(
self.compression_assessment,
CompressionAssessment::Improvable { .. }
)
}
pub fn recommended_effort(&self) -> u32 {
match self.compression_ratio {
r if r > 0.7 => 7, r if r > 0.4 => 13, r if r > 0.2 => 19, _ => 27, }
}
pub fn bits_per_pixel(&self) -> u16 {
self.color_type.channels() as u16 * self.bit_depth as u16
}
}
impl zencodec::SourceEncodingDetails for PngProbe {
fn source_generic_quality(&self) -> Option<f32> {
None
}
fn is_lossless(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_probe_too_short() {
assert_eq!(probe(&[]).unwrap_err(), ProbeError::TooShort);
assert_eq!(probe(&[0; 7]).unwrap_err(), ProbeError::TooShort);
}
#[test]
fn test_probe_not_png() {
assert_eq!(probe(&[0; 32]).unwrap_err(), ProbeError::NotPng);
}
#[test]
fn test_color_type_channels() {
assert_eq!(ColorType::Grayscale.channels(), 1);
assert_eq!(ColorType::Rgb.channels(), 3);
assert_eq!(ColorType::Indexed.channels(), 1);
assert_eq!(ColorType::GrayscaleAlpha.channels(), 2);
assert_eq!(ColorType::Rgba.channels(), 4);
}
}