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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4da2d4d0a8
commit
5b6ffd7040
1 file changed
+55
-6
+55
-6
@@ -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
|
||||
/// 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> {
|
||||
let v = value.trim();
|
||||
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);
|
||||
}
|
||||
"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
|
||||
|
||||
Reference in new issue
Block a user