From ac4f0e458ce7519ff7f00632bb6a4ac63527d2e0 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 10 Oct 2026 22:57:31 -0400 Subject: [PATCH] fix(dates): read exiftool's word-form GPS hemisphere refs (#163) The exiftool branch matched GPSLatitudeRef / GPSLongitudeRef with an exact one-letter compare ("S" / "W"), but exiftool's print conversion (which parse_gps_dms relies on) renders them as "South" / "West". The flags never set, so every western longitude and southern latitude reached the camera-offset cascade positive and the GPS step looked up the mirrored point: northeast Ohio became China, offset UTC+8. On prod, all 280 western-hemisphere `gps`-sourced rows carry +480, putting their date_utc 12-13 h off. gps_ref_is matches the first letter case-insensitively, the same rule as exif::extract_gps_coordinate (whose kamadak path was already correct; the stored gps_latitude / gps_longitude columns are unaffected). Tests use exiftool's real output strings ("West" / "South", verified against exiftool 12.76 with the resolver's -j -q -G1 flags), including a southern-hemisphere case; the new payload test fails on the old compare. The existing test fed "W", which exiftool doesn't emit by default, so it couldn't catch this. Existing rows need a re-resolve after deploy; see #163 for the reset. Co-Authored-By: Claude Opus 5.5 --- src/date_resolver.rs | 61 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/date_resolver.rs b/src/date_resolver.rs index 3d9b48c..c72209a 100644 --- a/src/date_resolver.rs +++ b/src/date_resolver.rs @@ -629,6 +629,18 @@ fn parse_offset_minutes(value: &str) -> Option { /// Parse exiftool's DMS GPS rendering, e.g. `"40 deg 42' 36.00\""`, into /// 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 { let v = value.trim(); let deg_end = v.find(" deg")?; @@ -839,13 +851,9 @@ fn parse_exiftool_payload(stdout: &[u8]) -> Vec { data.evidence.offset_time_original = parse_offset_minutes(v); } "GPSLatitude" => data.evidence.gps_lat = parse_gps_dms(v), - "GPSLatitudeRef" => { - data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S") - } + "GPSLatitudeRef" => data.evidence.gps_lat_south = gps_ref_is(v, 'S'), "GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v), - "GPSLongitudeRef" => { - data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W") - } + "GPSLongitudeRef" => data.evidence.gps_lon_west = gps_ref_is(v, 'W'), _ => {} } } @@ -938,6 +946,47 @@ mod tests { 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] fn container_date_is_true_instant_not_wall_clock() { // QuickTime creation_time renders as a UTC-naive string: the