1use std::path::Path;
25
26#[derive(Debug, Clone, Default, PartialEq)]
28pub struct Camera {
29 pub make: Option<String>,
31 pub model: Option<String>,
34 pub lens: Option<String>,
35 pub exposure: Option<(u32, u32)>,
38 pub f_number: Option<f64>,
39 pub iso: Option<u32>,
40 pub focal_length_mm: Option<f64>,
41 pub taken: Option<Taken>,
45 pub location: Option<Location>,
46}
47
48#[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#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct Location {
61 pub latitude: f64,
62 pub longitude: f64,
63}
64
65pub 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 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 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 pub fn is_empty(&self) -> bool {
149 self.body().is_none() && self.lens.is_none() && self.exposure_line().is_none()
150 }
151
152 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 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 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 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
224pub 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
248pub fn aperture(f: f64) -> String {
250 format!("f/{}", trim_number(f))
251}
252
253fn 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
263fn 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 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 #[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 enum V {
389 Ascii(&'static str),
390 Short(u16),
391 Long(u32),
392 Rationals(Vec<(u32, u32)>),
393 }
394
395 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 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 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}