Skip to main content

exiftool_rs_wrapper/
format.rs

1//! 输出格式支持模块
2
3use crate::ExifTool;
4use crate::error::Result;
5use std::path::Path;
6
7/// 输出格式枚举
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum OutputFormat {
10    /// JSON 格式(默认)
11    #[default]
12    Json,
13    /// XML 格式
14    Xml,
15    /// CSV 格式
16    Csv,
17    /// TSV 格式
18    Tsv,
19    /// HTML 表格格式
20    Html,
21    /// 纯文本格式
22    Text,
23    /// 结构化数据
24    Struct,
25}
26
27impl OutputFormat {
28    /// 获取格式对应的参数
29    fn arg(&self) -> &'static str {
30        match self {
31            Self::Json => "-json",
32            Self::Xml => "-X", // 或 -xml
33            Self::Csv => "-csv",
34            Self::Tsv => "-t",  // 或 -tab
35            Self::Html => "-h", // 或 -html
36            Self::Text => "-s", // 短格式
37            Self::Struct => "-struct",
38        }
39    }
40}
41
42/// 高级读取选项
43#[derive(Debug, Clone, Default)]
44pub struct ReadOptions {
45    /// 输出格式
46    pub format: OutputFormat,
47    /// 排除特定标签
48    pub exclude_tags: Vec<String>,
49    /// 仅显示特定标签
50    pub specific_tags: Vec<String>,
51    /// 表格格式输出
52    pub table_format: bool,
53    /// 十六进制转储
54    pub hex_dump: bool,
55    /// 详细级别 (0-5)
56    pub verbose: Option<u8>,
57    /// 语言设置
58    pub lang: Option<String>,
59    /// 字符集
60    pub charset: Option<String>,
61    /// 显示原始数值
62    pub raw_values: bool,
63    /// 递归处理目录
64    pub recursive: bool,
65    /// 文件扩展名过滤
66    pub extensions: Vec<String>,
67    /// 条件过滤
68    pub condition: Option<String>,
69}
70
71impl ReadOptions {
72    /// 创建新的读取选项
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// 设置输出格式
78    pub fn format(mut self, format: OutputFormat) -> Self {
79        self.format = format;
80        self
81    }
82
83    /// 排除标签
84    pub fn exclude(mut self, tag: impl Into<String>) -> Self {
85        self.exclude_tags.push(tag.into());
86        self
87    }
88
89    /// 仅显示特定标签
90    pub fn tag(mut self, tag: impl Into<String>) -> Self {
91        self.specific_tags.push(tag.into());
92        self
93    }
94
95    /// 表格格式输出
96    pub fn table(mut self, yes: bool) -> Self {
97        self.table_format = yes;
98        self
99    }
100
101    /// 十六进制转储
102    pub fn hex(mut self, yes: bool) -> Self {
103        self.hex_dump = yes;
104        self
105    }
106
107    /// 设置详细级别
108    pub fn verbose(mut self, level: u8) -> Self {
109        self.verbose = Some(level.min(5));
110        self
111    }
112
113    /// 设置语言
114    pub fn lang(mut self, lang: impl Into<String>) -> Self {
115        self.lang = Some(lang.into());
116        self
117    }
118
119    /// 设置字符集
120    pub fn charset(mut self, charset: impl Into<String>) -> Self {
121        self.charset = Some(charset.into());
122        self
123    }
124
125    /// 显示原始数值
126    pub fn raw(mut self, yes: bool) -> Self {
127        self.raw_values = yes;
128        self
129    }
130
131    /// 递归处理
132    pub fn recursive(mut self, yes: bool) -> Self {
133        self.recursive = yes;
134        self
135    }
136
137    /// 添加文件扩展名过滤
138    pub fn extension(mut self, ext: impl Into<String>) -> Self {
139        self.extensions.push(ext.into());
140        self
141    }
142
143    /// 条件过滤
144    pub fn condition(mut self, expr: impl Into<String>) -> Self {
145        self.condition = Some(expr.into());
146        self
147    }
148
149    /// 构建参数列表
150    pub(crate) fn build_args(&self, paths: &[impl AsRef<Path>]) -> Vec<String> {
151        let mut args = vec![self.format.arg().to_string()];
152
153        // 表格格式
154        if self.table_format {
155            args.push("-T".to_string());
156        }
157
158        // 十六进制转储
159        if self.hex_dump {
160            args.push("-H".to_string());
161        }
162
163        // 详细级别
164        if let Some(level) = self.verbose {
165            args.push(format!("-v{}", level));
166        }
167
168        // 语言:在 stay_open 模式下,参数通过 stdin 逐行传递,
169        // 必须将选项名和值拆分为两个独立参数
170        if let Some(ref lang) = self.lang {
171            args.push("-lang".to_string());
172            args.push(lang.clone());
173        }
174
175        // 字符集:拆分为两个独立参数
176        if let Some(ref charset) = self.charset {
177            args.push("-charset".to_string());
178            args.push(charset.clone());
179        }
180
181        // 原始数值
182        if self.raw_values {
183            args.push("-n".to_string());
184        }
185
186        // 递归
187        if self.recursive {
188            args.push("-r".to_string());
189        }
190
191        // 文件扩展名过滤:拆分为两个独立参数
192        for ext in &self.extensions {
193            args.push("-ext".to_string());
194            args.push(ext.clone());
195        }
196
197        // 条件过滤:拆分为两个独立参数
198        if let Some(ref condition) = self.condition {
199            args.push("-if".to_string());
200            args.push(condition.clone());
201        }
202
203        // 排除标签
204        for tag in &self.exclude_tags {
205            args.push(format!("-{}=", tag));
206        }
207
208        // 特定标签
209        for tag in &self.specific_tags {
210            args.push(format!("-{}", tag));
211        }
212
213        // 文件路径
214        for path in paths {
215            args.push(path.as_ref().to_string_lossy().to_string());
216        }
217
218        args
219    }
220}
221
222/// 格式化输出结果
223#[derive(Debug, Clone)]
224pub struct FormattedOutput {
225    /// 输出格式
226    pub format: OutputFormat,
227    /// 内容
228    pub content: String,
229}
230
231impl FormattedOutput {
232    /// 解析为 JSON
233    pub fn to_json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
234        serde_json::from_str(&self.content).map_err(|e| e.into())
235    }
236
237    /// 获取纯文本内容
238    pub fn text(&self) -> &str {
239        &self.content
240    }
241}
242
243/// 扩展 ExifTool 以支持格式化输出
244pub trait FormatOperations {
245    /// 使用自定义格式读取元数据
246    fn read_formatted<P: AsRef<Path>>(
247        &self,
248        path: P,
249        options: &ReadOptions,
250    ) -> Result<FormattedOutput>;
251
252    /// 读取为 XML
253    fn read_xml<P: AsRef<Path>>(&self, path: P) -> Result<String>;
254
255    /// 读取为 CSV
256    fn read_csv<P: AsRef<Path>>(&self, path: P) -> Result<String>;
257
258    /// 读取为 HTML 表格
259    fn read_html<P: AsRef<Path>>(&self, path: P) -> Result<String>;
260
261    /// 读取为纯文本
262    fn read_text<P: AsRef<Path>>(&self, path: P) -> Result<String>;
263
264    /// 递归读取目录
265    fn read_directory<P: AsRef<Path>>(
266        &self,
267        path: P,
268        options: &ReadOptions,
269    ) -> Result<Vec<FormattedOutput>>;
270}
271
272impl FormatOperations for ExifTool {
273    fn read_formatted<P: AsRef<Path>>(
274        &self,
275        path: P,
276        options: &ReadOptions,
277    ) -> Result<FormattedOutput> {
278        let args = options.build_args(&[path.as_ref()]);
279        let response = self.execute_raw(&args)?;
280
281        Ok(FormattedOutput {
282            format: options.format,
283            content: response.text(),
284        })
285    }
286
287    fn read_xml<P: AsRef<Path>>(&self, path: P) -> Result<String> {
288        let options = ReadOptions::new().format(OutputFormat::Xml);
289        let output = self.read_formatted(path, &options)?;
290        Ok(output.content)
291    }
292
293    fn read_csv<P: AsRef<Path>>(&self, path: P) -> Result<String> {
294        let options = ReadOptions::new().format(OutputFormat::Csv);
295        let output = self.read_formatted(path, &options)?;
296        Ok(output.content)
297    }
298
299    fn read_html<P: AsRef<Path>>(&self, path: P) -> Result<String> {
300        let options = ReadOptions::new().format(OutputFormat::Html);
301        let output = self.read_formatted(path, &options)?;
302        Ok(output.content)
303    }
304
305    fn read_text<P: AsRef<Path>>(&self, path: P) -> Result<String> {
306        let options = ReadOptions::new().format(OutputFormat::Text);
307        let output = self.read_formatted(path, &options)?;
308        Ok(output.content)
309    }
310
311    fn read_directory<P: AsRef<Path>>(
312        &self,
313        path: P,
314        options: &ReadOptions,
315    ) -> Result<Vec<FormattedOutput>> {
316        // 使用 JSON 格式获取目录下所有文件的元数据,
317        // ExifTool 会返回一个 JSON 数组,每个元素对应一个文件,
318        // 这样可以避免之前使用 split("\n[{\\n") 分割文本的脆弱逻辑。
319        let mut opts = options.clone();
320        opts.recursive = true;
321        // 强制使用 JSON 格式以确保解析的可靠性
322        opts.format = OutputFormat::Json;
323
324        let args = opts.build_args(&[path.as_ref()]);
325        let response = self.execute_raw(&args)?;
326        let content = response.text();
327
328        // 解析 JSON 数组,每个元素对应一个文件的元数据
329        let json_array: Vec<serde_json::Value> =
330            serde_json::from_str(content.trim()).unwrap_or_default();
331
332        let outputs: Vec<FormattedOutput> = json_array
333            .into_iter()
334            .map(|item| {
335                // 将每个文件的元数据转换回用户请求的格式
336                let file_content = match options.format {
337                    OutputFormat::Json => {
338                        // 保持 JSON 格式,将单个对象包装为数组以兼容 ExifTool 输出格式
339                        serde_json::to_string_pretty(&vec![&item])
340                            .unwrap_or_else(|_| item.to_string())
341                    }
342                    _ => {
343                        // 对于非 JSON 格式,返回 JSON 字符串形式(用户可进一步处理)
344                        serde_json::to_string_pretty(&item).unwrap_or_else(|_| item.to_string())
345                    }
346                };
347                FormattedOutput {
348                    format: options.format,
349                    content: file_content,
350                }
351            })
352            .collect();
353
354        Ok(outputs)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::ExifTool;
362    use crate::error::Error;
363
364    /// 最小有效 JPEG 文件字节数组,用于创建临时测试文件
365    const TINY_JPEG: &[u8] = &[
366        0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
367        0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
368        0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
369        0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
370        0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
371        0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
372        0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
373        0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
374        0x00, 0x00, 0x09, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
375        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
376        0x00, 0x00, 0x3F, 0x00, 0xD2, 0xCF, 0x20, 0xFF, 0xD9,
377    ];
378
379    #[test]
380    fn test_output_format() {
381        assert_eq!(OutputFormat::Json.arg(), "-json");
382        assert_eq!(OutputFormat::Xml.arg(), "-X");
383        assert_eq!(OutputFormat::Csv.arg(), "-csv");
384    }
385
386    #[test]
387    fn test_read_options() {
388        let opts = ReadOptions::new()
389            .format(OutputFormat::Json)
390            .tag("Make")
391            .tag("Model")
392            .verbose(2)
393            .raw(true);
394
395        assert_eq!(opts.format, OutputFormat::Json);
396        assert_eq!(opts.specific_tags.len(), 2);
397        assert_eq!(opts.verbose, Some(2));
398        assert!(opts.raw_values);
399    }
400
401    #[test]
402    fn test_read_options_build_args() {
403        let opts = ReadOptions::new()
404            .format(OutputFormat::Json)
405            .tag("Make")
406            .raw(true);
407
408        let args = opts.build_args(&[std::path::Path::new("test.jpg")]);
409
410        assert!(args.contains(&"-json".to_string()));
411        assert!(args.contains(&"-Make".to_string()));
412        assert!(args.contains(&"-n".to_string()));
413        assert!(args.contains(&"test.jpg".to_string()));
414    }
415
416    #[test]
417    fn test_build_args_split_options() {
418        // 验证 -lang、-charset、-ext、-if 参数被正确拆分为两个独立参数
419        let opts = ReadOptions::new()
420            .format(OutputFormat::Json)
421            .lang("zh-CN")
422            .charset("UTF8")
423            .extension("jpg")
424            .condition("$ImageWidth > 1000");
425
426        let args = opts.build_args(&[std::path::Path::new("test.jpg")]);
427
428        // 验证 -lang 和值是相邻的两个独立参数
429        assert!(args.windows(2).any(|w| w == ["-lang", "zh-CN"]));
430        // 验证 -charset 和值是相邻的两个独立参数
431        assert!(args.windows(2).any(|w| w == ["-charset", "UTF8"]));
432        // 验证 -ext 和值是相邻的两个独立参数
433        assert!(args.windows(2).any(|w| w == ["-ext", "jpg"]));
434        // 验证 -if 和条件表达式是相邻的两个独立参数
435        assert!(args.windows(2).any(|w| w == ["-if", "$ImageWidth > 1000"]));
436    }
437
438    #[test]
439    fn test_read_xml_contains_xml_tags() {
440        // 检查 ExifTool 是否可用,不可用则跳过
441        let et = match ExifTool::new() {
442            Ok(et) => et,
443            Err(Error::ExifToolNotFound) => return,
444            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
445        };
446
447        // 创建临时 JPEG 文件
448        let tmp_dir = tempfile::tempdir().expect("创建临时目录失败");
449        let test_file = tmp_dir.path().join("format_xml.jpg");
450        std::fs::write(&test_file, TINY_JPEG).expect("写入临时 JPEG 文件失败");
451
452        // 读取 XML 格式输出
453        let xml_output = et.read_xml(&test_file).expect("读取 XML 格式元数据失败");
454
455        // 验证输出包含 XML 标签特征
456        assert!(
457            xml_output.contains('<') && xml_output.contains('>'),
458            "XML 输出应包含 XML 标签(尖括号),实际输出: {}",
459            &xml_output[..xml_output.len().min(200)]
460        );
461        // ExifTool 的 -X 输出通常包含 rdf:RDF 或 rdf:Description
462        assert!(
463            xml_output.contains("rdf:")
464                || xml_output.contains("<?xml")
465                || xml_output.contains("et:"),
466            "XML 输出应包含 XML 命名空间(如 rdf: 或 et:),实际输出: {}",
467            &xml_output[..xml_output.len().min(300)]
468        );
469    }
470
471    #[test]
472    fn test_read_text_non_empty() {
473        // 检查 ExifTool 是否可用,不可用则跳过
474        let et = match ExifTool::new() {
475            Ok(et) => et,
476            Err(Error::ExifToolNotFound) => return,
477            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
478        };
479
480        // 创建临时 JPEG 文件
481        let tmp_dir = tempfile::tempdir().expect("创建临时目录失败");
482        let test_file = tmp_dir.path().join("format_text.jpg");
483        std::fs::write(&test_file, TINY_JPEG).expect("写入临时 JPEG 文件失败");
484
485        // 读取纯文本格式输出
486        let text_output = et.read_text(&test_file).expect("读取纯文本格式元数据失败");
487
488        // 验证输出非空
489        let trimmed = text_output.trim();
490        assert!(!trimmed.is_empty(), "纯文本格式输出不应为空");
491
492        // 纯文本输出至少应包含文件相关信息(如 FileName、FileSize、MIMEType 等)
493        // ExifTool -s 格式输出的每行格式为 "TagName: Value"
494        assert!(
495            trimmed.contains("FileName")
496                || trimmed.contains("FileSize")
497                || trimmed.contains("MIMEType"),
498            "纯文本输出应包含基本文件信息标签,实际输出: {}",
499            &trimmed[..trimmed.len().min(300)]
500        );
501    }
502}