Skip to main content

agx/
metadata.rs

1//! Image metadata extraction and representation.
2//!
3//! Provides a unified interface for extracting EXIF and ICC profile metadata
4//! from various image formats (JPEG, PNG, TIFF-based raw, LibRaw-parsed raw).
5
6use std::path::Path;
7
8/// Extracted metadata from an input image (EXIF, ICC profile).
9#[derive(Debug, Clone)]
10pub struct ImageMetadata {
11    /// Raw EXIF bytes.
12    pub exif: Option<Vec<u8>>,
13    /// Raw ICC profile bytes.
14    pub icc_profile: Option<Vec<u8>>,
15}
16
17/// Extract metadata (EXIF, ICC profile) from an input image file.
18///
19/// Extraction strategy (best-effort, cascading):
20/// 1. `img-parts` for JPEG/PNG — lossless byte-level copy
21/// 2. `kamadak-exif` for TIFF-based raw files (behind `raw` feature)
22/// 3. LibRaw parsed fields for non-TIFF raw files (behind `raw` feature)
23/// 4. Return None — no metadata extracted
24///
25/// Returns `None` for unsupported formats or if the file can't be read.
26/// This is best-effort — metadata extraction failure should never block processing.
27pub fn extract_metadata(path: &Path) -> Option<ImageMetadata> {
28    let bytes = std::fs::read(path).ok()?;
29
30    // Strategy 1: Try img-parts for JPEG
31    if let Some(meta) = extract_metadata_jpeg(&bytes) {
32        return Some(meta);
33    }
34
35    // Strategy 2: Try img-parts for PNG
36    if let Some(meta) = extract_metadata_png(&bytes) {
37        return Some(meta);
38    }
39
40    // Strategy 3: Try kamadak-exif for TIFF-based raw files (CR2, NEF, DNG, ARW, PEF, ORF)
41    #[cfg(feature = "raw")]
42    {
43        if crate::decode::is_raw_extension(path) {
44            if let Some(meta) = extract_metadata_raw_tiff(path) {
45                return Some(meta);
46            }
47        }
48    }
49
50    // Strategy 4: Try LibRaw parsed fields for non-TIFF raw files (RAF, RW2, CR3, etc.)
51    #[cfg(feature = "raw")]
52    {
53        if crate::decode::is_raw_extension(path) {
54            if let Some(exif_bytes) = crate::decode::raw::extract_raw_metadata(path) {
55                return Some(ImageMetadata {
56                    exif: Some(exif_bytes),
57                    icc_profile: None,
58                });
59            }
60        }
61    }
62
63    None
64}
65
66fn extract_metadata_jpeg(bytes: &[u8]) -> Option<ImageMetadata> {
67    use img_parts::{ImageEXIF, ImageICC};
68
69    let jpeg = img_parts::jpeg::Jpeg::from_bytes(bytes.to_vec().into()).ok()?;
70    let exif = jpeg.exif().map(|b| b.to_vec());
71    let icc = jpeg.icc_profile().map(|b| b.to_vec());
72    if exif.is_some() || icc.is_some() {
73        return Some(ImageMetadata {
74            exif,
75            icc_profile: icc,
76        });
77    }
78    None
79}
80
81fn extract_metadata_png(bytes: &[u8]) -> Option<ImageMetadata> {
82    use img_parts::{ImageEXIF, ImageICC};
83
84    let png = img_parts::png::Png::from_bytes(bytes.to_vec().into()).ok()?;
85    let exif = png.exif().map(|b| b.to_vec());
86    let icc = png.icc_profile().map(|b| b.to_vec());
87    if exif.is_some() || icc.is_some() {
88        return Some(ImageMetadata {
89            exif,
90            icc_profile: icc,
91        });
92    }
93    None
94}
95
96/// Extract EXIF from a TIFF-based raw file using kamadak-exif.
97///
98/// Works for: CR2, NEF, DNG, ARW, PEF, ORF (TIFF-container raw formats).
99/// Returns raw EXIF bytes suitable for injection into output files.
100#[cfg(feature = "raw")]
101fn extract_metadata_raw_tiff(path: &Path) -> Option<ImageMetadata> {
102    let file = std::fs::File::open(path).ok()?;
103    let mut reader = std::io::BufReader::new(file);
104    let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
105    let raw_buf = exif.buf();
106    if raw_buf.is_empty() {
107        return None;
108    }
109    // kamadak-exif returns raw EXIF bytes (TIFF header + IFDs).
110    // For injection into JPEG via img-parts, we need "Exif\0\0" prefix.
111    let exif_bytes = if raw_buf.starts_with(b"Exif\0\0") {
112        raw_buf.to_vec()
113    } else {
114        let mut prefixed = b"Exif\0\0".to_vec();
115        prefixed.extend_from_slice(raw_buf);
116        prefixed
117    };
118    Some(ImageMetadata {
119        exif: Some(exif_bytes),
120        icc_profile: None,
121    })
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn extract_metadata_from_jpeg_with_no_exif() {
130        use image::{ImageBuffer, Rgb};
131
132        let temp_path = std::env::temp_dir().join("agx_test_no_exif.jpg");
133        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
134            ImageBuffer::from_pixel(4, 4, Rgb([128u8, 128, 128]));
135        img.save(&temp_path).unwrap();
136
137        let meta = extract_metadata(&temp_path);
138        if let Some(m) = meta {
139            assert!(m.exif.is_none() || !m.exif.as_ref().unwrap().is_empty());
140        }
141
142        let _ = std::fs::remove_file(&temp_path);
143    }
144
145    #[test]
146    fn extract_metadata_nonexistent_file_returns_none() {
147        let meta = extract_metadata(std::path::Path::new("/nonexistent/file.jpg"));
148        assert!(meta.is_none());
149    }
150
151    #[test]
152    fn extract_metadata_from_png() {
153        use image::{ImageBuffer, Rgb};
154
155        let temp_path = std::env::temp_dir().join("agx_test_meta.png");
156        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
157            ImageBuffer::from_pixel(4, 4, Rgb([128u8, 128, 128]));
158        img.save(&temp_path).unwrap();
159
160        let _meta = extract_metadata(&temp_path);
161        // Should not crash
162        let _ = std::fs::remove_file(&temp_path);
163    }
164}
165
166#[cfg(all(test, feature = "raw"))]
167mod raw_metadata_tests {
168    use super::*;
169
170    #[test]
171    fn extract_metadata_raw_tiff_nonexistent_returns_none() {
172        let meta = extract_metadata_raw_tiff(std::path::Path::new("/nonexistent/photo.cr2"));
173        assert!(meta.is_none());
174    }
175
176    #[test]
177    fn extract_metadata_raw_tiff_non_tiff_file_returns_none() {
178        let temp_path = std::env::temp_dir().join("agx_test_not_tiff_raw.jpg");
179        let img: image::ImageBuffer<image::Rgb<u8>, Vec<u8>> =
180            image::ImageBuffer::from_pixel(4, 4, image::Rgb([128u8, 128, 128]));
181        img.save(&temp_path).unwrap();
182
183        let _meta = extract_metadata_raw_tiff(&temp_path);
184        // kamadak-exif may or may not return EXIF from a JPEG — either way is fine
185        let _ = std::fs::remove_file(&temp_path);
186    }
187
188    #[test]
189    fn extract_metadata_falls_through_to_none_for_unknown() {
190        let temp_path = std::env::temp_dir().join("agx_test_unknown.bmp");
191        let img: image::ImageBuffer<image::Rgb<u8>, Vec<u8>> =
192            image::ImageBuffer::from_pixel(4, 4, image::Rgb([128u8, 128, 128]));
193        img.save(&temp_path).unwrap();
194        let meta = extract_metadata(&temp_path);
195        assert!(meta.is_none());
196        let _ = std::fs::remove_file(&temp_path);
197    }
198}