use std::io::Cursor;
use std::path::PathBuf;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::{Rgb, Rgb32FImage, RgbImage};
use crate::color_space::{
adobe_rgb_curve_signed, srgb_curve_signed, LINEAR_REC2020_TO_LINEAR_ADOBE_RGB,
LINEAR_REC2020_TO_LINEAR_P3, LINEAR_REC2020_TO_LINEAR_SRGB,
};
use crate::error::Result;
use crate::metadata::ImageMetadata;
mod icc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Jpeg,
Png,
Tiff,
}
impl OutputFormat {
pub fn extension(&self) -> &'static str {
match self {
OutputFormat::Jpeg => "jpeg",
OutputFormat::Png => "png",
OutputFormat::Tiff => "tiff",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => Some(OutputFormat::Jpeg),
"png" => Some(OutputFormat::Png),
"tif" | "tiff" => Some(OutputFormat::Tiff),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputGamut {
#[default]
Srgb,
DisplayP3,
AdobeRgb,
}
impl std::fmt::Display for OutputGamut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Srgb => write!(f, "srgb"),
Self::DisplayP3 => write!(f, "p3"),
Self::AdobeRgb => write!(f, "adobe-rgb"),
}
}
}
impl std::str::FromStr for OutputGamut {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"srgb" => Ok(Self::Srgb),
"p3" => Ok(Self::DisplayP3),
"adobe-rgb" => Ok(Self::AdobeRgb),
_ => Err(format!(
"invalid output gamut '{s}'. Use: srgb, p3, or adobe-rgb"
)),
}
}
}
pub struct EncodeOptions {
pub jpeg_quality: u8,
pub format: Option<OutputFormat>,
pub output_gamut: OutputGamut,
}
impl Default for EncodeOptions {
fn default() -> Self {
Self {
jpeg_quality: 92,
format: None,
output_gamut: OutputGamut::Srgb,
}
}
}
pub fn resolve_output(
path: &std::path::Path,
format: Option<OutputFormat>,
) -> (std::path::PathBuf, OutputFormat) {
let ext_format = path
.extension()
.and_then(|e| e.to_str())
.and_then(OutputFormat::from_extension);
match (format, ext_format) {
(Some(fmt), Some(ext_fmt)) if fmt == ext_fmt => (path.to_path_buf(), fmt),
(Some(fmt), _) => {
let mut new_path = path.as_os_str().to_owned();
new_path.push(".");
new_path.push(fmt.extension());
(std::path::PathBuf::from(new_path), fmt)
}
(None, Some(ext_fmt)) => (path.to_path_buf(), ext_fmt),
(None, None) => {
let mut new_path = path.as_os_str().to_owned();
new_path.push(".jpeg");
(std::path::PathBuf::from(new_path), OutputFormat::Jpeg)
}
}
}
#[inline]
fn quantize_u8(x: f32) -> u8 {
let normalized = if x.is_nan() || x >= 1.0 {
1.0
} else {
x.max(0.0)
};
(normalized * 255.0).round() as u8
}
fn encode_linear_rec2020_to_rgb8(
linear_rec2020: &Rgb32FImage,
matrix: &[[f32; 3]; 3],
curve: fn(f32) -> f32,
) -> RgbImage {
let (w, h) = linear_rec2020.dimensions();
RgbImage::from_fn(w, h, |x, y| {
let p = linear_rec2020.get_pixel(x, y);
let r = p.0[0];
let g = p.0[1];
let b = p.0[2];
let r_t = matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b;
let g_t = matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b;
let b_t = matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b;
Rgb([
quantize_u8(curve(r_t)),
quantize_u8(curve(g_t)),
quantize_u8(curve(b_t)),
])
})
}
pub fn encode_linear_rec2020_to_srgb_rgb8(linear_rec2020: &Rgb32FImage) -> RgbImage {
encode_linear_rec2020_to_rgb8(
linear_rec2020,
&LINEAR_REC2020_TO_LINEAR_SRGB,
srgb_curve_signed,
)
}
type GamutRecipe = (&'static [[f32; 3]; 3], fn(f32) -> f32);
fn gamut_recipe(gamut: OutputGamut) -> GamutRecipe {
match gamut {
OutputGamut::Srgb => (&LINEAR_REC2020_TO_LINEAR_SRGB, srgb_curve_signed),
OutputGamut::DisplayP3 => (&LINEAR_REC2020_TO_LINEAR_P3, srgb_curve_signed),
OutputGamut::AdobeRgb => (&LINEAR_REC2020_TO_LINEAR_ADOBE_RGB, adobe_rgb_curve_signed),
}
}
pub fn encode_to_file_with_options(
linear: &Rgb32FImage,
path: &std::path::Path,
options: &EncodeOptions,
metadata: Option<&ImageMetadata>,
) -> Result<PathBuf> {
let (final_path, format) = resolve_output(path, options.format);
let (matrix, curve) = gamut_recipe(options.output_gamut);
let rgb8 = encode_linear_rec2020_to_rgb8(linear, matrix, curve);
let icc = icc::icc_for(options.output_gamut);
let buf = match format {
OutputFormat::Jpeg => {
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, options.jpeg_quality);
rgb8.write_with_encoder(encoder)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
buf
}
OutputFormat::Png => {
let mut buf = Vec::new();
let encoder = PngEncoder::new(&mut buf);
rgb8.write_with_encoder(encoder)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
buf
}
OutputFormat::Tiff => {
use tiff::encoder::{colortype, TiffEncoder};
use tiff::tags::Tag;
let raw = rgb8.into_raw();
let (w, h) = (linear.width(), linear.height());
let mut buf = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut tiff = TiffEncoder::new(cursor)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
let mut img = tiff
.new_image::<colortype::RGB8>(w, h)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
img.encoder()
.write_tag(Tag::IccProfile, icc)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
img.write_data(&raw)
.map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
}
buf
}
};
let buf = match format {
OutputFormat::Jpeg => inject_jpeg_icc_and_exif(buf, icc, metadata)?,
OutputFormat::Png => inject_png_icc_and_exif(buf, icc, metadata)?,
OutputFormat::Tiff => buf,
};
std::fs::write(&final_path, &buf)?;
if format == OutputFormat::Tiff {
if let Some(meta) = metadata {
inject_metadata_tiff(&final_path, meta);
}
}
Ok(final_path)
}
pub fn encode_to_file(linear: &Rgb32FImage, path: &std::path::Path) -> Result<()> {
encode_to_file_with_options(linear, path, &EncodeOptions::default(), None)?;
Ok(())
}
fn inject_jpeg_icc_and_exif(
buf: Vec<u8>,
icc: &[u8],
metadata: Option<&ImageMetadata>,
) -> Result<Vec<u8>> {
use img_parts::{ImageEXIF, ImageICC};
let mut jpeg = img_parts::jpeg::Jpeg::from_bytes(buf.into())
.map_err(|e| crate::error::AgxError::Encode(format!("metadata injection: {e}")))?;
jpeg.set_icc_profile(Some(icc.to_vec().into()));
if let Some(exif) = metadata.and_then(|m| m.exif.as_ref()) {
jpeg.set_exif(Some(exif.clone().into()));
}
let mut out = Vec::new();
jpeg.encoder()
.write_to(&mut out)
.map_err(|e| crate::error::AgxError::Encode(format!("metadata write: {e}")))?;
Ok(out)
}
fn inject_png_icc_and_exif(
buf: Vec<u8>,
icc: &[u8],
metadata: Option<&ImageMetadata>,
) -> Result<Vec<u8>> {
use img_parts::{ImageEXIF, ImageICC};
let mut png = img_parts::png::Png::from_bytes(buf.into())
.map_err(|e| crate::error::AgxError::Encode(format!("metadata injection: {e}")))?;
png.set_icc_profile(Some(icc.to_vec().into()));
if let Some(exif) = metadata.and_then(|m| m.exif.as_ref()) {
png.set_exif(Some(exif.clone().into()));
}
let mut out = Vec::new();
png.encoder()
.write_to(&mut out)
.map_err(|e| crate::error::AgxError::Encode(format!("metadata write: {e}")))?;
Ok(out)
}
fn inject_metadata_tiff(path: &std::path::Path, metadata: &ImageMetadata) {
if let Some(exif_bytes) = &metadata.exif {
let file_ext = little_exif::filetype::FileExtension::TIFF;
if let Ok(exif_meta) = little_exif::metadata::Metadata::new_from_vec(exif_bytes, file_ext) {
let _ = exif_meta.write_to_file(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageBuffer;
use std::path::PathBuf;
#[test]
fn roundtrip_linear_to_srgb_pixel_values() {
let linear: Rgb32FImage = ImageBuffer::from_pixel(1, 1, Rgb([0.2159f32, 0.2159, 0.2159]));
let rgb8 = encode_linear_rec2020_to_srgb_rgb8(&linear);
let pixel = rgb8.get_pixel(0, 0);
assert!(
(pixel.0[0] as i32 - 128).unsigned_abs() <= 1,
"Expected ~128, got {}",
pixel.0[0]
);
}
#[test]
fn quantize_u8_handles_edge_values() {
assert_eq!(quantize_u8(f32::NAN), 255);
assert_eq!(quantize_u8(f32::INFINITY), 255);
assert_eq!(quantize_u8(f32::NEG_INFINITY), 0);
assert_eq!(quantize_u8(0.0), 0);
assert_eq!(quantize_u8(1.0), 255);
assert_eq!(quantize_u8(-1.0), 0);
assert_eq!(quantize_u8(2.0), 255);
}
#[test]
fn encode_rec2020_to_srgb_rgb8_quantization_table() {
let table: &[(f32, u8)] = &[
(0.0, 0),
(0.0001, 0),
(0.0031308, 10),
(0.04, 56),
(0.18, 118),
(0.2159, 128),
(0.5, 188),
(0.9, 243),
(0.999, 255),
(1.0, 255),
(1.0001, 255),
(2.0, 255),
(-0.001, 0),
(-1.0, 0),
(f32::NAN, 255),
];
for &(linear, expected) in table {
let img: Rgb32FImage = ImageBuffer::from_pixel(1, 1, Rgb([linear, linear, linear]));
let rgb8 = encode_linear_rec2020_to_srgb_rgb8(&img);
let actual = rgb8.get_pixel(0, 0).0[0];
assert_eq!(
actual, expected,
"linear {linear} should encode to u8 {expected}, got {actual}"
);
}
}
#[test]
fn encode_rec2020_to_srgb_rgb8_preserves_pure_srgb_via_inverse_matrix() {
use crate::color_space::LINEAR_SRGB_TO_LINEAR_REC2020;
let m_in = &LINEAR_SRGB_TO_LINEAR_REC2020;
let rec2020_red = [
m_in[0][0] * 1.0 + m_in[0][1] * 0.0 + m_in[0][2] * 0.0,
m_in[1][0] * 1.0 + m_in[1][1] * 0.0 + m_in[1][2] * 0.0,
m_in[2][0] * 1.0 + m_in[2][1] * 0.0 + m_in[2][2] * 0.0,
];
let img: Rgb32FImage = ImageBuffer::from_pixel(1, 1, Rgb(rec2020_red));
let rgb8 = encode_linear_rec2020_to_srgb_rgb8(&img);
let px = rgb8.get_pixel(0, 0).0;
assert_eq!(px[0], 255, "sRGB red R: got {}", px[0]);
assert_eq!(px[1], 0, "sRGB red G: got {}", px[1]);
assert_eq!(px[2], 0, "sRGB red B: got {}", px[2]);
}
#[test]
fn encode_saves_file() {
let temp_path = std::env::temp_dir().join("agx_test_encode.png");
let linear: Rgb32FImage = ImageBuffer::from_pixel(2, 2, Rgb([0.5f32, 0.5, 0.5]));
encode_to_file(&linear, &temp_path).unwrap();
assert!(temp_path.exists());
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_options_default_quality_is_92() {
let opts = EncodeOptions::default();
assert_eq!(opts.jpeg_quality, 92);
assert!(opts.format.is_none());
}
#[test]
fn output_format_extensions() {
assert_eq!(OutputFormat::Jpeg.extension(), "jpeg");
assert_eq!(OutputFormat::Png.extension(), "png");
assert_eq!(OutputFormat::Tiff.extension(), "tiff");
}
#[test]
fn resolve_output_infers_jpeg_from_jpg() {
let (path, fmt) = resolve_output(std::path::Path::new("out.jpg"), None);
assert_eq!(fmt, OutputFormat::Jpeg);
assert_eq!(path, PathBuf::from("out.jpg"));
}
#[test]
fn resolve_output_infers_png() {
let (path, fmt) = resolve_output(std::path::Path::new("out.png"), None);
assert_eq!(fmt, OutputFormat::Png);
assert_eq!(path, PathBuf::from("out.png"));
}
#[test]
fn resolve_output_infers_tiff() {
let (path, fmt) = resolve_output(std::path::Path::new("out.tif"), None);
assert_eq!(fmt, OutputFormat::Tiff);
assert_eq!(path, PathBuf::from("out.tif"));
}
#[test]
fn resolve_output_format_override_matching_ext() {
let (path, fmt) = resolve_output(std::path::Path::new("out.jpg"), Some(OutputFormat::Jpeg));
assert_eq!(fmt, OutputFormat::Jpeg);
assert_eq!(path, PathBuf::from("out.jpg"));
}
#[test]
fn resolve_output_format_override_mismatched_ext_appends() {
let (path, fmt) = resolve_output(std::path::Path::new("out.png"), Some(OutputFormat::Jpeg));
assert_eq!(fmt, OutputFormat::Jpeg);
assert_eq!(path, PathBuf::from("out.png.jpeg"));
}
#[test]
fn resolve_output_unknown_ext_defaults_to_jpeg() {
let (path, fmt) = resolve_output(std::path::Path::new("out.xyz"), None);
assert_eq!(fmt, OutputFormat::Jpeg);
assert_eq!(path, PathBuf::from("out.xyz.jpeg"));
}
#[test]
fn resolve_output_no_extension_defaults_to_jpeg() {
let (path, fmt) = resolve_output(std::path::Path::new("output"), None);
assert_eq!(fmt, OutputFormat::Jpeg);
assert_eq!(path, PathBuf::from("output.jpeg"));
}
#[test]
fn encode_jpeg_with_quality_produces_file() {
let temp_path = std::env::temp_dir().join("agx_test_quality.jpg");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 95,
format: None,
output_gamut: OutputGamut::Srgb,
};
let result = encode_to_file_with_options(&linear, &temp_path, &opts, None);
assert!(result.is_ok());
let final_path = result.unwrap();
assert!(final_path.exists());
let _ = std::fs::remove_file(&final_path);
}
#[test]
fn encode_jpeg_quality_affects_file_size() {
let linear: Rgb32FImage = ImageBuffer::from_pixel(64, 64, Rgb([0.5f32, 0.3, 0.1]));
let path_low = std::env::temp_dir().join("agx_test_q50.jpg");
let path_high = std::env::temp_dir().join("agx_test_q95.jpg");
let opts_low = EncodeOptions {
jpeg_quality: 50,
format: None,
output_gamut: OutputGamut::Srgb,
};
let opts_high = EncodeOptions {
jpeg_quality: 95,
format: None,
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &path_low, &opts_low, None).unwrap();
encode_to_file_with_options(&linear, &path_high, &opts_high, None).unwrap();
let size_low = std::fs::metadata(&path_low).unwrap().len();
let size_high = std::fs::metadata(&path_high).unwrap().len();
assert!(
size_high > size_low,
"Higher quality should produce larger file: q95={size_high} vs q50={size_low}"
);
let _ = std::fs::remove_file(&path_low);
let _ = std::fs::remove_file(&path_high);
}
#[test]
fn encode_png_format() {
let temp_path = std::env::temp_dir().join("agx_test_fmt.png");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: None,
output_gamut: OutputGamut::Srgb,
};
let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
assert!(final_path.exists());
let img = image::open(&final_path).unwrap();
assert_eq!(img.width(), 4);
let _ = std::fs::remove_file(&final_path);
}
#[test]
fn encode_tiff_format() {
let temp_path = std::env::temp_dir().join("agx_test_fmt.tiff");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: None,
output_gamut: OutputGamut::Srgb,
};
let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
assert!(final_path.exists());
let img = image::open(&final_path).unwrap();
assert_eq!(img.width(), 4);
let _ = std::fs::remove_file(&final_path);
}
#[test]
fn encode_format_override_appends_extension() {
let temp_path = std::env::temp_dir().join("agx_test_override.png");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Jpeg),
output_gamut: OutputGamut::Srgb,
};
let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
assert_eq!(
final_path,
std::env::temp_dir().join("agx_test_override.png.jpeg")
);
assert!(final_path.exists());
let _ = std::fs::remove_file(&final_path);
}
#[test]
fn metadata_roundtrip_jpeg() {
let exif_bytes = vec![
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08, ];
let meta = ImageMetadata {
exif: Some(exif_bytes.clone()),
};
let temp_path = std::env::temp_dir().join("agx_test_meta_rt.jpg");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: None,
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &temp_path, &opts, Some(&meta)).unwrap();
let meta_out = crate::metadata::extract_metadata(&temp_path);
assert!(meta_out.is_some(), "Should have metadata in output");
assert!(
meta_out.as_ref().unwrap().exif.is_some(),
"Should have EXIF in output"
);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_without_metadata_still_works() {
let temp_path = std::env::temp_dir().join("agx_test_no_meta.jpg");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions::default();
let result = encode_to_file_with_options(&linear, &temp_path, &opts, None);
assert!(result.is_ok());
let _ = std::fs::remove_file(result.unwrap());
}
#[test]
fn encode_jpeg_embeds_srgb_v4_icc() {
use crate::encode::icc::SRGB_V4_ICC;
use img_parts::ImageICC;
let temp_path = std::env::temp_dir().join("agx_test_icc_jpeg.jpg");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
encode_to_file(&linear, &temp_path).unwrap();
let bytes = std::fs::read(&temp_path).unwrap();
let jpeg = img_parts::jpeg::Jpeg::from_bytes(bytes.into()).unwrap();
let icc = jpeg
.icc_profile()
.expect("output JPEG must carry an ICC profile");
assert_eq!(
&icc[..],
SRGB_V4_ICC,
"embedded ICC must equal the SRGB_V4_ICC blob"
);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_tiff_embeds_srgb_v4_icc() {
use crate::encode::icc::SRGB_V4_ICC;
let temp_path = std::env::temp_dir().join("agx_test_icc_tiff.tiff");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Tiff),
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
let bytes = std::fs::read(&temp_path).unwrap();
let mut decoder = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes))
.expect("output must be parseable as TIFF");
let icc = decoder
.get_tag_u8_vec(tiff::tags::Tag::IccProfile)
.expect("output TIFF must carry an ICCProfile tag");
assert_eq!(icc, SRGB_V4_ICC);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_tiff_with_exif_still_embeds_srgb_v4_icc() {
use crate::encode::icc::SRGB_V4_ICC;
let exif_bytes = vec![
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, b'M', b'M', 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08,
];
let meta = ImageMetadata {
exif: Some(exif_bytes),
};
let temp_path = std::env::temp_dir().join("agx_test_icc_tiff_with_exif.tiff");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Tiff),
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &temp_path, &opts, Some(&meta)).unwrap();
let bytes = std::fs::read(&temp_path).unwrap();
let mut decoder = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes))
.expect("output must be parseable as TIFF after EXIF injection");
let icc = decoder.get_tag_u8_vec(tiff::tags::Tag::IccProfile).expect(
"ICC tag must survive little_exif post-write — regression in EXIF-then-ICC ordering",
);
assert_eq!(icc, SRGB_V4_ICC);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_png_embeds_srgb_v4_icc() {
use crate::encode::icc::SRGB_V4_ICC;
use img_parts::ImageICC;
let temp_path = std::env::temp_dir().join("agx_test_icc_png.png");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Png),
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
let bytes = std::fs::read(&temp_path).unwrap();
let png = img_parts::png::Png::from_bytes(bytes.into()).unwrap();
let icc = png
.icc_profile()
.expect("output PNG must carry an ICC profile");
assert_eq!(&icc[..], SRGB_V4_ICC);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn encode_jpeg_overrides_any_input_metadata_with_srgb_icc() {
use crate::encode::icc::SRGB_V4_ICC;
use img_parts::ImageICC;
let temp_path = std::env::temp_dir().join("agx_test_icc_override.jpg");
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
let exif_bytes = vec![
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, b'M', b'M', 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08,
];
let meta = ImageMetadata {
exif: Some(exif_bytes),
};
let opts = EncodeOptions::default();
encode_to_file_with_options(&linear, &temp_path, &opts, Some(&meta)).unwrap();
let bytes = std::fs::read(&temp_path).unwrap();
let jpeg = img_parts::jpeg::Jpeg::from_bytes(bytes.into()).unwrap();
let icc = jpeg.icc_profile().expect("output must have ICC");
assert_eq!(
&icc[..],
SRGB_V4_ICC,
"input metadata must not influence output ICC"
);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn output_gamut_default_is_srgb() {
assert_eq!(OutputGamut::default(), OutputGamut::Srgb);
}
#[test]
fn output_gamut_round_trips_through_string() {
use std::str::FromStr;
for (s, g) in [
("srgb", OutputGamut::Srgb),
("p3", OutputGamut::DisplayP3),
("adobe-rgb", OutputGamut::AdobeRgb),
] {
assert_eq!(OutputGamut::from_str(s).unwrap(), g);
assert_eq!(g.to_string(), s);
}
}
#[test]
fn output_gamut_rejects_unknown() {
use std::str::FromStr;
let err = OutputGamut::from_str("rec2020").unwrap_err();
assert!(err.contains("rec2020"));
}
#[test]
fn srgb_recipe_matches_legacy_srgb_encode() {
use crate::color_space::{srgb_curve_signed, LINEAR_REC2020_TO_LINEAR_SRGB};
let mut img = Rgb32FImage::new(4, 4);
for (i, p) in img.pixels_mut().enumerate() {
let v = i as f32 / 16.0;
*p = Rgb([v, 1.0 - v, 0.5 * v]);
}
let legacy = encode_linear_rec2020_to_srgb_rgb8(&img);
let generic =
encode_linear_rec2020_to_rgb8(&img, &LINEAR_REC2020_TO_LINEAR_SRGB, srgb_curve_signed);
assert_eq!(legacy.into_raw(), generic.into_raw());
}
#[test]
fn encode_jpeg_embeds_selected_gamut_icc() {
use crate::encode::icc::{ADOBE_RGB_V4_ICC, DISPLAY_P3_V4_ICC};
use img_parts::ImageICC;
let img = Rgb32FImage::from_pixel(2, 2, Rgb([0.5, 0.4, 0.3]));
let dir = tempfile::tempdir().unwrap();
for (gamut, expected) in [
(OutputGamut::DisplayP3, DISPLAY_P3_V4_ICC),
(OutputGamut::AdobeRgb, ADOBE_RGB_V4_ICC),
] {
let path = dir.path().join(format!("{gamut}.jpg"));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Jpeg),
output_gamut: gamut,
};
encode_to_file_with_options(&img, &path, &opts, None).unwrap();
let bytes = std::fs::read(&path).unwrap();
let jpeg = img_parts::jpeg::Jpeg::from_bytes(bytes.into()).unwrap();
let icc = jpeg.icc_profile().expect("jpeg has icc");
assert_eq!(&icc[..], expected, "{gamut} must embed its own ICC");
}
}
#[test]
fn encode_png_embeds_selected_gamut_icc() {
use crate::encode::icc::{ADOBE_RGB_V4_ICC, DISPLAY_P3_V4_ICC};
use img_parts::ImageICC;
let img = Rgb32FImage::from_pixel(2, 2, Rgb([0.5, 0.4, 0.3]));
let dir = tempfile::tempdir().unwrap();
for (gamut, expected) in [
(OutputGamut::DisplayP3, DISPLAY_P3_V4_ICC),
(OutputGamut::AdobeRgb, ADOBE_RGB_V4_ICC),
] {
let path = dir.path().join(format!("{gamut}.png"));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Png),
output_gamut: gamut,
};
encode_to_file_with_options(&img, &path, &opts, None).unwrap();
let bytes = std::fs::read(&path).unwrap();
let png = img_parts::png::Png::from_bytes(bytes.into()).unwrap();
let icc = png.icc_profile().expect("png has icc");
assert_eq!(&icc[..], expected, "{gamut} must embed its own ICC");
}
}
#[test]
fn encode_tiff_embeds_selected_gamut_icc() {
use crate::encode::icc::{ADOBE_RGB_V4_ICC, DISPLAY_P3_V4_ICC};
let img = Rgb32FImage::from_pixel(2, 2, Rgb([0.5, 0.4, 0.3]));
let dir = tempfile::tempdir().unwrap();
for (gamut, expected) in [
(OutputGamut::DisplayP3, DISPLAY_P3_V4_ICC),
(OutputGamut::AdobeRgb, ADOBE_RGB_V4_ICC),
] {
let path = dir.path().join(format!("{gamut}.tiff"));
let opts = EncodeOptions {
jpeg_quality: 92,
format: Some(OutputFormat::Tiff),
output_gamut: gamut,
};
encode_to_file_with_options(&img, &path, &opts, None).unwrap();
let bytes = std::fs::read(&path).unwrap();
let mut decoder = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes))
.expect("output must be parseable as TIFF");
let icc = decoder
.get_tag_u8_vec(tiff::tags::Tag::IccProfile)
.expect("output TIFF must carry an ICCProfile tag");
assert_eq!(icc, expected, "{gamut} must embed its own ICC");
}
}
#[test]
fn gamut_recipe_selects_distinct_matrices() {
let mut img = Rgb32FImage::new(2, 2);
for p in img.pixels_mut() {
*p = Rgb([0.9, 0.1, 0.2]);
}
let srgb = {
let (m, c) = gamut_recipe(OutputGamut::Srgb);
encode_linear_rec2020_to_rgb8(&img, m, c).into_raw()
};
for g in [OutputGamut::DisplayP3, OutputGamut::AdobeRgb] {
let (m, c) = gamut_recipe(g);
let out = encode_linear_rec2020_to_rgb8(&img, m, c).into_raw();
assert_ne!(srgb, out, "{g} should differ from srgb");
}
}
#[test]
fn encode_pixel_bytes_unchanged_after_icc_embed() {
let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.1, 0.2]));
let expected_rgb8 = encode_linear_rec2020_to_srgb_rgb8(&linear);
let expected_pixel = expected_rgb8.get_pixel(0, 0).0;
for (fmt, ext) in [
(OutputFormat::Jpeg, "jpg"),
(OutputFormat::Png, "png"),
(OutputFormat::Tiff, "tiff"),
] {
let path = std::env::temp_dir().join(format!("agx_test_pix_{ext}.{ext}"));
let opts = EncodeOptions {
jpeg_quality: 100,
format: Some(fmt),
output_gamut: OutputGamut::Srgb,
};
encode_to_file_with_options(&linear, &path, &opts, None).unwrap();
let img = image::open(&path).unwrap().to_rgb8();
assert_eq!(img.dimensions(), (4, 4), "fmt {fmt:?} dims wrong");
let px = img.get_pixel(0, 0).0;
let tol: i32 = if fmt == OutputFormat::Jpeg { 3 } else { 0 };
for c in 0..3 {
let d = (px[c] as i32 - expected_pixel[c] as i32).abs();
assert!(
d <= tol,
"fmt {fmt:?} channel {c} px={} expected={} tol={tol}",
px[c],
expected_pixel[c],
);
}
let _ = std::fs::remove_file(&path);
}
}
}