fix(dates): read exiftool's word-form GPS hemisphere refs (#163) #165

Merged
cameron merged 1 commits from fix/exiftool-gps-hemisphere into master 2026-10-13 14:32:37 +00:00
+55 -6
View File
@@ -629,6 +629,18 @@ fn parse_offset_minutes(value: &str) -> Option<i32> {
/// Parse exiftool's DMS GPS rendering, e.g. `"40 deg 42' 36.00\""`, into /// Parse exiftool's DMS GPS rendering, e.g. `"40 deg 42' 36.00\""`, into
/// unsigned decimal degrees. The Ref field carries the sign. /// unsigned decimal degrees. The Ref field carries the sign.
/// GPS hemisphere ref test. exiftool's print conversion renders the refs
/// as words ("West" / "South", ticket #163), `-n` as letters ("W" / "S"):
/// match on the first letter, case-insensitively, the same rule as
/// `exif::extract_gps_coordinate`.
fn gps_ref_is(value: &str, letter: char) -> bool {
value
.trim()
.chars()
.next()
.is_some_and(|c| c.eq_ignore_ascii_case(&letter))
}
fn parse_gps_dms(value: &str) -> Option<f64> { fn parse_gps_dms(value: &str) -> Option<f64> {
let v = value.trim(); let v = value.trim();
let deg_end = v.find(" deg")?; let deg_end = v.find(" deg")?;
@@ -839,13 +851,9 @@ fn parse_exiftool_payload(stdout: &[u8]) -> Vec<ExiftoolFileData> {
data.evidence.offset_time_original = parse_offset_minutes(v); data.evidence.offset_time_original = parse_offset_minutes(v);
} }
"GPSLatitude" => data.evidence.gps_lat = parse_gps_dms(v), "GPSLatitude" => data.evidence.gps_lat = parse_gps_dms(v),
"GPSLatitudeRef" => { "GPSLatitudeRef" => data.evidence.gps_lat_south = gps_ref_is(v, 'S'),
data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S")
}
"GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v), "GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v),
"GPSLongitudeRef" => { "GPSLongitudeRef" => data.evidence.gps_lon_west = gps_ref_is(v, 'W'),
data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W")
}
_ => {} _ => {}
} }
} }
@@ -938,6 +946,47 @@ mod tests {
assert!((lon - (-74.006)).abs() < 1e-9); assert!((lon - (-74.006)).abs() < 1e-9);
} }
/// #163: exiftool's default print conversion renders hemisphere refs as
/// words. Before the fix "West" / "South" never matched, so western /
/// southern coordinates came out positive and the GPS offset lookup hit
/// the mirrored point (Ohio → China, UTC+8).
#[test]
fn parse_payload_reads_exiftool_word_hemisphere_refs() {
let json = br#"[{
"SourceFile": "/lib/ohio.jpg",
"ExifIFD:DateTimeOriginal": "2014:01:12 20:55:48",
"GPS:GPSLatitude": "41 deg 22' 19.20\"",
"GPS:GPSLatitudeRef": "North",
"GPS:GPSLongitude": "81 deg 50' 34.80\"",
"GPS:GPSLongitudeRef": "West"
}, {
"SourceFile": "/lib/sydney.jpg",
"ExifIFD:DateTimeOriginal": "2019:12:31 23:30:00",
"GPS:GPSLatitude": "33 deg 52' 4.00\"",
"GPS:GPSLatitudeRef": "South",
"GPS:GPSLongitude": "151 deg 12' 26.00\"",
"GPS:GPSLongitudeRef": "East"
}]"#;
let files = parse_exiftool_payload(json);
let (lat, lon) = files[0].evidence.gps().unwrap();
assert!((lat - 41.372).abs() < 1e-9, "lat {lat}");
assert!((lon - (-81.843)).abs() < 1e-9, "lon {lon}");
let (lat, lon) = files[1].evidence.gps().unwrap();
assert!(lat < 0.0 && lon > 0.0, "sydney ({lat}, {lon})");
}
#[test]
fn gps_ref_is_matches_letter_and_word_forms() {
for v in ["W", "w", "West", " west "] {
assert!(gps_ref_is(v, 'W'), "{v:?}");
}
for v in ["E", "East", "", "N"] {
assert!(!gps_ref_is(v, 'W'), "{v:?}");
}
assert!(gps_ref_is("South", 'S'));
assert!(!gps_ref_is("North", 'S'));
}
#[test] #[test]
fn container_date_is_true_instant_not_wall_clock() { fn container_date_is_true_instant_not_wall_clock() {
// QuickTime creation_time renders as a UTC-naive string: the // QuickTime creation_time renders as a UTC-naive string: the