exif_oxide/implementations/
value_conv.rs

1//! ValueConv implementations for exif-oxide
2//!
3//! ValueConv functions perform mathematical conversions on raw tag values
4//! to produce logical values. Unlike PrintConv which formats for display,
5//! ValueConv maintains precision for further calculations and round-trip operations.
6//!
7//! All implementations are direct translations from ExifTool source code.
8
9use crate::types::{ExifError, Result, TagValue};
10
11/// GPS coordinate conversion to decimal degrees (unsigned)
12///
13/// ExifTool: lib/Image/ExifTool/GPS.pm lines 12-14 (%coordConv)
14/// ExifTool: lib/Image/ExifTool/GPS.pm lines 364-374 (sub ToDegrees)
15/// Formula: $deg = $d + (($m || 0) + ($s || 0)/60) / 60; (GPS.pm:380)
16///
17/// Converts rational array [degrees, minutes, seconds] to decimal degrees
18/// NOTE: This produces UNSIGNED decimal degrees - hemisphere sign is applied
19/// in Composite tags that combine coordinate + ref (e.g., Composite:GPSLatitude)
20pub fn gps_coordinate_value_conv(value: &TagValue) -> Result<TagValue> {
21    match value {
22        TagValue::RationalArray(coords) if coords.len() >= 3 => {
23            // ExifTool's ToDegrees extracts 3 numeric values using regex:
24            // my ($d, $m, $s) = ($val =~ /((?:[+-]?)(?=\d|\.\d)\d*(?:\.\d*)?(?:[Ee][+-]\d+)?)/g);
25            // For rational arrays, we can extract directly as decimals
26
27            // Extract degrees (first rational)
28            let degrees = if coords[0].1 != 0 {
29                coords[0].0 as f64 / coords[0].1 as f64
30            } else {
31                0.0 // ExifTool uses 0 for undefined values
32            };
33
34            // Extract minutes (second rational)
35            let minutes = if coords.len() > 1 && coords[1].1 != 0 {
36                coords[1].0 as f64 / coords[1].1 as f64
37            } else {
38                0.0 // ExifTool: ($m || 0)
39            };
40
41            // Extract seconds (third rational)
42            let seconds = if coords.len() > 2 && coords[2].1 != 0 {
43                coords[2].0 as f64 / coords[2].1 as f64
44            } else {
45                0.0 // ExifTool: ($s || 0)/60
46            };
47
48            // ExifTool formula: $deg = $d + (($m || 0) + ($s || 0)/60) / 60;
49            let decimal_degrees = degrees + ((minutes + seconds / 60.0) / 60.0);
50
51            Ok(TagValue::F64(decimal_degrees))
52        }
53        _ => Err(ExifError::ParseError(
54            "GPS coordinate conversion requires rational array with at least 3 elements"
55                .to_string(),
56        )),
57    }
58}
59
60/// APEX shutter speed conversion: 2^-val to actual shutter speed
61///
62/// ExifTool: lib/Image/ExifTool/Exif.pm line 3826
63/// ShutterSpeedValue is stored as APEX value where actual_speed = 2^(-apex_value)
64pub fn apex_shutter_speed_value_conv(value: &TagValue) -> Result<TagValue> {
65    match value.as_f64() {
66        Some(apex_val) => {
67            let shutter_speed = (-apex_val).exp2(); // 2^(-val)
68            Ok(TagValue::F64(shutter_speed))
69        }
70        None => Err(ExifError::ParseError(
71            "APEX shutter speed conversion requires numeric value".to_string(),
72        )),
73    }
74}
75
76/// APEX aperture conversion: 2^(val/2) to f-number
77///
78/// ExifTool: lib/Image/ExifTool/Exif.pm line 3827  
79/// ApertureValue is stored as APEX value where f_number = 2^(apex_value/2)
80pub fn apex_aperture_value_conv(value: &TagValue) -> Result<TagValue> {
81    match value.as_f64() {
82        Some(apex_val) => {
83            let f_number = (apex_val / 2.0).exp2(); // 2^(val/2)
84            Ok(TagValue::F64(f_number))
85        }
86        None => Err(ExifError::ParseError(
87            "APEX aperture conversion requires numeric value".to_string(),
88        )),
89    }
90}
91
92/// APEX exposure compensation conversion
93///
94/// ExifTool: lib/Image/ExifTool/Exif.pm ExposureCompensation
95/// Usually no conversion needed - value is already in EV stops
96pub fn apex_exposure_compensation_value_conv(value: &TagValue) -> Result<TagValue> {
97    // Most exposure compensation values are already in the correct format
98    // Just ensure we have a consistent numeric representation
99    match value.as_f64() {
100        Some(ev_value) => Ok(TagValue::F64(ev_value)),
101        None => Ok(value.clone()), // Pass through if not numeric
102    }
103}
104
105/// FNumber conversion from rational to f-stop notation
106///
107/// ExifTool: lib/Image/ExifTool/Exif.pm FNumber
108/// Converts rational like [4, 1] to decimal 4.0 for f/4.0 display
109pub fn fnumber_value_conv(value: &TagValue) -> Result<TagValue> {
110    match value {
111        TagValue::Rational(num, denom) => {
112            if *denom != 0 {
113                let f_number = *num as f64 / *denom as f64;
114                Ok(TagValue::F64(f_number))
115            } else {
116                Err(ExifError::ParseError(
117                    "FNumber has zero denominator".to_string(),
118                ))
119            }
120        }
121        // Already converted or different format
122        _ => Ok(value.clone()),
123    }
124}
125
126/// GPS timestamp conversion
127///
128/// ExifTool: lib/Image/ExifTool/GPS.pm GPSTimeStamp
129/// Converts rational array [hours/1, minutes/1, seconds/100] to "HH:MM:SS" format
130pub fn gpstimestamp_value_conv(value: &TagValue) -> Result<TagValue> {
131    match value {
132        TagValue::RationalArray(rationals) if rationals.len() >= 3 => {
133            let hours = if rationals[0].1 != 0 {
134                rationals[0].0 / rationals[0].1
135            } else {
136                0
137            };
138
139            let minutes = if rationals[1].1 != 0 {
140                rationals[1].0 / rationals[1].1
141            } else {
142                0
143            };
144
145            let seconds = if rationals[2].1 != 0 {
146                rationals[2].0 / rationals[2].1
147            } else {
148                0
149            };
150
151            // Format as "HH:MM:SS"
152            let time_string = format!("{hours:02}:{minutes:02}:{seconds:02}");
153            Ok(TagValue::String(time_string))
154        }
155        _ => Err(ExifError::ParseError(
156            "GPS timestamp conversion requires rational array with at least 3 elements".to_string(),
157        )),
158    }
159}
160
161/// GPS date stamp conversion (placeholder)
162///
163/// ExifTool: lib/Image/ExifTool/GPS.pm GPSDateStamp
164/// TODO: Implement date parsing when we encounter actual GPS date formats
165pub fn gpsdatestamp_value_conv(value: &TagValue) -> Result<TagValue> {
166    // For now, pass through - implement when we see actual GPS date formats
167    Ok(value.clone())
168}
169
170/// White balance ValueConv (placeholder)
171///
172/// ExifTool: lib/Image/ExifTool/Exif.pm WhiteBalance
173/// TODO: Implement white balance conversion when we encounter specific formats
174pub fn whitebalance_value_conv(value: &TagValue) -> Result<TagValue> {
175    // For now, pass through - implement when we see actual white balance formats needing conversion
176    Ok(value.clone())
177}
178
179/// ExposureTime ValueConv - converts rational to decimal seconds
180/// ExifTool: lib/Image/ExifTool/Exif.pm ExposureTime ValueConv
181pub fn exposuretime_value_conv(value: &TagValue) -> Result<TagValue> {
182    match value {
183        TagValue::Rational(num, denom) => {
184            if *denom != 0 {
185                let exposure_time = *num as f64 / *denom as f64;
186                Ok(TagValue::F64(exposure_time))
187            } else {
188                Err(ExifError::ParseError(
189                    "ExposureTime has zero denominator".to_string(),
190                ))
191            }
192        }
193        // Already converted or different format
194        _ => Ok(value.clone()),
195    }
196}
197
198/// FocalLength ValueConv - converts rational to decimal millimeters
199/// ExifTool: lib/Image/ExifTool/Exif.pm FocalLength ValueConv
200pub fn focallength_value_conv(value: &TagValue) -> Result<TagValue> {
201    match value {
202        TagValue::Rational(num, denom) => {
203            if *denom != 0 {
204                let focal_length = *num as f64 / *denom as f64;
205                Ok(TagValue::F64(focal_length))
206            } else {
207                Err(ExifError::ParseError(
208                    "FocalLength has zero denominator".to_string(),
209                ))
210            }
211        }
212        // Already converted or different format
213        _ => Ok(value.clone()),
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_gps_coordinate_conversion() {
223        // Test typical GPS coordinate: 40° 26' 46.8" = 40.446333...
224        let coords = vec![(40, 1), (26, 1), (468, 10)]; // 46.8 seconds as 468/10
225        let coord_value = TagValue::RationalArray(coords);
226
227        let result = gps_coordinate_value_conv(&coord_value).unwrap();
228        if let TagValue::F64(decimal) = result {
229            // 40 + 26/60 + 46.8/3600 = 40.446333...
230            assert!((decimal - 40.446333333).abs() < 0.000001);
231        } else {
232            panic!("Expected F64 result");
233        }
234    }
235
236    #[test]
237    fn test_gps_coordinate_precision() {
238        // Test high precision: 12° 34' 56.789"
239        let coords = vec![(12, 1), (34, 1), (56789, 1000)]; // 56.789 seconds
240        let coord_value = TagValue::RationalArray(coords);
241
242        let result = gps_coordinate_value_conv(&coord_value).unwrap();
243        if let TagValue::F64(decimal) = result {
244            // 12 + 34/60 + 56.789/3600 = 12.582441388...
245            let expected = 12.0 + 34.0 / 60.0 + 56.789 / 3600.0;
246            assert!((decimal - expected).abs() < 0.0000001);
247        } else {
248            panic!("Expected F64 result");
249        }
250    }
251
252    #[test]
253    fn test_gps_coordinate_zero_values() {
254        // Test coordinates at exactly 0° 0' 0"
255        let coords = vec![(0, 1), (0, 1), (0, 1)];
256        let coord_value = TagValue::RationalArray(coords);
257
258        let result = gps_coordinate_value_conv(&coord_value).unwrap();
259        if let TagValue::F64(decimal) = result {
260            assert_eq!(decimal, 0.0);
261        } else {
262            panic!("Expected F64 result");
263        }
264    }
265
266    #[test]
267    fn test_gps_coordinate_only_degrees() {
268        // Test coordinate with only degrees: 45° 0' 0"
269        let coords = vec![(45, 1), (0, 1), (0, 1)];
270        let coord_value = TagValue::RationalArray(coords);
271
272        let result = gps_coordinate_value_conv(&coord_value).unwrap();
273        if let TagValue::F64(decimal) = result {
274            assert_eq!(decimal, 45.0);
275        } else {
276            panic!("Expected F64 result");
277        }
278    }
279
280    #[test]
281    fn test_gps_coordinate_zero_denominators() {
282        // Test handling of zero denominators (should be treated as 0)
283        let coords = vec![(40, 1), (30, 0), (45, 1)]; // minutes has zero denominator
284        let coord_value = TagValue::RationalArray(coords);
285
286        let result = gps_coordinate_value_conv(&coord_value).unwrap();
287        if let TagValue::F64(decimal) = result {
288            // 40 + 0/60 + 45/3600 = 40.0125
289            assert!((decimal - 40.0125).abs() < 0.0001);
290        } else {
291            panic!("Expected F64 result");
292        }
293    }
294
295    #[test]
296    fn test_gps_coordinate_invalid_input() {
297        // Test with wrong type
298        let value = TagValue::String("40.446333".to_string());
299        let result = gps_coordinate_value_conv(&value);
300        assert!(matches!(result, Err(ExifError::ParseError(_))));
301
302        // Test with too few elements
303        let coords = vec![(40, 1), (26, 1)]; // Only 2 elements instead of 3
304        let coord_value = TagValue::RationalArray(coords);
305        let result = gps_coordinate_value_conv(&coord_value);
306        assert!(matches!(result, Err(ExifError::ParseError(_))));
307
308        // Test with empty array
309        let coords = vec![];
310        let coord_value = TagValue::RationalArray(coords);
311        let result = gps_coordinate_value_conv(&coord_value);
312        assert!(matches!(result, Err(ExifError::ParseError(_))));
313    }
314
315    #[test]
316    fn test_apex_shutter_speed() {
317        // APEX value 11 should give shutter speed of 2^(-11) = 1/2048 ≈ 0.00048828125
318        let apex_value = TagValue::F64(11.0);
319        let result = apex_shutter_speed_value_conv(&apex_value).unwrap();
320
321        if let TagValue::F64(speed) = result {
322            assert!((speed - 0.00048828125).abs() < 0.000001);
323        } else {
324            panic!("Expected F64 result");
325        }
326    }
327
328    #[test]
329    fn test_apex_aperture() {
330        // APEX aperture value 4 should give f-number of 2^(4/2) = 2^2 = 4.0
331        let apex_value = TagValue::F64(4.0);
332        let result = apex_aperture_value_conv(&apex_value).unwrap();
333
334        if let TagValue::F64(f_number) = result {
335            assert!((f_number - 4.0).abs() < 0.001);
336        } else {
337            panic!("Expected F64 result");
338        }
339    }
340
341    #[test]
342    fn test_fnumber_conversion() {
343        // Rational [4, 1] should convert to 4.0
344        let fnumber_rational = TagValue::Rational(4, 1);
345        let result = fnumber_value_conv(&fnumber_rational).unwrap();
346
347        if let TagValue::F64(f_number) = result {
348            assert!((f_number - 4.0).abs() < 0.001);
349        } else {
350            panic!("Expected F64 result");
351        }
352    }
353}