Skip to main content

mimick/library/
local_exif.rs

1//! EXIF / XMP metadata extraction for local files.
2//!
3//! Uses `nom-exif` — pure-Rust, no native dependencies. Reads metadata from
4//! JPEG, PNG, TIFF, WebP, HEIF / HEIC, JXL and MP4 / MOV. RAW formats fall
5//! through as "no metadata"; that case is hidden in the UI rather than shown
6//! as an error.
7//!
8//! Results are cached on disk keyed by `(mtime, size)` so re-opening the
9//! lightbox on the same file doesn't re-parse a multi-megabyte image.
10//! EXIF / XMP extraction via `nom-exif`. Disk-cached by `(mtime, size)`.
11
12use std::fs;
13use std::io::{Read, Write};
14use std::path::{Path, PathBuf};
15
16use nom_exif::{EntryValue, Exif, ExifTag, read_exif as nom_read_exif};
17use serde::{Deserialize, Serialize};
18
19/// Mirrors remote `ExifInfo` so the renderer is source-agnostic.
20#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21pub struct LocalExif {
22    pub make: Option<String>,
23    pub model: Option<String>,
24    pub lens_model: Option<String>,
25    pub f_number: Option<f64>,
26    pub focal_length: Option<f64>,
27    pub iso: Option<u32>,
28    pub exposure_time: Option<String>,
29    /// RFC3339; consumed by `format_datetime_display`.
30    pub date_time_original: Option<String>,
31    pub latitude: Option<f64>,
32    pub longitude: Option<f64>,
33    pub image_width: Option<u32>,
34    pub image_height: Option<u32>,
35    pub description: Option<String>,
36}
37
38impl LocalExif {
39    /// True when no useful fields were parsed.
40    pub fn is_empty(&self) -> bool {
41        self.make.is_none()
42            && self.model.is_none()
43            && self.lens_model.is_none()
44            && self.f_number.is_none()
45            && self.focal_length.is_none()
46            && self.iso.is_none()
47            && self.exposure_time.is_none()
48            && self.date_time_original.is_none()
49            && self.latitude.is_none()
50            && self.longitude.is_none()
51            && self.image_width.is_none()
52            && self.image_height.is_none()
53            && self.description.is_none()
54    }
55}
56
57/// Returns `None` if the file has no recognisable metadata.
58pub fn read_exif(path: &Path) -> Option<LocalExif> {
59    let exif = nom_read_exif(path)
60        .map_err(|err| {
61            log::debug!("nom-exif read failed for {}: {}", path.display(), err);
62            err
63        })
64        .ok()?;
65
66    let local = LocalExif {
67        make: text(&exif, ExifTag::Make),
68        model: text(&exif, ExifTag::Model),
69        lens_model: text(&exif, ExifTag::LensModel).or_else(|| text(&exif, ExifTag::LensMake)),
70        f_number: rational_f64(&exif, ExifTag::FNumber),
71        focal_length: rational_f64(&exif, ExifTag::FocalLength),
72        iso: integer_u32(&exif, ExifTag::ISOSpeedRatings),
73        exposure_time: exposure_time(&exif),
74        date_time_original: datetime_rfc3339(&exif),
75        latitude: exif.gps_info().and_then(|g| g.latitude_decimal()),
76        longitude: exif.gps_info().and_then(|g| g.longitude_decimal()),
77        image_width: integer_u32(&exif, ExifTag::ImageWidth)
78            .or_else(|| integer_u32(&exif, ExifTag::ExifImageWidth)),
79        image_height: integer_u32(&exif, ExifTag::ImageHeight)
80            .or_else(|| integer_u32(&exif, ExifTag::ExifImageHeight)),
81        description: text(&exif, ExifTag::ImageDescription).filter(|s| !s.trim().is_empty()),
82    };
83
84    if local.is_empty() { None } else { Some(local) }
85}
86
87/// Disk-cached `read_exif`; key is `(mtime_nanos, size)`.
88pub fn load_or_extract(cache_root: &Path, path: &Path) -> Option<LocalExif> {
89    let key = cache_key(path)?;
90    let cache_path = cache_root.join("exif").join(format!("{}.json", key.digest));
91
92    if let Ok(mut f) = fs::File::open(&cache_path) {
93        let mut buf = String::new();
94        if f.read_to_string(&mut buf).is_ok()
95            && let Ok(entry) = serde_json::from_str::<CacheEntry>(&buf)
96            && entry.mtime_nanos == key.mtime_nanos
97            && entry.size == key.size
98        {
99            return entry.exif;
100        }
101    }
102
103    let parsed = read_exif(path);
104
105    if let Some(parent) = cache_path.parent() {
106        let _ = fs::create_dir_all(parent);
107    }
108    let entry = CacheEntry {
109        mtime_nanos: key.mtime_nanos,
110        size: key.size,
111        exif: parsed.clone(),
112    };
113    if let Ok(payload) = serde_json::to_vec(&entry)
114        && let Ok(mut f) = fs::File::create(&cache_path)
115    {
116        let _ = f.write_all(&payload);
117    }
118
119    parsed
120}
121
122#[derive(Serialize, Deserialize)]
123struct CacheEntry {
124    mtime_nanos: u128,
125    size: u64,
126    exif: Option<LocalExif>,
127}
128
129struct CacheKey {
130    digest: String,
131    mtime_nanos: u128,
132    size: u64,
133}
134
135fn cache_key(path: &Path) -> Option<CacheKey> {
136    let meta = fs::metadata(path).ok()?;
137    let size = meta.len();
138    let mtime = meta.modified().ok()?;
139    let mtime_nanos = mtime
140        .duration_since(std::time::UNIX_EPOCH)
141        .map_or(0, |d| d.as_nanos());
142    let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
143    let digest = blake3::hash(canonical.as_os_str().as_encoded_bytes())
144        .to_hex()
145        .to_string();
146    Some(CacheKey {
147        digest,
148        mtime_nanos,
149        size,
150    })
151}
152
153fn text(exif: &Exif, tag: ExifTag) -> Option<String> {
154    let value = exif.get(tag)?;
155    value
156        .as_str()
157        .map(|s| s.trim().to_string())
158        .filter(|s| !s.is_empty())
159}
160
161fn integer_u32(exif: &Exif, tag: ExifTag) -> Option<u32> {
162    let value = exif.get(tag)?;
163    match value {
164        EntryValue::U32(v) => Some(*v),
165        EntryValue::U16(v) => Some(u32::from(*v)),
166        EntryValue::U8(v) => Some(u32::from(*v)),
167        _ => None,
168    }
169}
170
171fn rational_f64(exif: &Exif, tag: ExifTag) -> Option<f64> {
172    let value = exif.get(tag)?;
173    if let Some(r) = value.as_urational() {
174        let n = r.numerator();
175        let d = r.denominator();
176        if d == 0 || n == 0 {
177            return None;
178        }
179        return Some(f64::from(n) / f64::from(d));
180    }
181    value.as_f64()
182}
183
184/// Display exposure as "1/250" rather than the raw rational.
185fn exposure_time(exif: &Exif) -> Option<String> {
186    let r = exif.get(ExifTag::ExposureTime)?.as_urational()?;
187    let n = r.numerator();
188    let d = r.denominator();
189    if d == 0 || n == 0 {
190        return None;
191    }
192    if n == 1 {
193        Some(format!("1/{d}"))
194    } else if n > d {
195        // Long exposures like 2/1 → "2 s"
196        let secs = f64::from(n) / f64::from(d);
197        Some(format!("{secs:.1} s"))
198    } else {
199        // Reduce 5/100 → "1/20"-ish display by formatting as the fraction.
200        Some(format!("{n}/{d}"))
201    }
202}
203
204/// Combine DateTimeOriginal with OffsetTimeOriginal (when present) into a
205/// stable RFC3339 string.
206fn datetime_rfc3339(exif: &Exif) -> Option<String> {
207    use nom_exif::ExifDateTime;
208    let dt = exif.get(ExifTag::DateTimeOriginal)?.as_datetime()?;
209    let formatted = match dt {
210        ExifDateTime::Aware(dt) => dt.to_rfc3339(),
211        ExifDateTime::Naive(naive) => {
212            // Try to attach an offset if one was recorded separately.
213            let offset = exif
214                .get(ExifTag::OffsetTimeOriginal)
215                .and_then(|v| v.as_str())
216                .and_then(parse_offset);
217            match offset {
218                Some(off) => format!("{}{}", naive.format("%Y-%m-%dT%H:%M:%S"), off),
219                None => format!("{}Z", naive.format("%Y-%m-%dT%H:%M:%S")),
220            }
221        }
222    };
223    Some(formatted)
224}
225
226/// Validate a raw "+HH:MM" / "-HH:MM" offset string from OffsetTimeOriginal.
227fn parse_offset(s: &str) -> Option<String> {
228    let s = s.trim();
229    if s.len() != 6 || !s.is_ascii() {
230        return None;
231    }
232    let sign = s.as_bytes()[0];
233    if sign != b'+' && sign != b'-' {
234        return None;
235    }
236    if s.as_bytes()[3] != b':' {
237        return None;
238    }
239    s[1..3].parse::<u8>().ok().filter(|h| *h <= 14)?;
240    s[4..6].parse::<u8>().ok().filter(|m| *m <= 59)?;
241    Some(s.to_string())
242}
243
244/// Cache directory for EXIF entries; lives under the same XDG cache root used
245/// by the thumbnail cache.
246pub fn cache_root() -> PathBuf {
247    crate::profile::cache_dir().unwrap_or_else(std::env::temp_dir)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn is_empty_detects_no_fields() {
256        assert!(LocalExif::default().is_empty());
257    }
258
259    #[test]
260    fn is_empty_false_when_any_field_set() {
261        let e = LocalExif {
262            iso: Some(400),
263            ..Default::default()
264        };
265        assert!(!e.is_empty());
266
267        let e = LocalExif {
268            description: Some("hi".into()),
269            ..Default::default()
270        };
271        assert!(!e.is_empty());
272    }
273
274    #[test]
275    fn parse_offset_accepts_typical_form() {
276        assert_eq!(parse_offset("+05:30").as_deref(), Some("+05:30"));
277        assert_eq!(parse_offset("-08:00").as_deref(), Some("-08:00"));
278    }
279
280    #[test]
281    fn parse_offset_rejects_bad_strings() {
282        assert!(parse_offset("0530").is_none());
283        assert!(parse_offset("+5:30").is_none());
284        assert!(parse_offset("+15:00").is_none());
285        assert!(parse_offset("+05:60").is_none());
286        assert!(parse_offset("").is_none());
287    }
288
289    #[test]
290    fn cache_key_invalidates_on_mtime_change() {
291        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
292        let k1 = cache_key(tmp.path()).expect("key1");
293        std::thread::sleep(std::time::Duration::from_millis(20));
294        std::fs::write(tmp.path(), b"changed").unwrap();
295        let k2 = cache_key(tmp.path()).expect("key2");
296        assert_eq!(k1.digest, k2.digest, "digest is canonical-path-based");
297        assert_ne!(
298            k1.mtime_nanos, k2.mtime_nanos,
299            "rewriting the file must bump mtime"
300        );
301    }
302
303    #[test]
304    fn load_or_extract_caches_negative_result() {
305        // A plain text file has no EXIF; verify we persist the negative
306        // result so we don't re-parse on every reopen.
307        let cache_dir = tempfile::tempdir().expect("cache dir");
308        let mut data = tempfile::NamedTempFile::new().expect("tempfile");
309        use std::io::Write;
310        data.write_all(b"not an image").unwrap();
311        let first = load_or_extract(cache_dir.path(), data.path());
312        let count_after_first = std::fs::read_dir(cache_dir.path().join("exif"))
313            .map(|d| d.count())
314            .unwrap_or(0);
315        let second = load_or_extract(cache_dir.path(), data.path());
316        assert_eq!(first.is_some(), second.is_some());
317        assert_eq!(count_after_first, 1, "negative result is persisted once");
318    }
319}