1use std::io::Cursor;
4use std::path::PathBuf;
5
6use image::codecs::jpeg::JpegEncoder;
7use image::codecs::png::PngEncoder;
8use image::codecs::tiff::TiffEncoder;
9use image::{DynamicImage, Rgb, Rgb32FImage};
10use palette::{LinSrgb, Srgb};
11
12use crate::error::Result;
13use crate::metadata::ImageMetadata;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputFormat {
18 Jpeg,
20 Png,
22 Tiff,
24}
25
26impl OutputFormat {
27 pub fn extension(&self) -> &'static str {
29 match self {
30 OutputFormat::Jpeg => "jpeg",
31 OutputFormat::Png => "png",
32 OutputFormat::Tiff => "tiff",
33 }
34 }
35
36 pub fn from_extension(ext: &str) -> Option<Self> {
38 match ext.to_ascii_lowercase().as_str() {
39 "jpg" | "jpeg" => Some(OutputFormat::Jpeg),
40 "png" => Some(OutputFormat::Png),
41 "tif" | "tiff" => Some(OutputFormat::Tiff),
42 _ => None,
43 }
44 }
45}
46
47pub struct EncodeOptions {
49 pub jpeg_quality: u8,
51 pub format: Option<OutputFormat>,
53}
54
55impl Default for EncodeOptions {
56 fn default() -> Self {
57 Self {
58 jpeg_quality: 92,
59 format: None,
60 }
61 }
62}
63
64pub fn resolve_output(
72 path: &std::path::Path,
73 format: Option<OutputFormat>,
74) -> (std::path::PathBuf, OutputFormat) {
75 let ext_format = path
76 .extension()
77 .and_then(|e| e.to_str())
78 .and_then(OutputFormat::from_extension);
79
80 match (format, ext_format) {
81 (Some(fmt), Some(ext_fmt)) if fmt == ext_fmt => (path.to_path_buf(), fmt),
83 (Some(fmt), _) => {
85 let mut new_path = path.as_os_str().to_owned();
86 new_path.push(".");
87 new_path.push(fmt.extension());
88 (std::path::PathBuf::from(new_path), fmt)
89 }
90 (None, Some(ext_fmt)) => (path.to_path_buf(), ext_fmt),
92 (None, None) => {
94 let mut new_path = path.as_os_str().to_owned();
95 new_path.push(".jpeg");
96 (std::path::PathBuf::from(new_path), OutputFormat::Jpeg)
97 }
98 }
99}
100
101pub fn linear_to_srgb_dynamic(linear: &Rgb32FImage) -> DynamicImage {
103 let (w, h) = linear.dimensions();
104 let srgb = Rgb32FImage::from_fn(w, h, |x, y| {
105 let p = linear.get_pixel(x, y);
106 let srgb: Srgb<f32> = LinSrgb::new(p.0[0], p.0[1], p.0[2]).into_encoding();
107 Rgb([srgb.red, srgb.green, srgb.blue])
108 });
109 DynamicImage::ImageRgb32F(srgb)
110}
111
112pub fn encode_to_file_with_options(
118 linear: &Rgb32FImage,
119 path: &std::path::Path,
120 options: &EncodeOptions,
121 metadata: Option<&ImageMetadata>,
122) -> Result<PathBuf> {
123 let (final_path, format) = resolve_output(path, options.format);
124
125 let dynamic = linear_to_srgb_dynamic(linear);
126 let rgb8 = dynamic.to_rgb8();
127
128 let buf = match format {
130 OutputFormat::Jpeg => {
131 let mut buf = Vec::new();
132 let encoder = JpegEncoder::new_with_quality(&mut buf, options.jpeg_quality);
133 rgb8.write_with_encoder(encoder)
134 .map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
135 buf
136 }
137 OutputFormat::Png => {
138 let mut buf = Vec::new();
139 let encoder = PngEncoder::new(&mut buf);
140 rgb8.write_with_encoder(encoder)
141 .map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
142 buf
143 }
144 OutputFormat::Tiff => {
145 let mut buf = Vec::new();
146 let cursor = Cursor::new(&mut buf);
147 let encoder = TiffEncoder::new(cursor);
148 rgb8.write_with_encoder(encoder)
149 .map_err(|e| crate::error::AgxError::Encode(e.to_string()))?;
150 buf
151 }
152 };
153
154 let buf = if let Some(meta) = metadata {
156 inject_metadata(buf, format, meta)?
157 } else {
158 buf
159 };
160
161 std::fs::write(&final_path, &buf)?;
162
163 if format == OutputFormat::Tiff {
165 if let Some(meta) = metadata {
166 inject_metadata_tiff(&final_path, meta);
167 }
168 }
169
170 Ok(final_path)
171}
172
173pub fn encode_to_file(linear: &Rgb32FImage, path: &std::path::Path) -> Result<()> {
178 encode_to_file_with_options(linear, path, &EncodeOptions::default(), None)?;
179 Ok(())
180}
181
182fn inject_metadata(
184 buf: Vec<u8>,
185 format: OutputFormat,
186 metadata: &ImageMetadata,
187) -> Result<Vec<u8>> {
188 use img_parts::{ImageEXIF, ImageICC};
189
190 match format {
191 OutputFormat::Jpeg => {
192 let mut jpeg = img_parts::jpeg::Jpeg::from_bytes(buf.into())
193 .map_err(|e| crate::error::AgxError::Encode(format!("metadata injection: {e}")))?;
194 if let Some(exif) = &metadata.exif {
195 jpeg.set_exif(Some(exif.clone().into()));
196 }
197 if let Some(icc) = &metadata.icc_profile {
198 jpeg.set_icc_profile(Some(icc.clone().into()));
199 }
200 let mut out = Vec::new();
201 jpeg.encoder()
202 .write_to(&mut out)
203 .map_err(|e| crate::error::AgxError::Encode(format!("metadata write: {e}")))?;
204 Ok(out)
205 }
206 OutputFormat::Png => {
207 let mut png = img_parts::png::Png::from_bytes(buf.into())
208 .map_err(|e| crate::error::AgxError::Encode(format!("metadata injection: {e}")))?;
209 if let Some(exif) = &metadata.exif {
210 png.set_exif(Some(exif.clone().into()));
211 }
212 if let Some(icc) = &metadata.icc_profile {
213 png.set_icc_profile(Some(icc.clone().into()));
214 }
215 let mut out = Vec::new();
216 png.encoder()
217 .write_to(&mut out)
218 .map_err(|e| crate::error::AgxError::Encode(format!("metadata write: {e}")))?;
219 Ok(out)
220 }
221 OutputFormat::Tiff => Ok(buf), }
223}
224
225fn inject_metadata_tiff(path: &std::path::Path, metadata: &ImageMetadata) {
227 if let Some(exif_bytes) = &metadata.exif {
228 let file_ext = little_exif::filetype::FileExtension::TIFF;
229 if let Ok(exif_meta) = little_exif::metadata::Metadata::new_from_vec(exif_bytes, file_ext) {
230 let _ = exif_meta.write_to_file(path);
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use image::ImageBuffer;
239 use std::path::PathBuf;
240
241 #[test]
242 fn roundtrip_linear_to_srgb_pixel_values() {
243 let linear: Rgb32FImage = ImageBuffer::from_pixel(1, 1, Rgb([0.2159f32, 0.2159, 0.2159]));
245 let dynamic = linear_to_srgb_dynamic(&linear);
246 let rgb8 = dynamic.to_rgb8();
247 let pixel = rgb8.get_pixel(0, 0);
248 assert!(
249 (pixel.0[0] as i32 - 128).unsigned_abs() <= 1,
250 "Expected ~128, got {}",
251 pixel.0[0]
252 );
253 }
254
255 #[test]
256 fn encode_saves_file() {
257 let temp_path = std::env::temp_dir().join("agx_test_encode.png");
258 let linear: Rgb32FImage = ImageBuffer::from_pixel(2, 2, Rgb([0.5f32, 0.5, 0.5]));
259 encode_to_file(&linear, &temp_path).unwrap();
260 assert!(temp_path.exists());
261 let _ = std::fs::remove_file(&temp_path);
262 }
263
264 #[test]
265 fn encode_options_default_quality_is_92() {
266 let opts = EncodeOptions::default();
267 assert_eq!(opts.jpeg_quality, 92);
268 assert!(opts.format.is_none());
269 }
270
271 #[test]
272 fn output_format_extensions() {
273 assert_eq!(OutputFormat::Jpeg.extension(), "jpeg");
274 assert_eq!(OutputFormat::Png.extension(), "png");
275 assert_eq!(OutputFormat::Tiff.extension(), "tiff");
276 }
277
278 #[test]
279 fn resolve_output_infers_jpeg_from_jpg() {
280 let (path, fmt) = resolve_output(std::path::Path::new("out.jpg"), None);
281 assert_eq!(fmt, OutputFormat::Jpeg);
282 assert_eq!(path, PathBuf::from("out.jpg"));
283 }
284
285 #[test]
286 fn resolve_output_infers_png() {
287 let (path, fmt) = resolve_output(std::path::Path::new("out.png"), None);
288 assert_eq!(fmt, OutputFormat::Png);
289 assert_eq!(path, PathBuf::from("out.png"));
290 }
291
292 #[test]
293 fn resolve_output_infers_tiff() {
294 let (path, fmt) = resolve_output(std::path::Path::new("out.tif"), None);
295 assert_eq!(fmt, OutputFormat::Tiff);
296 assert_eq!(path, PathBuf::from("out.tif"));
297 }
298
299 #[test]
300 fn resolve_output_format_override_matching_ext() {
301 let (path, fmt) = resolve_output(std::path::Path::new("out.jpg"), Some(OutputFormat::Jpeg));
302 assert_eq!(fmt, OutputFormat::Jpeg);
303 assert_eq!(path, PathBuf::from("out.jpg"));
304 }
305
306 #[test]
307 fn resolve_output_format_override_mismatched_ext_appends() {
308 let (path, fmt) = resolve_output(std::path::Path::new("out.png"), Some(OutputFormat::Jpeg));
309 assert_eq!(fmt, OutputFormat::Jpeg);
310 assert_eq!(path, PathBuf::from("out.png.jpeg"));
311 }
312
313 #[test]
314 fn resolve_output_unknown_ext_defaults_to_jpeg() {
315 let (path, fmt) = resolve_output(std::path::Path::new("out.xyz"), None);
316 assert_eq!(fmt, OutputFormat::Jpeg);
317 assert_eq!(path, PathBuf::from("out.xyz.jpeg"));
318 }
319
320 #[test]
321 fn resolve_output_no_extension_defaults_to_jpeg() {
322 let (path, fmt) = resolve_output(std::path::Path::new("output"), None);
323 assert_eq!(fmt, OutputFormat::Jpeg);
324 assert_eq!(path, PathBuf::from("output.jpeg"));
325 }
326
327 #[test]
328 fn encode_jpeg_with_quality_produces_file() {
329 let temp_path = std::env::temp_dir().join("agx_test_quality.jpg");
330 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
331 let opts = EncodeOptions {
332 jpeg_quality: 95,
333 format: None,
334 };
335 let result = encode_to_file_with_options(&linear, &temp_path, &opts, None);
336 assert!(result.is_ok());
337 let final_path = result.unwrap();
338 assert!(final_path.exists());
339 let _ = std::fs::remove_file(&final_path);
340 }
341
342 #[test]
343 fn encode_jpeg_quality_affects_file_size() {
344 let linear: Rgb32FImage = ImageBuffer::from_pixel(64, 64, Rgb([0.5f32, 0.3, 0.1]));
345
346 let path_low = std::env::temp_dir().join("agx_test_q50.jpg");
347 let path_high = std::env::temp_dir().join("agx_test_q95.jpg");
348
349 let opts_low = EncodeOptions {
350 jpeg_quality: 50,
351 format: None,
352 };
353 let opts_high = EncodeOptions {
354 jpeg_quality: 95,
355 format: None,
356 };
357
358 encode_to_file_with_options(&linear, &path_low, &opts_low, None).unwrap();
359 encode_to_file_with_options(&linear, &path_high, &opts_high, None).unwrap();
360
361 let size_low = std::fs::metadata(&path_low).unwrap().len();
362 let size_high = std::fs::metadata(&path_high).unwrap().len();
363 assert!(
364 size_high > size_low,
365 "Higher quality should produce larger file: q95={size_high} vs q50={size_low}"
366 );
367
368 let _ = std::fs::remove_file(&path_low);
369 let _ = std::fs::remove_file(&path_high);
370 }
371
372 #[test]
373 fn encode_png_format() {
374 let temp_path = std::env::temp_dir().join("agx_test_fmt.png");
375 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
376 let opts = EncodeOptions {
377 jpeg_quality: 92,
378 format: None,
379 };
380 let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
381 assert!(final_path.exists());
382 let img = image::open(&final_path).unwrap();
383 assert_eq!(img.width(), 4);
384 let _ = std::fs::remove_file(&final_path);
385 }
386
387 #[test]
388 fn encode_tiff_format() {
389 let temp_path = std::env::temp_dir().join("agx_test_fmt.tiff");
390 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
391 let opts = EncodeOptions {
392 jpeg_quality: 92,
393 format: None,
394 };
395 let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
396 assert!(final_path.exists());
397 let img = image::open(&final_path).unwrap();
398 assert_eq!(img.width(), 4);
399 let _ = std::fs::remove_file(&final_path);
400 }
401
402 #[test]
403 fn encode_format_override_appends_extension() {
404 let temp_path = std::env::temp_dir().join("agx_test_override.png");
405 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
406 let opts = EncodeOptions {
407 jpeg_quality: 92,
408 format: Some(OutputFormat::Jpeg),
409 };
410 let final_path = encode_to_file_with_options(&linear, &temp_path, &opts, None).unwrap();
411 assert_eq!(
412 final_path,
413 std::env::temp_dir().join("agx_test_override.png.jpeg")
414 );
415 assert!(final_path.exists());
416 let _ = std::fs::remove_file(&final_path);
417 }
418
419 #[test]
420 fn metadata_roundtrip_jpeg() {
421 let exif_bytes = vec![
422 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08, ];
427 let meta = ImageMetadata {
428 exif: Some(exif_bytes.clone()),
429 icc_profile: None,
430 };
431
432 let temp_path = std::env::temp_dir().join("agx_test_meta_rt.jpg");
433 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
434 let opts = EncodeOptions {
435 jpeg_quality: 92,
436 format: None,
437 };
438 encode_to_file_with_options(&linear, &temp_path, &opts, Some(&meta)).unwrap();
439
440 let meta_out = crate::metadata::extract_metadata(&temp_path);
441 assert!(meta_out.is_some(), "Should have metadata in output");
442 assert!(
443 meta_out.as_ref().unwrap().exif.is_some(),
444 "Should have EXIF in output"
445 );
446
447 let _ = std::fs::remove_file(&temp_path);
448 }
449
450 #[test]
451 fn encode_without_metadata_still_works() {
452 let temp_path = std::env::temp_dir().join("agx_test_no_meta.jpg");
453 let linear: Rgb32FImage = ImageBuffer::from_pixel(4, 4, Rgb([0.5f32, 0.5, 0.5]));
454 let opts = EncodeOptions::default();
455 let result = encode_to_file_with_options(&linear, &temp_path, &opts, None);
456 assert!(result.is_ok());
457 let _ = std::fs::remove_file(result.unwrap());
458 }
459}