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
65pub trait GeoOperations {
67 fn get_gps<P: AsRef<Path>>(&self, path: P) -> Result<Option<GpsCoordinate>>;
69
70 fn set_gps<P: AsRef<Path>>(&self, path: P, coord: &GpsCoordinate) -> Result<()>;
72
73 fn remove_gps<P: AsRef<Path>>(&self, path: P) -> Result<()>;
75
76 fn geotag_from_track<P: AsRef<Path>, Q: AsRef<Path>>(
78 &self,
79 image: P,
80 track_file: Q,
81 ) -> Result<()>;
82}
83
84impl GeoOperations for ExifTool {
85 fn get_gps<P: AsRef<Path>>(&self, path: P) -> Result<Option<GpsCoordinate>> {
86 let metadata = self
87 .query(path)
88 .tag(TagId::GpsLatitude.name())
89 .tag(TagId::GpsLongitude.name())
90 .execute()?;
91
92 let lat_val = metadata.get(TagId::GpsLatitude.name());
93 let lon_val = metadata.get(TagId::GpsLongitude.name());
94
95 if let (Some(lat), Some(lon)) = (lat_val, lon_val) {
96 let lat_str = lat.to_string_lossy();
97 let lon_str = lon.to_string_lossy();
98
99 let lat_f = parse_gps_value(&lat_str);
101 let lon_f = parse_gps_value(&lon_str);
102
103 if let (Some(lat_v), Some(lon_v)) = (lat_f, lon_f) {
104 let coord = GpsCoordinate::new(lat_v, lon_v)?;
105 return Ok(Some(coord));
106 }
107 }
108
109 Ok(None)
110 }
111
112 fn set_gps<P: AsRef<Path>>(&self, path: P, coord: &GpsCoordinate) -> Result<()> {
113 let (lat, lon, alt) = coord.format();
114
115 let mut write = self.write(path);
116
117 write = write
118 .tag(TagId::GpsLatitude.name(), &lat)
119 .tag(TagId::GpsLongitude.name(), &lon);
120
121 if let Some(altitude) = alt {
122 write = write.tag(TagId::GpsAltitude.name(), altitude);
123 }
124
125 write.overwrite_original(true).execute()?;
126
127 Ok(())
128 }
129
130 fn remove_gps<P: AsRef<Path>>(&self, path: P) -> Result<()> {
131 self.write(path)
133 .delete(TagId::GpsLatitude.name())
134 .delete(TagId::GpsLongitude.name())
135 .delete(TagId::GpsAltitude.name())
136 .overwrite_original(true)
137 .execute()?;
138
139 Ok(())
140 }
141
142 fn geotag_from_track<P: AsRef<Path>, Q: AsRef<Path>>(
143 &self,
144 image: P,
145 track_file: Q,
146 ) -> Result<()> {
147 self.write(image)
149 .arg("-geotag")
150 .arg(track_file.as_ref().to_string_lossy().to_string())
151 .overwrite_original(true)
152 .execute()?;
153
154 Ok(())
155 }
156}
157
158fn parse_gps_value(s: &str) -> Option<f64> {
165 let trimmed = s.trim();
166
167 if let Ok(v) = trimmed.parse::<f64>() {
169 return Some(v);
170 }
171
172 parse_dms(trimmed)
175}
176
177fn parse_dms(s: &str) -> Option<f64> {
184 let upper = s.to_uppercase();
186 let direction = if upper.ends_with('N') || upper.ends_with('E') {
187 Some(1.0)
188 } else if upper.ends_with('S') || upper.ends_with('W') {
189 Some(-1.0)
190 } else {
191 None
192 };
193
194 let cleaned = if direction.is_some() {
196 s[..s.len() - 1].trim()
197 } else {
198 s.trim()
199 };
200
201 let normalized = cleaned
204 .replace("deg", " ")
205 .replace(['\u{00B0}', '\'', '"', '\u{2032}', '\u{2033}'], " ");
206
207 let parts: Vec<&str> = normalized.split_whitespace().collect();
209
210 if parts.is_empty() || parts.len() > 3 {
211 return None;
212 }
213
214 let degrees: f64 = parts.first()?.parse().ok()?;
216 let minutes: f64 = if parts.len() >= 2 {
218 parts[1].parse().ok()?
219 } else {
220 0.0
221 };
222 let seconds: f64 = if parts.len() >= 3 {
224 parts[2].parse().ok()?
225 } else {
226 0.0
227 };
228
229 let decimal = degrees.abs() + minutes / 60.0 + seconds / 3600.0;
231
232 let sign = direction.unwrap_or(if degrees < 0.0 { -1.0 } else { 1.0 });
234
235 Some(decimal * sign)
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::ExifTool;
242 use crate::error::Error;
243
244 const TINY_JPEG: &[u8] = &[
246 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
247 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
248 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
249 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
250 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
251 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
252 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
253 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
254 0x00, 0x00, 0x09, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
255 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
256 0x00, 0x00, 0x3F, 0x00, 0xD2, 0xCF, 0x20, 0xFF, 0xD9,
257 ];
258
259 #[test]
260 fn test_gps_coordinate() {
261 let coord = GpsCoordinate::new(39.9042, 116.4074).unwrap();
262 assert_eq!(coord.latitude, 39.9042);
263 assert_eq!(coord.longitude, 116.4074);
264 assert_eq!(coord.altitude, None);
265
266 let coord = coord.with_altitude(50.0);
267 assert_eq!(coord.altitude, Some(50.0));
268 }
269
270 #[test]
271 fn test_gps_coordinate_format() {
272 let coord = GpsCoordinate::new(39.9042, 116.4074).unwrap();
273 let (lat, lon, alt) = coord.format();
274
275 assert!(lat.contains("N"));
276 assert!(lon.contains("E"));
277 assert_eq!(alt, None);
278 }
279
280 #[test]
281 fn test_gps_coordinate_validation() {
282 assert!(GpsCoordinate::new(100.0, 0.0).is_err());
284 assert!(GpsCoordinate::new(0.0, 200.0).is_err());
286 assert!(GpsCoordinate::new(0.0, 0.0).is_ok());
288 }
289
290 #[test]
291 fn test_parse_gps_decimal() {
292 assert_eq!(parse_gps_value("39.9042"), Some(39.9042));
294 assert_eq!(parse_gps_value("-116.4074"), Some(-116.4074));
295 assert_eq!(parse_gps_value("0.0"), Some(0.0));
296 }
297
298 #[test]
299 fn test_parse_gps_dms() {
300 let result = parse_gps_value("54 deg 59' 22.80\" N").unwrap();
302 assert!((result - 54.9896667).abs() < 0.0001);
304
305 let result = parse_gps_value("1 deg 54' 57.60\" W").unwrap();
307 assert!((result - (-1.916)).abs() < 0.001);
308
309 let result = parse_gps_value("33 deg 51' 54.00\" S").unwrap();
311 assert!(result < 0.0);
312 assert!((result - (-33.865)).abs() < 0.001);
313 }
314
315 #[test]
316 fn test_set_and_get_gps_roundtrip() {
317 let et = match ExifTool::new() {
319 Ok(et) => et,
320 Err(Error::ExifToolNotFound) => return,
321 Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
322 };
323
324 let tmp_dir = tempfile::tempdir().expect("创建临时目录失败");
326 let test_file = tmp_dir.path().join("gps_roundtrip.jpg");
327 std::fs::write(&test_file, TINY_JPEG).expect("写入临时 JPEG 文件失败");
328
329 let coord = GpsCoordinate::new(39.9042, 116.4074)
331 .expect("创建 GPS 坐标失败")
332 .with_altitude(50.0);
333 et.set_gps(&test_file, &coord).expect("写入 GPS 坐标失败");
334
335 let read_coord = et
337 .get_gps(&test_file)
338 .expect("读取 GPS 坐标失败")
339 .expect("GPS 坐标应存在,但返回了 None");
340
341 assert!(
343 (read_coord.latitude - 39.9042).abs() < 0.01,
344 "纬度应接近 39.9042,实际为: {}",
345 read_coord.latitude
346 );
347 assert!(
348 (read_coord.longitude - 116.4074).abs() < 0.01,
349 "经度应接近 116.4074,实际为: {}",
350 read_coord.longitude
351 );
352 }
353
354 #[test]
355 fn test_remove_gps_clears_coordinates() {
356 let et = match ExifTool::new() {
358 Ok(et) => et,
359 Err(Error::ExifToolNotFound) => return,
360 Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
361 };
362
363 let tmp_dir = tempfile::tempdir().expect("创建临时目录失败");
365 let test_file = tmp_dir.path().join("gps_remove.jpg");
366 std::fs::write(&test_file, TINY_JPEG).expect("写入临时 JPEG 文件失败");
367
368 let coord = GpsCoordinate::new(51.5074, -0.1278).expect("创建 GPS 坐标失败(伦敦坐标)");
370 et.set_gps(&test_file, &coord).expect("写入 GPS 坐标失败");
371
372 let before = et.get_gps(&test_file).expect("写入后读取 GPS 坐标失败");
374 assert!(before.is_some(), "写入 GPS 后应能读取到坐标");
375
376 et.remove_gps(&test_file).expect("删除 GPS 坐标失败");
378
379 let after = et.get_gps(&test_file).expect("删除后读取 GPS 坐标失败");
381 assert!(
382 after.is_none(),
383 "删除 GPS 后应返回 None,但实际返回了: {:?}",
384 after
385 );
386 }
387}