Skip to main content

nom_exif/exif/
gps.rs

1use std::str::FromStr;
2
3use iso6709parse::ISO6709Coord;
4
5use crate::values::{IRational, URational};
6
7/// Parsed GPS information from the GPSInfo subIFD.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GPSInfo {
10    pub latitude_ref: LatRef,
11    pub latitude: LatLng,
12    pub longitude_ref: LonRef,
13    pub longitude: LatLng,
14    pub altitude: Altitude,
15    pub speed: Option<Speed>,
16}
17
18impl Default for GPSInfo {
19    fn default() -> Self {
20        Self {
21            latitude_ref: LatRef::North,
22            latitude: LatLng::default(),
23            longitude_ref: LonRef::East,
24            longitude: LatLng::default(),
25            altitude: Altitude::Unknown,
26            speed: None,
27        }
28    }
29}
30
31/// Latitude or longitude expressed as degrees / minutes / seconds.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct LatLng {
34    pub degrees: URational,
35    pub minutes: URational,
36    pub seconds: URational,
37}
38
39/// Latitude hemisphere reference.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LatRef {
42    North,
43    South,
44}
45
46impl LatRef {
47    /// Construct from the 'N' / 'S' character carried in EXIF GPSLatitudeRef.
48    pub fn from_char(c: char) -> Option<Self> {
49        match c {
50            'N' | 'n' => Some(Self::North),
51            'S' | 's' => Some(Self::South),
52            _ => None,
53        }
54    }
55
56    pub fn as_char(self) -> char {
57        match self {
58            Self::North => 'N',
59            Self::South => 'S',
60        }
61    }
62
63    /// +1.0 or -1.0 — useful when assembling decimal-degrees latitude.
64    pub fn sign(self) -> f64 {
65        match self {
66            Self::North => 1.0,
67            Self::South => -1.0,
68        }
69    }
70}
71
72/// Longitude hemisphere reference.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum LonRef {
75    East,
76    West,
77}
78
79impl LonRef {
80    pub fn from_char(c: char) -> Option<Self> {
81        match c {
82            'E' | 'e' => Some(Self::East),
83            'W' | 'w' => Some(Self::West),
84            _ => None,
85        }
86    }
87
88    pub fn as_char(self) -> char {
89        match self {
90            Self::East => 'E',
91            Self::West => 'W',
92        }
93    }
94
95    pub fn sign(self) -> f64 {
96        match self {
97            Self::East => 1.0,
98            Self::West => -1.0,
99        }
100    }
101}
102
103/// Altitude relative to sea level.
104///
105/// Combines EXIF's `GPSAltitudeRef` (0 = above, 1 = below) with the magnitude
106/// from `GPSAltitude` so the two cannot drift out of sync.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub enum Altitude {
109    /// Absent or unparseable.
110    #[default]
111    Unknown,
112    AboveSeaLevel(URational),
113    BelowSeaLevel(URational),
114}
115
116impl Altitude {
117    /// Signed altitude in meters; `None` when Unknown or denominator=0.
118    pub fn meters(&self) -> Option<f64> {
119        match self {
120            Altitude::Unknown => None,
121            Altitude::AboveSeaLevel(r) => r.to_f64(),
122            Altitude::BelowSeaLevel(r) => r.to_f64().map(|m| -m),
123        }
124    }
125
126    /// The underlying magnitude rational, regardless of sign. None for `Unknown`.
127    pub fn magnitude(&self) -> Option<URational> {
128        match self {
129            Altitude::Unknown => None,
130            Altitude::AboveSeaLevel(r) | Altitude::BelowSeaLevel(r) => Some(*r),
131        }
132    }
133}
134
135/// EXIF GPS speed reference unit (`GPSSpeedRef`).
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum SpeedUnit {
138    KmPerHour,
139    MilesPerHour,
140    Knots,
141}
142
143impl SpeedUnit {
144    pub fn from_char(c: char) -> Option<Self> {
145        match c {
146            'K' | 'k' => Some(Self::KmPerHour),
147            'M' | 'm' => Some(Self::MilesPerHour),
148            'N' | 'n' => Some(Self::Knots),
149            _ => None,
150        }
151    }
152
153    pub fn as_char(self) -> char {
154        match self {
155            Self::KmPerHour => 'K',
156            Self::MilesPerHour => 'M',
157            Self::Knots => 'N',
158        }
159    }
160}
161
162/// EXIF GPS speed: unit + value paired so they cannot drift out of sync.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct Speed {
165    pub unit: SpeedUnit,
166    pub value: URational,
167}
168
169impl LatLng {
170    pub const fn new(degrees: URational, minutes: URational, seconds: URational) -> Self {
171        Self {
172            degrees,
173            minutes,
174            seconds,
175        }
176    }
177
178    /// Convert to decimal degrees. Returns `None` if any component has a zero
179    /// denominator.
180    pub fn to_decimal_degrees(&self) -> Option<f64> {
181        let d = self.degrees.to_f64()?;
182        let m = self.minutes.to_f64()?;
183        let s = self.seconds.to_f64()?;
184        Some(d + m / 60.0 + s / 3600.0)
185    }
186
187    /// Construct from decimal degrees. Rejects NaN / ±inf and values whose
188    /// magnitude exceeds 180° with `ConvertError::InvalidDecimalDegrees`.
189    pub fn try_from_decimal_degrees(degrees: f64) -> Result<Self, crate::ConvertError> {
190        if !degrees.is_finite() || degrees.abs() > 180.0 {
191            return Err(crate::ConvertError::InvalidDecimalDegrees(degrees));
192        }
193        let abs = degrees.abs();
194        let d = abs.trunc() as u32;
195        let mins_total = (abs - d as f64) * 60.0;
196        let m = mins_total.trunc() as u32;
197        let secs_hundredths = ((mins_total - m as f64) * 60.0 * 100.0).round() as u32;
198        Ok(Self::new(
199            URational::new(d, 1),
200            URational::new(m, 1),
201            URational::new(secs_hundredths, 100),
202        ))
203    }
204}
205
206impl GPSInfo {
207    /// Latitude in decimal degrees, signed by `latitude_ref` (positive = north).
208    pub fn latitude_decimal(&self) -> Option<f64> {
209        Some(self.latitude.to_decimal_degrees()? * self.latitude_ref.sign())
210    }
211
212    /// Longitude in decimal degrees, signed by `longitude_ref` (positive = east).
213    pub fn longitude_decimal(&self) -> Option<f64> {
214        Some(self.longitude.to_decimal_degrees()? * self.longitude_ref.sign())
215    }
216
217    /// Signed altitude in meters; `None` if altitude is `Unknown` or denominator=0.
218    pub fn altitude_meters(&self) -> Option<f64> {
219        self.altitude.meters()
220    }
221
222    /// Returns an ISO 6709 geographic point location string such as
223    /// `+48.8577+002.295/`.
224    pub fn to_iso6709(&self) -> String {
225        let latitude = self.latitude.to_decimal_degrees().unwrap_or(0.0);
226        let longitude = self.longitude.to_decimal_degrees().unwrap_or(0.0);
227        let altitude_meters = self.altitude.meters();
228        format!(
229            "{}{latitude:08.5}{}{longitude:09.5}{}/",
230            match self.latitude_ref {
231                LatRef::North => '+',
232                LatRef::South => '-',
233            },
234            match self.longitude_ref {
235                LonRef::East => '+',
236                LonRef::West => '-',
237            },
238            match altitude_meters {
239                None | Some(0.0) => String::new(),
240                Some(m) => format!(
241                    "{}{}CRSWGS_84",
242                    if m >= 0.0 { "+" } else { "-" },
243                    Self::format_float(m.abs())
244                ),
245            }
246        )
247    }
248
249    fn format_float(f: f64) -> String {
250        if f.fract() == 0.0 {
251            f.to_string()
252        } else {
253            format!("{f:.3}")
254        }
255    }
256}
257
258impl TryFrom<&[URational]> for LatLng {
259    type Error = crate::Error;
260    fn try_from(value: &[URational]) -> Result<Self, Self::Error> {
261        if value.len() < 3 {
262            return Err(crate::Error::Malformed {
263                kind: crate::error::MalformedKind::IfdEntry,
264                message: "need at least 3 URational components for LatLng".into(),
265            });
266        }
267        Ok(Self {
268            degrees: value[0],
269            minutes: value[1],
270            seconds: value[2],
271        })
272    }
273}
274
275impl TryFrom<&[IRational]> for LatLng {
276    type Error = crate::Error;
277    fn try_from(value: &[IRational]) -> Result<Self, Self::Error> {
278        if value.len() < 3 {
279            return Err(crate::Error::Malformed {
280                kind: crate::error::MalformedKind::IfdEntry,
281                message: "need at least 3 IRational components for LatLng".into(),
282            });
283        }
284        let map_negative = |_| crate::Error::Malformed {
285            kind: crate::error::MalformedKind::IfdEntry,
286            message: "negative LatLng component".into(),
287        };
288        Ok(Self {
289            degrees: URational::try_from(value[0]).map_err(map_negative)?,
290            minutes: URational::try_from(value[1]).map_err(map_negative)?,
291            seconds: URational::try_from(value[2]).map_err(map_negative)?,
292        })
293    }
294}
295
296impl TryFrom<&Vec<URational>> for LatLng {
297    type Error = crate::Error;
298    fn try_from(value: &Vec<URational>) -> Result<Self, Self::Error> {
299        Self::try_from(value.as_slice())
300    }
301}
302
303impl TryFrom<&Vec<IRational>> for LatLng {
304    type Error = crate::Error;
305    fn try_from(value: &Vec<IRational>) -> Result<Self, Self::Error> {
306        Self::try_from(value.as_slice())
307    }
308}
309
310impl FromStr for GPSInfo {
311    type Err = crate::ConvertError;
312    fn from_str(s: &str) -> Result<Self, Self::Err> {
313        iso6709parse::parse::<ISO6709Coord>(s)
314            .map(|mut coord| {
315                // `iso6709parse` only decodes the altitude field when a `CRS`
316                // tag follows it, so Apple's default `±lat±lon±alt/` form (no
317                // CRS) loses the altitude. Recover it ourselves. See #66.
318                if coord.altitude.is_none() {
319                    coord.altitude = parse_iso6709_altitude(s);
320                }
321                GPSInfo::from_iso6709_coord(coord)
322            })
323            .map_err(|_| crate::ConvertError::InvalidIso6709(s.to_string()))
324    }
325}
326
327/// Extract the altitude (third signed numeric field) from an ISO 6709 string
328/// representation such as `+47.7199-117.4931+522.171/`. Each field is a `+`/`-`
329/// sign followed by a run of digits and dots, so a trailing `CRS…` suffix and
330/// the closing `/` are skipped naturally. Returns `None` when there is no third
331/// field (a plain `±lat±lon/` pair), so a two-field string never mistakes the
332/// longitude for an altitude.
333fn parse_iso6709_altitude(s: &str) -> Option<f64> {
334    let bytes = s.as_bytes();
335    let mut fields = 0;
336    let mut i = 0;
337    while i < bytes.len() {
338        if bytes[i] == b'+' || bytes[i] == b'-' {
339            let start = i;
340            i += 1;
341            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
342                i += 1;
343            }
344            fields += 1;
345            if fields == 3 {
346                return s[start..i].parse::<f64>().ok();
347            }
348        } else {
349            i += 1;
350        }
351    }
352    None
353}
354
355impl GPSInfo {
356    /// Build a `GPSInfo` from a parsed ISO 6709 coordinate. Crate-internal:
357    /// the public path is [`GPSInfo::from_str`] / `<GPSInfo as FromStr>::from_str`,
358    /// which keeps `iso6709parse::ISO6709Coord` out of the public API surface
359    /// (so an `iso6709parse` major-version bump does not force one here).
360    pub(crate) fn from_iso6709_coord(v: ISO6709Coord) -> Self {
361        let latitude_ref = if v.lat >= 0.0 {
362            LatRef::North
363        } else {
364            LatRef::South
365        };
366        let longitude_ref = if v.lon >= 0.0 {
367            LonRef::East
368        } else {
369            LonRef::West
370        };
371        let latitude = LatLng::try_from_decimal_degrees(v.lat.abs()).unwrap_or_default();
372        let longitude = LatLng::try_from_decimal_degrees(v.lon.abs()).unwrap_or_default();
373        let altitude = match v.altitude {
374            None => Altitude::Unknown,
375            Some(x) => {
376                let mag = URational::new((x.abs() * 1000.0).trunc() as u32, 1000);
377                if x >= 0.0 {
378                    Altitude::AboveSeaLevel(mag)
379                } else {
380                    Altitude::BelowSeaLevel(mag)
381                }
382            }
383        };
384        Self {
385            latitude_ref,
386            latitude,
387            longitude_ref,
388            longitude,
389            altitude,
390            speed: None,
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn gps_iso6709() {
401        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
402
403        let palace = GPSInfo {
404            latitude_ref: LatRef::North,
405            latitude: LatLng::new(
406                URational::new(39, 1),
407                URational::new(55, 1),
408                URational::new(0, 1),
409            ),
410            longitude_ref: LonRef::East,
411            longitude: LatLng::new(
412                URational::new(116, 1),
413                URational::new(23, 1),
414                URational::new(27, 1),
415            ),
416            altitude: Altitude::AboveSeaLevel(URational::new(0, 1)),
417            speed: None,
418        };
419        assert_eq!(palace.to_iso6709(), "+39.91667+116.39083/");
420
421        let liberty = GPSInfo {
422            latitude_ref: LatRef::North,
423            latitude: LatLng::new(
424                URational::new(40, 1),
425                URational::new(41, 1),
426                URational::new(21, 1),
427            ),
428            longitude_ref: LonRef::West,
429            longitude: LatLng::new(
430                URational::new(74, 1),
431                URational::new(2, 1),
432                URational::new(40, 1),
433            ),
434            altitude: Altitude::AboveSeaLevel(URational::new(0, 1)),
435            speed: None,
436        };
437        assert_eq!(liberty.to_iso6709(), "+40.68917-074.04444/");
438
439        let above = GPSInfo {
440            latitude_ref: LatRef::North,
441            latitude: LatLng::new(
442                URational::new(40, 1),
443                URational::new(41, 1),
444                URational::new(21, 1),
445            ),
446            longitude_ref: LonRef::West,
447            longitude: LatLng::new(
448                URational::new(74, 1),
449                URational::new(2, 1),
450                URational::new(40, 1),
451            ),
452            altitude: Altitude::AboveSeaLevel(URational::new(123, 1)),
453            speed: None,
454        };
455        assert_eq!(above.to_iso6709(), "+40.68917-074.04444+123CRSWGS_84/");
456
457        let below = GPSInfo {
458            latitude_ref: LatRef::North,
459            latitude: LatLng::new(
460                URational::new(40, 1),
461                URational::new(41, 1),
462                URational::new(21, 1),
463            ),
464            longitude_ref: LonRef::West,
465            longitude: LatLng::new(
466                URational::new(74, 1),
467                URational::new(2, 1),
468                URational::new(40, 1),
469            ),
470            altitude: Altitude::BelowSeaLevel(URational::new(123, 1)),
471            speed: None,
472        };
473        assert_eq!(below.to_iso6709(), "+40.68917-074.04444-123CRSWGS_84/");
474
475        let below = GPSInfo {
476            latitude_ref: LatRef::North,
477            latitude: LatLng::new(
478                URational::new(40, 1),
479                URational::new(41, 1),
480                URational::new(21, 1),
481            ),
482            longitude_ref: LonRef::West,
483            longitude: LatLng::new(
484                URational::new(74, 1),
485                URational::new(2, 1),
486                URational::new(40, 1),
487            ),
488            altitude: Altitude::BelowSeaLevel(URational::new(100, 3)),
489            speed: None,
490        };
491        assert_eq!(below.to_iso6709(), "+40.68917-074.04444-33.333CRSWGS_84/");
492    }
493
494    #[test]
495    fn gps_iso6709_altitude_without_crs() {
496        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
497
498        // `iso6709parse` itself drops the altitude when no `CRS` tag follows...
499        let iso: ISO6709Coord = iso6709parse::parse("+26.5322-078.1969+019.099/").unwrap();
500        assert_eq!(iso.lat, 26.5322);
501        assert_eq!(iso.lon, -78.1969);
502        assert_eq!(iso.altitude, None);
503
504        // ...but GPSInfo::from_str recovers it (see #66).
505        let iso: GPSInfo = "+26.5322-078.1969+019.099/".parse().unwrap();
506        assert_eq!(iso.latitude_ref, LatRef::North);
507        assert_eq!(
508            iso.latitude,
509            LatLng::new(
510                URational::new(26, 1),
511                URational::new(31, 1),
512                URational::new(5592, 100),
513            )
514        );
515
516        assert_eq!(iso.longitude_ref, LonRef::West);
517        assert_eq!(
518            iso.longitude,
519            LatLng::new(
520                URational::new(78, 1),
521                URational::new(11, 1),
522                URational::new(4884, 100),
523            )
524        );
525
526        assert_eq!(
527            iso.altitude,
528            Altitude::AboveSeaLevel(URational::new(19099, 1000))
529        );
530    }
531
532    #[test]
533    fn gps_iso6709_apple_altitude_without_crs_issue_66() {
534        // Regression for #66: Apple's default QuickTime location form has a
535        // signed altitude field but no `CRS` suffix, e.g.
536        // `+47.7199-117.4931+522.171/`. The `iso6709parse` crate only decodes
537        // altitude when a `CRS` tag follows, so it drops the altitude here; we
538        // fall back to parsing the third signed field ourselves.
539        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
540
541        let gps: GPSInfo = "+47.7199-117.4931+522.171/".parse().unwrap();
542        assert_eq!(
543            gps.altitude,
544            Altitude::AboveSeaLevel(URational::new(522171, 1000))
545        );
546        assert_eq!(gps.altitude_meters(), Some(522.171));
547
548        // Negative altitude, still no CRS.
549        let gps: GPSInfo = "+47.7199-117.4931-12.5/".parse().unwrap();
550        assert_eq!(gps.altitude_meters(), Some(-12.5));
551
552        // No altitude field at all -> stays Unknown (the trailing longitude
553        // must not be mistaken for an altitude).
554        let gps: GPSInfo = "+47.7199-117.4931/".parse().unwrap();
555        assert_eq!(gps.altitude, Altitude::Unknown);
556
557        // CRS-suffixed altitude still works (handled by iso6709parse directly).
558        let gps: GPSInfo = "+47.7199-117.4931+522.171CRSWGS_84/".parse().unwrap();
559        assert_eq!(gps.altitude_meters(), Some(522.171));
560    }
561
562    #[test]
563    fn latlng_to_decimal_degrees() {
564        let p = LatLng::new(
565            URational::new(40, 1),
566            URational::new(41, 1),
567            URational::new(21, 1),
568        );
569        let d = p.to_decimal_degrees().unwrap();
570        assert!((d - 40.689_167).abs() < 1e-5);
571    }
572
573    #[test]
574    fn latlng_to_decimal_degrees_zero_denominator() {
575        let p = LatLng::new(
576            URational::new(40, 0),
577            URational::new(41, 1),
578            URational::new(21, 1),
579        );
580        assert_eq!(p.to_decimal_degrees(), None);
581    }
582
583    #[test]
584    fn latlng_try_from_decimal_degrees_ok() {
585        let p = LatLng::try_from_decimal_degrees(43.5).unwrap();
586        let back = p.to_decimal_degrees().unwrap();
587        assert!((back - 43.5).abs() < 1e-3);
588    }
589
590    #[test]
591    fn latlng_try_from_decimal_degrees_rejects_nan_inf_oob() {
592        use crate::ConvertError;
593        assert!(matches!(
594            LatLng::try_from_decimal_degrees(f64::NAN),
595            Err(ConvertError::InvalidDecimalDegrees(_))
596        ));
597        assert!(matches!(
598            LatLng::try_from_decimal_degrees(f64::INFINITY),
599            Err(ConvertError::InvalidDecimalDegrees(_))
600        ));
601        assert!(matches!(
602            LatLng::try_from_decimal_degrees(181.0),
603            Err(ConvertError::InvalidDecimalDegrees(_))
604        ));
605    }
606
607    #[test]
608    fn lat_lon_ref_round_trip() {
609        for c in ['N', 'S', 'n', 's'] {
610            assert!(LatRef::from_char(c).is_some());
611        }
612        for c in ['E', 'W', 'e', 'w'] {
613            assert!(LonRef::from_char(c).is_some());
614        }
615        assert_eq!(LatRef::North.as_char(), 'N');
616        assert_eq!(LonRef::West.as_char(), 'W');
617        assert_eq!(LatRef::South.sign(), -1.0);
618        assert_eq!(LonRef::East.sign(), 1.0);
619        assert_eq!(LatRef::from_char('X'), None);
620    }
621
622    #[test]
623    fn altitude_meters_signed() {
624        let above = Altitude::AboveSeaLevel(URational::new(123, 1));
625        let below = Altitude::BelowSeaLevel(URational::new(123, 1));
626        assert_eq!(above.meters(), Some(123.0));
627        assert_eq!(below.meters(), Some(-123.0));
628        assert_eq!(Altitude::Unknown.meters(), None);
629        assert_eq!(Altitude::AboveSeaLevel(URational::new(1, 0)).meters(), None);
630    }
631
632    #[test]
633    fn speed_unit_round_trip() {
634        assert_eq!(SpeedUnit::from_char('K'), Some(SpeedUnit::KmPerHour));
635        assert_eq!(SpeedUnit::from_char('M'), Some(SpeedUnit::MilesPerHour));
636        assert_eq!(SpeedUnit::from_char('N'), Some(SpeedUnit::Knots));
637        assert_eq!(SpeedUnit::from_char('X'), None);
638        assert_eq!(SpeedUnit::Knots.as_char(), 'N');
639    }
640
641    #[test]
642    fn gps_info_decimal_accessors() {
643        let liberty = GPSInfo {
644            latitude_ref: LatRef::North,
645            latitude: LatLng::new(
646                URational::new(40, 1),
647                URational::new(41, 1),
648                URational::new(21, 1),
649            ),
650            longitude_ref: LonRef::West,
651            longitude: LatLng::new(
652                URational::new(74, 1),
653                URational::new(2, 1),
654                URational::new(40, 1),
655            ),
656            altitude: Altitude::AboveSeaLevel(URational::new(123, 1)),
657            speed: None,
658        };
659        let lat = liberty.latitude_decimal().unwrap();
660        let lon = liberty.longitude_decimal().unwrap();
661        assert!((lat - 40.689_167).abs() < 1e-5);
662        assert!((lon - (-74.044_444)).abs() < 1e-5);
663        assert_eq!(liberty.altitude_meters(), Some(123.0));
664    }
665
666    #[test]
667    fn gps_info_from_str_uses_convert_error() {
668        use crate::ConvertError;
669        let err = "garbage".parse::<GPSInfo>().unwrap_err();
670        assert!(matches!(err, ConvertError::InvalidIso6709(_)));
671    }
672}