use fovea::image::{Image, ImageView, PlainImage};
use fovea::pixel::{
Indexed8, Mono8, Mono16, MonoA8, MonoA16, PlainPixel, Rgb8, Rgb16, Rgba8, Rgba16, Srgb8,
Srgb16, SrgbMono8, SrgbMono16, SrgbMonoA8, SrgbMonoA16, Srgba8, Srgba16,
};
use crate::IoError;
impl std::fmt::Debug for PngImage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PngImage::SrgbMono8(img) => write!(f, "SrgbMono8({}x{})", img.width(), img.height()),
PngImage::SrgbMonoA8(img) => write!(f, "SrgbMonoA8({}x{})", img.width(), img.height()),
PngImage::Srgb8(img) => write!(f, "Srgb8({}x{})", img.width(), img.height()),
PngImage::Srgba8(img) => write!(f, "Srgba8({}x{})", img.width(), img.height()),
PngImage::Mono8(img) => write!(f, "Mono8({}x{})", img.width(), img.height()),
PngImage::MonoA8(img) => write!(f, "MonoA8({}x{})", img.width(), img.height()),
PngImage::Rgb8(img) => write!(f, "Rgb8({}x{})", img.width(), img.height()),
PngImage::Rgba8(img) => write!(f, "Rgba8({}x{})", img.width(), img.height()),
PngImage::SrgbMono16(img) => write!(f, "SrgbMono16({}x{})", img.width(), img.height()),
PngImage::SrgbMonoA16(img) => {
write!(f, "SrgbMonoA16({}x{})", img.width(), img.height())
}
PngImage::Srgb16(img) => write!(f, "Srgb16({}x{})", img.width(), img.height()),
PngImage::Srgba16(img) => write!(f, "Srgba16({}x{})", img.width(), img.height()),
PngImage::Mono16(img) => write!(f, "Mono16({}x{})", img.width(), img.height()),
PngImage::MonoA16(img) => write!(f, "MonoA16({}x{})", img.width(), img.height()),
PngImage::Rgb16(img) => write!(f, "Rgb16({}x{})", img.width(), img.height()),
PngImage::Rgba16(img) => write!(f, "Rgba16({}x{})", img.width(), img.height()),
PngImage::Indexed8 { data, .. } => {
write!(f, "Indexed8({}x{})", data.width(), data.height())
}
}
}
}
pub enum PngImage {
SrgbMono8(Image<SrgbMono8>),
SrgbMonoA8(Image<SrgbMonoA8>),
Srgb8(Image<Srgb8>),
Srgba8(Image<Srgba8>),
Mono8(Image<Mono8>),
MonoA8(Image<MonoA8>),
Rgb8(Image<Rgb8>),
Rgba8(Image<Rgba8>),
SrgbMono16(Image<SrgbMono16>),
SrgbMonoA16(Image<SrgbMonoA16>),
Srgb16(Image<Srgb16>),
Srgba16(Image<Srgba16>),
Mono16(Image<Mono16>),
MonoA16(Image<MonoA16>),
Rgb16(Image<Rgb16>),
Rgba16(Image<Rgba16>),
Indexed8 {
data: Image<Indexed8>,
palette: Box<[Srgba8; 256]>,
},
}
impl PngImage {
#[must_use]
pub fn width(&self) -> usize {
use fovea::image::ImageView;
match self {
PngImage::SrgbMono8(img) => img.width(),
PngImage::SrgbMonoA8(img) => img.width(),
PngImage::Srgb8(img) => img.width(),
PngImage::Srgba8(img) => img.width(),
PngImage::Mono8(img) => img.width(),
PngImage::MonoA8(img) => img.width(),
PngImage::Rgb8(img) => img.width(),
PngImage::Rgba8(img) => img.width(),
PngImage::SrgbMono16(img) => img.width(),
PngImage::SrgbMonoA16(img) => img.width(),
PngImage::Srgb16(img) => img.width(),
PngImage::Srgba16(img) => img.width(),
PngImage::Mono16(img) => img.width(),
PngImage::MonoA16(img) => img.width(),
PngImage::Rgb16(img) => img.width(),
PngImage::Rgba16(img) => img.width(),
PngImage::Indexed8 { data, .. } => data.width(),
}
}
#[must_use]
pub fn height(&self) -> usize {
use fovea::image::ImageView;
match self {
PngImage::SrgbMono8(img) => img.height(),
PngImage::SrgbMonoA8(img) => img.height(),
PngImage::Srgb8(img) => img.height(),
PngImage::Srgba8(img) => img.height(),
PngImage::Mono8(img) => img.height(),
PngImage::MonoA8(img) => img.height(),
PngImage::Rgb8(img) => img.height(),
PngImage::Rgba8(img) => img.height(),
PngImage::SrgbMono16(img) => img.height(),
PngImage::SrgbMonoA16(img) => img.height(),
PngImage::Srgb16(img) => img.height(),
PngImage::Srgba16(img) => img.height(),
PngImage::Mono16(img) => img.height(),
PngImage::MonoA16(img) => img.height(),
PngImage::Rgb16(img) => img.height(),
PngImage::Rgba16(img) => img.height(),
PngImage::Indexed8 { data, .. } => data.height(),
}
}
#[must_use]
pub fn size(&self) -> fovea::Size {
fovea::Size::new(self.width(), self.height())
}
}
#[derive(Debug, Clone)]
pub enum PngColorSpace {
Unknown,
Srgb {
rendering_intent: u8,
},
Gamma {
gamma: f64,
chromaticities: Option<[f64; 8]>,
},
IccProfile {
profile: Box<[u8]>,
},
Cicp {
colour_primaries: u8,
transfer_characteristics: u8,
matrix_coefficients: u8,
video_full_range: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PngTextChunk {
pub keyword: String,
pub text: String,
pub language: Option<String>,
pub translated_keyword: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PngTimestamp {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
#[derive(Debug, Clone)]
pub struct PngMetadata {
pub color_space: PngColorSpace,
pub source_bit_depth: u8,
pub significant_bits: Option<Box<[u8]>>,
pub pixel_dimensions: Option<(u32, u32, u8)>,
pub text_chunks: Vec<PngTextChunk>,
pub last_modified: Option<PngTimestamp>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct PngDecoded {
pub image: PngImage,
pub metadata: PngMetadata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TransferAssumption {
Linear,
Srgb,
Unknown,
}
impl TransferAssumption {
fn from_color_space(cs: &PngColorSpace) -> Self {
match cs {
PngColorSpace::Cicp {
transfer_characteristics: 8,
..
} => TransferAssumption::Linear,
PngColorSpace::Cicp {
transfer_characteristics: 1 | 13,
..
} => TransferAssumption::Srgb,
PngColorSpace::Cicp { .. } => TransferAssumption::Srgb,
PngColorSpace::Srgb { .. } => TransferAssumption::Srgb,
PngColorSpace::Gamma { gamma, .. } if (*gamma - 1.0).abs() < 0.001 => {
TransferAssumption::Linear
}
PngColorSpace::Gamma { .. } => TransferAssumption::Srgb,
PngColorSpace::IccProfile { .. } => TransferAssumption::Srgb,
PngColorSpace::Unknown => TransferAssumption::Unknown,
}
}
fn is_linear(self, bit_depth: png::BitDepth) -> bool {
match self {
TransferAssumption::Linear => true,
TransferAssumption::Srgb => false,
TransferAssumption::Unknown => bit_depth == png::BitDepth::Sixteen,
}
}
}
pub fn decode(data: &[u8]) -> Result<PngDecoded, IoError> {
let time = parse_time_chunk(data);
let mut decoded = decode_reader(std::io::Cursor::new(data))?;
decoded.metadata.last_modified = time;
Ok(decoded)
}
pub fn decode_reader(reader: impl std::io::Read) -> Result<PngDecoded, IoError> {
use fovea::pixel::PlainPixel;
use png::{BitDepth, ColorType, Transformations};
let mut decoder = png::Decoder::new(reader);
decoder.set_transformations(Transformations::IDENTITY);
let mut png_reader = decoder.read_info().map_err(decode_error)?;
let info = png_reader.info();
let width = info.width as usize;
let height = info.height as usize;
let color_type = info.color_type;
let bit_depth = info.bit_depth;
let source_bit_depth = bit_depth as u8;
let metadata = extract_metadata(info, source_bit_depth);
if let PngColorSpace::Cicp {
transfer_characteristics: 16 | 18,
..
} = &metadata.color_space
{
return Err(IoError::UnsupportedFeature {
reason: "PQ (16) and HLG (18) transfer functions are not supported",
});
}
let transfer = TransferAssumption::from_color_space(&metadata.color_space);
let mut buf = vec![0u8; png_reader.output_buffer_size()];
let frame_info = png_reader.next_frame(&mut buf).map_err(decode_error)?;
let line_size = frame_info.line_size;
buf.truncate(frame_info.buffer_size());
let image = match (color_type, bit_depth) {
(ColorType::Grayscale, BitDepth::One | BitDepth::Two | BitDepth::Four) => {
let is_linear = transfer.is_linear(BitDepth::Eight);
let bits_per_sample = source_bit_depth as usize;
let scale = match bit_depth {
BitDepth::One => 255u8, BitDepth::Two => 85u8, BitDepth::Four => 17u8, _ => unreachable!(),
};
let unpacked = unpack_sub_byte(&buf, width, height, line_size, bits_per_sample);
if is_linear {
let pixels: Vec<Mono8> = unpacked
.into_iter()
.map(|v| Mono8::new(v.saturating_mul(scale)))
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Mono8(img)
} else {
let pixels: Vec<SrgbMono8> = unpacked
.into_iter()
.map(|v| SrgbMono8::new(v.saturating_mul(scale)))
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::SrgbMono8(img)
}
}
(ColorType::Grayscale, BitDepth::Eight) => {
if transfer.is_linear(bit_depth) {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::Mono8(img)
} else {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::SrgbMono8(img)
}
}
(ColorType::Grayscale, BitDepth::Sixteen) => {
if transfer.is_linear(bit_depth) {
let pixels: Vec<Mono16> = buf
.chunks_exact(2)
.map(|c| {
Mono16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Mono16(img)
} else {
let pixels: Vec<SrgbMono16> = buf
.chunks_exact(2)
.map(|c| {
SrgbMono16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::SrgbMono16(img)
}
}
(ColorType::GrayscaleAlpha, BitDepth::Eight) => {
if transfer.is_linear(bit_depth) {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::MonoA8(img)
} else {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::SrgbMonoA8(img)
}
}
(ColorType::GrayscaleAlpha, BitDepth::Sixteen) => {
if transfer.is_linear(bit_depth) {
let pixels: Vec<MonoA16> = buf
.chunks_exact(4)
.map(|c| {
MonoA16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::MonoA16(img)
} else {
let pixels: Vec<SrgbMonoA16> = buf
.chunks_exact(4)
.map(|c| {
SrgbMonoA16::from_bytes_be(c)
.expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::SrgbMonoA16(img)
}
}
(ColorType::Rgb, BitDepth::Eight) => {
if transfer.is_linear(bit_depth) {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::Rgb8(img)
} else {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::Srgb8(img)
}
}
(ColorType::Rgb, BitDepth::Sixteen) => {
if transfer.is_linear(bit_depth) {
let pixels: Vec<Rgb16> = buf
.chunks_exact(6)
.map(|c| {
Rgb16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Rgb16(img)
} else {
let pixels: Vec<Srgb16> = buf
.chunks_exact(6)
.map(|c| {
Srgb16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Srgb16(img)
}
}
(ColorType::Rgba, BitDepth::Eight) => {
if transfer.is_linear(bit_depth) {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::Rgba8(img)
} else {
let img = Image::from_raw_bytes(width, height, buf).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
}
})?;
PngImage::Srgba8(img)
}
}
(ColorType::Rgba, BitDepth::Sixteen) => {
if transfer.is_linear(bit_depth) {
let pixels: Vec<Rgba16> = buf
.chunks_exact(8)
.map(|c| {
Rgba16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Rgba16(img)
} else {
let pixels: Vec<Srgba16> = buf
.chunks_exact(8)
.map(|c| {
Srgba16::from_bytes_be(c).expect("chunk size guaranteed by chunks_exact")
})
.collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Srgba16(img)
}
}
(ColorType::Indexed, BitDepth::One | BitDepth::Two | BitDepth::Four) => {
let bits_per_sample = source_bit_depth as usize;
let unpacked = unpack_sub_byte(&buf, width, height, line_size, bits_per_sample);
let palette_info = png_reader.info();
let palette = build_palette(palette_info)?;
let pixels: Vec<Indexed8> = unpacked.into_iter().map(Indexed8).collect();
let img =
Image::from_vec(width, height, pixels).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Indexed8 { data: img, palette }
}
(ColorType::Indexed, BitDepth::Eight) => {
let palette_info = png_reader.info();
let palette = build_palette(palette_info)?;
let img =
Image::from_raw_bytes(width, height, buf).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions".into(),
})?;
PngImage::Indexed8 { data: img, palette }
}
_ => {
return Err(IoError::UnsupportedFeature {
reason: "unsupported PNG colour type / bit depth combination",
});
}
};
Ok(PngDecoded { image, metadata })
}
fn decode_error(e: png::DecodingError) -> IoError {
if let png::DecodingError::IoError(io) = e {
IoError::Io(io)
} else {
IoError::DecodeFailed {
source: Box::new(e),
}
}
}
fn unpack_sub_byte(
buf: &[u8],
width: usize,
height: usize,
line_size: usize,
bits_per_sample: usize,
) -> Vec<u8> {
debug_assert!(
bits_per_sample == 1 || bits_per_sample == 2 || bits_per_sample == 4,
"unpack_sub_byte only handles 1, 2, or 4 bits per sample"
);
let samples_per_byte = 8 / bits_per_sample;
let mask: u8 = (1u8 << bits_per_sample) - 1;
let mut pixels = Vec::with_capacity(width * height);
for y in 0..height {
let row_start = y * line_size;
let row = &buf[row_start..row_start + line_size];
let mut col = 0;
for &byte in row {
for i in 0..samples_per_byte {
if col >= width {
break;
}
let shift = 8 - bits_per_sample * (i + 1);
let sample = (byte >> shift) & mask;
pixels.push(sample);
col += 1;
}
}
}
pixels
}
fn extract_color_space(info: &png::Info<'_>) -> PngColorSpace {
if let Some(cicp) = &info.coding_independent_code_points {
return PngColorSpace::Cicp {
colour_primaries: cicp.color_primaries,
transfer_characteristics: cicp.transfer_function,
matrix_coefficients: cicp.matrix_coefficients,
video_full_range: cicp.is_video_full_range_image,
};
}
if let Some(icc) = &info.icc_profile {
return PngColorSpace::IccProfile {
profile: icc.to_vec().into_boxed_slice(),
};
}
if let Some(intent) = &info.srgb {
return PngColorSpace::Srgb {
rendering_intent: *intent as u8,
};
}
if let Some(gamma) = &info.gama_chunk {
let chromaticities = info.chrm_chunk.map(|c| {
[
c.white.0.into_value() as f64,
c.white.1.into_value() as f64,
c.red.0.into_value() as f64,
c.red.1.into_value() as f64,
c.green.0.into_value() as f64,
c.green.1.into_value() as f64,
c.blue.0.into_value() as f64,
c.blue.1.into_value() as f64,
]
});
return PngColorSpace::Gamma {
gamma: gamma.into_value() as f64,
chromaticities,
};
}
PngColorSpace::Unknown
}
fn extract_text_chunks(info: &png::Info<'_>) -> Vec<PngTextChunk> {
let mut chunks = Vec::new();
for t in &info.uncompressed_latin1_text {
chunks.push(PngTextChunk {
keyword: t.keyword.clone(),
text: t.text.clone(),
language: None,
translated_keyword: None,
});
}
for z in &info.compressed_latin1_text {
if let Ok(text) = z.get_text() {
chunks.push(PngTextChunk {
keyword: z.keyword.clone(),
text,
language: None,
translated_keyword: None,
});
}
}
for i in &info.utf8_text {
if let Ok(text) = i.get_text() {
chunks.push(PngTextChunk {
keyword: i.keyword.clone(),
text,
language: if i.language_tag.is_empty() {
None
} else {
Some(i.language_tag.clone())
},
translated_keyword: if i.translated_keyword.is_empty() {
None
} else {
Some(i.translated_keyword.clone())
},
});
}
}
chunks
}
fn extract_metadata(info: &png::Info<'_>, source_bit_depth: u8) -> PngMetadata {
let color_space = extract_color_space(info);
let significant_bits = info
.sbit
.as_ref()
.map(|sbit| sbit.to_vec().into_boxed_slice());
let pixel_dimensions = info.pixel_dims.map(|pd| {
let unit = match pd.unit {
png::Unit::Meter => 1u8,
png::Unit::Unspecified => 0u8,
};
(pd.xppu, pd.yppu, unit)
});
let text_chunks = extract_text_chunks(info);
let last_modified = None;
PngMetadata {
color_space,
source_bit_depth,
significant_bits,
pixel_dimensions,
text_chunks,
last_modified,
}
}
fn build_palette(info: &png::Info<'_>) -> Result<Box<[Srgba8; 256]>, IoError> {
let plte = info.palette.as_ref().ok_or(IoError::InvalidFormat {
reason: "indexed PNG missing PLTE chunk",
})?;
let entry_count = plte.len() / 3;
let trns = info.trns.as_deref().unwrap_or(&[]);
let mut palette = Box::new([Srgba8::new(0, 0, 0, 255); 256]);
for i in 0..entry_count.min(256) {
let r = plte[i * 3];
let g = plte[i * 3 + 1];
let b = plte[i * 3 + 2];
let a = if i < trns.len() { trns[i] } else { 255 };
palette[i] = Srgba8::new(r, g, b, a);
}
Ok(palette)
}
fn parse_time_chunk(data: &[u8]) -> Option<PngTimestamp> {
if data.len() < 8 {
return None;
}
let mut pos = 8;
while pos + 12 <= data.len() {
let length =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
let chunk_type = &data[pos + 4..pos + 8];
if chunk_type == b"tIME" {
if length == 7 && pos + 8 + 7 <= data.len() {
let d = &data[pos + 8..];
return Some(PngTimestamp {
year: u16::from_be_bytes([d[0], d[1]]),
month: d[2],
day: d[3],
hour: d[4],
minute: d[5],
second: d[6],
});
}
return None; }
pos = pos.checked_add(12 + length)?;
}
None
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferTag {
Srgb,
Linear,
}
mod png_pixel_sealed {
pub trait Sealed {}
}
pub trait PngPixel: png_pixel_sealed::Sealed + PlainPixel {
const PNG_COLOR_TYPE: png::ColorType;
const PNG_BIT_DEPTH: png::BitDepth;
#[doc(hidden)]
const TRANSFER: TransferTag;
}
macro_rules! impl_png_pixel {
($ty:ty, $color:expr, $depth:expr, $transfer:expr) => {
impl png_pixel_sealed::Sealed for $ty {}
impl PngPixel for $ty {
const PNG_COLOR_TYPE: png::ColorType = $color;
const PNG_BIT_DEPTH: png::BitDepth = $depth;
const TRANSFER: TransferTag = $transfer;
}
};
}
impl_png_pixel!(
SrgbMono8,
png::ColorType::Grayscale,
png::BitDepth::Eight,
TransferTag::Srgb
);
impl_png_pixel!(
SrgbMonoA8,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Eight,
TransferTag::Srgb
);
impl_png_pixel!(
Srgb8,
png::ColorType::Rgb,
png::BitDepth::Eight,
TransferTag::Srgb
);
impl_png_pixel!(
Srgba8,
png::ColorType::Rgba,
png::BitDepth::Eight,
TransferTag::Srgb
);
impl_png_pixel!(
Mono8,
png::ColorType::Grayscale,
png::BitDepth::Eight,
TransferTag::Linear
);
impl_png_pixel!(
MonoA8,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Eight,
TransferTag::Linear
);
impl_png_pixel!(
Rgb8,
png::ColorType::Rgb,
png::BitDepth::Eight,
TransferTag::Linear
);
impl_png_pixel!(
Rgba8,
png::ColorType::Rgba,
png::BitDepth::Eight,
TransferTag::Linear
);
impl_png_pixel!(
SrgbMono16,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
TransferTag::Srgb
);
impl_png_pixel!(
SrgbMonoA16,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Sixteen,
TransferTag::Srgb
);
impl_png_pixel!(
Srgb16,
png::ColorType::Rgb,
png::BitDepth::Sixteen,
TransferTag::Srgb
);
impl_png_pixel!(
Srgba16,
png::ColorType::Rgba,
png::BitDepth::Sixteen,
TransferTag::Srgb
);
impl_png_pixel!(
Mono16,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
TransferTag::Linear
);
impl_png_pixel!(
MonoA16,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Sixteen,
TransferTag::Linear
);
impl_png_pixel!(
Rgb16,
png::ColorType::Rgb,
png::BitDepth::Sixteen,
TransferTag::Linear
);
impl_png_pixel!(
Rgba16,
png::ColorType::Rgba,
png::BitDepth::Sixteen,
TransferTag::Linear
);
impl_png_pixel!(
Indexed8,
png::ColorType::Indexed,
png::BitDepth::Eight,
TransferTag::Srgb
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PngFilterStrategy {
None,
Sub,
Up,
Avg,
Paeth,
Adaptive,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default)]
pub struct PngEncodeOptions {
pub text_chunks: Vec<PngTextChunk>,
pub pixel_dimensions: Option<(u32, u32, u8)>,
pub last_modified: Option<PngTimestamp>,
pub compression_level: Option<u8>,
pub filter: Option<PngFilterStrategy>,
}
pub fn encode<P: PngPixel>(
image: &(impl ImageView<Pixel = P> + PlainImage),
options: &PngEncodeOptions,
) -> Result<Vec<u8>, IoError> {
let mut buf = Vec::new();
encode_writer(image, &mut buf, options)?;
Ok(buf)
}
pub fn encode_writer<P: PngPixel>(
image: &(impl ImageView<Pixel = P> + PlainImage),
writer: impl std::io::Write,
options: &PngEncodeOptions,
) -> Result<(), IoError> {
let width = image.width() as u32;
let height = image.height() as u32;
let mut encoder = png::Encoder::new(writer, width, height);
encoder.set_color(P::PNG_COLOR_TYPE);
encoder.set_depth(P::PNG_BIT_DEPTH);
match P::TRANSFER {
TransferTag::Srgb => {
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
}
TransferTag::Linear => {
encoder.set_source_gamma(png::ScaledFloat::new(1.0));
}
}
apply_compression(&mut encoder, options);
apply_pixel_dimensions(&mut encoder, options);
apply_text_chunks(&mut encoder, options)?;
let mut png_writer = encoder.write_header().map_err(encode_error)?;
write_time_chunk(&mut png_writer, options)?;
match P::PNG_BIT_DEPTH {
png::BitDepth::Eight => {
let bytes: &[u8] = image.as_bytes();
png_writer.write_image_data(bytes).map_err(encode_error)?;
}
png::BitDepth::Sixteen => {
let bytes = image.as_bytes_be();
png_writer.write_image_data(&bytes).map_err(encode_error)?;
}
_ => unreachable!("PngPixel only uses Eight or Sixteen"),
}
Ok(())
}
pub fn encode_indexed(
image: &(impl ImageView<Pixel = Indexed8> + PlainImage),
palette: &[Srgba8],
options: &PngEncodeOptions,
) -> Result<Vec<u8>, IoError> {
let mut buf = Vec::new();
encode_indexed_writer(image, palette, &mut buf, options)?;
Ok(buf)
}
fn encode_indexed_writer(
image: &(impl ImageView<Pixel = Indexed8> + PlainImage),
palette: &[Srgba8],
writer: impl std::io::Write,
options: &PngEncodeOptions,
) -> Result<(), IoError> {
if palette.is_empty() || palette.len() > 256 {
return Err(IoError::EncodeFailed {
source: "palette must have 1\u{2013}256 entries".into(),
});
}
let max_index = (palette.len() - 1) as u8;
for pixel in image.as_slice() {
if pixel.0 > max_index {
return Err(IoError::EncodeFailed {
source: "pixel index exceeds palette size".into(),
});
}
}
let width = image.width() as u32;
let height = image.height() as u32;
let mut encoder = png::Encoder::new(writer, width, height);
encoder.set_color(png::ColorType::Indexed);
encoder.set_depth(png::BitDepth::Eight);
let plte: Vec<u8> = palette.iter().flat_map(|c| [c.r.0, c.g.0, c.b.0]).collect();
encoder.set_palette(plte);
let has_transparency = palette.iter().any(|c| c.a.0 < 255);
if has_transparency {
let mut trns: Vec<u8> = palette.iter().map(|c| c.a.0).collect();
while trns.last() == Some(&255) {
trns.pop();
}
encoder.set_trns(trns);
}
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
apply_compression(&mut encoder, options);
apply_pixel_dimensions(&mut encoder, options);
apply_text_chunks(&mut encoder, options)?;
let mut png_writer = encoder.write_header().map_err(encode_error)?;
write_time_chunk(&mut png_writer, options)?;
let bytes: &[u8] = image.as_bytes();
png_writer.write_image_data(bytes).map_err(encode_error)?;
Ok(())
}
pub fn encode_png_image(image: &PngImage, options: &PngEncodeOptions) -> Result<Vec<u8>, IoError> {
match image {
PngImage::SrgbMono8(img) => encode(img, options),
PngImage::SrgbMonoA8(img) => encode(img, options),
PngImage::Srgb8(img) => encode(img, options),
PngImage::Srgba8(img) => encode(img, options),
PngImage::Mono8(img) => encode(img, options),
PngImage::MonoA8(img) => encode(img, options),
PngImage::Rgb8(img) => encode(img, options),
PngImage::Rgba8(img) => encode(img, options),
PngImage::SrgbMono16(img) => encode(img, options),
PngImage::SrgbMonoA16(img) => encode(img, options),
PngImage::Srgb16(img) => encode(img, options),
PngImage::Srgba16(img) => encode(img, options),
PngImage::Mono16(img) => encode(img, options),
PngImage::MonoA16(img) => encode(img, options),
PngImage::Rgb16(img) => encode(img, options),
PngImage::Rgba16(img) => encode(img, options),
PngImage::Indexed8 { data, palette } => encode_indexed(data, palette.as_ref(), options),
}
}
fn encode_error(e: png::EncodingError) -> IoError {
use png::EncodingError;
match e {
EncodingError::IoError(io) => IoError::Io(io),
other => IoError::EncodeFailed {
source: Box::new(other),
},
}
}
fn apply_compression<W: std::io::Write>(encoder: &mut png::Encoder<W>, options: &PngEncodeOptions) {
if let Some(level) = options.compression_level {
let compression = match level {
0..=2 => png::Compression::Fast,
3..=6 => png::Compression::Default,
_ => png::Compression::Best,
};
encoder.set_compression(compression);
}
if let Some(ref filter) = options.filter {
match filter {
PngFilterStrategy::Adaptive => {
encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive);
}
other => {
let ft = match other {
PngFilterStrategy::None => png::FilterType::NoFilter,
PngFilterStrategy::Sub => png::FilterType::Sub,
PngFilterStrategy::Up => png::FilterType::Up,
PngFilterStrategy::Avg => png::FilterType::Avg,
PngFilterStrategy::Paeth => png::FilterType::Paeth,
PngFilterStrategy::Adaptive => unreachable!(),
};
encoder.set_filter(ft);
}
}
}
}
fn apply_pixel_dimensions<W: std::io::Write>(
encoder: &mut png::Encoder<W>,
options: &PngEncodeOptions,
) {
if let Some((xppu, yppu, unit)) = options.pixel_dimensions {
encoder.set_pixel_dims(Some(png::PixelDimensions {
xppu,
yppu,
unit: if unit == 1 {
png::Unit::Meter
} else {
png::Unit::Unspecified
},
}));
}
}
fn apply_text_chunks<W: std::io::Write>(
encoder: &mut png::Encoder<W>,
options: &PngEncodeOptions,
) -> Result<(), IoError> {
for chunk in &options.text_chunks {
if chunk.language.is_some() {
encoder
.add_itxt_chunk(chunk.keyword.clone(), chunk.text.clone())
.map_err(encode_error)?;
} else {
encoder
.add_text_chunk(chunk.keyword.clone(), chunk.text.clone())
.map_err(encode_error)?;
}
}
Ok(())
}
fn write_time_chunk<W: std::io::Write>(
writer: &mut png::Writer<W>,
options: &PngEncodeOptions,
) -> Result<(), IoError> {
if let Some(ref ts) = options.last_modified {
let mut time_data = [0u8; 7];
time_data[0..2].copy_from_slice(&ts.year.to_be_bytes());
time_data[2] = ts.month;
time_data[3] = ts.day;
time_data[4] = ts.hour;
time_data[5] = ts.minute;
time_data[6] = ts.second;
writer
.write_chunk(png::chunk::tIME, &time_data)
.map_err(encode_error)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use fovea::image::ContiguousImage;
use std::mem;
use std::num::Saturating;
#[test]
fn png_image_enum_is_compact() {
let indexed_variant_size =
mem::size_of::<Image<Indexed8>>() + mem::size_of::<Box<[Srgba8; 256]>>();
let mono_variant_size = mem::size_of::<Image<SrgbMono8>>();
assert!(
indexed_variant_size < mono_variant_size + 64,
"Indexed8 variant is too large ({indexed_variant_size} bytes), \
suggests the palette is inline instead of boxed"
);
}
#[test]
fn png_decoded_field_access() {
let decoded = PngDecoded {
image: PngImage::SrgbMono8(Image::generate(1, 1, |_, _| SrgbMono8::new(42))),
metadata: PngMetadata {
color_space: PngColorSpace::Unknown,
source_bit_depth: 8,
significant_bits: None,
pixel_dimensions: None,
text_chunks: vec![],
last_modified: None,
},
};
let _img = decoded.image;
let _meta = decoded.metadata;
}
#[test]
fn color_space_variants_are_constructible() {
let _ = PngColorSpace::Unknown;
let _ = PngColorSpace::Srgb {
rendering_intent: 0,
};
let _ = PngColorSpace::Gamma {
gamma: 0.45455,
chromaticities: None,
};
let _ = PngColorSpace::IccProfile {
profile: vec![0u8; 128].into_boxed_slice(),
};
let _ = PngColorSpace::Cicp {
colour_primaries: 1,
transfer_characteristics: 13,
matrix_coefficients: 0,
video_full_range: true,
};
}
#[test]
fn text_chunk_is_constructible() {
let chunk = PngTextChunk {
keyword: "Title".into(),
text: "Test Image".into(),
language: None,
translated_keyword: None,
};
let clone = chunk.clone();
assert_eq!(chunk, clone);
assert_eq!(chunk.keyword, "Title");
assert_eq!(chunk.text, "Test Image");
assert!(chunk.language.is_none());
assert!(chunk.translated_keyword.is_none());
}
#[test]
fn timestamp_is_constructible_and_copy() {
let ts = PngTimestamp {
year: 2025,
month: 1,
day: 15,
hour: 12,
minute: 30,
second: 45,
};
let copy = ts; assert_eq!(ts, copy);
}
#[test]
fn timestamp_allows_leap_second() {
let ts = PngTimestamp {
year: 2016,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 60,
};
assert_eq!(ts.second, 60);
}
#[test]
fn metadata_fully_populated() {
let meta = PngMetadata {
color_space: PngColorSpace::Srgb {
rendering_intent: 1,
},
source_bit_depth: 8,
significant_bits: Some(vec![8, 8, 8].into_boxed_slice()),
pixel_dimensions: Some((3780, 3780, 1)),
text_chunks: vec![PngTextChunk {
keyword: "Software".into(),
text: "fovea-io".into(),
language: None,
translated_keyword: None,
}],
last_modified: Some(PngTimestamp {
year: 2025,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
}),
};
assert_eq!(meta.source_bit_depth, 8);
assert!(meta.significant_bits.is_some());
assert!(meta.pixel_dimensions.is_some());
assert_eq!(meta.text_chunks.len(), 1);
assert!(meta.last_modified.is_some());
}
#[test]
fn metadata_minimal() {
let meta = PngMetadata {
color_space: PngColorSpace::Unknown,
source_bit_depth: 1,
significant_bits: None,
pixel_dimensions: None,
text_chunks: vec![],
last_modified: None,
};
assert!(meta.significant_bits.is_none());
assert!(meta.pixel_dimensions.is_none());
assert!(meta.text_chunks.is_empty());
assert!(meta.last_modified.is_none());
}
#[test]
fn icc_profile_is_boxed_slice() {
let profile_data = vec![0u8; 1024];
let cs = PngColorSpace::IccProfile {
profile: profile_data.into_boxed_slice(),
};
if let PngColorSpace::IccProfile { profile } = cs {
assert_eq!(profile.len(), 1024);
} else {
panic!("expected IccProfile variant");
}
}
fn encode_png(
width: u32,
height: u32,
color_type: png::ColorType,
bit_depth: png::BitDepth,
data: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(color_type);
encoder.set_depth(bit_depth);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(data).unwrap();
}
out
}
fn encode_indexed_png(
width: u32,
height: u32,
bit_depth: png::BitDepth,
palette: &[u8],
trns: Option<&[u8]>,
data: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Indexed);
encoder.set_depth(bit_depth);
encoder.set_palette(palette);
if let Some(t) = trns {
encoder.set_trns(t);
}
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(data).unwrap();
}
out
}
fn encode_png_with_srgb(
width: u32,
height: u32,
color_type: png::ColorType,
bit_depth: png::BitDepth,
intent: png::SrgbRenderingIntent,
data: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(color_type);
encoder.set_depth(bit_depth);
encoder.set_source_srgb(intent);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(data).unwrap();
}
out
}
#[test]
fn decode_grayscale_8bit() {
use fovea::image::ImageView;
let data = [10u8, 20, 30, 40];
let png_data = encode_png(2, 2, png::ColorType::Grayscale, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(10));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(20));
assert_eq!(img.get(0, 1).unwrap(), SrgbMono8::new(30));
assert_eq!(img.get(1, 1).unwrap(), SrgbMono8::new(40));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_16bit() {
use fovea::image::ImageView;
let mut data = Vec::new();
data.extend_from_slice(&1000u16.to_be_bytes());
data.extend_from_slice(&60000u16.to_be_bytes());
let png_data = encode_png(
2,
1,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono16(img) => {
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 1);
assert_eq!(img.get(0, 0).unwrap(), Mono16::new(1000));
assert_eq!(img.get(1, 0).unwrap(), Mono16::new(60000));
}
other => panic!("expected Mono16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_1bit_full_range_scaling() {
use fovea::image::ImageView;
let data = [0x55u8];
let png_data = encode_png(8, 1, png::ColorType::Grayscale, png::BitDepth::One, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.width(), 8);
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(255));
assert_eq!(img.get(2, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(3, 0).unwrap(), SrgbMono8::new(255));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_4bit_full_range_scaling() {
use fovea::image::ImageView;
let data = [0x0Fu8];
let png_data = encode_png(2, 1, png::ColorType::Grayscale, png::BitDepth::Four, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(255));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_rgb_8bit() {
use fovea::image::ImageView;
let data = [100u8, 150, 200];
let png_data = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
let px = img.get(0, 0).unwrap();
assert_eq!(px, Srgb8::new(100, 150, 200));
}
other => panic!("expected Srgb8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_rgba_8bit() {
use fovea::image::ImageView;
let data = [10u8, 20, 30, 128];
let png_data = encode_png(1, 1, png::ColorType::Rgba, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgba8(img) => {
assert_eq!(img.get(0, 0).unwrap(), Srgba8::new(10, 20, 30, 128));
}
other => panic!("expected Srgba8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_rgb_16bit_endianness() {
use fovea::image::ImageView;
let mut data = Vec::new();
data.extend_from_slice(&0x1234u16.to_be_bytes());
data.extend_from_slice(&0x5678u16.to_be_bytes());
data.extend_from_slice(&0x9ABCu16.to_be_bytes());
let png_data = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Sixteen, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgb16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Rgb16::new(0x1234, 0x5678, 0x9ABC));
}
other => panic!("expected Rgb16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_rgba_16bit_endianness() {
use fovea::image::ImageView;
let mut data = Vec::new();
data.extend_from_slice(&100u16.to_be_bytes());
data.extend_from_slice(&200u16.to_be_bytes());
data.extend_from_slice(&300u16.to_be_bytes());
data.extend_from_slice(&400u16.to_be_bytes());
let png_data = encode_png(1, 1, png::ColorType::Rgba, png::BitDepth::Sixteen, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgba16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Rgba16::new(100, 200, 300, 400));
}
other => panic!("expected Rgba16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_alpha_8bit() {
use fovea::image::ImageView;
let data = [100u8, 255, 200, 128];
let png_data = encode_png(
2,
1,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Eight,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMonoA8(img) => {
assert_eq!(img.width(), 2);
let px0 = img.get(0, 0).unwrap();
let px1 = img.get(1, 0).unwrap();
assert_eq!(px0.v.0, 100);
assert_eq!(px0.a.0, 255);
assert_eq!(px1.v.0, 200);
assert_eq!(px1.a.0, 128);
}
other => panic!("expected SrgbMonoA8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_alpha_16bit() {
use fovea::image::ImageView;
let mut data = Vec::new();
data.extend_from_slice(&1000u16.to_be_bytes());
data.extend_from_slice(&50000u16.to_be_bytes());
let png_data = encode_png(
1,
1,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Sixteen,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::MonoA16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px.v.0, 1000);
assert_eq!(px.a.0, 50000);
}
other => panic!("expected MonoA16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_indexed_8bit() {
use fovea::image::ImageView;
let palette = [
255u8, 0, 0, 0, 255, 0, 0, 0, 255, ];
let data = [0u8, 2]; let png_data = encode_indexed_png(2, 1, png::BitDepth::Eight, &palette, None, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Indexed8 {
data: img,
palette: pal,
} => {
assert_eq!(img.width(), 2);
assert_eq!(img.get(0, 0).unwrap().0, 0);
assert_eq!(img.get(1, 0).unwrap().0, 2);
assert_eq!(pal[0], Srgba8::new(255, 0, 0, 255));
assert_eq!(pal[2], Srgba8::new(0, 0, 255, 255));
}
other => panic!("expected Indexed8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_indexed_with_trns() {
let palette = [255u8, 0, 0, 0, 255, 0];
let trns = [128u8, 64];
let data = [0u8, 1];
let png_data = encode_indexed_png(2, 1, png::BitDepth::Eight, &palette, Some(&trns), &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Indexed8 { palette: pal, .. } => {
assert_eq!(pal[0], Srgba8::new(255, 0, 0, 128));
assert_eq!(pal[1], Srgba8::new(0, 255, 0, 64));
}
other => panic!("expected Indexed8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_srgb_chunk_detected() {
let data = [42u8, 43, 44];
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::Rgb,
png::BitDepth::Eight,
png::SrgbRenderingIntent::Perceptual,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.metadata.color_space {
PngColorSpace::Srgb {
rendering_intent: 0,
} => {}
other => panic!("expected Srgb {{ rendering_intent: 0 }}, got {other:?}"),
}
}
#[test]
fn decode_no_color_chunks_gives_unknown() {
let data = [42u8];
let png_data = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
assert!(
matches!(decoded.metadata.color_space, PngColorSpace::Unknown),
"expected Unknown, got {:?}",
decoded.metadata.color_space
);
}
#[test]
fn decode_preserves_dimensions() {
use fovea::image::ImageView;
let data = vec![0u8; 17 * 31 * 3]; let png_data = encode_png(17, 31, png::ColorType::Rgb, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.width(), 17);
assert_eq!(img.height(), 31);
}
other => panic!("expected Srgb8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_invalid_data_returns_error() {
let result = decode(b"not a png file");
assert!(result.is_err());
}
#[test]
fn decode_empty_data_returns_error() {
let result = decode(b"");
assert!(result.is_err());
}
#[test]
fn decode_reader_from_cursor() {
use fovea::image::ImageView;
let data = [10u8, 20, 30];
let png_data = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &data);
let cursor = std::io::Cursor::new(png_data);
let decoded = decode_reader(cursor).unwrap();
match &decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.get(0, 0).unwrap(), Srgb8::new(10, 20, 30));
}
other => panic!("expected Srgb8, got {:?}", variant_name(other)),
}
}
#[test]
fn roundtrip_rgb8_pixel_exact() {
use fovea::image::ImageView;
let data: Vec<u8> = (0..24).collect(); let png_data = encode_png(4, 2, png::ColorType::Rgb, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 2);
for y in 0..2 {
for x in 0..4 {
let idx = (y * 4 + x) * 3;
let expected = Srgb8::new(data[idx], data[idx + 1], data[idx + 2]);
let actual = img.get(x, y).unwrap();
assert_eq!(
actual, expected,
"pixel ({x},{y}): expected {expected:?}, got {actual:?}"
);
}
}
}
other => panic!("expected Srgb8, got {:?}", variant_name(other)),
}
}
#[test]
fn roundtrip_rgb16_pixel_exact() {
use fovea::image::ImageView;
let pixels_native: Vec<(u16, u16, u16)> = vec![(0, 32768, 65535), (1000, 2000, 3000)];
let mut data = Vec::new();
for (r, g, b) in &pixels_native {
data.extend_from_slice(&r.to_be_bytes());
data.extend_from_slice(&g.to_be_bytes());
data.extend_from_slice(&b.to_be_bytes());
}
let png_data = encode_png(2, 1, png::ColorType::Rgb, png::BitDepth::Sixteen, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgb16(img) => {
assert_eq!(img.width(), 2);
let px0 = img.get(0, 0).unwrap();
let px1 = img.get(1, 0).unwrap();
assert_eq!(px0, Rgb16::new(0, 32768, 65535));
assert_eq!(px1, Rgb16::new(1000, 2000, 3000));
}
other => panic!("expected Rgb16, got {:?}", variant_name(other)),
}
}
#[test]
fn roundtrip_mono16_pixel_exact() {
use fovea::image::ImageView;
let values: Vec<u16> = vec![0, 1, 32768, 65534, 65535];
let mut data = Vec::new();
for v in &values {
data.extend_from_slice(&v.to_be_bytes());
}
let png_data = encode_png(
5,
1,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono16(img) => {
assert_eq!(img.width(), 5);
for (i, &v) in values.iter().enumerate() {
assert_eq!(
img.get(i, 0).unwrap(),
Mono16::new(v),
"pixel {i}: expected {v}"
);
}
}
other => panic!("expected Mono16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_grayscale_2bit_full_range_scaling() {
use fovea::image::ImageView;
let data = [0x1Bu8];
let png_data = encode_png(4, 1, png::ColorType::Grayscale, png::BitDepth::Two, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(85));
assert_eq!(img.get(2, 0).unwrap(), SrgbMono8::new(170));
assert_eq!(img.get(3, 0).unwrap(), SrgbMono8::new(255));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
fn variant_name(img: &PngImage) -> &'static str {
match img {
PngImage::SrgbMono8(_) => "SrgbMono8",
PngImage::SrgbMonoA8(_) => "SrgbMonoA8",
PngImage::Srgb8(_) => "Srgb8",
PngImage::Srgba8(_) => "Srgba8",
PngImage::Mono8(_) => "Mono8",
PngImage::MonoA8(_) => "MonoA8",
PngImage::Rgb8(_) => "Rgb8",
PngImage::Rgba8(_) => "Rgba8",
PngImage::SrgbMono16(_) => "SrgbMono16",
PngImage::SrgbMonoA16(_) => "SrgbMonoA16",
PngImage::Srgb16(_) => "Srgb16",
PngImage::Srgba16(_) => "Srgba16",
PngImage::Mono16(_) => "Mono16",
PngImage::MonoA16(_) => "MonoA16",
PngImage::Rgb16(_) => "Rgb16",
PngImage::Rgba16(_) => "Rgba16",
PngImage::Indexed8 { .. } => "Indexed8",
}
}
#[test]
fn transfer_assumption_cicp_linear() {
let cs = PngColorSpace::Cicp {
colour_primaries: 1,
transfer_characteristics: 8,
matrix_coefficients: 0,
video_full_range: true,
};
let ta = TransferAssumption::from_color_space(&cs);
assert_eq!(ta, TransferAssumption::Linear);
assert!(ta.is_linear(png::BitDepth::Eight));
assert!(ta.is_linear(png::BitDepth::Sixteen));
}
#[test]
fn transfer_assumption_cicp_srgb() {
for tf in [1u8, 13] {
let cs = PngColorSpace::Cicp {
colour_primaries: 1,
transfer_characteristics: tf,
matrix_coefficients: 0,
video_full_range: true,
};
let ta = TransferAssumption::from_color_space(&cs);
assert_eq!(ta, TransferAssumption::Srgb, "TF={tf} should be Srgb");
assert!(!ta.is_linear(png::BitDepth::Eight));
assert!(!ta.is_linear(png::BitDepth::Sixteen));
}
}
#[test]
fn transfer_assumption_cicp_other_is_srgb() {
for tf in [16u8, 18, 4, 5, 6, 7, 9, 10, 11, 12, 14, 15, 17] {
let cs = PngColorSpace::Cicp {
colour_primaries: 1,
transfer_characteristics: tf,
matrix_coefficients: 0,
video_full_range: true,
};
let ta = TransferAssumption::from_color_space(&cs);
assert_eq!(ta, TransferAssumption::Srgb, "TF={tf} should be Srgb");
}
}
#[test]
fn transfer_assumption_srgb_chunk() {
let cs = PngColorSpace::Srgb {
rendering_intent: 0,
};
let ta = TransferAssumption::from_color_space(&cs);
assert_eq!(ta, TransferAssumption::Srgb);
assert!(!ta.is_linear(png::BitDepth::Eight));
assert!(!ta.is_linear(png::BitDepth::Sixteen));
}
#[test]
fn transfer_assumption_gamma_linear() {
let cs = PngColorSpace::Gamma {
gamma: 1.0,
chromaticities: None,
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Linear
);
let cs = PngColorSpace::Gamma {
gamma: 0.9995,
chromaticities: None,
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Linear
);
let cs = PngColorSpace::Gamma {
gamma: 1.0005,
chromaticities: None,
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Linear
);
}
#[test]
fn transfer_assumption_gamma_nonlinear() {
let cs = PngColorSpace::Gamma {
gamma: 0.45455,
chromaticities: None,
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Srgb
);
let cs = PngColorSpace::Gamma {
gamma: 2.2,
chromaticities: None,
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Srgb
);
}
#[test]
fn transfer_assumption_icc_profile() {
let cs = PngColorSpace::IccProfile {
profile: vec![0u8; 64].into_boxed_slice(),
};
assert_eq!(
TransferAssumption::from_color_space(&cs),
TransferAssumption::Srgb
);
}
#[test]
fn transfer_assumption_unknown() {
let ta = TransferAssumption::from_color_space(&PngColorSpace::Unknown);
assert_eq!(ta, TransferAssumption::Unknown);
}
#[test]
fn transfer_assumption_is_linear_resolution() {
assert!(!TransferAssumption::Unknown.is_linear(png::BitDepth::Eight));
assert!(!TransferAssumption::Unknown.is_linear(png::BitDepth::Four));
assert!(!TransferAssumption::Unknown.is_linear(png::BitDepth::Two));
assert!(!TransferAssumption::Unknown.is_linear(png::BitDepth::One));
assert!(TransferAssumption::Unknown.is_linear(png::BitDepth::Sixteen));
}
fn encode_png_with_gamma(
width: u32,
height: u32,
color_type: png::ColorType,
bit_depth: png::BitDepth,
gamma: f32,
data: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(color_type);
encoder.set_depth(bit_depth);
encoder.set_source_gamma(png::ScaledFloat::new(gamma));
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(data).unwrap();
}
out
}
#[test]
fn decode_linear_grayscale_8bit_via_gamma() {
use fovea::image::ImageView;
let data = [10u8, 20, 30, 40];
let png_data = encode_png_with_gamma(
2,
2,
png::ColorType::Grayscale,
png::BitDepth::Eight,
1.0,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono8(img) => {
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
assert_eq!(img.get(0, 0).unwrap(), Mono8::new(10));
assert_eq!(img.get(1, 0).unwrap(), Mono8::new(20));
assert_eq!(img.get(0, 1).unwrap(), Mono8::new(30));
assert_eq!(img.get(1, 1).unwrap(), Mono8::new(40));
}
other => panic!("expected Mono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_linear_rgb_8bit_via_gamma() {
use fovea::image::ImageView;
let data = [100u8, 150, 200];
let png_data =
encode_png_with_gamma(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, 1.0, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgb8(img) => {
assert_eq!(img.get(0, 0).unwrap(), Rgb8::new(100, 150, 200));
}
other => panic!("expected Rgb8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_linear_rgba_8bit_via_gamma() {
use fovea::image::ImageView;
let data = [10u8, 20, 30, 128];
let png_data =
encode_png_with_gamma(1, 1, png::ColorType::Rgba, png::BitDepth::Eight, 1.0, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgba8(img) => {
assert_eq!(img.get(0, 0).unwrap(), Rgba8::new(10, 20, 30, 128));
}
other => panic!("expected Rgba8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_linear_grayscale_alpha_8bit_via_gamma() {
use fovea::image::ImageView;
let data = [100u8, 255, 200, 128];
let png_data = encode_png_with_gamma(
2,
1,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Eight,
1.0,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::MonoA8(img) => {
assert_eq!(img.width(), 2);
let px0 = img.get(0, 0).unwrap();
let px1 = img.get(1, 0).unwrap();
assert_eq!(px0.v.0, 100);
assert_eq!(px0.a.0, 255);
assert_eq!(px1.v.0, 200);
assert_eq!(px1.a.0, 128);
}
other => panic!("expected MonoA8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_sub_byte_grayscale_linear_1bit() {
use fovea::image::ImageView;
let data = [0x55u8]; let png_data = encode_png_with_gamma(
8,
1,
png::ColorType::Grayscale,
png::BitDepth::One,
1.0,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono8(img) => {
assert_eq!(img.width(), 8);
assert_eq!(img.get(0, 0).unwrap(), Mono8::new(0));
assert_eq!(img.get(1, 0).unwrap(), Mono8::new(255));
assert_eq!(img.get(2, 0).unwrap(), Mono8::new(0));
assert_eq!(img.get(3, 0).unwrap(), Mono8::new(255));
}
other => panic!("expected Mono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_sub_byte_grayscale_linear_4bit() {
use fovea::image::ImageView;
let data = [0x0Fu8];
let png_data = encode_png_with_gamma(
2,
1,
png::ColorType::Grayscale,
png::BitDepth::Four,
1.0,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono8(img) => {
assert_eq!(img.get(0, 0).unwrap(), Mono8::new(0));
assert_eq!(img.get(1, 0).unwrap(), Mono8::new(255));
}
other => panic!("expected Mono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_srgb_grayscale_8bit_with_srgb_chunk() {
use fovea::image::ImageView;
let data = [42u8];
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::Grayscale,
png::BitDepth::Eight,
png::SrgbRenderingIntent::Perceptual,
&data,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(42));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_untagged_8bit_defaults_to_srgb() {
let data = [42u8];
let png_data = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(_) => {}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_rgb_with_srgb_chunk_produces_srgb16() {
use fovea::image::ImageView;
let mut raw = Vec::new();
raw.extend_from_slice(&0x1234u16.to_be_bytes());
raw.extend_from_slice(&0x5678u16.to_be_bytes());
raw.extend_from_slice(&0x9ABCu16.to_be_bytes());
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::Rgb,
png::BitDepth::Sixteen,
png::SrgbRenderingIntent::Perceptual,
&raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgb16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Srgb16::new(0x1234, 0x5678, 0x9ABC));
}
other => panic!("expected Srgb16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_rgba_with_srgb_chunk_produces_srgba16() {
use fovea::image::ImageView;
let mut raw = Vec::new();
raw.extend_from_slice(&100u16.to_be_bytes());
raw.extend_from_slice(&200u16.to_be_bytes());
raw.extend_from_slice(&300u16.to_be_bytes());
raw.extend_from_slice(&400u16.to_be_bytes());
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::Rgba,
png::BitDepth::Sixteen,
png::SrgbRenderingIntent::Perceptual,
&raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgba16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Srgba16::new(100, 200, 300, 400));
}
other => panic!("expected Srgba16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_grayscale_with_srgb_chunk_produces_srgb_mono16() {
use fovea::image::ImageView;
let raw = 32768u16.to_be_bytes();
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
png::SrgbRenderingIntent::Perceptual,
&raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono16(img) => {
assert_eq!(img.get(0, 0).unwrap(), SrgbMono16::new(32768));
}
other => panic!("expected SrgbMono16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_grayscale_alpha_with_srgb_chunk_produces_srgb_mono_a16() {
use fovea::image::ImageView;
let mut raw = Vec::new();
raw.extend_from_slice(&1000u16.to_be_bytes());
raw.extend_from_slice(&50000u16.to_be_bytes());
let png_data = encode_png_with_srgb(
1,
1,
png::ColorType::GrayscaleAlpha,
png::BitDepth::Sixteen,
png::SrgbRenderingIntent::Perceptual,
&raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMonoA16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px.v.0, 1000);
assert_eq!(px.a.0, 50000);
}
other => panic!("expected SrgbMonoA16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_untagged_defaults_to_linear() {
let raw = 12345u16.to_be_bytes();
let png_data = encode_png(
1,
1,
png::ColorType::Grayscale,
png::BitDepth::Sixteen,
&raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Mono16(_) => {}
other => panic!("expected Mono16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_rgb_with_gamma_1_produces_rgb16() {
use fovea::image::ImageView;
let mut raw = Vec::new();
raw.extend_from_slice(&100u16.to_be_bytes());
raw.extend_from_slice(&200u16.to_be_bytes());
raw.extend_from_slice(&300u16.to_be_bytes());
let png_data =
encode_png_with_gamma(1, 1, png::ColorType::Rgb, png::BitDepth::Sixteen, 1.0, &raw);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Rgb16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Rgb16::new(100, 200, 300));
}
other => panic!("expected Rgb16, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_16bit_rgb_with_nonlinear_gamma_produces_srgb16() {
use fovea::image::ImageView;
let mut raw = Vec::new();
raw.extend_from_slice(&100u16.to_be_bytes());
raw.extend_from_slice(&200u16.to_be_bytes());
raw.extend_from_slice(&300u16.to_be_bytes());
let png_data = encode_png_with_gamma(
1,
1,
png::ColorType::Rgb,
png::BitDepth::Sixteen,
0.45455, &raw,
);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Srgb16(img) => {
let px = img.get(0, 0).unwrap();
assert_eq!(px, Srgb16::new(100, 200, 300));
}
other => panic!("expected Srgb16, got {:?}", variant_name(other)),
}
}
fn png_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFF_FFFF
}
fn inject_cicp_chunk(
png_bytes: &[u8],
colour_primaries: u8,
transfer_characteristics: u8,
matrix_coefficients: u8,
video_full_range: bool,
) -> Vec<u8> {
let ihdr_end = 8 + 4 + 4 + 13 + 4;
assert!(png_bytes.len() > ihdr_end, "PNG too short for IHDR");
let cicp_data = [
colour_primaries,
transfer_characteristics,
matrix_coefficients,
u8::from(video_full_range),
];
let cicp_type = b"cICP";
let length_bytes = 4u32.to_be_bytes();
let mut crc_input = Vec::with_capacity(8);
crc_input.extend_from_slice(cicp_type);
crc_input.extend_from_slice(&cicp_data);
let crc_bytes = png_crc32(&crc_input).to_be_bytes();
let mut out = Vec::with_capacity(png_bytes.len() + 16);
out.extend_from_slice(&png_bytes[..ihdr_end]);
out.extend_from_slice(&length_bytes);
out.extend_from_slice(cicp_type);
out.extend_from_slice(&cicp_data);
out.extend_from_slice(&crc_bytes);
out.extend_from_slice(&png_bytes[ihdr_end..]);
out
}
fn inject_time_chunk(
png_bytes: &[u8],
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
) -> Vec<u8> {
let ihdr_end = 8 + 4 + 4 + 13 + 4;
assert!(png_bytes.len() > ihdr_end, "PNG too short for IHDR");
let year_bytes = year.to_be_bytes();
let time_data = [
year_bytes[0],
year_bytes[1],
month,
day,
hour,
minute,
second,
];
let time_type = b"tIME";
let length_bytes = 7u32.to_be_bytes();
let mut crc_input = Vec::with_capacity(11);
crc_input.extend_from_slice(time_type);
crc_input.extend_from_slice(&time_data);
let crc_bytes = png_crc32(&crc_input).to_be_bytes();
let mut out = Vec::with_capacity(png_bytes.len() + 19);
out.extend_from_slice(&png_bytes[..ihdr_end]);
out.extend_from_slice(&length_bytes);
out.extend_from_slice(time_type);
out.extend_from_slice(&time_data);
out.extend_from_slice(&crc_bytes);
out.extend_from_slice(&png_bytes[ihdr_end..]);
out
}
#[test]
fn decode_cicp_pq_returns_unsupported() {
let base = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &[0, 0, 0]);
let patched = inject_cicp_chunk(&base, 1, 16, 0, true);
let result = decode(&patched);
assert!(result.is_err(), "PQ should be rejected");
let err = result.unwrap_err();
match err {
IoError::UnsupportedFeature { reason } => {
assert!(
reason.contains("PQ") || reason.contains("16"),
"error should mention PQ: {reason}"
);
}
other => panic!("expected UnsupportedFeature, got: {other}"),
}
}
#[test]
fn decode_cicp_hlg_returns_unsupported() {
let base = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &[0, 0, 0]);
let patched = inject_cicp_chunk(&base, 1, 18, 0, true);
let result = decode(&patched);
assert!(result.is_err(), "HLG should be rejected");
let err = result.unwrap_err();
match err {
IoError::UnsupportedFeature { reason } => {
assert!(
reason.contains("HLG") || reason.contains("18"),
"error should mention HLG: {reason}"
);
}
other => panic!("expected UnsupportedFeature, got: {other}"),
}
}
#[test]
fn decode_cicp_srgb_tf_is_accepted() {
let base = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &[0, 0, 0]);
let patched = inject_cicp_chunk(&base, 1, 13, 0, true);
let result = decode(&patched);
assert!(result.is_ok(), "sRGB cICP (TF=13) should be accepted");
}
#[test]
fn decode_cicp_linear_tf_is_accepted() {
let base = encode_png(1, 1, png::ColorType::Rgb, png::BitDepth::Eight, &[0, 0, 0]);
let patched = inject_cicp_chunk(&base, 1, 8, 0, true);
let result = decode(&patched);
assert!(result.is_ok(), "linear cICP (TF=8) should be accepted");
}
#[test]
fn parse_time_chunk_extracts_timestamp() {
let base = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &[42]);
let patched = inject_time_chunk(&base, 2025, 7, 15, 10, 30, 45);
let ts = parse_time_chunk(&patched);
assert!(ts.is_some(), "should find tIME chunk");
let ts = ts.unwrap();
assert_eq!(ts.year, 2025);
assert_eq!(ts.month, 7);
assert_eq!(ts.day, 15);
assert_eq!(ts.hour, 10);
assert_eq!(ts.minute, 30);
assert_eq!(ts.second, 45);
}
#[test]
fn parse_time_chunk_none_when_absent() {
let base = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &[42]);
assert!(parse_time_chunk(&base).is_none());
}
#[test]
fn parse_time_chunk_too_short() {
assert!(parse_time_chunk(&[]).is_none());
assert!(parse_time_chunk(&[0; 7]).is_none());
}
#[test]
fn decode_populates_last_modified() {
let base = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &[42]);
let patched = inject_time_chunk(&base, 2024, 12, 25, 0, 0, 60);
let decoded = decode(&patched).unwrap();
let ts = decoded
.metadata
.last_modified
.expect("should have timestamp");
assert_eq!(ts.year, 2024);
assert_eq!(ts.month, 12);
assert_eq!(ts.day, 25);
assert_eq!(ts.hour, 0);
assert_eq!(ts.minute, 0);
assert_eq!(ts.second, 60); }
#[test]
fn decode_reader_does_not_populate_last_modified() {
let base = encode_png(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight, &[42]);
let patched = inject_time_chunk(&base, 2025, 1, 1, 0, 0, 0);
let decoded = decode_reader(std::io::Cursor::new(patched)).unwrap();
assert!(
decoded.metadata.last_modified.is_none(),
"decode_reader should not populate last_modified"
);
}
#[test]
fn png_image_debug_shows_variant_and_dimensions() {
let img = PngImage::Srgb8(Image::generate(320, 240, |_, _| Srgb8::new(0, 0, 0)));
let dbg = format!("{:?}", img);
assert_eq!(dbg, "Srgb8(320x240)");
}
#[test]
fn png_image_debug_indexed() {
let img = PngImage::Indexed8 {
data: Image::generate(16, 8, |_, _| Indexed8(0)),
palette: Box::new([Srgba8::new(0, 0, 0, 255); 256]),
};
let dbg = format!("{:?}", img);
assert_eq!(dbg, "Indexed8(16x8)");
}
#[test]
fn png_image_debug_all_variants() {
let variants: Vec<PngImage> = vec![
PngImage::SrgbMono8(Image::generate(1, 1, |_, _| SrgbMono8::new(0))),
PngImage::SrgbMonoA8(Image::generate(1, 1, |_, _| SrgbMonoA8::new(0, 255))),
PngImage::Srgb8(Image::generate(1, 1, |_, _| Srgb8::new(0, 0, 0))),
PngImage::Srgba8(Image::generate(1, 1, |_, _| Srgba8::new(0, 0, 0, 255))),
PngImage::Mono8(Image::generate(2, 3, |_, _| Mono8::new(0))),
PngImage::MonoA8(Image::generate(1, 1, |_, _| MonoA8::new(0, 255))),
PngImage::Rgb8(Image::generate(1, 1, |_, _| Rgb8::new(0, 0, 0))),
PngImage::Rgba8(Image::generate(1, 1, |_, _| Rgba8::new(0, 0, 0, 255))),
PngImage::SrgbMono16(Image::generate(4, 5, |_, _| SrgbMono16::new(0))),
PngImage::SrgbMonoA16(Image::generate(1, 1, |_, _| SrgbMonoA16::new(0, 65535))),
PngImage::Srgb16(Image::generate(1, 1, |_, _| Srgb16::new(0, 0, 0))),
PngImage::Srgba16(Image::generate(1, 1, |_, _| Srgba16::new(0, 0, 0, 65535))),
PngImage::Mono16(Image::generate(6, 7, |_, _| Mono16::new(0))),
PngImage::MonoA16(Image::generate(1, 1, |_, _| MonoA16::new(0, 65535))),
PngImage::Rgb16(Image::generate(8, 9, |_, _| Rgb16::new(0, 0, 0))),
PngImage::Rgba16(Image::generate(1, 1, |_, _| Rgba16::new(0, 0, 0, 65535))),
PngImage::Indexed8 {
data: Image::generate(1, 1, |_, _| Indexed8(0)),
palette: Box::new([Srgba8::new(0, 0, 0, 255); 256]),
},
];
assert_eq!(variants.len(), 17, "must cover all 17 PngImage variants");
for v in &variants {
let dbg = format!("{v:?}");
assert!(!dbg.is_empty());
}
}
#[test]
fn png_decoded_implements_debug() {
let decoded = PngDecoded {
image: PngImage::Mono8(Image::generate(1, 1, |_, _| Mono8::new(0))),
metadata: PngMetadata {
color_space: PngColorSpace::Unknown,
source_bit_depth: 8,
significant_bits: None,
pixel_dimensions: None,
text_chunks: vec![],
last_modified: None,
},
};
let dbg = format!("{:?}", decoded);
assert!(!dbg.is_empty());
}
#[test]
fn variant_name_covers_all() {
let variants: Vec<PngImage> = vec![
PngImage::SrgbMono8(Image::generate(1, 1, |_, _| SrgbMono8::new(0))),
PngImage::SrgbMonoA8(Image::generate(1, 1, |_, _| SrgbMonoA8::new(0, 0))),
PngImage::Srgb8(Image::generate(1, 1, |_, _| Srgb8::new(0, 0, 0))),
PngImage::Srgba8(Image::generate(1, 1, |_, _| Srgba8::new(0, 0, 0, 0))),
PngImage::Mono8(Image::generate(1, 1, |_, _| Mono8::new(0))),
PngImage::MonoA8(Image::generate(1, 1, |_, _| MonoA8::new(0, 0))),
PngImage::Rgb8(Image::generate(1, 1, |_, _| Rgb8::new(0, 0, 0))),
PngImage::Rgba8(Image::generate(1, 1, |_, _| Rgba8::new(0, 0, 0, 0))),
PngImage::SrgbMono16(Image::generate(1, 1, |_, _| SrgbMono16::new(0))),
PngImage::SrgbMonoA16(Image::generate(1, 1, |_, _| SrgbMonoA16::new(0, 0))),
PngImage::Srgb16(Image::generate(1, 1, |_, _| Srgb16::new(0, 0, 0))),
PngImage::Srgba16(Image::generate(1, 1, |_, _| Srgba16::new(0, 0, 0, 0))),
PngImage::Mono16(Image::generate(1, 1, |_, _| Mono16::new(0))),
PngImage::MonoA16(Image::generate(1, 1, |_, _| MonoA16::new(0, 0))),
PngImage::Rgb16(Image::generate(1, 1, |_, _| Rgb16::new(0, 0, 0))),
PngImage::Rgba16(Image::generate(1, 1, |_, _| Rgba16::new(0, 0, 0, 0))),
PngImage::Indexed8 {
data: Image::generate(1, 1, |_, _| Indexed8(0)),
palette: Box::new([Srgba8::new(0, 0, 0, 255); 256]),
},
];
let names: Vec<&str> = variants.iter().map(|v| variant_name(v)).collect();
for name in &names {
assert!(!name.is_empty());
}
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
names.len(),
"duplicate variant names detected: {names:?}"
);
}
#[test]
fn transfer_tag_variants() {
assert_ne!(TransferTag::Srgb, TransferTag::Linear);
}
#[test]
fn transfer_tag_is_copy() {
let t = TransferTag::Srgb;
let t2 = t; assert_eq!(t, t2);
}
#[test]
fn transfer_tag_debug() {
assert_eq!(format!("{:?}", TransferTag::Srgb), "Srgb");
assert_eq!(format!("{:?}", TransferTag::Linear), "Linear");
}
#[test]
fn png_pixel_8bit_srgb() {
assert_eq!(SrgbMono8::PNG_COLOR_TYPE, png::ColorType::Grayscale);
assert_eq!(SrgbMono8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(SrgbMono8::TRANSFER, TransferTag::Srgb);
assert_eq!(SrgbMonoA8::PNG_COLOR_TYPE, png::ColorType::GrayscaleAlpha);
assert_eq!(SrgbMonoA8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(SrgbMonoA8::TRANSFER, TransferTag::Srgb);
assert_eq!(Srgb8::PNG_COLOR_TYPE, png::ColorType::Rgb);
assert_eq!(Srgb8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Srgb8::TRANSFER, TransferTag::Srgb);
assert_eq!(Srgba8::PNG_COLOR_TYPE, png::ColorType::Rgba);
assert_eq!(Srgba8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Srgba8::TRANSFER, TransferTag::Srgb);
}
#[test]
fn png_pixel_8bit_linear() {
assert_eq!(Mono8::PNG_COLOR_TYPE, png::ColorType::Grayscale);
assert_eq!(Mono8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Mono8::TRANSFER, TransferTag::Linear);
assert_eq!(MonoA8::PNG_COLOR_TYPE, png::ColorType::GrayscaleAlpha);
assert_eq!(MonoA8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(MonoA8::TRANSFER, TransferTag::Linear);
assert_eq!(Rgb8::PNG_COLOR_TYPE, png::ColorType::Rgb);
assert_eq!(Rgb8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Rgb8::TRANSFER, TransferTag::Linear);
assert_eq!(Rgba8::PNG_COLOR_TYPE, png::ColorType::Rgba);
assert_eq!(Rgba8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Rgba8::TRANSFER, TransferTag::Linear);
}
#[test]
fn png_pixel_16bit_srgb() {
assert_eq!(SrgbMono16::PNG_COLOR_TYPE, png::ColorType::Grayscale);
assert_eq!(SrgbMono16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(SrgbMono16::TRANSFER, TransferTag::Srgb);
assert_eq!(SrgbMonoA16::PNG_COLOR_TYPE, png::ColorType::GrayscaleAlpha);
assert_eq!(SrgbMonoA16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(SrgbMonoA16::TRANSFER, TransferTag::Srgb);
assert_eq!(Srgb16::PNG_COLOR_TYPE, png::ColorType::Rgb);
assert_eq!(Srgb16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(Srgb16::TRANSFER, TransferTag::Srgb);
assert_eq!(Srgba16::PNG_COLOR_TYPE, png::ColorType::Rgba);
assert_eq!(Srgba16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(Srgba16::TRANSFER, TransferTag::Srgb);
}
#[test]
fn png_pixel_16bit_linear() {
assert_eq!(Mono16::PNG_COLOR_TYPE, png::ColorType::Grayscale);
assert_eq!(Mono16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(Mono16::TRANSFER, TransferTag::Linear);
assert_eq!(MonoA16::PNG_COLOR_TYPE, png::ColorType::GrayscaleAlpha);
assert_eq!(MonoA16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(MonoA16::TRANSFER, TransferTag::Linear);
assert_eq!(Rgb16::PNG_COLOR_TYPE, png::ColorType::Rgb);
assert_eq!(Rgb16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(Rgb16::TRANSFER, TransferTag::Linear);
assert_eq!(Rgba16::PNG_COLOR_TYPE, png::ColorType::Rgba);
assert_eq!(Rgba16::PNG_BIT_DEPTH, png::BitDepth::Sixteen);
assert_eq!(Rgba16::TRANSFER, TransferTag::Linear);
}
#[test]
fn png_pixel_indexed8() {
assert_eq!(Indexed8::PNG_COLOR_TYPE, png::ColorType::Indexed);
assert_eq!(Indexed8::PNG_BIT_DEPTH, png::BitDepth::Eight);
assert_eq!(Indexed8::TRANSFER, TransferTag::Srgb);
}
#[test]
fn png_pixel_all_17_types_compile() {
let _types: [png::ColorType; 17] = [
SrgbMono8::PNG_COLOR_TYPE,
SrgbMonoA8::PNG_COLOR_TYPE,
Srgb8::PNG_COLOR_TYPE,
Srgba8::PNG_COLOR_TYPE,
Mono8::PNG_COLOR_TYPE,
MonoA8::PNG_COLOR_TYPE,
Rgb8::PNG_COLOR_TYPE,
Rgba8::PNG_COLOR_TYPE,
SrgbMono16::PNG_COLOR_TYPE,
SrgbMonoA16::PNG_COLOR_TYPE,
Srgb16::PNG_COLOR_TYPE,
Srgba16::PNG_COLOR_TYPE,
Mono16::PNG_COLOR_TYPE,
MonoA16::PNG_COLOR_TYPE,
Rgb16::PNG_COLOR_TYPE,
Rgba16::PNG_COLOR_TYPE,
Indexed8::PNG_COLOR_TYPE,
];
}
#[test]
fn png_filter_strategy_all_variants() {
let variants = [
PngFilterStrategy::None,
PngFilterStrategy::Sub,
PngFilterStrategy::Up,
PngFilterStrategy::Avg,
PngFilterStrategy::Paeth,
PngFilterStrategy::Adaptive,
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b, "{:?} should differ from {:?}", a, b);
}
}
}
}
#[test]
fn png_filter_strategy_is_copy() {
let f = PngFilterStrategy::Paeth;
let f2 = f; assert_eq!(f, f2);
}
#[test]
fn png_encode_options_default() {
let opts = PngEncodeOptions::default();
assert!(opts.text_chunks.is_empty());
assert!(opts.pixel_dimensions.is_none());
assert!(opts.last_modified.is_none());
assert!(opts.compression_level.is_none());
assert!(opts.filter.is_none());
}
#[test]
fn png_encode_options_with_all_fields() {
let opts = PngEncodeOptions {
text_chunks: vec![PngTextChunk {
keyword: "Title".into(),
text: "Test".into(),
language: None,
translated_keyword: None,
}],
pixel_dimensions: Some((300, 300, 1)),
last_modified: Some(PngTimestamp {
year: 2025,
month: 6,
day: 25,
hour: 14,
minute: 30,
second: 0,
}),
compression_level: Some(9),
filter: Some(PngFilterStrategy::Adaptive),
};
assert_eq!(opts.text_chunks.len(), 1);
assert_eq!(opts.text_chunks[0].keyword, "Title");
assert_eq!(opts.pixel_dimensions, Some((300, 300, 1)));
assert_eq!(opts.compression_level, Some(9));
assert_eq!(opts.filter, Some(PngFilterStrategy::Adaptive));
assert!(opts.last_modified.is_some());
}
#[test]
fn png_encode_options_debug() {
let opts = PngEncodeOptions::default();
let dbg = format!("{:?}", opts);
assert!(dbg.contains("PngEncodeOptions"));
}
#[test]
fn png_encode_options_clone() {
let opts = PngEncodeOptions {
compression_level: Some(3),
filter: Some(PngFilterStrategy::Sub),
..PngEncodeOptions::default()
};
let opts2 = opts.clone();
assert_eq!(opts2.compression_level, Some(3));
assert_eq!(opts2.filter, Some(PngFilterStrategy::Sub));
}
fn roundtrip_encode<P: PngPixel + std::fmt::Debug + PartialEq>(
pixels: Vec<P>,
width: usize,
height: usize,
) -> PngDecoded {
let image = Image::from_vec(width, height, pixels).unwrap();
let opts = PngEncodeOptions::default();
let bytes = encode(&image, &opts).unwrap();
decode(&bytes).unwrap()
}
#[test]
fn encode_roundtrip_srgb_mono8() {
let pixels = vec![SrgbMono8::new(0), SrgbMono8::new(128), SrgbMono8::new(255)];
let decoded = roundtrip_encode(pixels.clone(), 3, 1);
match decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.width(), 3);
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgb_mono_a8() {
let pixels = vec![SrgbMonoA8 {
v: Saturating(100),
a: Saturating(200),
}];
let decoded = roundtrip_encode(pixels.clone(), 1, 1);
match decoded.image {
PngImage::SrgbMonoA8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected SrgbMonoA8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgb8() {
let pixels = vec![Srgb8::new(10, 20, 30), Srgb8::new(200, 100, 50)];
let decoded = roundtrip_encode(pixels.clone(), 2, 1);
match decoded.image {
PngImage::Srgb8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgba8() {
let pixels = vec![Srgba8::new(10, 20, 30, 128)];
let decoded = roundtrip_encode(pixels.clone(), 1, 1);
match decoded.image {
PngImage::Srgba8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Srgba8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_mono8() {
let pixels = vec![Mono8::new(0), Mono8::new(42), Mono8::new(255)];
let image = Image::from_vec(3, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Mono8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Mono8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_mono_a8() {
let pixels = vec![MonoA8 {
v: Saturating(50),
a: Saturating(200),
}];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::MonoA8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected MonoA8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_rgb8() {
let pixels = vec![Rgb8::new(100, 150, 200)];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Rgb8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Rgb8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_rgba8() {
let pixels = vec![Rgba8::new(10, 20, 30, 128)];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Rgba8(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Rgba8, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgb_mono16() {
let pixels = vec![
SrgbMono16::new(0),
SrgbMono16::new(1000),
SrgbMono16::new(65535),
];
let decoded = roundtrip_encode(pixels.clone(), 3, 1);
match decoded.image {
PngImage::SrgbMono16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected SrgbMono16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgb_mono_a16() {
let pixels = vec![SrgbMonoA16 {
v: Saturating(5000),
a: Saturating(60000),
}];
let decoded = roundtrip_encode(pixels.clone(), 1, 1);
match decoded.image {
PngImage::SrgbMonoA16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected SrgbMonoA16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgb16() {
let pixels = vec![Srgb16::new(100, 200, 300)];
let decoded = roundtrip_encode(pixels.clone(), 1, 1);
match decoded.image {
PngImage::Srgb16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Srgb16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_srgba16() {
let pixels = vec![Srgba16::new(100, 200, 300, 400)];
let decoded = roundtrip_encode(pixels.clone(), 1, 1);
match decoded.image {
PngImage::Srgba16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Srgba16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_mono16() {
let pixels = vec![Mono16::new(0), Mono16::new(32768), Mono16::new(65535)];
let image = Image::from_vec(3, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Mono16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Mono16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_mono_a16() {
let pixels = vec![MonoA16 {
v: Saturating(1234),
a: Saturating(5678),
}];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::MonoA16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected MonoA16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_rgb16() {
let pixels = vec![Rgb16::new(0x0102, 0x0304, 0x0506)];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Rgb16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Rgb16, got {:?}", other),
}
}
#[test]
fn encode_roundtrip_rgba16() {
let pixels = vec![Rgba16::new(100, 200, 300, 400)];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Rgba16(img) => assert_eq!(img.as_slice(), &pixels),
other => panic!("expected Rgba16, got {:?}", other),
}
}
#[test]
fn encode_colour_space_srgb_8bit() {
let image = Image::from_vec(1, 1, vec![Srgb8::new(42, 84, 126)]).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(
matches!(decoded.metadata.color_space, PngColorSpace::Srgb { .. }),
"expected Srgb, got {:?}",
decoded.metadata.color_space
);
}
#[test]
fn encode_colour_space_linear_8bit() {
let image = Image::from_vec(1, 1, vec![Rgb8::new(42, 84, 126)]).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.metadata.color_space {
PngColorSpace::Gamma { gamma, .. } => {
assert!(
(gamma - 1.0).abs() < 0.001,
"expected gamma ≈ 1.0, got {}",
gamma
);
}
other => panic!("expected Gamma, got {:?}", other),
}
}
#[test]
fn encode_colour_space_srgb_16bit() {
let image = Image::from_vec(1, 1, vec![Srgb16::new(100, 200, 300)]).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(
matches!(decoded.metadata.color_space, PngColorSpace::Srgb { .. }),
"expected Srgb, got {:?}",
decoded.metadata.color_space
);
assert!(
matches!(decoded.image, PngImage::Srgb16(_)),
"expected Srgb16 variant, got {:?}",
decoded.image
);
}
#[test]
fn encode_colour_space_linear_16bit() {
let image = Image::from_vec(1, 1, vec![Rgb16::new(100, 200, 300)]).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.metadata.color_space {
PngColorSpace::Gamma { gamma, .. } => {
assert!(
(gamma - 1.0).abs() < 0.001,
"expected gamma ≈ 1.0, got {}",
gamma
);
}
other => panic!("expected Gamma, got {:?}", other),
}
assert!(
matches!(decoded.image, PngImage::Rgb16(_)),
"expected Rgb16 variant, got {:?}",
decoded.image
);
}
#[test]
fn encode_16bit_endianness_roundtrip() {
let pixels = vec![Rgb16::new(0x0102, 0x0304, 0x0506)];
let image = Image::from_vec(1, 1, pixels).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Rgb16(img) => {
let p = img.as_slice()[0];
assert_eq!(p.r.0, 0x0102, "red channel mismatch");
assert_eq!(p.g.0, 0x0304, "green channel mismatch");
assert_eq!(p.b.0, 0x0506, "blue channel mismatch");
}
other => panic!("expected Rgb16, got {:?}", other),
}
}
#[test]
fn encode_indexed_roundtrip_no_transparency() {
let pixels = vec![Indexed8(0), Indexed8(1), Indexed8(2)];
let image = Image::from_vec(3, 1, pixels.clone()).unwrap();
let palette = [
Srgba8::new(255, 0, 0, 255),
Srgba8::new(0, 255, 0, 255),
Srgba8::new(0, 0, 255, 255),
];
let bytes = encode_indexed(&image, &palette, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Indexed8 { data, palette: pal } => {
assert_eq!(data.as_slice(), &pixels);
assert_eq!(pal[0], Srgba8::new(255, 0, 0, 255));
assert_eq!(pal[1], Srgba8::new(0, 255, 0, 255));
assert_eq!(pal[2], Srgba8::new(0, 0, 255, 255));
}
other => panic!("expected Indexed8, got {:?}", other),
}
}
#[test]
fn encode_indexed_roundtrip_with_transparency() {
let pixels = vec![Indexed8(0), Indexed8(1)];
let image = Image::from_vec(2, 1, pixels).unwrap();
let palette = [
Srgba8::new(255, 0, 0, 128), Srgba8::new(0, 255, 0, 255), ];
let bytes = encode_indexed(&image, &palette, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Indexed8 { palette: pal, .. } => {
assert_eq!(pal[0].a.0, 128, "alpha of entry 0 not preserved");
assert_eq!(pal[1].a.0, 255, "alpha of entry 1 not preserved");
}
other => panic!("expected Indexed8, got {:?}", other),
}
}
#[test]
fn encode_indexed_empty_palette() {
let image = Image::from_vec(1, 1, vec![Indexed8(0)]).unwrap();
let result = encode_indexed(&image, &[], &PngEncodeOptions::default());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, IoError::EncodeFailed { .. }),
"expected EncodeFailed, got {:?}",
err
);
}
#[test]
fn encode_indexed_palette_too_large() {
let image = Image::from_vec(1, 1, vec![Indexed8(0)]).unwrap();
let palette = vec![Srgba8::new(0, 0, 0, 255); 257];
let result = encode_indexed(&image, &palette, &PngEncodeOptions::default());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), IoError::EncodeFailed { .. }));
}
#[test]
fn encode_indexed_index_out_of_range() {
let image = Image::from_vec(1, 1, vec![Indexed8(5)]).unwrap();
let palette = [Srgba8::new(0, 0, 0, 255); 3]; let result = encode_indexed(&image, &palette, &PngEncodeOptions::default());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), IoError::EncodeFailed { .. }));
}
#[test]
fn encode_text_chunk_roundtrip() {
let image = Image::from_vec(1, 1, vec![Srgb8::new(0, 0, 0)]).unwrap();
let opts = PngEncodeOptions {
text_chunks: vec![PngTextChunk {
keyword: "Title".into(),
text: "Test Image".into(),
language: None,
translated_keyword: None,
}],
..PngEncodeOptions::default()
};
let bytes = encode(&image, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(
decoded
.metadata
.text_chunks
.iter()
.any(|c| c.keyword == "Title" && c.text == "Test Image"),
"text chunk not found in decoded metadata: {:?}",
decoded.metadata.text_chunks
);
}
#[test]
fn encode_timestamp_roundtrip() {
let image = Image::from_vec(1, 1, vec![Srgb8::new(0, 0, 0)]).unwrap();
let ts = PngTimestamp {
year: 2025,
month: 6,
day: 25,
hour: 14,
minute: 30,
second: 0,
};
let opts = PngEncodeOptions {
last_modified: Some(ts),
..PngEncodeOptions::default()
};
let bytes = encode(&image, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
assert_eq!(
decoded.metadata.last_modified,
Some(ts),
"timestamp not preserved in roundtrip"
);
}
#[test]
fn encode_writer_byte_identical_to_encode() {
let image = Image::from_vec(
2,
2,
vec![
Srgb8::new(10, 20, 30),
Srgb8::new(40, 50, 60),
Srgb8::new(70, 80, 90),
Srgb8::new(100, 110, 120),
],
)
.unwrap();
let opts = PngEncodeOptions::default();
let via_encode = encode(&image, &opts).unwrap();
let mut via_writer = Vec::new();
encode_writer(&image, &mut via_writer, &opts).unwrap();
assert_eq!(via_encode, via_writer, "encode and encode_writer differ");
}
#[test]
fn encode_writer_byte_identical_16bit() {
let image = Image::from_vec(1, 1, vec![Rgb16::new(0x1234, 0x5678, 0x9ABC)]).unwrap();
let opts = PngEncodeOptions::default();
let via_encode = encode(&image, &opts).unwrap();
let mut via_writer = Vec::new();
encode_writer(&image, &mut via_writer, &opts).unwrap();
assert_eq!(via_encode, via_writer);
}
#[test]
fn encode_with_compression_level() {
let image = Image::from_vec(
2,
2,
vec![
Srgb8::new(0, 0, 0),
Srgb8::new(255, 255, 255),
Srgb8::new(128, 128, 128),
Srgb8::new(64, 64, 64),
],
)
.unwrap();
let opts = PngEncodeOptions {
compression_level: Some(9),
..PngEncodeOptions::default()
};
let bytes = encode(&image, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(matches!(decoded.image, PngImage::Srgb8(_)));
}
#[test]
fn encode_with_filter_strategy() {
let image = Image::from_vec(
2,
2,
vec![
Srgb8::new(0, 0, 0),
Srgb8::new(255, 255, 255),
Srgb8::new(128, 128, 128),
Srgb8::new(64, 64, 64),
],
)
.unwrap();
let opts = PngEncodeOptions {
filter: Some(PngFilterStrategy::Paeth),
..PngEncodeOptions::default()
};
let bytes = encode(&image, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(matches!(decoded.image, PngImage::Srgb8(_)));
}
#[test]
fn encode_with_adaptive_filter() {
let image = Image::from_vec(
2,
2,
vec![
Srgb8::new(0, 0, 0),
Srgb8::new(255, 255, 255),
Srgb8::new(128, 128, 128),
Srgb8::new(64, 64, 64),
],
)
.unwrap();
let opts = PngEncodeOptions {
filter: Some(PngFilterStrategy::Adaptive),
..PngEncodeOptions::default()
};
let bytes = encode(&image, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
assert!(matches!(decoded.image, PngImage::Srgb8(_)));
}
#[test]
fn encode_preserves_dimensions() {
let pixels: Vec<Srgb8> = (0..17 * 31)
.map(|i| {
let v = (i % 256) as u8;
Srgb8::new(v, v, v)
})
.collect();
let image = Image::from_vec(17, 31, pixels.clone()).unwrap();
let bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.width(), 17);
assert_eq!(img.height(), 31);
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_png_image_roundtrip_srgb8() {
let pixels = vec![Srgb8::new(10, 20, 30), Srgb8::new(40, 50, 60)];
let image = Image::from_vec(2, 1, pixels.clone()).unwrap();
let png_image = PngImage::Srgb8(image);
let bytes = encode_png_image(&png_image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_png_image_roundtrip_mono16() {
let pixels = vec![Mono16::new(1000), Mono16::new(2000), Mono16::new(3000)];
let image = Image::from_vec(3, 1, pixels.clone()).unwrap();
let png_image = PngImage::Mono16(image);
let bytes = encode_png_image(&png_image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Mono16(img) => {
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected Mono16, got {:?}", other),
}
}
#[test]
fn encode_png_image_roundtrip_srgba16() {
let pixels = vec![Srgba16::new(100, 200, 300, 400)];
let image = Image::from_vec(1, 1, pixels.clone()).unwrap();
let png_image = PngImage::Srgba16(image);
let bytes = encode_png_image(&png_image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Srgba16(img) => {
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected Srgba16, got {:?}", other),
}
}
#[test]
fn encode_png_image_roundtrip_indexed() {
let palette_entries = [
Srgba8::new(255, 0, 0, 255),
Srgba8::new(0, 255, 0, 255),
Srgba8::new(0, 0, 255, 128),
];
let mut full_palette = [Srgba8::new(0, 0, 0, 255); 256];
for (i, p) in palette_entries.iter().enumerate() {
full_palette[i] = *p;
}
let pixels = vec![Indexed8(0), Indexed8(1), Indexed8(2), Indexed8(0)];
let image = Image::from_vec(2, 2, pixels.clone()).unwrap();
let png_image = PngImage::Indexed8 {
data: image,
palette: Box::new(full_palette),
};
let bytes = encode_png_image(&png_image, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match decoded.image {
PngImage::Indexed8 { data, palette } => {
assert_eq!(data.as_slice(), &pixels);
for i in 0..3 {
assert_eq!(palette[i], palette_entries[i]);
}
}
other => panic!("expected Indexed8, got {:?}", other),
}
}
#[test]
fn encode_png_image_decode_roundtrip() {
let pixels = vec![
Srgb8::new(11, 22, 33),
Srgb8::new(44, 55, 66),
Srgb8::new(77, 88, 99),
Srgb8::new(111, 122, 133),
];
let image = Image::from_vec(2, 2, pixels.clone()).unwrap();
let first_bytes = encode(&image, &PngEncodeOptions::default()).unwrap();
let first_decoded = decode(&first_bytes).unwrap();
let second_bytes =
encode_png_image(&first_decoded.image, &PngEncodeOptions::default()).unwrap();
let second_decoded = decode(&second_bytes).unwrap();
match second_decoded.image {
PngImage::Srgb8(img) => {
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
assert_eq!(img.as_slice(), &pixels);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_png_image_all_non_indexed_variants() {
macro_rules! check_variant {
($variant:ident, $pixel:expr) => {{
let img = Image::fill(1, 1, $pixel);
let png_img = PngImage::$variant(img);
let bytes = encode_png_image(&png_img, &PngEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
let got = variant_name(&decoded.image);
assert_eq!(
got,
stringify!($variant),
"variant mismatch for {}",
stringify!($variant)
);
}};
}
check_variant!(SrgbMono8, SrgbMono8::new(42));
check_variant!(SrgbMonoA8, SrgbMonoA8::new(42, 200));
check_variant!(Srgb8, Srgb8::new(10, 20, 30));
check_variant!(Srgba8, Srgba8::new(10, 20, 30, 40));
check_variant!(Mono8, Mono8::new(42));
check_variant!(MonoA8, MonoA8::new(42, 200));
check_variant!(Rgb8, Rgb8::new(10, 20, 30));
check_variant!(Rgba8, Rgba8::new(10, 20, 30, 40));
check_variant!(SrgbMono16, SrgbMono16::new(1000));
check_variant!(SrgbMonoA16, SrgbMonoA16::new(1000, 2000));
check_variant!(Srgb16, Srgb16::new(100, 200, 300));
check_variant!(Srgba16, Srgba16::new(100, 200, 300, 400));
check_variant!(Mono16, Mono16::new(1000));
check_variant!(MonoA16, MonoA16::new(1000, 2000));
check_variant!(Rgb16, Rgb16::new(100, 200, 300));
check_variant!(Rgba16, Rgba16::new(100, 200, 300, 400));
}
#[test]
fn encode_png_image_with_options() {
let image = Image::fill(2, 2, Srgb8::new(10, 20, 30));
let png_image = PngImage::Srgb8(image);
let options = PngEncodeOptions {
text_chunks: vec![PngTextChunk {
keyword: "Comment".into(),
text: "test comment".into(),
language: None,
translated_keyword: None,
}],
last_modified: Some(PngTimestamp {
year: 2025,
month: 6,
day: 15,
hour: 12,
minute: 30,
second: 45,
}),
..PngEncodeOptions::default()
};
let bytes = encode_png_image(&png_image, &options).unwrap();
let decoded = decode(&bytes).unwrap();
assert_eq!(decoded.metadata.text_chunks.len(), 1);
assert_eq!(decoded.metadata.text_chunks[0].keyword, "Comment");
assert_eq!(decoded.metadata.text_chunks[0].text, "test comment");
let ts = decoded.metadata.last_modified.unwrap();
assert_eq!(ts.year, 2025);
assert_eq!(ts.month, 6);
assert_eq!(ts.day, 15);
}
#[test]
fn decode_indexed_1bit_sub_byte() {
use fovea::image::ImageView;
let palette = [
255u8, 0, 0, 0, 255, 0, ];
let data = [0x55u8]; let png_data = encode_indexed_png(8, 1, png::BitDepth::One, &palette, None, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Indexed8 {
data: img,
palette: pal,
} => {
assert_eq!(img.width(), 8);
assert_eq!(img.height(), 1);
assert_eq!(img.get(0, 0).unwrap().0, 0);
assert_eq!(img.get(1, 0).unwrap().0, 1);
assert_eq!(img.get(2, 0).unwrap().0, 0);
assert_eq!(img.get(3, 0).unwrap().0, 1);
assert_eq!(img.get(4, 0).unwrap().0, 0);
assert_eq!(img.get(5, 0).unwrap().0, 1);
assert_eq!(img.get(6, 0).unwrap().0, 0);
assert_eq!(img.get(7, 0).unwrap().0, 1);
assert_eq!(pal[0], Srgba8::new(255, 0, 0, 255));
assert_eq!(pal[1], Srgba8::new(0, 255, 0, 255));
}
other => panic!("expected Indexed8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_indexed_4bit_sub_byte() {
use fovea::image::ImageView;
let mut palette_rgb = [0u8; 16 * 3];
for i in 0..16 {
palette_rgb[i * 3] = (i * 17) as u8; palette_rgb[i * 3 + 1] = 0; palette_rgb[i * 3 + 2] = 0; }
let data = [0x37u8, 0x0F];
let png_data = encode_indexed_png(4, 1, png::BitDepth::Four, &palette_rgb, None, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Indexed8 {
data: img,
palette: pal,
} => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 1);
assert_eq!(img.get(0, 0).unwrap().0, 3);
assert_eq!(img.get(1, 0).unwrap().0, 7);
assert_eq!(img.get(2, 0).unwrap().0, 0);
assert_eq!(img.get(3, 0).unwrap().0, 15);
assert_eq!(pal[3].r.0, 3 * 17);
assert_eq!(pal[15].r.0, 15 * 17);
}
other => panic!("expected Indexed8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_indexed_2bit_sub_byte() {
use fovea::image::ImageView;
let palette = [
10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, ];
let data = [0x1Bu8];
let png_data = encode_indexed_png(4, 1, png::BitDepth::Two, &palette, None, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::Indexed8 {
data: img,
palette: pal,
} => {
assert_eq!(img.width(), 4);
assert_eq!(img.get(0, 0).unwrap().0, 0);
assert_eq!(img.get(1, 0).unwrap().0, 1);
assert_eq!(img.get(2, 0).unwrap().0, 2);
assert_eq!(img.get(3, 0).unwrap().0, 3);
assert_eq!(pal[0], Srgba8::new(10, 20, 30, 255));
assert_eq!(pal[3], Srgba8::new(100, 110, 120, 255));
}
other => panic!("expected Indexed8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_sub_byte_non_aligned_width() {
use fovea::image::ImageView;
let data = [0xA0u8];
let png_data = encode_png(3, 1, png::ColorType::Grayscale, png::BitDepth::One, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.width(), 3);
assert_eq!(img.height(), 1);
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(255));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(2, 0).unwrap(), SrgbMono8::new(255));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
#[test]
fn decode_sub_byte_non_aligned_width_multirow() {
use fovea::image::ImageView;
let data = [0xC0u8, 0x60];
let png_data = encode_png(3, 2, png::ColorType::Grayscale, png::BitDepth::One, &data);
let decoded = decode(&png_data).unwrap();
match &decoded.image {
PngImage::SrgbMono8(img) => {
assert_eq!(img.width(), 3);
assert_eq!(img.height(), 2);
assert_eq!(img.get(0, 0).unwrap(), SrgbMono8::new(255));
assert_eq!(img.get(1, 0).unwrap(), SrgbMono8::new(255));
assert_eq!(img.get(2, 0).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(0, 1).unwrap(), SrgbMono8::new(0));
assert_eq!(img.get(1, 1).unwrap(), SrgbMono8::new(255));
assert_eq!(img.get(2, 1).unwrap(), SrgbMono8::new(255));
}
other => panic!("expected SrgbMono8, got {:?}", variant_name(other)),
}
}
fn inject_iccp_chunk(png_bytes: &[u8], profile_bytes: &[u8]) -> Vec<u8> {
let ihdr_end = 8 + 4 + 4 + 13 + 4; assert!(png_bytes.len() > ihdr_end, "PNG too short for IHDR");
let len = profile_bytes.len() as u16;
let nlen = !len;
let mut zlib = Vec::new();
zlib.push(0x78);
zlib.push(0x01);
zlib.push(0x01); zlib.extend_from_slice(&len.to_le_bytes());
zlib.extend_from_slice(&nlen.to_le_bytes());
zlib.extend_from_slice(profile_bytes);
let mut s1: u32 = 1;
let mut s2: u32 = 0;
for &b in profile_bytes {
s1 = (s1 + b as u32) % 65521;
s2 = (s2 + s1) % 65521;
}
let adler32 = (s2 << 16) | s1;
zlib.extend_from_slice(&adler32.to_be_bytes());
let keyword = b"TestProfile";
let mut chunk_data = Vec::new();
chunk_data.extend_from_slice(keyword);
chunk_data.push(0); chunk_data.push(0); chunk_data.extend_from_slice(&zlib);
let chunk_type = b"iCCP";
let length_bytes = (chunk_data.len() as u32).to_be_bytes();
let mut crc_input = Vec::new();
crc_input.extend_from_slice(chunk_type);
crc_input.extend_from_slice(&chunk_data);
let crc_bytes = png_crc32(&crc_input).to_be_bytes();
let mut out = Vec::with_capacity(png_bytes.len() + 12 + chunk_data.len());
out.extend_from_slice(&png_bytes[..ihdr_end]);
out.extend_from_slice(&length_bytes);
out.extend_from_slice(chunk_type);
out.extend_from_slice(&chunk_data);
out.extend_from_slice(&crc_bytes);
out.extend_from_slice(&png_bytes[ihdr_end..]);
out
}
#[test]
fn decode_with_icc_profile() {
let data = [128u8; 4];
let base_png = encode_png(2, 2, png::ColorType::Grayscale, png::BitDepth::Eight, &data);
let profile_bytes: Vec<u8> = (0..32).collect();
let png_data = inject_iccp_chunk(&base_png, &profile_bytes);
let decoded = decode(&png_data).unwrap();
match &decoded.metadata.color_space {
PngColorSpace::IccProfile { profile } => {
assert_eq!(profile.as_ref(), &profile_bytes[..]);
}
other => panic!("expected IccProfile, got {:?}", other),
}
}
fn inject_chrm_chunk(
png_bytes: &[u8],
white: (u32, u32),
red: (u32, u32),
green: (u32, u32),
blue: (u32, u32),
) -> Vec<u8> {
let ihdr_end = 8 + 4 + 4 + 13 + 4;
assert!(png_bytes.len() > ihdr_end, "PNG too short for IHDR");
let mut chrm_data = [0u8; 32];
chrm_data[0..4].copy_from_slice(&white.0.to_be_bytes());
chrm_data[4..8].copy_from_slice(&white.1.to_be_bytes());
chrm_data[8..12].copy_from_slice(&red.0.to_be_bytes());
chrm_data[12..16].copy_from_slice(&red.1.to_be_bytes());
chrm_data[16..20].copy_from_slice(&green.0.to_be_bytes());
chrm_data[20..24].copy_from_slice(&green.1.to_be_bytes());
chrm_data[24..28].copy_from_slice(&blue.0.to_be_bytes());
chrm_data[28..32].copy_from_slice(&blue.1.to_be_bytes());
let chunk_type = b"cHRM";
let length_bytes = 32u32.to_be_bytes();
let mut crc_input = Vec::with_capacity(36);
crc_input.extend_from_slice(chunk_type);
crc_input.extend_from_slice(&chrm_data);
let crc_bytes = png_crc32(&crc_input).to_be_bytes();
let mut out = Vec::with_capacity(png_bytes.len() + 44);
out.extend_from_slice(&png_bytes[..ihdr_end]);
out.extend_from_slice(&length_bytes);
out.extend_from_slice(chunk_type);
out.extend_from_slice(&chrm_data);
out.extend_from_slice(&crc_bytes);
out.extend_from_slice(&png_bytes[ihdr_end..]);
out
}
#[test]
fn decode_with_chrm_chunk() {
let data = [100u8; 4];
let base_png = encode_png_with_gamma(
2,
2,
png::ColorType::Grayscale,
png::BitDepth::Eight,
1.0 / 2.2,
&data,
);
let png_data = inject_chrm_chunk(
&base_png,
(31270, 32900), (64000, 33000), (30000, 60000), (15000, 6000), );
let decoded = decode(&png_data).unwrap();
match &decoded.metadata.color_space {
PngColorSpace::Gamma {
gamma,
chromaticities,
} => {
assert!((*gamma - (1.0 / 2.2)).abs() < 0.001, "gamma = {}", gamma);
let c = chromaticities
.as_ref()
.expect("chromaticities should be Some");
assert!((c[0] - 0.3127).abs() < 0.001, "white_x = {}", c[0]);
assert!((c[1] - 0.3290).abs() < 0.001, "white_y = {}", c[1]);
assert!((c[2] - 0.64).abs() < 0.001, "red_x = {}", c[2]);
assert!((c[3] - 0.33).abs() < 0.001, "red_y = {}", c[3]);
}
other => panic!("expected Gamma with chromaticities, got {:?}", other),
}
}
#[test]
fn decode_ztxt_text_chunk() {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
encoder
.add_ztxt_chunk("Comment".into(), "compressed text data".into())
.unwrap();
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[0u8]).unwrap();
}
let decoded = decode(&out).unwrap();
let ztxt = decoded
.metadata
.text_chunks
.iter()
.find(|c| c.keyword == "Comment")
.expect("zTXt chunk not found");
assert_eq!(ztxt.text, "compressed text data");
assert!(ztxt.language.is_none(), "zTXt should not have language");
assert!(
ztxt.translated_keyword.is_none(),
"zTXt should not have translated_keyword"
);
}
#[test]
fn decode_itxt_text_chunk() {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let mut itxt = png::text_metadata::ITXtChunk::new(
"Description".to_string(),
"hello world".to_string(),
);
itxt.language_tag = "en".to_string();
itxt.translated_keyword = "Beschreibung".to_string();
writer.write_text_chunk(&itxt).unwrap();
writer.write_image_data(&[0u8]).unwrap();
}
let decoded = decode(&out).unwrap();
let chunk = decoded
.metadata
.text_chunks
.iter()
.find(|c| c.keyword == "Description")
.expect("iTXt chunk not found");
assert_eq!(chunk.text, "hello world");
assert_eq!(chunk.language.as_deref(), Some("en"));
assert_eq!(chunk.translated_keyword.as_deref(), Some("Beschreibung"));
}
}