coordfmt/
dms.rs

1// Copyright (C) 2025 Dr. Michael Steffens
2//
3// SPDX-License-Identifier:     GPL-3.0-or-later
4//
5
6use crate::{Coordinates, Error, Reader, Writer};
7use regex_lite::Regex;
8use std::{fmt, str::FromStr, sync::LazyLock};
9
10/// [`Reader`] and [`Writer`] for degrees, minutes, seconds format,
11/// e.g `50°53'39.70''N 10°57'19.23''E`.
12///
13/// Degrees, minutes, and seconds are parsed with any number of digits.
14/// Optional space characters are accepted between latitude and longitude,
15/// between numeric values and the '°', "\'", "\'\'", and the hemisphere
16/// letters.
17pub struct DMS {
18    coordinates: Coordinates,
19}
20
21impl Reader for DMS {
22    fn get_coordinates(&self) -> Coordinates {
23        self.coordinates.clone()
24    }
25}
26
27impl FromStr for DMS {
28    type Err = Error;
29
30    /// Construct DMS type from string slice.
31    ///
32    /// # Errors
33    ///  - [`Error::UnknownInputFormat`]
34    ///  - [`Error::LatitudeOutOfRange`]
35    ///  - [`Error::LongitudeOutOfRange`]
36    ///  - [`Error::DegreesOutOfRange`]
37    ///  - [`Error::MinutesOutOfRange`]
38    ///  - [`Error::SecondsOutOfRange`]
39    ///
40    /// # Examples
41    /// ```
42    /// use coordfmt::DMS;
43    /// use std::str::FromStr;
44    /// let a = DMS::from_str("50°53'39.70''N 10°57'19.23''E").unwrap();
45    /// let b = "50°53'39.70''N 10°57'19.23''E".parse::<DMS>().unwrap();
46    /// ```
47    fn from_str(input: &str) -> Result<Self, Self::Err> {
48        struct Composition {
49            hemisphere: char,
50            degrees: f64,
51            minutes: f64,
52            seconds: f64,
53        }
54
55        impl Composition {
56            pub fn new(
57                degrees: &str,
58                minutes: &str,
59                seconds: &str,
60                hemisphere: &str,
61            ) -> Result<Self, Error> {
62                let result = Self {
63                    degrees: degrees.parse::<f64>().unwrap(),
64                    minutes: minutes.parse::<f64>().unwrap(),
65                    seconds: seconds.parse::<f64>().unwrap(),
66                    hemisphere: hemisphere.parse::<char>().unwrap(),
67                };
68                if result.degrees >= 360.0 {
69                    Err(Error::DegreesOutOfRange)
70                } else if result.minutes >= 60.0 {
71                    Err(Error::MinutesOutOfRange)
72                } else if result.seconds >= 60.0 {
73                    Err(Error::SecondsOutOfRange)
74                } else {
75                    Ok(result)
76                }
77            }
78
79            pub fn value(&self) -> f64 {
80                let degrees = self.degrees + (self.minutes + self.seconds / 60.0) / 60.0;
81                if self.hemisphere == 'N' || self.hemisphere == 'E' {
82                    degrees
83                } else {
84                    -degrees
85                }
86            }
87        }
88
89        static RE: LazyLock<Regex> = LazyLock::new(|| {
90            Regex::new("^[[:space:]]*(?<lat_degrees>[[:digit:]]+)[[:space:]]*°[[:space:]]*(?<lat_minutes>[[:digit:]]+)[[:space:]]*'[[:space:]]*(?<lat_seconds>[[:digit:]]+(\\.[[:digit:]]*)?)[[:space:]]*''[[:space:]]*(?<lat_hemisphere>[NS])[[:space:]]*(?<lon_degrees>[[:digit:]]+)[[:space:]]*°[[:space:]]*(?<lon_minutes>[[:digit:]]+)[[:space:]]*'[[:space:]]*(?<lon_seconds>[[:digit:]]+(\\.[[:digit:]]*)?)[[:space:]]*''[[:space:]]*(?<lon_hemisphere>[EW])[[:space:]]*$").unwrap()
91        });
92        if let Some(caps) = RE.captures(input) {
93            let latitude = Composition::new(
94                &caps["lat_degrees"],
95                &caps["lat_minutes"],
96                &caps["lat_seconds"],
97                &caps["lat_hemisphere"],
98            )?;
99            let longitude = Composition::new(
100                &caps["lon_degrees"],
101                &caps["lon_minutes"],
102                &caps["lon_seconds"],
103                &caps["lon_hemisphere"],
104            )?;
105            let coordinates = Coordinates {
106                latitude: latitude.value(),
107                longitude: longitude.value(),
108            };
109            if coordinates.latitude < -90.0 || coordinates.latitude > 90.0 {
110                Err(Error::LatitudeOutOfRange)
111            } else if coordinates.longitude < -180.0 || coordinates.longitude > 180.0 {
112                Err(Error::LongitudeOutOfRange)
113            } else {
114                Ok(Self { coordinates })
115            }
116        } else {
117            Err(Error::UnknownInputFormat)
118        }
119    }
120}
121
122impl Writer for DMS {}
123
124impl DMS {
125    /// Construct DMS type from [`Reader`]'s [`Coordinates`].
126    pub fn from_reader(reader: &dyn Reader) -> Self {
127        Self {
128            coordinates: reader.get_coordinates(),
129        }
130    }
131}
132
133impl fmt::Display for DMS {
134    /// # Examples
135    /// ```
136    /// use coordfmt::DMS;
137    /// fn foo(c: &DMS) -> String {
138    ///     println!("{}", c);
139    ///     c.to_string()
140    /// }
141    /// ```
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        struct Decomposition {
144            hemisphere: char,
145            degrees: u32,
146            minutes: u32,
147            seconds: u32,
148            second_fraction: u32,
149        }
150
151        impl Decomposition {
152            pub fn new(hemisphere: char, coordinate: f64) -> Self {
153                let mut degrees = (coordinate.abs() * (60 * 60 * 100) as f64).round() as u32;
154                let second_fraction = degrees % 100;
155                degrees /= 100;
156                let seconds = degrees % 60;
157                degrees /= 60;
158                let minutes = degrees % 60;
159                degrees /= 60;
160                Self {
161                    hemisphere,
162                    degrees,
163                    minutes,
164                    seconds,
165                    second_fraction,
166                }
167            }
168        }
169
170        let lat = Decomposition::new(
171            if self.coordinates.latitude.is_sign_positive() {
172                'N'
173            } else {
174                'S'
175            },
176            self.coordinates.latitude,
177        );
178        let lon = Decomposition::new(
179            if self.coordinates.longitude.is_sign_positive() {
180                'E'
181            } else {
182                'W'
183            },
184            self.coordinates.longitude,
185        );
186
187        write!(
188            f,
189            "{}°{}'{}.{:02}''{} {}°{}'{}.{:02}''{}",
190            lat.degrees,
191            lat.minutes,
192            lat.seconds,
193            lat.second_fraction,
194            lat.hemisphere,
195            lon.degrees,
196            lon.minutes,
197            lon.seconds,
198            lon.second_fraction,
199            lon.hemisphere,
200        )
201    }
202}
203
204/// Custom serializer used in tests.
205impl fmt::Debug for DMS {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        write!(
208            f,
209            "DMS{{latitude: {:.6}, longitude: {:.6}}}",
210            self.coordinates.latitude, self.coordinates.longitude
211        )
212    }
213}
214
215#[cfg(test)]
216mod tests;