Skip to main content

exiftool_rs_wrapper/
geo.rs

1//! 地理信息模块
2//!
3//! 支持地理标记、反向地理编码等功能
4
5use crate::ExifTool;
6use crate::error::{Error, Result};
7use crate::types::TagId;
8use std::path::Path;
9
10/// GPS 坐标
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct GpsCoordinate {
13    /// 纬度(-90 到 90)
14    pub latitude: f64,
15    /// 经度(-180 到 180)
16    pub longitude: f64,
17    /// 海拔(米,可选)
18    pub altitude: Option<f64>,
19}
20
21impl GpsCoordinate {
22    /// 创建新的 GPS 坐标
23    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    /// 设置海拔
39    pub fn with_altitude(mut self, altitude: f64) -> Self {
40        self.altitude = Some(altitude);
41        self
42    }
43
44    /// 格式化为 ExifTool 格式
45    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/// 地理信息操作 trait
66pub trait GeoOperations {
67    /// 获取文件的 GPS 坐标
68    fn get_gps<P: AsRef<Path>>(&self, path: P) -> Result<Option<GpsCoordinate>>;
69
70    /// 设置文件的 GPS 坐标
71    fn set_gps<P: AsRef<Path>>(&self, path: P, coord: &GpsCoordinate) -> Result<()>;
72
73    /// 删除 GPS 信息
74    fn remove_gps<P: AsRef<Path>>(&self, path: P) -> Result<()>;
75
76    /// 从 GPS 轨迹文件地理标记
77    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            // 尝试解析坐标值:先尝试纯数字格式,再尝试度分秒(DMS)格式
100            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        // 删除所有 GPS 相关标签
132        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        // 在 stay_open 模式下,选项名和值必须分开为两个独立参数
148        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
158/// 解析 GPS 坐标值,支持纯数字格式和度分秒(DMS)格式
159///
160/// 支持的格式示例:
161/// - 纯数字:`"39.9042"`, `"-116.4074"`
162/// - 度分秒:`"54 deg 59' 22.80\" N"`, `"1 deg 54' 57.60\" W"`
163/// - 带符号的度分秒:`"54 deg 59' 22.80\""` (无方向后缀时保留原始正负)
164fn parse_gps_value(s: &str) -> Option<f64> {
165    let trimmed = s.trim();
166
167    // 首先尝试直接解析为浮点数(纯数字格式)
168    if let Ok(v) = trimmed.parse::<f64>() {
169        return Some(v);
170    }
171
172    // 尝试解析度分秒(DMS)格式
173    // 典型格式:`54 deg 59' 22.80" N` 或 `1 deg 54' 57.60" W`
174    parse_dms(trimmed)
175}
176
177/// 解析度分秒(DMS)格式的 GPS 坐标字符串
178///
179/// 支持的格式:
180/// - `54 deg 59' 22.80" N`
181/// - `1 deg 54' 57.60" W`
182/// - `39 deg 54' 15.12"`(无方向后缀)
183fn parse_dms(s: &str) -> Option<f64> {
184    // 判断方向后缀(N/S/E/W),确定正负号
185    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    // 移除方向后缀字符,保留数字部分
195    let cleaned = if direction.is_some() {
196        s[..s.len() - 1].trim()
197    } else {
198        s.trim()
199    };
200
201    // 将常见的分隔符替换为空格,方便统一解析
202    // 移除 `deg`、`°`、`'`、`"`、`′`、`″` 等符号
203    let normalized = cleaned
204        .replace("deg", " ")
205        .replace(['\u{00B0}', '\'', '"', '\u{2032}', '\u{2033}'], " ");
206
207    // 将连续空格分割为数字 token
208    let parts: Vec<&str> = normalized.split_whitespace().collect();
209
210    if parts.is_empty() || parts.len() > 3 {
211        return None;
212    }
213
214    // 解析度
215    let degrees: f64 = parts.first()?.parse().ok()?;
216    // 解析分(可选)
217    let minutes: f64 = if parts.len() >= 2 {
218        parts[1].parse().ok()?
219    } else {
220        0.0
221    };
222    // 解析秒(可选)
223    let seconds: f64 = if parts.len() >= 3 {
224        parts[2].parse().ok()?
225    } else {
226        0.0
227    };
228
229    // 计算十进制度数
230    let decimal = degrees.abs() + minutes / 60.0 + seconds / 3600.0;
231
232    // 应用方向符号
233    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    /// 最小有效 JPEG 文件字节数组,用于创建临时测试文件
245    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        // 无效的纬度
283        assert!(GpsCoordinate::new(100.0, 0.0).is_err());
284        // 无效的经度
285        assert!(GpsCoordinate::new(0.0, 200.0).is_err());
286        // 有效坐标
287        assert!(GpsCoordinate::new(0.0, 0.0).is_ok());
288    }
289
290    #[test]
291    fn test_parse_gps_decimal() {
292        // 纯数字格式
293        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        // 度分秒格式:54 deg 59' 22.80" N
301        let result = parse_gps_value("54 deg 59' 22.80\" N").unwrap();
302        // 54 + 59/60 + 22.80/3600 = 54.989666...
303        assert!((result - 54.9896667).abs() < 0.0001);
304
305        // 度分秒格式:1 deg 54' 57.60" W(西经,结果为负)
306        let result = parse_gps_value("1 deg 54' 57.60\" W").unwrap();
307        assert!((result - (-1.916)).abs() < 0.001);
308
309        // 南纬
310        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        // 检查 ExifTool 是否可用,不可用则跳过
318        let et = match ExifTool::new() {
319            Ok(et) => et,
320            Err(Error::ExifToolNotFound) => return,
321            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
322        };
323
324        // 创建临时 JPEG 文件
325        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        // 写入 GPS 坐标(北京天安门广场附近)
330        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        // 读回 GPS 坐标并验证
336        let read_coord = et
337            .get_gps(&test_file)
338            .expect("读取 GPS 坐标失败")
339            .expect("GPS 坐标应存在,但返回了 None");
340
341        // 验证纬度误差在合理范围内(ExifTool 可能有精度损失)
342        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        // 检查 ExifTool 是否可用,不可用则跳过
357        let et = match ExifTool::new() {
358            Ok(et) => et,
359            Err(Error::ExifToolNotFound) => return,
360            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
361        };
362
363        // 创建临时 JPEG 文件
364        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        // 先写入 GPS 坐标
369        let coord = GpsCoordinate::new(51.5074, -0.1278).expect("创建 GPS 坐标失败(伦敦坐标)");
370        et.set_gps(&test_file, &coord).expect("写入 GPS 坐标失败");
371
372        // 确认 GPS 坐标已写入
373        let before = et.get_gps(&test_file).expect("写入后读取 GPS 坐标失败");
374        assert!(before.is_some(), "写入 GPS 后应能读取到坐标");
375
376        // 删除 GPS 信息
377        et.remove_gps(&test_file).expect("删除 GPS 坐标失败");
378
379        // 验证 GPS 坐标已被删除
380        let after = et.get_gps(&test_file).expect("删除后读取 GPS 坐标失败");
381        assert!(
382            after.is_none(),
383            "删除 GPS 后应返回 None,但实际返回了: {:?}",
384            after
385        );
386    }
387}