| 1 | //! File and EXIF metadata for the info panel. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | use std::time::SystemTime; |
| 5 | |
| 6 | use exif::{In, Tag}; |
| 7 | |
| 8 | /// Label/value rows describing `path` (the UI adds the decoded format and size). Never fails: missing data just means fewer rows. |
| 9 | pub fn read(path: &Path) -> Vec<(String, String)> { |
| 10 | let mut rows: Vec<(String, String)> = Vec::new(); |
| 11 | let mut push = |l: &str, v: String| { |
| 12 | if !v.trim().is_empty() { |
| 13 | rows.push((l.to_string(), v)); |
| 14 | } |
| 15 | }; |
| 16 | push("File", crate::state::file_name(path)); |
| 17 | if let Some(d) = path.parent() { |
| 18 | push("Folder", d.display().to_string()); |
| 19 | } |
| 20 | if let Ok(m) = std::fs::metadata(path) { |
| 21 | push("Size", human_size(m.len())); |
| 22 | if let Ok(t) = m.modified() { |
| 23 | push("Modified", datetime(t)); |
| 24 | } |
| 25 | } |
| 26 | if let Some(exif) = std::fs::File::open(path) |
| 27 | .ok() |
| 28 | .map(std::io::BufReader::new) |
| 29 | .and_then(|mut r| exif::Reader::new().read_from_container(&mut r).ok()) |
| 30 | { |
| 31 | let field = |t: Tag| { |
| 32 | exif.get_field(t, In::PRIMARY) |
| 33 | .map(|f| f.display_value().with_unit(&exif).to_string()) |
| 34 | .map(|s| s.trim_matches('"').trim().to_string()) |
| 35 | }; |
| 36 | let make = field(Tag::Make).unwrap_or_default(); |
| 37 | let model = field(Tag::Model).unwrap_or_default(); |
| 38 | // Most models already start with the make ("Canon EOS R6"). |
| 39 | let camera = if model.to_lowercase().starts_with(&make.to_lowercase()) { |
| 40 | model |
| 41 | } else { |
| 42 | format!("{make} {model}") |
| 43 | }; |
| 44 | push("Camera", camera); |
| 45 | push("Lens", field(Tag::LensModel).unwrap_or_default()); |
| 46 | push( |
| 47 | "Taken", |
| 48 | field(Tag::DateTimeOriginal) |
| 49 | .or_else(|| field(Tag::DateTime)) |
| 50 | .unwrap_or_default(), |
| 51 | ); |
| 52 | let exposure = [ |
| 53 | field(Tag::ExposureTime), |
| 54 | field(Tag::FNumber), |
| 55 | field(Tag::PhotographicSensitivity).map(|i| format!("ISO {i}")), |
| 56 | field(Tag::FocalLength), |
| 57 | ]; |
| 58 | push( |
| 59 | "Exposure", |
| 60 | exposure |
| 61 | .into_iter() |
| 62 | .flatten() |
| 63 | .collect::<Vec<_>>() |
| 64 | .join(" "), |
| 65 | ); |
| 66 | push("Flash", field(Tag::Flash).unwrap_or_default()); |
| 67 | push("Software", field(Tag::Software).unwrap_or_default()); |
| 68 | push("Artist", field(Tag::Artist).unwrap_or_default()); |
| 69 | push("Copyright", field(Tag::Copyright).unwrap_or_default()); |
| 70 | if let (Some(lat), Some(lon)) = ( |
| 71 | gps(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef), |
| 72 | gps(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef), |
| 73 | ) { |
| 74 | push("GPS", format!("{lat:.5}, {lon:.5}")); |
| 75 | } |
| 76 | } |
| 77 | rows |
| 78 | } |
| 79 | |
| 80 | /// A GPS coordinate in signed decimal degrees. |
| 81 | fn gps(exif: &exif::Exif, tag: Tag, reference: Tag) -> Option<f64> { |
| 82 | let f = exif.get_field(tag, In::PRIMARY)?; |
| 83 | let exif::Value::Rational(v) = &f.value else { |
| 84 | return None; |
| 85 | }; |
| 86 | let deg = v.first()?.to_f64() |
| 87 | + v.get(1).map_or(0.0, |m| m.to_f64() / 60.0) |
| 88 | + v.get(2).map_or(0.0, |s| s.to_f64() / 3600.0); |
| 89 | let neg = exif |
| 90 | .get_field(reference, In::PRIMARY) |
| 91 | .map(|r| r.display_value().to_string()) |
| 92 | .is_some_and(|r| r.contains('S') || r.contains('W')); |
| 93 | Some(if neg { -deg } else { deg }) |
| 94 | } |
| 95 | |
| 96 | pub fn human_size(bytes: u64) -> String { |
| 97 | const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; |
| 98 | let mut v = bytes as f64; |
| 99 | let mut u = 0; |
| 100 | while v >= 1024.0 && u < UNITS.len() - 1 { |
| 101 | v /= 1024.0; |
| 102 | u += 1; |
| 103 | } |
| 104 | if u == 0 { |
| 105 | format!("{bytes} B") |
| 106 | } else { |
| 107 | format!("{v:.1} {}", UNITS[u]) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// `YYYY-MM-DD HH:MM` in UTC (no time zone database needed). |
| 112 | fn datetime(t: SystemTime) -> String { |
| 113 | let secs = t |
| 114 | .duration_since(SystemTime::UNIX_EPOCH) |
| 115 | .map_or(0, |d| d.as_secs() as i64); |
| 116 | let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); |
| 117 | let (y, m, d) = civil(days); |
| 118 | format!( |
| 119 | "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", |
| 120 | rem / 3600, |
| 121 | rem % 3600 / 60 |
| 122 | ) |
| 123 | } |
| 124 | |
| 125 | /// Days since 1970-01-01 to a calendar date (Howard Hinnant's algorithm). |
| 126 | fn civil(z: i64) -> (i64, u32, u32) { |
| 127 | let z = z + 719_468; |
| 128 | let era = z.div_euclid(146_097); |
| 129 | let doe = z.rem_euclid(146_097); |
| 130 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 131 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 132 | let mp = (5 * doy + 2) / 153; |
| 133 | let d = (doy - (153 * mp + 2) / 5 + 1) as u32; |
| 134 | let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; |
| 135 | let y = yoe + era * 400 + i64::from(m <= 2); |
| 136 | (y, m, d) |
| 137 | } |
| 138 | |
| 139 | #[cfg(test)] |
| 140 | mod tests { |
| 141 | use super::*; |
| 142 | |
| 143 | #[test] |
| 144 | fn sizes_and_dates() { |
| 145 | assert_eq!(human_size(512), "512 B"); |
| 146 | assert_eq!(human_size(1536), "1.5 KB"); |
| 147 | assert_eq!(human_size(5 << 20), "5.0 MB"); |
| 148 | assert_eq!(civil(0), (1970, 1, 1)); |
| 149 | assert_eq!(civil(19_723), (2024, 1, 1)); |
| 150 | assert_eq!(civil(11_016), (2000, 2, 29)); |
| 151 | let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); |
| 152 | assert_eq!(datetime(t), "2023-11-14 22:13 UTC"); |
| 153 | } |
| 154 | |
| 155 | #[test] |
| 156 | fn reads_fixture_metadata() { |
| 157 | let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); |
| 158 | let Some(f) = std::fs::read_dir(&dir).ok().and_then(|mut d| { |
| 159 | d.find_map(|e| { |
| 160 | let p = e.ok()?.path(); |
| 161 | (p.extension()? == "png").then_some(p) |
| 162 | }) |
| 163 | }) else { |
| 164 | return; |
| 165 | }; |
| 166 | let rows = read(&f); |
| 167 | assert_eq!(rows[0].0, "File"); |
| 168 | assert!(rows.iter().any(|(l, _)| l == "Size"), "{rows:?}"); |
| 169 | } |
| 170 | } |