1use crate::ExifTool;
6use crate::error::{Error, Result};
7use crate::types::TagId;
8use std::path::Path;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct GpsCoordinate {
13 pub latitude: f64,
15 pub longitude: f64,
17 pub altitude: Option<f64>,
19}
20
21impl GpsCoordinate {
22 pub fn new(latitude: f64, longitude: f64) -> Result<Self> {
24 if !(-90.0..=90.0).contains(&latitude) {
25 return Err(Error::invalid_arg("Latitude must be between -90 and 90"));
26 }
27 if !(-180.0..=180.0).contains(&longitude) {
28 return Err(Error::invalid_arg("Longitude must be between -180 and 180"));
29 }
30
31 Ok(Self {
32 latitude,
33 longitude,
34 altitude: None,
35 })
36 }
37
38 pub fn with_altitude(mut self, altitude: f64) -> Self {
40 self.altitude = Some(altitude);
41 self
42 }
43
44 pub fn format(&self) -> (String, String, Option<String>) {
46 let lat_ref = if self.latitude >= 0.0 { "N" } else { "S" };
47 let lon_ref = if self.longitude >= 0.0 { "E" } else { "W" };
48
49 let lat_val = self.latitude.abs();
50 let lon_val = self.longitude.abs();
51
52 let lat_str = format!("{:.6}", lat_val);
53 let lon_str = format!("{:.6}", lon_val);
54
55 let alt_str = self.altitude.map(|a| format!("{:.2}", a));
56
57 (
58 format!("{} {}", lat_str, lat_ref),
59 format!("{} {}", lon_str, lon_ref),
60 alt_str,
61 )
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct GeocodeResult {
68 pub city: Option<String>,
70 pub region: Option<String>,
72 pub country: Option<String>,
74 pub country_code: Option<String>,
76 pub address: Option<String>,
78}
79
80pub trait GeoOperations {
82 fn get_gps<P: AsRef<Path>>(&self, path: P) -> Result<Option<GpsCoordinate>>;
84
85 fn set_gps<P: AsRef<Path>>(&self, path: P, coord: &GpsCoordinate) -> Result<()>;
87
88 fn remove_gps<P: AsRef<Path>>(&self, path: P) -> Result<()>;
90
91 fn geotag_from_track<P: AsRef<Path>, Q: AsRef<Path>>(
93 &self,
94 image: P,
95 track_file: Q,
96 ) -> Result<()>;
97
98 fn generate_tracklog<P: AsRef<Path>, Q: AsRef<Path>>(
100 &self,
101 images: &[P],
102 output: Q,
103 ) -> Result<()>;
104
105 fn reverse_geocode<P: AsRef<Path>>(&self, coord: &GpsCoordinate) -> Result<GeocodeResult>;
107}
108
109impl GeoOperations for ExifTool {
110 fn get_gps<P: AsRef<Path>>(&self, path: P) -> Result<Option<GpsCoordinate>> {
111 let metadata = self
112 .query(path)
113 .tag(TagId::GPS_LATITUDE.name())
114 .tag(TagId::GPS_LONGITUDE.name())
115 .execute()?;
116
117 let lat_val = metadata.get(TagId::GPS_LATITUDE.name());
118 let lon_val = metadata.get(TagId::GPS_LONGITUDE.name());
119
120 if let (Some(lat), Some(lon)) = (lat_val, lon_val) {
121 let lat_str = lat.to_string_lossy();
122 let lon_str = lon.to_string_lossy();
123
124 if let (Ok(lat_f), Ok(lon_f)) = (lat_str.parse::<f64>(), lon_str.parse::<f64>()) {
126 let coord = GpsCoordinate::new(lat_f, lon_f)?;
127 return Ok(Some(coord));
128 }
129 }
130
131 Ok(None)
132 }
133
134 fn set_gps<P: AsRef<Path>>(&self, path: P, coord: &GpsCoordinate) -> Result<()> {
135 let (lat, lon, alt) = coord.format();
136
137 let mut write = self.write(path);
138
139 write = write
140 .tag(TagId::GPS_LATITUDE.name(), &lat)
141 .tag(TagId::GPS_LONGITUDE.name(), &lon);
142
143 if let Some(altitude) = alt {
144 write = write.tag(TagId::GPS_ALTITUDE.name(), altitude);
145 }
146
147 write.overwrite_original(true).execute()?;
148
149 Ok(())
150 }
151
152 fn remove_gps<P: AsRef<Path>>(&self, path: P) -> Result<()> {
153 self.write(path)
155 .delete(TagId::GPS_LATITUDE.name())
156 .delete(TagId::GPS_LONGITUDE.name())
157 .delete(TagId::GPS_ALTITUDE.name())
158 .overwrite_original(true)
159 .execute()?;
160
161 Ok(())
162 }
163
164 fn geotag_from_track<P: AsRef<Path>, Q: AsRef<Path>>(
165 &self,
166 image: P,
167 track_file: Q,
168 ) -> Result<()> {
169 self.write(image)
170 .arg(format!("-geotag {}", track_file.as_ref().display()))
171 .overwrite_original(true)
172 .execute()?;
173
174 Ok(())
175 }
176
177 fn generate_tracklog<P: AsRef<Path>, Q: AsRef<Path>>(
178 &self,
179 images: &[P],
180 output: Q,
181 ) -> Result<()> {
182 let mut track_points = Vec::new();
184
185 for image in images {
186 if let Some(coord) = self.get_gps(image)? {
187 let timestamp = self
188 .read_tag::<String, _, _>(image, TagId::DATE_TIME_ORIGINAL.name())
189 .ok();
190
191 track_points.push((coord, timestamp));
192 }
193 }
194
195 use std::fs::File;
197 use std::io::Write;
198
199 let mut file = File::create(output)?;
200 writeln!(file, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
201 writeln!(file, "<gpx version=\"1.1\">")?;
202 writeln!(file, " <trk><trkseg>")?;
203
204 for (coord, _timestamp) in track_points {
205 writeln!(
206 file,
207 " <trkpt lat=\"{}\" lon=\"{}\">",
208 coord.latitude, coord.longitude
209 )?;
210 if let Some(alt) = coord.altitude {
211 writeln!(file, " <ele>{}</ele>", alt)?;
212 }
213 writeln!(file, " </trkpt>")?;
214 }
215
216 writeln!(file, " </trkseg></trk>")?;
217 writeln!(file, "</gpx>")?;
218
219 Ok(())
220 }
221
222 fn reverse_geocode<P: AsRef<Path>>(&self, coord: &GpsCoordinate) -> Result<GeocodeResult> {
223 let mut temp_file = std::env::temp_dir();
228 temp_file.push(format!("geocode_{}.txt", std::process::id()));
229
230 let coord_str = format!("{:.6},{:.6}", coord.latitude, coord.longitude);
232 std::fs::write(&temp_file, &coord_str).map_err(Error::Io)?;
233
234 let args = vec![
237 "-geolocation".to_string(),
238 temp_file.to_string_lossy().to_string(),
239 ];
240
241 let response = self.execute_raw(&args)?;
242
243 let _ = std::fs::remove_file(&temp_file);
245
246 let output = response.text();
248 parse_geocode_result(&output)
249 }
250}
251
252fn parse_geocode_result(output: &str) -> Result<GeocodeResult> {
254 let mut result = GeocodeResult {
255 city: None,
256 region: None,
257 country: None,
258 country_code: None,
259 address: None,
260 };
261
262 for line in output.lines() {
263 let line = line.trim();
264 if line.starts_with("City") {
265 result.city = extract_value(line);
266 } else if line.starts_with("Region") {
267 result.region = extract_value(line);
268 } else if line.starts_with("Country") {
269 result.country = extract_value(line);
270 } else if line.starts_with("Country Code") {
271 result.country_code = extract_value(line);
272 } else if line.starts_with("Address") {
273 result.address = extract_value(line);
274 }
275 }
276
277 Ok(result)
278}
279
280fn extract_value(line: &str) -> Option<String> {
282 line.split_once(':')
283 .map(|(_, s)| s.trim().to_string())
284 .filter(|s| !s.is_empty())
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_gps_coordinate() {
293 let coord = GpsCoordinate::new(39.9042, 116.4074).unwrap();
294 assert_eq!(coord.latitude, 39.9042);
295 assert_eq!(coord.longitude, 116.4074);
296 assert_eq!(coord.altitude, None);
297
298 let coord = coord.with_altitude(50.0);
299 assert_eq!(coord.altitude, Some(50.0));
300 }
301
302 #[test]
303 fn test_gps_coordinate_format() {
304 let coord = GpsCoordinate::new(39.9042, 116.4074).unwrap();
305 let (lat, lon, alt) = coord.format();
306
307 assert!(lat.contains("N"));
308 assert!(lon.contains("E"));
309 assert_eq!(alt, None);
310 }
311
312 #[test]
313 fn test_gps_coordinate_validation() {
314 assert!(GpsCoordinate::new(100.0, 0.0).is_err());
316 assert!(GpsCoordinate::new(0.0, 200.0).is_err());
318 assert!(GpsCoordinate::new(0.0, 0.0).is_ok());
320 }
321}