Skip to main content

hyprforge_image/
camera.rs

1//! What the camera wrote down: body, lens, exposure, when and where.
2//!
3//! Read with `kamadak-exif`, which iced already brings into the tree for
4//! its own orientation handling — so the inspector's Camera and Location
5//! sections cost this crate a dependency line and no new download.
6//!
7//! # Never an error
8//!
9//! Like [`measure`](crate::measure::measure)'s orientation, a missing or damaged EXIF block
10//! is [`Camera::default`], not a failure. Screenshots, exported PNGs and
11//! pictures that went through a messenger have none, and a panel that
12//! says nothing about a camera is right for all of them. What *is*
13//! written survives damage elsewhere: the reader is told to carry on past
14//! a bad IFD and keep whatever parsed.
15//!
16//! # Split in two
17//!
18//! [`read`] is the only part that touches a file. Everything a person
19//! sees — "Fujifilm X-T5", "1/500 · f/2 · ISO 160", "38.7139° N" — is a
20//! pure function over [`Camera`], because the formatting is where the
21//! mistakes are (a shutter of 0.3s is not "1/3", a make in capitals is
22//! not a brand) and a test should be able to reach it without a JPEG.
23
24use std::path::Path;
25
26/// The EXIF a person cares about, as plain values.
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct Camera {
29    /// `Make` as written — often in capitals (`FUJIFILM`).
30    pub make: Option<String>,
31    /// `Model` as written — sometimes repeats the make (`Canon EOS R5`),
32    /// sometimes does not (`X-T5`). [`Camera::body`] reconciles the two.
33    pub model: Option<String>,
34    pub lens: Option<String>,
35    /// Exposure time as the camera's own fraction, so 1/500 stays 1/500
36    /// rather than going through a float and coming back as 1/499.
37    pub exposure: Option<(u32, u32)>,
38    pub f_number: Option<f64>,
39    pub iso: Option<u32>,
40    pub focal_length_mm: Option<f64>,
41    /// When the shutter was pressed, in the camera's own clock — EXIF
42    /// usually has no time zone, and inventing one would be a claim this
43    /// crate has no basis for.
44    pub taken: Option<Taken>,
45    pub location: Option<Location>,
46}
47
48/// `DateTimeOriginal`, unzoned.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Taken {
51    pub year: u16,
52    pub month: u8,
53    pub day: u8,
54    pub hour: u8,
55    pub minute: u8,
56}
57
58/// Where, in signed decimal degrees: north and east positive.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct Location {
61    pub latitude: f64,
62    pub longitude: f64,
63}
64
65/// Reads the EXIF of `path`. Blocking file I/O; call it off the UI
66/// thread. Never fails — see the module doc.
67pub fn read(path: &Path) -> Camera {
68    let Ok(file) = std::fs::File::open(path) else {
69        return Camera::default();
70    };
71    let mut reader = std::io::BufReader::new(file);
72    let exif = match exif::Reader::new().continue_on_error(true).read_from_container(&mut reader) {
73        Ok(exif) => exif,
74        // Some of it parsed: keep that. A bad maker note must not cost
75        // the shutter speed.
76        Err(exif::Error::PartialResult(partial)) => partial.into_inner().0,
77        Err(_) => return Camera::default(),
78    };
79    from_exif(&exif)
80}
81
82fn from_exif(exif: &exif::Exif) -> Camera {
83    let field = |tag| exif.get_field(tag, exif::In::PRIMARY).map(|f| &f.value);
84    let ascii = |tag| field(tag).and_then(ascii_value);
85    let rational = |tag| match field(tag) {
86        Some(exif::Value::Rational(v)) => v.first().copied(),
87        _ => None,
88    };
89
90    Camera {
91        make: ascii(exif::Tag::Make),
92        model: ascii(exif::Tag::Model),
93        lens: ascii(exif::Tag::LensModel),
94        exposure: rational(exif::Tag::ExposureTime)
95            .filter(|r| r.num > 0 && r.denom > 0)
96            .map(|r| (r.num, r.denom)),
97        f_number: rational(exif::Tag::FNumber).filter(|r| r.denom > 0).map(|r| r.to_f64()),
98        iso: field(exif::Tag::PhotographicSensitivity).and_then(|v| v.get_uint(0)),
99        focal_length_mm: rational(exif::Tag::FocalLength).filter(|r| r.denom > 0).map(|r| r.to_f64()),
100        taken: match field(exif::Tag::DateTimeOriginal) {
101            Some(exif::Value::Ascii(values)) => values
102                .first()
103                .and_then(|raw| exif::DateTime::from_ascii(raw).ok())
104                .filter(|t| (1..=12).contains(&t.month) && (1..=31).contains(&t.day))
105                .map(|t| Taken { year: t.year, month: t.month, day: t.day, hour: t.hour, minute: t.minute }),
106            _ => None,
107        },
108        location: location(exif),
109    }
110}
111
112fn ascii_value(value: &exif::Value) -> Option<String> {
113    match value {
114        exif::Value::Ascii(values) => values
115            .first()
116            .map(|raw| String::from_utf8_lossy(raw).trim().to_string())
117            .filter(|s| !s.is_empty()),
118        _ => None,
119    }
120}
121
122fn location(exif: &exif::Exif) -> Option<Location> {
123    let degrees = |tag, reference| {
124        let parts = match exif.get_field(tag, exif::In::PRIMARY).map(|f| &f.value) {
125            Some(exif::Value::Rational(v)) if v.len() == 3 && v.iter().all(|r| r.denom > 0) => v,
126            _ => return None,
127        };
128        let value = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0;
129        let negative = exif
130            .get_field(reference, exif::In::PRIMARY)
131            .and_then(|f| ascii_value(&f.value))
132            .is_some_and(|r| r.eq_ignore_ascii_case("S") || r.eq_ignore_ascii_case("W"));
133        Some(if negative { -value } else { value })
134    };
135    let latitude = degrees(exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)?;
136    let longitude = degrees(exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)?;
137    // A receiver with no fix writes zeroes, and "0° N, 0° E" is a point
138    // in the Atlantic nobody photographed.
139    if latitude == 0.0 && longitude == 0.0 {
140        return None;
141    }
142    ((-90.0..=90.0).contains(&latitude) && (-180.0..=180.0).contains(&longitude))
143        .then_some(Location { latitude, longitude })
144}
145
146impl Camera {
147    /// Whether there is anything at all to show under "Camera".
148    pub fn is_empty(&self) -> bool {
149        self.body().is_none() && self.lens.is_none() && self.exposure_line().is_none()
150    }
151
152    /// The camera, the way a person names it: "Fujifilm X-T5",
153    /// "Canon EOS R5" — never "FUJIFILM X-T5" or "Canon Canon EOS R5".
154    ///
155    /// Makers disagree about whether the model repeats the brand, so the
156    /// brand (the make's first word) is added only when the model does
157    /// not already start with it. A make written in capitals is
158    /// title-cased when it is a word rather than an initialism.
159    pub fn body(&self) -> Option<String> {
160        let model = self.model.as_deref().map(str::trim).filter(|m| !m.is_empty());
161        let brand = self.make.as_deref().and_then(|m| m.split_whitespace().next()).map(brand_case);
162        match (brand, model) {
163            (Some(brand), Some(model)) => {
164                if model.to_lowercase().starts_with(&brand.to_lowercase()) {
165                    Some(model.to_string())
166                } else {
167                    Some(format!("{brand} {model}"))
168                }
169            }
170            (None, Some(model)) => Some(model.to_string()),
171            (Some(brand), None) => Some(brand),
172            (None, None) => None,
173        }
174    }
175
176    /// "1/500 · f/2 · ISO 160" — whichever of the three are known.
177    pub fn exposure_line(&self) -> Option<String> {
178        let parts: Vec<String> = [
179            self.exposure.map(|(n, d)| shutter(n, d)),
180            self.f_number.map(aperture),
181            self.iso.map(|iso| format!("ISO {iso}")),
182        ]
183        .into_iter()
184        .flatten()
185        .collect();
186        (!parts.is_empty()).then(|| parts.join(" · "))
187    }
188
189    /// One line for a status bar: "X-T5 · 23mm f/2 · 1/500 · ISO 160".
190    ///
191    /// The model alone, not [`Camera::body`]: the bar is short, and the
192    /// brand is the part a person already knows.
193    pub fn summary(&self) -> Option<String> {
194        let mut parts = Vec::new();
195        if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) {
196            parts.push(model.to_string());
197        }
198        match (self.focal_length_mm, self.f_number) {
199            (Some(mm), Some(f)) => parts.push(format!("{}mm {}", trim_number(mm), aperture(f))),
200            (Some(mm), None) => parts.push(format!("{}mm", trim_number(mm))),
201            (None, Some(f)) => parts.push(aperture(f)),
202            (None, None) => {}
203        }
204        if let Some((n, d)) = self.exposure {
205            parts.push(shutter(n, d));
206        }
207        if let Some(iso) = self.iso {
208            parts.push(format!("ISO {iso}"));
209        }
210        (!parts.is_empty()).then(|| parts.join(" · "))
211    }
212}
213
214impl Location {
215    /// "38.7139° N, 9.1334° W" — four places is about ten metres, which
216    /// is as precise as a phone's fix is honest about.
217    pub fn describe(&self) -> String {
218        let ns = if self.latitude < 0.0 { 'S' } else { 'N' };
219        let ew = if self.longitude < 0.0 { 'W' } else { 'E' };
220        format!("{:.4}° {ns}, {:.4}° {ew}", self.latitude.abs(), self.longitude.abs())
221    }
222}
223
224/// A shutter speed the way a camera's own screen shows it: a fraction
225/// under a second (`1/500`), seconds from there up (`0.3s`, `2s`).
226///
227/// A camera often stores `10/5000` rather than `1/500`, so the fraction
228/// is reduced — but only when that lands on a whole denominator. `3/10`
229/// is not a stop anyone dials in as `1/3.33`.
230pub fn shutter(num: u32, denom: u32) -> String {
231    if num == 0 || denom == 0 {
232        return "—".to_string();
233    }
234    if num >= denom {
235        return format!("{}s", trim_number(num as f64 / denom as f64));
236    }
237    if denom.is_multiple_of(num) {
238        return format!("1/{}", denom / num);
239    }
240    let seconds = num as f64 / denom as f64;
241    if seconds >= 0.25 {
242        format!("{}s", trim_number(seconds))
243    } else {
244        format!("1/{}", (1.0 / seconds).round() as u32)
245    }
246}
247
248/// `f/2`, `f/1.4`, `f/5.6` — one decimal, and none when it is whole.
249pub fn aperture(f: f64) -> String {
250    format!("f/{}", trim_number(f))
251}
252
253/// At most one decimal place, and no `.0`.
254fn trim_number(value: f64) -> String {
255    let rounded = (value * 10.0).round() / 10.0;
256    if rounded.fract() == 0.0 {
257        format!("{}", rounded as i64)
258    } else {
259        format!("{rounded:.1}")
260    }
261}
262
263/// `FUJIFILM` → `Fujifilm`; `LG`, `DJI` and `Canon` stay as they are.
264fn brand_case(word: &str) -> String {
265    let all_caps = word.chars().all(|c| !c.is_alphabetic() || c.is_uppercase());
266    if all_caps && word.chars().filter(|c| c.is_alphabetic()).count() > 3 {
267        let mut chars = word.chars();
268        match chars.next() {
269            Some(first) => first.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect(),
270            None => String::new(),
271        }
272    } else {
273        word.to_string()
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn x_t5() -> Camera {
282        Camera {
283            make: Some("FUJIFILM".into()),
284            model: Some("X-T5".into()),
285            lens: Some("XF23mmF2 R WR".into()),
286            exposure: Some((1, 500)),
287            f_number: Some(2.0),
288            iso: Some(160),
289            focal_length_mm: Some(23.0),
290            taken: None,
291            location: None,
292        }
293    }
294
295    #[test]
296    fn a_make_in_capitals_is_named_the_way_people_say_it() {
297        assert_eq!(x_t5().body().as_deref(), Some("Fujifilm X-T5"));
298    }
299
300    #[test]
301    fn a_model_that_already_names_its_brand_is_not_given_it_twice() {
302        let canon = Camera { make: Some("Canon".into()), model: Some("Canon EOS R5".into()), ..Camera::default() };
303        assert_eq!(canon.body().as_deref(), Some("Canon EOS R5"));
304        let nikon = Camera { make: Some("NIKON CORPORATION".into()), model: Some("NIKON Z 6".into()), ..Camera::default() };
305        assert_eq!(nikon.body().as_deref(), Some("NIKON Z 6"));
306    }
307
308    #[test]
309    fn an_initialism_keeps_its_capitals() {
310        assert_eq!(brand_case("DJI"), "DJI");
311        assert_eq!(brand_case("LG"), "LG");
312        assert_eq!(brand_case("SONY"), "Sony");
313    }
314
315    #[test]
316    fn the_status_line_reads_like_the_mockup() {
317        assert_eq!(x_t5().summary().as_deref(), Some("X-T5 · 23mm f/2 · 1/500 · ISO 160"));
318        assert_eq!(x_t5().exposure_line().as_deref(), Some("1/500 · f/2 · ISO 160"));
319    }
320
321    #[test]
322    fn shutter_speeds_read_the_way_a_camera_shows_them() {
323        assert_eq!(shutter(1, 500), "1/500");
324        // Stored unreduced by plenty of bodies.
325        assert_eq!(shutter(10, 5000), "1/500");
326        assert_eq!(shutter(3, 10), "0.3s");
327        assert_eq!(shutter(2, 1), "2s");
328        assert_eq!(shutter(5, 2), "2.5s");
329        assert_eq!(shutter(0, 1), "—");
330    }
331
332    #[test]
333    fn apertures_carry_a_decimal_only_when_they_have_one() {
334        assert_eq!(aperture(2.0), "f/2");
335        assert_eq!(aperture(1.4), "f/1.4");
336        assert_eq!(aperture(5.6), "f/5.6");
337    }
338
339    #[test]
340    fn a_picture_with_no_camera_data_has_nothing_to_show() {
341        assert!(Camera::default().is_empty());
342        assert_eq!(Camera::default().summary(), None);
343        assert!(!x_t5().is_empty());
344    }
345
346    #[test]
347    fn a_location_says_which_hemisphere() {
348        let lisbon = Location { latitude: 38.71389, longitude: -9.13339 };
349        assert_eq!(lisbon.describe(), "38.7139° N, 9.1334° W");
350    }
351
352    #[test]
353    fn a_file_with_no_exif_reads_as_no_camera_rather_than_failing() {
354        let dir = tempfile::tempdir().unwrap();
355        let path = dir.path().join("plain.png");
356        image::RgbImage::new(4, 4).save(&path).unwrap();
357        assert_eq!(read(&path), Camera::default());
358        assert_eq!(read(&dir.path().join("missing.jpg")), Camera::default());
359    }
360
361    /// The marshalling, against real EXIF bytes rather than a struct
362    /// built by hand: tags in the Exif and GPS sub-IFDs, rationals,
363    /// a short and ASCII, parsed by the library this module trusts.
364    #[test]
365    fn the_camera_section_is_read_from_real_exif() {
366        let exif = exif::Reader::new().read_raw(tiff_fixture()).unwrap();
367        let camera = from_exif(&exif);
368        assert_eq!(camera.body().as_deref(), Some("Fujifilm X-T5"));
369        assert_eq!(camera.lens.as_deref(), Some("XF23mmF2 R WR"));
370        assert_eq!(camera.exposure_line().as_deref(), Some("1/500 · f/2 · ISO 160"));
371        assert_eq!(camera.focal_length_mm, Some(23.0));
372        assert_eq!(
373            camera.taken,
374            Some(Taken { year: 2026, month: 9, day: 12, hour: 18, minute: 42 })
375        );
376        let at = camera.location.expect("a GPS block was written");
377        assert_eq!(at.describe(), "38.7139° N, 9.1334° W");
378    }
379
380    #[test]
381    fn a_gps_block_of_zeroes_is_no_location() {
382        let exif = exif::Reader::new().read_raw(tiff_with_zero_gps()).unwrap();
383        assert_eq!(from_exif(&exif).location, None);
384    }
385
386    // --- a tiny big-endian TIFF writer, enough for the tests above ---
387
388    enum V {
389        Ascii(&'static str),
390        Short(u16),
391        Long(u32),
392        Rationals(Vec<(u32, u32)>),
393    }
394
395    /// Lays out IFDs one after another, each entry's out-of-line data
396    /// straight after its IFD. `pointers` patch a LONG entry (the Exif
397    /// and GPS IFD pointers) with the offset of another IFD by index.
398    fn tiff(ifds: Vec<Vec<(u16, V)>>, pointers: &[(usize, u16, usize)]) -> Vec<u8> {
399        let mut out = vec![0x4D, 0x4D, 0x00, 0x2A, 0, 0, 0, 8];
400        let mut starts = Vec::new();
401        // First pass: sizes, so pointers can be resolved.
402        let mut at = 8usize;
403        for ifd in &ifds {
404            starts.push(at);
405            let extra: usize = ifd.iter().map(|(_, v)| out_of_line(v).len()).sum();
406            at += 2 + ifd.len() * 12 + 4 + extra;
407        }
408        for (i, ifd) in ifds.iter().enumerate() {
409            let data_start = starts[i] + 2 + ifd.len() * 12 + 4;
410            let mut data = Vec::new();
411            out.extend_from_slice(&(ifd.len() as u16).to_be_bytes());
412            for (tag, value) in ifd {
413                let (kind, count) = match value {
414                    V::Ascii(s) => (2u16, s.len() as u32 + 1),
415                    V::Short(_) => (3, 1),
416                    V::Long(_) => (4, 1),
417                    V::Rationals(r) => (5, r.len() as u32),
418                };
419                out.extend_from_slice(&tag.to_be_bytes());
420                out.extend_from_slice(&kind.to_be_bytes());
421                out.extend_from_slice(&count.to_be_bytes());
422                let extra = out_of_line(value);
423                if extra.is_empty() {
424                    let mut inline = match value {
425                        V::Ascii(s) => {
426                            let mut b = s.as_bytes().to_vec();
427                            b.push(0);
428                            b
429                        }
430                        V::Short(n) => n.to_be_bytes().to_vec(),
431                        V::Long(n) => {
432                            let patched = pointers
433                                .iter()
434                                .find(|(from, t, _)| *from == i && t == tag)
435                                .map(|(_, _, to)| starts[*to] as u32)
436                                .unwrap_or(*n);
437                            patched.to_be_bytes().to_vec()
438                        }
439                        V::Rationals(_) => unreachable!(),
440                    };
441                    inline.resize(4, 0);
442                    out.extend_from_slice(&inline);
443                } else {
444                    out.extend_from_slice(&((data_start + data.len()) as u32).to_be_bytes());
445                    data.extend_from_slice(&extra);
446                }
447            }
448            out.extend_from_slice(&[0, 0, 0, 0]);
449            out.extend_from_slice(&data);
450        }
451        out
452    }
453
454    fn out_of_line(value: &V) -> Vec<u8> {
455        match value {
456            V::Ascii(s) if s.len() + 1 > 4 => {
457                let mut b = s.as_bytes().to_vec();
458                b.push(0);
459                // Keep every offset even, as TIFF asks.
460                if b.len() % 2 == 1 {
461                    b.push(0);
462                }
463                b
464            }
465            V::Rationals(r) => r.iter().flat_map(|(n, d)| [n.to_be_bytes(), d.to_be_bytes()]).flatten().collect(),
466            _ => Vec::new(),
467        }
468    }
469
470    fn tiff_fixture() -> Vec<u8> {
471        tiff(
472            vec![
473                vec![
474                    (0x010F, V::Ascii("FUJIFILM")),
475                    (0x0110, V::Ascii("X-T5")),
476                    (0x8769, V::Long(0)),
477                    (0x8825, V::Long(0)),
478                ],
479                vec![
480                    (0x829A, V::Rationals(vec![(1, 500)])),
481                    (0x829D, V::Rationals(vec![(20, 10)])),
482                    (0x8827, V::Short(160)),
483                    (0x9003, V::Ascii("2026:09:12 18:42:07")),
484                    (0x920A, V::Rationals(vec![(230, 10)])),
485                    (0xA434, V::Ascii("XF23mmF2 R WR")),
486                ],
487                vec![
488                    (0x0001, V::Ascii("N")),
489                    (0x0002, V::Rationals(vec![(38, 1), (42, 1), (50, 1)])),
490                    (0x0003, V::Ascii("W")),
491                    (0x0004, V::Rationals(vec![(9, 1), (8, 1), (241, 1000)])),
492                ],
493            ],
494            &[(0, 0x8769, 1), (0, 0x8825, 2)],
495        )
496    }
497
498    fn tiff_with_zero_gps() -> Vec<u8> {
499        tiff(
500            vec![
501                vec![(0x8825, V::Long(0))],
502                vec![
503                    (0x0001, V::Ascii("N")),
504                    (0x0002, V::Rationals(vec![(0, 1), (0, 1), (0, 1)])),
505                    (0x0003, V::Ascii("E")),
506                    (0x0004, V::Rationals(vec![(0, 1), (0, 1), (0, 1)])),
507                ],
508            ],
509            &[(0, 0x8825, 1)],
510        )
511    }
512}