Skip to main content

metadata/
exif.rs

1use crate::types::ExifMeta;
2use exif::{In, Reader as ExifReader, Tag, Value};
3use std::fs::File;
4use std::io::BufReader;
5use std::path::Path;
6
7/// Best-effort EXIF extraction.
8pub fn read_exif(path: &Path) -> Option<ExifMeta> {
9    let file = File::open(path).ok()?;
10    let mut bufreader = BufReader::new(&file);
11    let exifreader = ExifReader::new();
12    let exif = exifreader.read_from_container(&mut bufreader).ok()?;
13
14    let mut info = ExifMeta::default();
15
16    let make = exif
17        .get_field(Tag::Make, In::PRIMARY)
18        .map(|f| f.display_value().to_string());
19    let model = exif
20        .get_field(Tag::Model, In::PRIMARY)
21        .map(|f| f.display_value().to_string());
22    info.camera = match (make, model) {
23        (Some(m), Some(md)) if !m.is_empty() && !md.is_empty() => Some(format!("{m} {md}")),
24        (Some(m), _) if !m.is_empty() => Some(m),
25        (_, Some(md)) if !md.is_empty() => Some(md),
26        _ => None,
27    };
28
29    if let Some(f) = exif.get_field(Tag::LensModel, In::PRIMARY) {
30        let s = f.display_value().to_string();
31        if !s.is_empty() {
32            info.lens = Some(s);
33        }
34    }
35
36    let lat_ref = exif
37        .get_field(Tag::GPSLatitudeRef, In::PRIMARY)
38        .map(|f| f.display_value().to_string());
39    let lon_ref = exif
40        .get_field(Tag::GPSLongitudeRef, In::PRIMARY)
41        .map(|f| f.display_value().to_string());
42    if let Some(f) = exif.get_field(Tag::GPSLatitude, In::PRIMARY) {
43        info.gps_lat = parse_gps_decimal(f.display_value().to_string(), lat_ref.as_deref());
44    }
45    if let Some(f) = exif.get_field(Tag::GPSLongitude, In::PRIMARY) {
46        info.gps_lon = parse_gps_decimal(f.display_value().to_string(), lon_ref.as_deref());
47    }
48
49    for field in exif.fields() {
50        if is_known_tag(field.tag) {
51            continue;
52        }
53        let key = format!("{:?}", field.tag);
54        info.extra.insert(key, exif_value_to_json(&field.value));
55    }
56
57    if info.camera.is_none()
58        && info.lens.is_none()
59        && info.gps_lat.is_none()
60        && info.gps_lon.is_none()
61        && info.extra.is_empty()
62    {
63        None
64    } else {
65        Some(info)
66    }
67}
68
69fn is_known_tag(tag: Tag) -> bool {
70    matches!(
71        tag,
72        Tag::Make
73            | Tag::Model
74            | Tag::LensModel
75            | Tag::GPSLatitude
76            | Tag::GPSLongitude
77            | Tag::GPSLatitudeRef
78            | Tag::GPSLongitudeRef
79    )
80}
81
82fn parse_gps_decimal(text: String, reference: Option<&str>) -> Option<f64> {
83    let filtered = text
84        .replace("deg", " ")
85        .replace("min", " ")
86        .replace("sec", " ")
87        .replace(['"', '\''], " ");
88    let mut nums = filtered
89        .split_whitespace()
90        .filter_map(|p| p.parse::<f64>().ok());
91    let deg = nums.next()?;
92    let min = nums.next().unwrap_or(0.0);
93    let sec = nums.next().unwrap_or(0.0);
94    let mut decimal = deg + (min / 60.0) + (sec / 3600.0);
95    if let Some(reference) = reference {
96        let upper = reference.trim().to_ascii_uppercase();
97        if upper.contains('S') || upper.contains('W') {
98            decimal *= -1.0;
99        }
100    }
101    Some(decimal)
102}
103
104fn exif_value_to_json(value: &Value) -> serde_json::Value {
105    match value {
106        Value::Byte(v) => serde_json::json!(v),
107        Value::Ascii(v) => serde_json::json!(v
108            .iter()
109            .map(|s| String::from_utf8_lossy(s).to_string())
110            .collect::<Vec<_>>()),
111        Value::Short(v) => serde_json::json!(v),
112        Value::Long(v) => serde_json::json!(v),
113        Value::Rational(v) => serde_json::json!(v
114            .iter()
115            .map(|r| {
116                if r.denom == 0 {
117                    serde_json::Value::Null
118                } else {
119                    serde_json::json!(r.num as f64 / r.denom as f64)
120                }
121            })
122            .collect::<Vec<_>>()),
123        Value::SByte(v) => serde_json::json!(v),
124        Value::Undefined(v, _) => serde_json::json!(v),
125        Value::SShort(v) => serde_json::json!(v),
126        Value::SLong(v) => serde_json::json!(v),
127        Value::SRational(v) => serde_json::json!(v
128            .iter()
129            .map(|r| {
130                if r.denom == 0 {
131                    serde_json::Value::Null
132                } else {
133                    serde_json::json!(r.num as f64 / r.denom as f64)
134                }
135            })
136            .collect::<Vec<_>>()),
137        Value::Float(v) => serde_json::json!(v),
138        Value::Double(v) => serde_json::json!(v),
139        Value::Unknown(_, _, _) => serde_json::Value::Null,
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::parse_gps_decimal;
146
147    #[test]
148    fn gps_ref_south_and_west_are_negative() {
149        let lat = parse_gps_decimal("40 deg 26 min 46 sec".to_string(), Some("S")).unwrap();
150        let lon = parse_gps_decimal("79 deg 58 min 56 sec".to_string(), Some("W")).unwrap();
151        assert!(lat < 0.0);
152        assert!(lon < 0.0);
153    }
154
155    #[test]
156    fn gps_ref_north_and_east_stay_positive() {
157        let lat = parse_gps_decimal("40 deg 26 min 46 sec".to_string(), Some("N")).unwrap();
158        let lon = parse_gps_decimal("79 deg 58 min 56 sec".to_string(), Some("E")).unwrap();
159        assert!(lat > 0.0);
160        assert!(lon > 0.0);
161    }
162}