Krolyxon
Tag v0.1.0

Branches

Tags

Refs →

remagesrcdecodemeta.rs

170 lines · 5.6 KB · Rust

1//! File and EXIF metadata for the info panel.
2
3use std::path::Path;
4use std::time::SystemTime;
5
6use 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.
9pub 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.
81fn 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
96pub 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).
112fn 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).
126fn 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)]
140mod 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}