Skip to main content

rawshift_core/
metadata.rs

1//! Unified metadata types for image formats.
2//!
3//! This module provides format-agnostic metadata structures that represent
4//! a superset of all metadata that can exist in any supported RAW format.
5//! Extend as new formats reveal additional fields.
6
7/// Unsigned rational (numerator, denominator).
8///
9/// The format-agnostic rational used throughout the metadata model. TIFF-based
10/// decoders carry their own wire-level `tiff::Rational` and convert into this
11/// type at the metadata boundary via `From<tiff::Rational>`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct URational {
15    /// Numerator
16    pub numerator: u32,
17    /// Denominator
18    pub denominator: u32,
19}
20
21impl URational {
22    /// Create a new URational.
23    pub fn new(numerator: u32, denominator: u32) -> Self {
24        Self {
25            numerator,
26            denominator,
27        }
28    }
29    /// Convert to f64.
30    pub fn to_f64(&self) -> f64 {
31        if self.denominator == 0 {
32            f64::NAN
33        } else {
34            self.numerator as f64 / self.denominator as f64
35        }
36    }
37}
38
39/// Signed rational (numerator, denominator).
40///
41/// The format-agnostic signed rational used throughout the metadata model.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct SRational {
45    /// Numerator
46    pub numerator: i32,
47    /// Denominator
48    pub denominator: i32,
49}
50
51impl SRational {
52    /// Create a new SRational.
53    pub fn new(numerator: i32, denominator: i32) -> Self {
54        Self {
55            numerator,
56            denominator,
57        }
58    }
59    /// Convert to f64.
60    pub fn to_f64(&self) -> f64 {
61        if self.denominator == 0 {
62            f64::NAN
63        } else {
64            self.numerator as f64 / self.denominator as f64
65        }
66    }
67}
68
69/// Camera identification information.
70#[derive(Debug, Clone, Default, PartialEq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct CameraInfo {
73    /// Camera manufacturer (e.g., "SONY", "Apple")
74    pub make: String,
75    /// Camera model (e.g., "ILCE-6700", "iPhone 17 Pro Max")
76    pub model: String,
77    /// DNG UniqueCameraModel identifier
78    pub unique_camera_model: Option<String>,
79    /// Lens manufacturer
80    pub lens_make: Option<String>,
81    /// Lens model name
82    pub lens_model: Option<String>,
83    /// Lens info: [MinFL, MaxFL, MinFNum, MaxFNum]
84    pub lens_info: Option<[f64; 4]>,
85    /// Camera serial number
86    pub serial_number: Option<String>,
87}
88
89/// EXIF exposure and capture settings.
90#[derive(Debug, Clone, Default, PartialEq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub struct ExifInfo {
93    /// ISO sensitivity
94    pub iso: Option<u32>,
95    /// Exposure time in seconds (num/denom)
96    pub exposure_time: Option<URational>,
97    /// F-number (num/denom)
98    pub f_number: Option<URational>,
99    /// Focal length in mm (num/denom)
100    pub focal_length: Option<URational>,
101    /// 35mm equivalent focal length
102    pub focal_length_35mm: Option<u16>,
103    /// Exposure program (EXIF enum)
104    pub exposure_program: Option<u16>,
105    /// Metering mode (EXIF enum)
106    pub metering_mode: Option<u16>,
107    /// Flash status (EXIF enum)
108    pub flash: Option<u16>,
109    /// Exposure compensation in EV (num/denom)
110    pub exposure_compensation: Option<SRational>,
111    /// Maximum aperture value (num/denom)
112    pub max_aperture: Option<URational>,
113    /// Brightness value (num/denom)
114    pub brightness_value: Option<SRational>,
115}
116
117/// Date/time information.
118#[derive(Debug, Clone, Default, PartialEq)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub struct DateTimeInfo {
121    /// Original capture date/time (EXIF format: "YYYY:MM:DD HH:MM:SS")
122    pub datetime_original: Option<String>,
123    /// Digitization date/time
124    pub create_date: Option<String>,
125    /// Last modification date/time
126    pub modify_date: Option<String>,
127    /// Timezone offset (e.g., "-05:00")
128    pub offset_time: Option<String>,
129    /// Sub-second time precision
130    pub subsec_time: Option<String>,
131}
132
133/// GPS geolocation data.
134#[derive(Debug, Clone, Default, PartialEq)]
135#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
136pub struct GpsInfo {
137    /// Latitude as [degrees, minutes, seconds] rationals
138    pub latitude: Option<[URational; 3]>,
139    /// Latitude reference: 'N' or 'S'
140    pub latitude_ref: Option<char>,
141    /// Longitude as [degrees, minutes, seconds] rationals
142    pub longitude: Option<[URational; 3]>,
143    /// Longitude reference: 'E' or 'W'
144    pub longitude_ref: Option<char>,
145    /// Altitude (num/denom) in meters
146    pub altitude: Option<URational>,
147    /// Altitude reference: 0 = above sea level, 1 = below
148    pub altitude_ref: Option<u8>,
149    /// GPS timestamp [hour, minute, second] rationals
150    pub timestamp: Option<[URational; 3]>,
151    /// GPS datestamp "YYYY:MM:DD"
152    pub datestamp: Option<String>,
153    /// Speed (num/denom)
154    pub speed: Option<URational>,
155    /// Image direction (num/denom) in degrees
156    pub img_direction: Option<URational>,
157}
158
159/// DNG color calibration data.
160#[derive(Debug, Clone, Default, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct DngColorInfo {
163    /// Color matrix 1 (3x3, row-major) - XYZ to camera native under illuminant 1
164    pub color_matrix_1: Option<[f64; 9]>,
165    /// Color matrix 2 (3x3, row-major) - XYZ to camera native under illuminant 2
166    pub color_matrix_2: Option<[f64; 9]>,
167    /// Standard light type for ColorMatrix1 (EXIF LightSource enum)
168    pub calibration_illuminant_1: Option<u16>,
169    /// Standard light type for ColorMatrix2 (EXIF LightSource enum)
170    pub calibration_illuminant_2: Option<u16>,
171    /// As-shot neutral white balance [R, G, B] multipliers
172    pub as_shot_neutral: Option<[f64; 3]>,
173    /// Analog balance [R, G, B]
174    pub analog_balance: Option<[f64; 3]>,
175    /// White balance setting name (e.g., "Cloudy", "Auto")
176    pub white_balance: Option<String>,
177    /// Color temperature in Kelvin
178    pub color_temperature: Option<u32>,
179}
180
181/// DNG calibration and noise data.
182#[derive(Debug, Clone, Default, PartialEq)]
183#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
184pub struct DngCalibrationInfo {
185    /// Baseline exposure offset in EV
186    pub baseline_exposure: Option<f64>,
187    /// Baseline noise level
188    pub baseline_noise: Option<f64>,
189    /// Baseline sharpness
190    pub baseline_sharpness: Option<f64>,
191    /// Noise profile coefficients
192    pub noise_profile: Option<Vec<f64>>,
193    /// Amount of noise reduction applied (0.0-1.0)
194    pub noise_reduction_applied: Option<f64>,
195}
196
197/// DNG profile data.
198#[derive(Debug, Clone, Default, PartialEq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct DngProfileInfo {
201    /// Embedded profile name
202    pub profile_name: Option<String>,
203    /// Profile tone curve (pairs of input/output values)
204    pub profile_tone_curve: Option<Vec<f32>>,
205}
206
207/// Image-level metadata.
208#[derive(Debug, Clone, Default, PartialEq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub struct ImageInfo {
211    /// EXIF orientation (1-8)
212    pub orientation: Option<u16>,
213    /// Bits per sample
214    pub bit_depth: u8,
215    /// Black level per channel
216    pub black_levels: Vec<u32>,
217    /// White/saturation level
218    pub white_level: Option<u32>,
219    /// Default crop origin (x, y)
220    pub default_crop_origin: Option<(u32, u32)>,
221    /// Default crop size (width, height)
222    pub default_crop_size: Option<(u32, u32)>,
223}
224
225/// A typed, format-agnostic metadata value.
226///
227/// Used by [`ImageMetadata::extra`] to represent any metadata tag that does not
228/// have a dedicated typed field. Every EXIF/TIFF/XMP/IPTC value type maps onto
229/// one of these variants, so the generic table can hold *anything* the library
230/// does not (yet) model without requiring a schema change.
231#[derive(Debug, Clone, PartialEq)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
233pub enum MetadataValue {
234    /// Unsigned integer (EXIF BYTE / SHORT / LONG widen losslessly into `u64`).
235    U64(u64),
236    /// Signed integer (EXIF SBYTE / SSHORT / SLONG).
237    I64(i64),
238    /// Floating point (EXIF FLOAT / DOUBLE).
239    F64(f64),
240    /// Unsigned rational (EXIF RATIONAL).
241    URational(URational),
242    /// Signed rational (EXIF SRATIONAL).
243    SRational(SRational),
244    /// Text (EXIF ASCII, XMP/IPTC strings).
245    Text(String),
246    /// Opaque/undefined byte payload (EXIF UNDEFINED, MakerNote fragments).
247    Bytes(Vec<u8>),
248    /// Homogeneous or heterogeneous array of values (EXIF count > 1).
249    Array(Vec<MetadataValue>),
250}
251
252/// Namespace identifying the origin of a generic metadata tag.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
255pub enum MetadataNamespace {
256    /// Standard TIFF/EXIF IFD tags.
257    Exif,
258    /// EXIF GPS IFD tags.
259    Gps,
260    /// Manufacturer MakerNote (uninterpreted sub-tags).
261    MakerNote,
262    /// XMP (RDF/XML) properties.
263    Xmp,
264    /// IPTC IIM datasets.
265    Iptc,
266    /// HEIC/HEIF container-level facts.
267    Heic,
268    /// Vendor/format-specific, identified by the accompanying tag string.
269    Other,
270}
271
272/// Fully-qualified key into [`ImageMetadata::extra`].
273#[derive(Debug, Clone, PartialEq, Eq, Hash)]
274#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
275pub struct MetadataKey {
276    /// Origin namespace.
277    pub namespace: MetadataNamespace,
278    /// Tag identifier — a string for serde stability and human-readable dumps
279    /// (e.g. `"0x9209"` for an EXIF tag, or an XMP property path).
280    pub tag: String,
281}
282
283impl MetadataKey {
284    /// Create a new metadata key.
285    pub fn new(namespace: MetadataNamespace, tag: impl Into<String>) -> Self {
286        Self {
287            namespace,
288            tag: tag.into(),
289        }
290    }
291}
292
293/// One entry in the generic metadata table ([`ImageMetadata::extra`]).
294#[derive(Debug, Clone, PartialEq)]
295#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
296pub struct MetadataEntry {
297    /// Namespaced tag identifier.
298    pub key: MetadataKey,
299    /// Typed value.
300    pub value: MetadataValue,
301}
302
303/// Complete image metadata — unified superset of all supported formats.
304///
305/// This struct is designed for extension. Common metadata has dedicated typed
306/// fields ([`camera`](Self::camera), [`exif`](Self::exif), …); anything the
307/// library does not model is preserved losslessly via the raw-blob fields
308/// ([`exif_raw`](Self::exif_raw), [`xmp`](Self::xmp), …) and the typed generic
309/// table [`extra`](Self::extra), so new formats never force a schema change.
310#[derive(Debug, Clone, Default, PartialEq)]
311#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
312pub struct ImageMetadata {
313    /// Camera identification
314    pub camera: CameraInfo,
315    /// EXIF exposure settings
316    pub exif: ExifInfo,
317    /// Date/time information
318    pub datetime: DateTimeInfo,
319    /// GPS location data
320    pub gps: GpsInfo,
321    /// DNG color science
322    pub dng_color: DngColorInfo,
323    /// DNG calibration/noise
324    pub dng_calibration: DngCalibrationInfo,
325    /// DNG profile data
326    pub dng_profile: DngProfileInfo,
327    /// Image-level metadata
328    pub image: ImageInfo,
329    /// Raw XMP (XML) metadata bytes, if present in source image
330    pub xmp: Option<Vec<u8>>,
331    /// Raw embedded ICC color profile bytes, if present
332    pub icc_profile: Option<Vec<u8>>,
333    /// Full raw EXIF block (TIFF byte stream) exactly as embedded, if present
334    pub exif_raw: Option<Vec<u8>>,
335    /// Raw manufacturer MakerNote blob, uninterpreted, if present
336    pub makernote_raw: Option<Vec<u8>>,
337    /// Raw IPTC IIM block, if present
338    pub iptc_raw: Option<Vec<u8>>,
339    /// Typed generic tag table.
340    ///
341    /// Holds metadata tags as a complete typed mirror of the source — including
342    /// tags also surfaced as dedicated fields above, and anything the library
343    /// does not model. Guarantees no metadata is silently dropped.
344    ///
345    /// Stored as a `Vec` (not a map) so it serializes cleanly in every serde
346    /// format and preserves source ordering. Use [`get`](Self::get) /
347    /// [`insert`](Self::insert) for map-like access.
348    pub extra: Vec<MetadataEntry>,
349}
350
351impl ImageMetadata {
352    /// Look up a generic tag in [`extra`](Self::extra).
353    ///
354    /// Returns the first entry matching `namespace` and `tag`.
355    pub fn get(&self, namespace: MetadataNamespace, tag: &str) -> Option<&MetadataValue> {
356        self.extra
357            .iter()
358            .find(|e| e.key.namespace == namespace && e.key.tag == tag)
359            .map(|e| &e.value)
360    }
361
362    /// Insert or overwrite a generic tag in [`extra`](Self::extra).
363    ///
364    /// If an entry with the same key already exists, its value is replaced;
365    /// otherwise the entry is appended.
366    pub fn insert(&mut self, key: MetadataKey, value: MetadataValue) {
367        if let Some(entry) = self.extra.iter_mut().find(|e| e.key == key) {
368            entry.value = value;
369        } else {
370            self.extra.push(MetadataEntry { key, value });
371        }
372    }
373}
374
375/// Trait for extracting unified metadata from format-specific structures.
376///
377/// Implementors MUST provide metadata extraction for their format.
378/// The compiler enforces implementation; incomplete data is handled via Option.
379pub trait MetadataExtractor {
380    /// Extract unified metadata from the format-specific representation.
381    fn extract_metadata(&self) -> ImageMetadata;
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_image_metadata_default() {
390        let meta = ImageMetadata::default();
391        assert!(meta.camera.make.is_empty());
392        assert!(meta.exif.iso.is_none());
393        assert!(meta.gps.latitude.is_none());
394    }
395
396    #[test]
397    fn test_rational_types() {
398        let ur = URational::new(1, 100);
399        assert_eq!(ur.numerator, 1);
400        assert_eq!(ur.denominator, 100);
401
402        let sr = SRational::new(-1, 3);
403        assert_eq!(sr.numerator, -1);
404        assert_eq!(sr.denominator, 3);
405    }
406
407    #[test]
408    fn test_metadata_value_variants() {
409        // Each variant constructs and compares as expected.
410        assert_eq!(MetadataValue::U64(42), MetadataValue::U64(42));
411        assert_ne!(MetadataValue::U64(1), MetadataValue::I64(1));
412        let nested = MetadataValue::Array(vec![
413            MetadataValue::Text("a".into()),
414            MetadataValue::Bytes(vec![1, 2, 3]),
415            MetadataValue::URational(URational::new(1, 2)),
416        ]);
417        match nested {
418            MetadataValue::Array(items) => assert_eq!(items.len(), 3),
419            _ => panic!("expected Array"),
420        }
421    }
422
423    #[test]
424    fn test_extra_get_insert() {
425        let mut md = ImageMetadata::default();
426        assert!(md.get(MetadataNamespace::Exif, "0x9209").is_none());
427
428        md.insert(
429            MetadataKey::new(MetadataNamespace::Exif, "0x9209"),
430            MetadataValue::U64(9),
431        );
432        assert_eq!(md.extra.len(), 1);
433        assert_eq!(
434            md.get(MetadataNamespace::Exif, "0x9209"),
435            Some(&MetadataValue::U64(9))
436        );
437
438        // Same key overwrites in place rather than appending.
439        md.insert(
440            MetadataKey::new(MetadataNamespace::Exif, "0x9209"),
441            MetadataValue::U64(16),
442        );
443        assert_eq!(md.extra.len(), 1);
444        assert_eq!(
445            md.get(MetadataNamespace::Exif, "0x9209"),
446            Some(&MetadataValue::U64(16))
447        );
448
449        // A different namespace with the same tag is a distinct entry.
450        md.insert(
451            MetadataKey::new(MetadataNamespace::Heic, "0x9209"),
452            MetadataValue::Text("hi".into()),
453        );
454        assert_eq!(md.extra.len(), 2);
455        assert!(md.get(MetadataNamespace::Gps, "0x9209").is_none());
456    }
457
458    #[cfg(feature = "serde")]
459    #[test]
460    fn test_image_metadata_serde_roundtrip_with_extra() {
461        let mut md = ImageMetadata {
462            icc_profile: Some(vec![0xAA, 0xBB]),
463            exif_raw: Some(vec![1, 2, 3, 4]),
464            ..Default::default()
465        };
466        md.insert(
467            MetadataKey::new(MetadataNamespace::Exif, "FlashEnergy"),
468            MetadataValue::Array(vec![
469                MetadataValue::URational(URational::new(3, 2)),
470                MetadataValue::F64(1.5),
471            ]),
472        );
473        md.insert(
474            MetadataKey::new(MetadataNamespace::Heic, "aux_count"),
475            MetadataValue::U64(2),
476        );
477
478        let json = serde_json::to_string(&md).expect("serialize");
479        let back: ImageMetadata = serde_json::from_str(&json).expect("deserialize");
480        assert_eq!(md, back, "ImageMetadata must survive a JSON round-trip");
481    }
482}