Skip to main content

exiftool_rs/
exiftool.rs

1//! Core ExifTool struct and public API.
2//!
3//! This is the main entry point for reading metadata from files.
4//! Mirrors ExifTool.pm's ImageInfo/ExtractInfo/GetInfo pipeline.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use crate::error::{Error, Result};
11use crate::file_type::{self, FileType};
12use crate::formats;
13use crate::metadata::exif::ByteOrderMark;
14use crate::tag::Tag;
15use crate::value::Value;
16use crate::writer::{exif_writer, iptc_writer, jpeg_writer, matroska_writer, mp4_writer, pdf_writer, png_writer, psd_writer, tiff_writer, webp_writer, xmp_writer};
17
18/// Processing options for metadata extraction.
19#[derive(Debug, Clone)]
20pub struct Options {
21    /// Include duplicate tags (different groups may have same tag name).
22    pub duplicates: bool,
23    /// Apply print conversions (human-readable values).
24    pub print_conv: bool,
25    /// Fast scan level: 0=normal, 1=skip composite, 2=skip maker notes, 3=skip thumbnails.
26    pub fast_scan: u8,
27    /// Only extract these tag names (empty = all).
28    pub requested_tags: Vec<String>,
29}
30
31impl Default for Options {
32    fn default() -> Self {
33        Self {
34            duplicates: false,
35            print_conv: true,
36            fast_scan: 0,
37            requested_tags: Vec::new(),
38        }
39    }
40}
41
42/// The main ExifTool struct. Create one and use it to extract metadata from files.
43///
44/// # Example
45/// ```no_run
46/// use exiftool_rs::ExifTool;
47///
48/// let mut et = ExifTool::new();
49/// let info = et.image_info("photo.jpg").unwrap();
50/// for (name, value) in &info {
51///     println!("{}: {}", name, value);
52/// }
53/// ```
54/// A queued tag change for writing.
55#[derive(Debug, Clone)]
56pub struct NewValue {
57    /// Tag name (e.g., "Artist", "Copyright", "XMP:Title")
58    pub tag: String,
59    /// Group prefix if specified (e.g., "EXIF", "XMP", "IPTC")
60    pub group: Option<String>,
61    /// New value (None = delete tag)
62    pub value: Option<String>,
63}
64
65pub struct ExifTool {
66    options: Options,
67    new_values: Vec<NewValue>,
68}
69
70/// Result of metadata extraction: maps tag names to display values.
71pub type ImageInfo = HashMap<String, String>;
72
73impl ExifTool {
74    /// Create a new ExifTool instance with default options.
75    pub fn new() -> Self {
76        Self {
77            options: Options::default(),
78            new_values: Vec::new(),
79        }
80    }
81
82    /// Create a new ExifTool instance with custom options.
83    pub fn with_options(options: Options) -> Self {
84        Self {
85            options,
86            new_values: Vec::new(),
87        }
88    }
89
90    /// Get a mutable reference to the options.
91    pub fn options_mut(&mut self) -> &mut Options {
92        &mut self.options
93    }
94
95    /// Get a reference to the options.
96    pub fn options(&self) -> &Options {
97        &self.options
98    }
99
100    // ================================================================
101    // Writing API
102    // ================================================================
103
104    /// Queue a new tag value for writing.
105    ///
106    /// Call this one or more times, then call `write_info()` to apply changes.
107    ///
108    /// # Arguments
109    /// * `tag` - Tag name, optionally prefixed with group (e.g., "Artist", "XMP:Title", "EXIF:Copyright")
110    /// * `value` - New value, or None to delete the tag
111    ///
112    /// # Example
113    /// ```no_run
114    /// use exiftool_rs::ExifTool;
115    /// let mut et = ExifTool::new();
116    /// et.set_new_value("Artist", Some("John Doe"));
117    /// et.set_new_value("Copyright", Some("2024 John Doe"));
118    /// et.set_new_value("XMP:Title", Some("My Photo"));
119    /// et.write_info("photo.jpg", "photo_out.jpg").unwrap();
120    /// ```
121    pub fn set_new_value(&mut self, tag: &str, value: Option<&str>) {
122        let (group, tag_name) = if let Some(colon_pos) = tag.find(':') {
123            (Some(tag[..colon_pos].to_string()), tag[colon_pos + 1..].to_string())
124        } else {
125            (None, tag.to_string())
126        };
127
128        self.new_values.push(NewValue {
129            tag: tag_name,
130            group,
131            value: value.map(|v| v.to_string()),
132        });
133    }
134
135    /// Clear all queued new values.
136    pub fn clear_new_values(&mut self) {
137        self.new_values.clear();
138    }
139
140    /// Copy tags from a source file, queuing them as new values.
141    ///
142    /// Reads all tags from `src_path` and queues them for writing.
143    /// Optionally filter by tag names.
144    pub fn set_new_values_from_file<P: AsRef<Path>>(
145        &mut self,
146        src_path: P,
147        tags_to_copy: Option<&[&str]>,
148    ) -> Result<u32> {
149        let src_tags = self.extract_info(src_path)?;
150        let mut count = 0u32;
151
152        for tag in &src_tags {
153            // Skip file-level tags that shouldn't be copied
154            if tag.group.family0 == "File" || tag.group.family0 == "Composite" {
155                continue;
156            }
157            // Skip binary/undefined data and empty values
158            if tag.print_value.starts_with("(Binary") || tag.print_value.starts_with("(Undefined") {
159                continue;
160            }
161            if tag.print_value.is_empty() {
162                continue;
163            }
164
165            // Filter by requested tags
166            if let Some(filter) = tags_to_copy {
167                let name_lower = tag.name.to_lowercase();
168                if !filter.iter().any(|f| f.to_lowercase() == name_lower) {
169                    continue;
170                }
171            }
172
173            let _full_tag = format!("{}:{}", tag.group.family0, tag.name);
174            self.new_values.push(NewValue {
175                tag: tag.name.clone(),
176                group: Some(tag.group.family0.clone()),
177                value: Some(tag.print_value.clone()),
178            });
179            count += 1;
180        }
181
182        Ok(count)
183    }
184
185    /// Set a file's name based on a tag value.
186    pub fn set_file_name_from_tag<P: AsRef<Path>>(
187        &self,
188        path: P,
189        tag_name: &str,
190        template: &str,
191    ) -> Result<String> {
192        let path = path.as_ref();
193        let tags = self.extract_info(path)?;
194
195        let tag_value = tags
196            .iter()
197            .find(|t| t.name.to_lowercase() == tag_name.to_lowercase())
198            .map(|t| &t.print_value)
199            .ok_or_else(|| Error::TagNotFound(tag_name.to_string()))?;
200
201        // Build new filename from template
202        // Template: "prefix%value%suffix.ext" or just use the tag value
203        let new_name = if template.contains('%') {
204            template.replace("%v", value_to_filename(tag_value).as_str())
205        } else {
206            // Default: use tag value as filename, keep extension
207            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
208            let clean = value_to_filename(tag_value);
209            if ext.is_empty() {
210                clean
211            } else {
212                format!("{}.{}", clean, ext)
213            }
214        };
215
216        let parent = path.parent().unwrap_or(Path::new(""));
217        let new_path = parent.join(&new_name);
218
219        fs::rename(path, &new_path).map_err(Error::Io)?;
220        Ok(new_path.to_string_lossy().to_string())
221    }
222
223    /// Write queued changes to a file.
224    ///
225    /// If `dst_path` is the same as `src_path`, the file is modified in-place
226    /// (via a temporary file).
227    pub fn write_info<P: AsRef<Path>, Q: AsRef<Path>>(&self, src_path: P, dst_path: Q) -> Result<u32> {
228        let src_path = src_path.as_ref();
229        let dst_path = dst_path.as_ref();
230        let data = fs::read(src_path).map_err(Error::Io)?;
231
232        let file_type = self.detect_file_type(&data, src_path)?;
233        let output = self.apply_changes(&data, file_type)?;
234
235        // Write to temp file first, then rename (atomic)
236        let temp_path = dst_path.with_extension("exiftool_tmp");
237        fs::write(&temp_path, &output).map_err(Error::Io)?;
238        fs::rename(&temp_path, dst_path).map_err(Error::Io)?;
239
240        Ok(self.new_values.len() as u32)
241    }
242
243    /// Apply queued changes to in-memory data.
244    fn apply_changes(&self, data: &[u8], file_type: FileType) -> Result<Vec<u8>> {
245        match file_type {
246            FileType::Jpeg => self.write_jpeg(data),
247            FileType::Png => self.write_png(data),
248            FileType::Tiff | FileType::Dng | FileType::Cr2 | FileType::Nef
249            | FileType::Arw | FileType::Orf | FileType::Pef => self.write_tiff(data),
250            FileType::WebP => self.write_webp(data),
251            FileType::Mp4 | FileType::QuickTime | FileType::M4a
252            | FileType::ThreeGP | FileType::F4v => self.write_mp4(data),
253            FileType::Psd => self.write_psd(data),
254            FileType::Pdf => self.write_pdf(data),
255            FileType::Heif | FileType::Avif => self.write_mp4(data),
256            FileType::Mkv | FileType::WebM => self.write_matroska(data),
257            FileType::Gif => {
258                let comment = self.new_values.iter()
259                    .find(|nv| nv.tag.to_lowercase() == "comment")
260                    .and_then(|nv| nv.value.clone());
261                crate::writer::gif_writer::write_gif(data, comment.as_deref())
262            }
263            FileType::Flac => {
264                let changes: Vec<(&str, &str)> = self.new_values.iter()
265                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
266                    .collect();
267                crate::writer::flac_writer::write_flac(data, &changes)
268            }
269            FileType::Mp3 | FileType::Aiff => {
270                let changes: Vec<(&str, &str)> = self.new_values.iter()
271                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
272                    .collect();
273                crate::writer::id3_writer::write_id3(data, &changes)
274            }
275            FileType::Jp2 | FileType::Jxl => {
276                let new_xmp = if self.new_values.iter().any(|nv| nv.group.as_deref() == Some("XMP")) {
277                    let refs: Vec<&NewValue> = self.new_values.iter()
278                        .filter(|nv| nv.group.as_deref() == Some("XMP"))
279                        .collect();
280                    Some(self.build_new_xmp(&refs))
281                } else { None };
282                crate::writer::jp2_writer::write_jp2(data, new_xmp.as_deref(), None)
283            }
284            FileType::PostScript => {
285                let changes: Vec<(&str, &str)> = self.new_values.iter()
286                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
287                    .collect();
288                crate::writer::ps_writer::write_postscript(data, &changes)
289            }
290            FileType::Ogg | FileType::Opus => {
291                let changes: Vec<(&str, &str)> = self.new_values.iter()
292                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
293                    .collect();
294                crate::writer::ogg_writer::write_ogg(data, &changes)
295            }
296            FileType::Xmp => {
297                let props: Vec<xmp_writer::XmpProperty> = self.new_values.iter()
298                    .filter_map(|nv| {
299                        let val = nv.value.as_deref()?;
300                        Some(xmp_writer::XmpProperty {
301                            namespace: nv.group.clone().unwrap_or_else(|| "dc".into()),
302                            property: nv.tag.clone(),
303                            values: vec![val.to_string()],
304                            prop_type: xmp_writer::XmpPropertyType::Simple,
305                        })
306                    })
307                    .collect();
308                Ok(crate::writer::xmp_sidecar_writer::write_xmp_sidecar(&props))
309            }
310            _ => Err(Error::UnsupportedFileType(format!("writing not yet supported for {}", file_type))),
311        }
312    }
313
314    /// Write metadata changes to JPEG data.
315    fn write_jpeg(&self, data: &[u8]) -> Result<Vec<u8>> {
316        // Classify new values by target group
317        let mut exif_values: Vec<&NewValue> = Vec::new();
318        let mut xmp_values: Vec<&NewValue> = Vec::new();
319        let mut iptc_values: Vec<&NewValue> = Vec::new();
320        let mut comment_value: Option<&str> = None;
321        let mut remove_exif = false;
322        let mut remove_xmp = false;
323        let mut remove_iptc = false;
324        let mut remove_comment = false;
325
326        for nv in &self.new_values {
327            let group = nv.group.as_deref().unwrap_or("");
328            let group_upper = group.to_uppercase();
329
330            // Check for group deletion
331            if nv.value.is_none() && nv.tag == "*" {
332                match group_upper.as_str() {
333                    "EXIF" => { remove_exif = true; continue; }
334                    "XMP" => { remove_xmp = true; continue; }
335                    "IPTC" => { remove_iptc = true; continue; }
336                    _ => {}
337                }
338            }
339
340            match group_upper.as_str() {
341                "XMP" => xmp_values.push(nv),
342                "IPTC" => iptc_values.push(nv),
343                "EXIF" | "IFD0" | "EXIFIFD" | "GPS" => exif_values.push(nv),
344                "" => {
345                    // Auto-detect best group based on tag name
346                    if nv.tag.to_lowercase() == "comment" {
347                        if nv.value.is_none() {
348                            remove_comment = true;
349                        } else {
350                            comment_value = nv.value.as_deref();
351                        }
352                    } else if is_xmp_tag(&nv.tag) {
353                        xmp_values.push(nv);
354                    } else {
355                        exif_values.push(nv);
356                    }
357                }
358                _ => exif_values.push(nv), // default to EXIF
359            }
360        }
361
362        // Build new EXIF data
363        let new_exif = if !exif_values.is_empty() {
364            Some(self.build_new_exif(data, &exif_values)?)
365        } else {
366            None
367        };
368
369        // Build new XMP data
370        let new_xmp = if !xmp_values.is_empty() {
371            Some(self.build_new_xmp(&xmp_values))
372        } else {
373            None
374        };
375
376        // Build new IPTC data
377        let new_iptc_data = if !iptc_values.is_empty() {
378            let records: Vec<iptc_writer::IptcRecord> = iptc_values
379                .iter()
380                .filter_map(|nv| {
381                    let value = nv.value.as_deref()?;
382                    let (record, dataset) = iptc_writer::tag_name_to_iptc(&nv.tag)?;
383                    Some(iptc_writer::IptcRecord {
384                        record,
385                        dataset,
386                        data: value.as_bytes().to_vec(),
387                    })
388                })
389                .collect();
390            if records.is_empty() {
391                None
392            } else {
393                Some(iptc_writer::build_iptc(&records))
394            }
395        } else {
396            None
397        };
398
399        // Rewrite JPEG
400        jpeg_writer::write_jpeg(
401            data,
402            new_exif.as_deref(),
403            new_xmp.as_deref(),
404            new_iptc_data.as_deref(),
405            comment_value,
406            remove_exif,
407            remove_xmp,
408            remove_iptc,
409            remove_comment,
410        )
411    }
412
413    /// Build new EXIF data by merging existing EXIF with queued changes.
414    fn build_new_exif(&self, jpeg_data: &[u8], values: &[&NewValue]) -> Result<Vec<u8>> {
415        let bo = ByteOrderMark::BigEndian;
416        let mut ifd0_entries = Vec::new();
417        let mut exif_entries = Vec::new();
418        let mut gps_entries = Vec::new();
419
420        // Step 1: Extract existing EXIF entries from the JPEG
421        let existing = extract_existing_exif_entries(jpeg_data, bo);
422        for entry in &existing {
423            match classify_exif_tag(entry.tag) {
424                ExifIfdGroup::Ifd0 => ifd0_entries.push(entry.clone()),
425                ExifIfdGroup::ExifIfd => exif_entries.push(entry.clone()),
426                ExifIfdGroup::Gps => gps_entries.push(entry.clone()),
427            }
428        }
429
430        // Step 2: Apply queued changes (add/replace/delete)
431        let deleted_tags: Vec<u16> = values
432            .iter()
433            .filter(|nv| nv.value.is_none())
434            .filter_map(|nv| tag_name_to_id(&nv.tag))
435            .collect();
436
437        // Remove deleted tags
438        ifd0_entries.retain(|e| !deleted_tags.contains(&e.tag));
439        exif_entries.retain(|e| !deleted_tags.contains(&e.tag));
440        gps_entries.retain(|e| !deleted_tags.contains(&e.tag));
441
442        // Add/replace new values
443        for nv in values {
444            if nv.value.is_none() {
445                continue;
446            }
447            let value_str = nv.value.as_deref().unwrap_or("");
448            let group = nv.group.as_deref().unwrap_or("");
449
450            if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, value_str, group, bo) {
451                let entry = exif_writer::IfdEntry {
452                    tag: tag_id,
453                    format,
454                    data: encoded,
455                };
456
457                let target = match group.to_uppercase().as_str() {
458                    "GPS" => &mut gps_entries,
459                    "EXIFIFD" => &mut exif_entries,
460                    _ => match classify_exif_tag(tag_id) {
461                        ExifIfdGroup::ExifIfd => &mut exif_entries,
462                        ExifIfdGroup::Gps => &mut gps_entries,
463                        ExifIfdGroup::Ifd0 => &mut ifd0_entries,
464                    },
465                };
466
467                // Replace existing or add new
468                if let Some(existing) = target.iter_mut().find(|e| e.tag == tag_id) {
469                    *existing = entry;
470                } else {
471                    target.push(entry);
472                }
473            }
474        }
475
476        // Remove sub-IFD pointers from entries (they'll be rebuilt by build_exif)
477        ifd0_entries.retain(|e| e.tag != 0x8769 && e.tag != 0x8825 && e.tag != 0xA005);
478
479        exif_writer::build_exif(&ifd0_entries, &exif_entries, &gps_entries, bo)
480    }
481
482    /// Write metadata changes to PNG data.
483    fn write_png(&self, data: &[u8]) -> Result<Vec<u8>> {
484        let mut new_text: Vec<(&str, &str)> = Vec::new();
485        let mut remove_text: Vec<&str> = Vec::new();
486
487        // Collect text-based changes
488        // We need to hold the strings in vectors that live long enough
489        let owned_pairs: Vec<(String, String)> = self.new_values.iter()
490            .filter(|nv| nv.value.is_some())
491            .map(|nv| (nv.tag.clone(), nv.value.clone().unwrap()))
492            .collect();
493
494        for (tag, value) in &owned_pairs {
495            new_text.push((tag.as_str(), value.as_str()));
496        }
497
498        for nv in &self.new_values {
499            if nv.value.is_none() {
500                remove_text.push(&nv.tag);
501            }
502        }
503
504        png_writer::write_png(data, &new_text, None, &remove_text)
505    }
506
507    /// Write metadata changes to PSD data.
508    fn write_psd(&self, data: &[u8]) -> Result<Vec<u8>> {
509        let mut iptc_values = Vec::new();
510        let mut xmp_values = Vec::new();
511
512        for nv in &self.new_values {
513            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
514            match group.as_str() {
515                "XMP" => xmp_values.push(nv),
516                "IPTC" => iptc_values.push(nv),
517                _ => {
518                    if is_xmp_tag(&nv.tag) { xmp_values.push(nv); }
519                    else { iptc_values.push(nv); }
520                }
521            }
522        }
523
524        let new_iptc = if !iptc_values.is_empty() {
525            let records: Vec<_> = iptc_values.iter().filter_map(|nv| {
526                let value = nv.value.as_deref()?;
527                let (record, dataset) = iptc_writer::tag_name_to_iptc(&nv.tag)?;
528                Some(iptc_writer::IptcRecord { record, dataset, data: value.as_bytes().to_vec() })
529            }).collect();
530            if records.is_empty() { None } else { Some(iptc_writer::build_iptc(&records)) }
531        } else { None };
532
533        let new_xmp = if !xmp_values.is_empty() {
534            let refs: Vec<&NewValue> = xmp_values.iter().copied().collect();
535            Some(self.build_new_xmp(&refs))
536        } else { None };
537
538        psd_writer::write_psd(data, new_iptc.as_deref(), new_xmp.as_deref())
539    }
540
541    /// Write metadata changes to Matroska (MKV/WebM) data.
542    fn write_matroska(&self, data: &[u8]) -> Result<Vec<u8>> {
543        let changes: Vec<(&str, &str)> = self.new_values.iter()
544            .filter_map(|nv| {
545                let value = nv.value.as_deref()?;
546                Some((nv.tag.as_str(), value))
547            })
548            .collect();
549
550        matroska_writer::write_matroska(data, &changes)
551    }
552
553    /// Write metadata changes to PDF data.
554    fn write_pdf(&self, data: &[u8]) -> Result<Vec<u8>> {
555        let changes: Vec<(&str, &str)> = self.new_values.iter()
556            .filter_map(|nv| {
557                let value = nv.value.as_deref()?;
558                Some((nv.tag.as_str(), value))
559            })
560            .collect();
561
562        pdf_writer::write_pdf(data, &changes)
563    }
564
565    /// Write metadata changes to MP4/MOV data.
566    fn write_mp4(&self, data: &[u8]) -> Result<Vec<u8>> {
567        let mut ilst_tags: Vec<([u8; 4], String)> = Vec::new();
568        let mut xmp_values: Vec<&NewValue> = Vec::new();
569
570        for nv in &self.new_values {
571            if nv.value.is_none() { continue; }
572            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
573            if group == "XMP" {
574                xmp_values.push(nv);
575            } else if let Some(key) = mp4_writer::tag_to_ilst_key(&nv.tag) {
576                ilst_tags.push((key, nv.value.clone().unwrap()));
577            }
578        }
579
580        let tag_refs: Vec<(&[u8; 4], &str)> = ilst_tags.iter()
581            .map(|(k, v)| (k, v.as_str()))
582            .collect();
583
584        let new_xmp = if !xmp_values.is_empty() {
585            let refs: Vec<&NewValue> = xmp_values.iter().copied().collect();
586            Some(self.build_new_xmp(&refs))
587        } else {
588            None
589        };
590
591        mp4_writer::write_mp4(data, &tag_refs, new_xmp.as_deref())
592    }
593
594    /// Write metadata changes to WebP data.
595    fn write_webp(&self, data: &[u8]) -> Result<Vec<u8>> {
596        let mut exif_values: Vec<&NewValue> = Vec::new();
597        let mut xmp_values: Vec<&NewValue> = Vec::new();
598        let mut remove_exif = false;
599        let mut remove_xmp = false;
600
601        for nv in &self.new_values {
602            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
603            if nv.value.is_none() && nv.tag == "*" {
604                if group == "EXIF" { remove_exif = true; }
605                if group == "XMP" { remove_xmp = true; }
606                continue;
607            }
608            match group.as_str() {
609                "XMP" => xmp_values.push(nv),
610                _ => exif_values.push(nv),
611            }
612        }
613
614        let new_exif = if !exif_values.is_empty() {
615            let bo = ByteOrderMark::BigEndian;
616            let mut entries = Vec::new();
617            for nv in &exif_values {
618                if let Some(ref v) = nv.value {
619                    let group = nv.group.as_deref().unwrap_or("");
620                    if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, v, group, bo) {
621                        entries.push(exif_writer::IfdEntry { tag: tag_id, format, data: encoded });
622                    }
623                }
624            }
625            if !entries.is_empty() {
626                Some(exif_writer::build_exif(&entries, &[], &[], bo)?)
627            } else {
628                None
629            }
630        } else {
631            None
632        };
633
634        let new_xmp = if !xmp_values.is_empty() {
635            Some(self.build_new_xmp(&xmp_values.iter().map(|v| *v).collect::<Vec<_>>()))
636        } else {
637            None
638        };
639
640        webp_writer::write_webp(
641            data,
642            new_exif.as_deref(),
643            new_xmp.as_deref(),
644            remove_exif,
645            remove_xmp,
646        )
647    }
648
649    /// Write metadata changes to TIFF data.
650    fn write_tiff(&self, data: &[u8]) -> Result<Vec<u8>> {
651        let bo = if data.starts_with(b"II") {
652            ByteOrderMark::LittleEndian
653        } else {
654            ByteOrderMark::BigEndian
655        };
656
657        let mut changes: Vec<(u16, Vec<u8>)> = Vec::new();
658        for nv in &self.new_values {
659            if let Some(ref value) = nv.value {
660                let group = nv.group.as_deref().unwrap_or("");
661                if let Some((tag_id, _format, encoded)) = encode_exif_tag(&nv.tag, value, group, bo) {
662                    changes.push((tag_id, encoded));
663                }
664            }
665        }
666
667        tiff_writer::write_tiff(data, &changes)
668    }
669
670    /// Build new XMP data from queued values.
671    fn build_new_xmp(&self, values: &[&NewValue]) -> Vec<u8> {
672        let mut properties = Vec::new();
673
674        for nv in values {
675            let value_str = match &nv.value {
676                Some(v) => v.clone(),
677                None => continue,
678            };
679
680            let ns = nv.group.as_deref().unwrap_or("dc").to_lowercase();
681            let ns = if ns == "xmp" { "xmp".to_string() } else { ns };
682
683            let prop_type = match nv.tag.to_lowercase().as_str() {
684                "title" | "description" | "rights" => xmp_writer::XmpPropertyType::LangAlt,
685                "subject" | "keywords" => xmp_writer::XmpPropertyType::Bag,
686                "creator" => xmp_writer::XmpPropertyType::Seq,
687                _ => xmp_writer::XmpPropertyType::Simple,
688            };
689
690            let values = if matches!(prop_type, xmp_writer::XmpPropertyType::Bag | xmp_writer::XmpPropertyType::Seq) {
691                value_str.split(',').map(|s| s.trim().to_string()).collect()
692            } else {
693                vec![value_str]
694            };
695
696            properties.push(xmp_writer::XmpProperty {
697                namespace: ns,
698                property: nv.tag.clone(),
699                values,
700                prop_type,
701            });
702        }
703
704        xmp_writer::build_xmp(&properties).into_bytes()
705    }
706
707    // ================================================================
708    // Reading API
709    // ================================================================
710
711    /// Extract metadata from a file and return a simple name→value map.
712    ///
713    /// This is the high-level one-shot API, equivalent to ExifTool's `ImageInfo()`.
714    pub fn image_info<P: AsRef<Path>>(&self, path: P) -> Result<ImageInfo> {
715        let tags = self.extract_info(path)?;
716        Ok(self.get_info(&tags))
717    }
718
719    /// Extract all metadata tags from a file.
720    ///
721    /// Returns the full `Tag` structs with groups, raw values, etc.
722    pub fn extract_info<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Tag>> {
723        let path = path.as_ref();
724        let data = fs::read(path).map_err(Error::Io)?;
725
726        self.extract_info_from_bytes(&data, path)
727    }
728
729    /// Extract metadata from in-memory data.
730    pub fn extract_info_from_bytes(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
731        let file_type_result = self.detect_file_type(data, path);
732        let (file_type, mut tags) = match file_type_result {
733            Ok(ft) => {
734                let t = self.process_file(data, ft).or_else(|_| {
735                    self.process_by_extension(data, path)
736                })?;
737                (Some(ft), t)
738            }
739            Err(_) => {
740                // File type unknown by magic/extension — try extension-based fallback
741                let t = self.process_by_extension(data, path)?;
742                (None, t)
743            }
744        };
745        let file_type = file_type.unwrap_or(FileType::Zip); // placeholder for file-level tags
746
747        // Add file-level tags
748        tags.push(Tag {
749            id: crate::tag::TagId::Text("FileType".into()),
750            name: "FileType".into(),
751            description: "File Type".into(),
752            group: crate::tag::TagGroup {
753                family0: "File".into(),
754                family1: "File".into(),
755                family2: "Other".into(),
756            },
757            raw_value: Value::String(format!("{:?}", file_type)),
758            print_value: file_type.description().to_string(),
759            priority: 0,
760        });
761
762        tags.push(Tag {
763            id: crate::tag::TagId::Text("MIMEType".into()),
764            name: "MIMEType".into(),
765            description: "MIME Type".into(),
766            group: crate::tag::TagGroup {
767                family0: "File".into(),
768                family1: "File".into(),
769                family2: "Other".into(),
770            },
771            raw_value: Value::String(file_type.mime_type().to_string()),
772            print_value: file_type.mime_type().to_string(),
773            priority: 0,
774        });
775
776        if let Ok(metadata) = fs::metadata(path) {
777            tags.push(Tag {
778                id: crate::tag::TagId::Text("FileSize".into()),
779                name: "FileSize".into(),
780                description: "File Size".into(),
781                group: crate::tag::TagGroup {
782                    family0: "File".into(),
783                    family1: "File".into(),
784                    family2: "Other".into(),
785                },
786                raw_value: Value::U32(metadata.len() as u32),
787                print_value: format_file_size(metadata.len()),
788                priority: 0,
789            });
790        }
791
792        // Add more file-level tags
793        let file_tag = |name: &str, val: Value| -> Tag {
794            Tag {
795                id: crate::tag::TagId::Text(name.to_string()),
796                name: name.to_string(), description: name.to_string(),
797                group: crate::tag::TagGroup { family0: "File".into(), family1: "File".into(), family2: "Other".into() },
798                raw_value: val.clone(), print_value: val.to_display_string(), priority: 0,
799            }
800        };
801
802        if let Some(fname) = path.file_name().and_then(|n| n.to_str()) {
803            tags.push(file_tag("FileName", Value::String(fname.to_string())));
804        }
805        if let Some(dir) = path.parent().and_then(|p| p.to_str()) {
806            tags.push(file_tag("Directory", Value::String(dir.to_string())));
807        }
808        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
809            tags.push(file_tag("FileTypeExtension", Value::String(ext.to_lowercase())));
810        }
811
812        #[cfg(unix)]
813        if let Ok(metadata) = fs::metadata(path) {
814            use std::os::unix::fs::MetadataExt;
815            let mode = metadata.mode();
816            tags.push(file_tag("FilePermissions", Value::String(format!("{:o}", mode & 0o7777))));
817
818            // FileModifyDate
819            if let Ok(modified) = metadata.modified() {
820                if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
821                    let secs = dur.as_secs() as i64;
822                    tags.push(file_tag("FileModifyDate", Value::String(unix_to_datetime(secs))));
823                }
824            }
825            // FileAccessDate
826            if let Ok(accessed) = metadata.accessed() {
827                if let Ok(dur) = accessed.duration_since(std::time::UNIX_EPOCH) {
828                    let secs = dur.as_secs() as i64;
829                    tags.push(file_tag("FileAccessDate", Value::String(unix_to_datetime(secs))));
830                }
831            }
832            // FileInodeChangeDate (ctime on Unix)
833            let ctime = metadata.ctime();
834            if ctime > 0 {
835                tags.push(file_tag("FileInodeChangeDate", Value::String(unix_to_datetime(ctime))));
836            }
837        }
838
839        // ExifByteOrder (from TIFF header)
840        if file_type == FileType::Jpeg || file_type == FileType::Tiff {
841            let bo_str = if data.len() > 8 {
842                // Check EXIF in JPEG or TIFF header
843                let check = if data.starts_with(&[0xFF, 0xD8]) {
844                    // JPEG: find APP1 EXIF header
845                    data.windows(6).position(|w| w == b"Exif\0\0")
846                        .map(|p| &data[p+6..])
847                } else {
848                    Some(&data[..])
849                };
850                if let Some(tiff) = check {
851                    if tiff.starts_with(b"II") { "Little-endian (Intel, II)" }
852                    else if tiff.starts_with(b"MM") { "Big-endian (Motorola, MM)" }
853                    else { "" }
854                } else { "" }
855            } else { "" };
856            if !bo_str.is_empty() {
857                tags.push(file_tag("ExifByteOrder", Value::String(bo_str.to_string())));
858            }
859        }
860
861        tags.push(file_tag("ExifToolVersion", Value::String(crate::VERSION.to_string())));
862
863        // Compute composite tags
864        let composite = crate::composite::compute_composite_tags(&tags);
865        tags.extend(composite);
866
867        // FLIR post-processing: remove LensID composite for FLIR cameras.
868        // Perl's LensID composite requires LensType EXIF tag (not present in FLIR images),
869        // and LensID-2 requires LensModel to match /(mm|\d\/F)/ (FLIR names like "FOL7"
870        // don't match).  Our composite.rs uses a simpler fallback that picks up any non-empty
871        // LensModel, so we remove LensID when the image is from a FLIR camera with FFF data.
872        {
873            let is_flir_fff = tags.iter().any(|t| t.group.family0 == "APP1"
874                && t.group.family1 == "FLIR");
875            if is_flir_fff {
876                tags.retain(|t| !(t.name == "LensID" && t.group.family0 == "Composite"));
877            }
878        }
879
880        // Olympus post-processing: remove the generic "Lens" composite for Olympus cameras.
881        // In Perl, the "Lens" composite tag requires Canon:MinFocalLength (Canon namespace).
882        // Our composite.rs generates Lens for any manufacturer that has MinFocalLength +
883        // MaxFocalLength (e.g., Olympus Equipment sub-IFD).  Remove it for non-Canon cameras.
884        {
885            let make = tags.iter().find(|t| t.name == "Make")
886                .map(|t| t.print_value.clone()).unwrap_or_default();
887            if !make.to_uppercase().contains("CANON") {
888                tags.retain(|t| t.name != "Lens" || t.group.family0 != "Composite");
889            }
890        }
891
892        // Filter by requested tags if specified
893        if !self.options.requested_tags.is_empty() {
894            let requested: Vec<String> = self
895                .options
896                .requested_tags
897                .iter()
898                .map(|t| t.to_lowercase())
899                .collect();
900            tags.retain(|t| requested.contains(&t.name.to_lowercase()));
901        }
902
903        Ok(tags)
904    }
905
906    /// Format extracted tags into a simple name→value map.
907    ///
908    /// Handles duplicate tag names by appending group info.
909    fn get_info(&self, tags: &[Tag]) -> ImageInfo {
910        let mut info = ImageInfo::new();
911        let mut seen: HashMap<String, usize> = HashMap::new();
912
913        for tag in tags {
914            let value = if self.options.print_conv {
915                &tag.print_value
916            } else {
917                &tag.raw_value.to_display_string()
918            };
919
920            let count = seen.entry(tag.name.clone()).or_insert(0);
921            *count += 1;
922
923            if *count == 1 {
924                info.insert(tag.name.clone(), value.clone());
925            } else if self.options.duplicates {
926                let key = format!("{} [{}:{}]", tag.name, tag.group.family0, tag.group.family1);
927                info.insert(key, value.clone());
928            }
929        }
930
931        info
932    }
933
934    /// Detect file type from magic bytes and extension.
935    fn detect_file_type(&self, data: &[u8], path: &Path) -> Result<FileType> {
936        // Try magic bytes first
937        let header_len = data.len().min(256);
938        if let Some(ft) = file_type::detect_from_magic(&data[..header_len]) {
939            return Ok(ft);
940        }
941
942        // Fall back to extension
943        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
944            if let Some(ft) = file_type::detect_from_extension(ext) {
945                return Ok(ft);
946            }
947        }
948
949        let ext_str = path
950            .extension()
951            .and_then(|e| e.to_str())
952            .unwrap_or("unknown");
953        Err(Error::UnsupportedFileType(ext_str.to_string()))
954    }
955
956    /// Dispatch to the appropriate format reader.
957    fn process_file(&self, data: &[u8], file_type: FileType) -> Result<Vec<Tag>> {
958        match file_type {
959            FileType::Jpeg => formats::jpeg::read_jpeg(data),
960            FileType::Png | FileType::Mng => formats::png::read_png(data),
961            // All TIFF-based formats (TIFF + most RAW formats)
962            FileType::Tiff
963            | FileType::Btf
964            | FileType::Dng
965            | FileType::Cr2
966            | FileType::Nef
967            | FileType::Arw
968            | FileType::Sr2
969            | FileType::Orf
970            | FileType::Pef
971            | FileType::Erf
972            | FileType::Fff
973            | FileType::Iiq
974            | FileType::Rwl
975            | FileType::Mef
976            | FileType::Srw
977            | FileType::Gpr
978            | FileType::Arq
979            | FileType::ThreeFR
980            | FileType::Dcr
981            | FileType::Rw2
982            | FileType::Srf => formats::tiff::read_tiff(data),
983            // Image formats
984            FileType::Gif => formats::gif::read_gif(data),
985            FileType::Bmp => formats::bmp::read_bmp(data),
986            FileType::WebP | FileType::Avi | FileType::Wav => formats::riff::read_riff(data),
987            FileType::Psd => formats::psd::read_psd(data),
988            // Audio formats
989            FileType::Mp3 => formats::id3::read_mp3(data),
990            FileType::Flac => formats::flac::read_flac(data),
991            FileType::Ogg | FileType::Opus => formats::ogg::read_ogg(data),
992            FileType::Aiff => formats::aiff::read_aiff(data),
993            // Video formats
994            FileType::Mp4
995            | FileType::QuickTime
996            | FileType::M4a
997            | FileType::ThreeGP
998            | FileType::Heif
999            | FileType::Avif
1000            | FileType::Cr3
1001            | FileType::F4v
1002            | FileType::Mqv
1003            | FileType::Lrv => formats::quicktime::read_quicktime(data),
1004            FileType::Mkv | FileType::WebM => formats::matroska::read_matroska(data),
1005            FileType::Asf | FileType::Wmv | FileType::Wma => formats::asf::read_asf(data),
1006            // RAW formats with custom containers
1007            FileType::Crw => formats::canon_raw::read_crw(data),
1008            FileType::Raf => formats::raf::read_raf(data),
1009            FileType::Mrw => formats::mrw::read_mrw(data),
1010            // Image formats
1011            FileType::Jp2 | FileType::J2c => formats::jp2::read_jp2(data),
1012            FileType::Jxl => formats::jp2::read_jxl(data),
1013            FileType::Ico => formats::ico::read_ico(data),
1014            FileType::Icc => formats::icc::read_icc(data),
1015            // Documents
1016            FileType::Pdf => formats::pdf::read_pdf(data),
1017            FileType::PostScript => formats::postscript::read_postscript(data),
1018            FileType::Zip | FileType::Docx | FileType::Xlsx | FileType::Pptx
1019            | FileType::Doc | FileType::Xls | FileType::Ppt => formats::zip::read_zip(data),
1020            FileType::Rtf => formats::rtf::read_rtf(data),
1021            // Metadata / Other
1022            FileType::Xmp => formats::xmp_file::read_xmp(data),
1023            FileType::Html => {
1024                // SVG files detected as HTML
1025                let is_svg = data.windows(4).take(512).any(|w| w == b"<svg");
1026                if is_svg {
1027                    formats::misc::read_svg(data)
1028                } else {
1029                    formats::html::read_html(data)
1030                }
1031            }
1032            FileType::Exe => formats::exe::read_exe(data),
1033            FileType::Font => formats::font::read_font(data),
1034            // Audio with ID3
1035            FileType::Aac | FileType::Ape | FileType::Mpc | FileType::Audible
1036            | FileType::WavPack | FileType::Dsf => formats::id3::read_mp3(data),
1037            FileType::RealAudio | FileType::RealMedia => {
1038                // Try ID3 first
1039                formats::id3::read_mp3(data).or_else(|_| Ok(Vec::new()))
1040            }
1041            // Misc formats
1042            FileType::Dicom => formats::misc::read_dicom(data),
1043            FileType::Fits => formats::misc::read_fits(data),
1044            FileType::Flv => formats::misc::read_flv(data),
1045            FileType::Swf => formats::misc::read_swf(data),
1046            FileType::Hdr => formats::misc::read_hdr(data),
1047            FileType::DjVu => formats::misc::read_djvu(data),
1048            FileType::Flif => formats::misc::read_flif(data),
1049            FileType::Bpg => formats::misc::read_bpg(data),
1050            FileType::Pcx => formats::misc::read_pcx(data),
1051            FileType::Pict => formats::misc::read_pict(data),
1052            FileType::M2ts => formats::misc::read_m2ts(data),
1053            FileType::Gzip => formats::misc::read_gzip(data),
1054            FileType::Rar => formats::misc::read_rar(data),
1055            _ => Err(Error::UnsupportedFileType(format!("{}", file_type))),
1056        }
1057    }
1058
1059    /// Fallback: try to read file based on extension for formats without magic detection.
1060    fn process_by_extension(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
1061        let ext = path
1062            .extension()
1063            .and_then(|e| e.to_str())
1064            .unwrap_or("")
1065            .to_ascii_lowercase();
1066
1067        match ext.as_str() {
1068            "ppm" | "pgm" | "pbm" => formats::misc::read_ppm(data),
1069            "pfm" => {
1070                // PFM can be Portable Float Map or Printer Font Metrics
1071                if data.len() >= 3 && data[0] == b'P' && (data[1] == b'f' || data[1] == b'F') {
1072                    formats::misc::read_ppm(data)
1073                } else {
1074                    Ok(Vec::new()) // Printer Font Metrics
1075                }
1076            }
1077            "json" => formats::misc::read_json(data),
1078            "svg" => formats::misc::read_svg(data),
1079            "txt" | "csv" | "log" | "igc" | "url" | "lnk" | "ram" => {
1080                Ok(Vec::new()) // Text-like: report file-level info only
1081            }
1082            "gpx" | "kml" | "xml" | "inx" => formats::xmp_file::read_xmp(data),
1083            "plist" | "aae" => {
1084                // Try XML plist or binary plist
1085                if data.starts_with(b"<?xml") || data.starts_with(b"bplist") {
1086                    formats::xmp_file::read_xmp(data).or_else(|_| Ok(Vec::new()))
1087                } else {
1088                    Ok(Vec::new())
1089                }
1090            }
1091            "vcf" | "ics" | "vcard" => Ok(Vec::new()), // vCard/iCal
1092            "xcf" => Ok(Vec::new()),      // GIMP
1093            "vrd" | "dr4" => Ok(Vec::new()), // Canon VRD
1094            "indd" | "indt" => Ok(Vec::new()), // InDesign
1095            "x3f" => Ok(Vec::new()),       // Sigma X3F
1096            "mie" => Ok(Vec::new()),       // MIE
1097            "exr" => Ok(Vec::new()),       // OpenEXR
1098            "dpx" | "dv" | "fpf" | "lfp" | "miff" | "moi" | "mrc"
1099            | "dss" | "mobi" | "pcapng" | "psp" | "pgf" | "raw"
1100            | "r3d" | "pmp" | "tnef" | "torrent" | "wpg" | "wtv"
1101            | "xisf" | "czi" | "iso" | "itc" | "macos" | "mxf"
1102            | "afm" | "pfb" | "ppt" | "dfont" => Ok(Vec::new()),
1103            _ => Err(Error::UnsupportedFileType(ext)),
1104        }
1105    }
1106}
1107
1108impl Default for ExifTool {
1109    fn default() -> Self {
1110        Self::new()
1111    }
1112}
1113
1114/// Detect the file type of a file at the given path.
1115pub fn get_file_type<P: AsRef<Path>>(path: P) -> Result<FileType> {
1116    let path = path.as_ref();
1117    let mut file = fs::File::open(path).map_err(Error::Io)?;
1118    let mut header = [0u8; 256];
1119    use std::io::Read;
1120    let n = file.read(&mut header).map_err(Error::Io)?;
1121
1122    if let Some(ft) = file_type::detect_from_magic(&header[..n]) {
1123        return Ok(ft);
1124    }
1125
1126    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1127        if let Some(ft) = file_type::detect_from_extension(ext) {
1128            return Ok(ft);
1129        }
1130    }
1131
1132    Err(Error::UnsupportedFileType("unknown".into()))
1133}
1134
1135/// Classification of EXIF tags into IFD groups.
1136enum ExifIfdGroup {
1137    Ifd0,
1138    ExifIfd,
1139    Gps,
1140}
1141
1142/// Determine which IFD a tag belongs to based on its ID.
1143fn classify_exif_tag(tag_id: u16) -> ExifIfdGroup {
1144    match tag_id {
1145        // ExifIFD tags
1146        0x829A..=0x829D | 0x8822..=0x8827 | 0x8830 | 0x9000..=0x9292
1147        | 0xA000..=0xA435 => ExifIfdGroup::ExifIfd,
1148        // GPS tags
1149        0x0000..=0x001F if tag_id <= 0x001F => ExifIfdGroup::Gps,
1150        // Everything else → IFD0
1151        _ => ExifIfdGroup::Ifd0,
1152    }
1153}
1154
1155/// Extract existing EXIF entries from a JPEG file's APP1 segment.
1156fn extract_existing_exif_entries(jpeg_data: &[u8], target_bo: ByteOrderMark) -> Vec<exif_writer::IfdEntry> {
1157    let mut entries = Vec::new();
1158
1159    // Find EXIF APP1 segment
1160    let mut pos = 2; // Skip SOI
1161    while pos + 4 <= jpeg_data.len() {
1162        if jpeg_data[pos] != 0xFF {
1163            pos += 1;
1164            continue;
1165        }
1166        let marker = jpeg_data[pos + 1];
1167        pos += 2;
1168
1169        if marker == 0xDA || marker == 0xD9 {
1170            break; // SOS or EOI
1171        }
1172        if marker == 0xFF || marker == 0x00 || marker == 0xD8 || (0xD0..=0xD7).contains(&marker) {
1173            continue;
1174        }
1175
1176        if pos + 2 > jpeg_data.len() {
1177            break;
1178        }
1179        let seg_len = u16::from_be_bytes([jpeg_data[pos], jpeg_data[pos + 1]]) as usize;
1180        if seg_len < 2 || pos + seg_len > jpeg_data.len() {
1181            break;
1182        }
1183
1184        let seg_data = &jpeg_data[pos + 2..pos + seg_len];
1185
1186        // EXIF APP1
1187        if marker == 0xE1 && seg_data.len() > 14 && seg_data.starts_with(b"Exif\0\0") {
1188            let tiff_data = &seg_data[6..];
1189            extract_ifd_entries(tiff_data, target_bo, &mut entries);
1190            break;
1191        }
1192
1193        pos += seg_len;
1194    }
1195
1196    entries
1197}
1198
1199/// Extract IFD entries from TIFF data, re-encoding values in the target byte order.
1200fn extract_ifd_entries(
1201    tiff_data: &[u8],
1202    target_bo: ByteOrderMark,
1203    entries: &mut Vec<exif_writer::IfdEntry>,
1204) {
1205    use crate::metadata::exif::parse_tiff_header;
1206
1207    let header = match parse_tiff_header(tiff_data) {
1208        Ok(h) => h,
1209        Err(_) => return,
1210    };
1211
1212    let src_bo = header.byte_order;
1213
1214    // Read IFD0
1215    read_ifd_for_merge(tiff_data, header.ifd0_offset as usize, src_bo, target_bo, entries);
1216
1217    // Find ExifIFD and GPS pointers
1218    let ifd0_offset = header.ifd0_offset as usize;
1219    if ifd0_offset + 2 > tiff_data.len() {
1220        return;
1221    }
1222    let count = read_u16_bo(tiff_data, ifd0_offset, src_bo) as usize;
1223    for i in 0..count {
1224        let eoff = ifd0_offset + 2 + i * 12;
1225        if eoff + 12 > tiff_data.len() {
1226            break;
1227        }
1228        let tag = read_u16_bo(tiff_data, eoff, src_bo);
1229        let value_off = read_u32_bo(tiff_data, eoff + 8, src_bo) as usize;
1230
1231        match tag {
1232            0x8769 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
1233            0x8825 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
1234            _ => {}
1235        }
1236    }
1237}
1238
1239/// Read a single IFD and extract entries for merge.
1240fn read_ifd_for_merge(
1241    data: &[u8],
1242    offset: usize,
1243    src_bo: ByteOrderMark,
1244    target_bo: ByteOrderMark,
1245    entries: &mut Vec<exif_writer::IfdEntry>,
1246) {
1247    if offset + 2 > data.len() {
1248        return;
1249    }
1250    let count = read_u16_bo(data, offset, src_bo) as usize;
1251
1252    for i in 0..count {
1253        let eoff = offset + 2 + i * 12;
1254        if eoff + 12 > data.len() {
1255            break;
1256        }
1257
1258        let tag = read_u16_bo(data, eoff, src_bo);
1259        let dtype = read_u16_bo(data, eoff + 2, src_bo);
1260        let count_val = read_u32_bo(data, eoff + 4, src_bo);
1261
1262        // Skip sub-IFD pointers and MakerNote
1263        if tag == 0x8769 || tag == 0x8825 || tag == 0xA005 || tag == 0x927C {
1264            continue;
1265        }
1266
1267        let type_size = match dtype {
1268            1 | 2 | 6 | 7 => 1usize,
1269            3 | 8 => 2,
1270            4 | 9 | 11 | 13 => 4,
1271            5 | 10 | 12 => 8,
1272            _ => continue,
1273        };
1274
1275        let total_size = type_size * count_val as usize;
1276        let raw_data = if total_size <= 4 {
1277            data[eoff + 8..eoff + 12].to_vec()
1278        } else {
1279            let voff = read_u32_bo(data, eoff + 8, src_bo) as usize;
1280            if voff + total_size > data.len() {
1281                continue;
1282            }
1283            data[voff..voff + total_size].to_vec()
1284        };
1285
1286        // Re-encode multi-byte values if byte orders differ
1287        let final_data = if src_bo != target_bo && type_size > 1 {
1288            reencode_bytes(&raw_data, dtype, count_val as usize, src_bo, target_bo)
1289        } else {
1290            raw_data[..total_size].to_vec()
1291        };
1292
1293        let format = match dtype {
1294            1 => exif_writer::ExifFormat::Byte,
1295            2 => exif_writer::ExifFormat::Ascii,
1296            3 => exif_writer::ExifFormat::Short,
1297            4 => exif_writer::ExifFormat::Long,
1298            5 => exif_writer::ExifFormat::Rational,
1299            6 => exif_writer::ExifFormat::SByte,
1300            7 => exif_writer::ExifFormat::Undefined,
1301            8 => exif_writer::ExifFormat::SShort,
1302            9 => exif_writer::ExifFormat::SLong,
1303            10 => exif_writer::ExifFormat::SRational,
1304            11 => exif_writer::ExifFormat::Float,
1305            12 => exif_writer::ExifFormat::Double,
1306            _ => continue,
1307        };
1308
1309        entries.push(exif_writer::IfdEntry {
1310            tag,
1311            format,
1312            data: final_data,
1313        });
1314    }
1315}
1316
1317/// Re-encode multi-byte values when converting between byte orders.
1318fn reencode_bytes(
1319    data: &[u8],
1320    dtype: u16,
1321    count: usize,
1322    src_bo: ByteOrderMark,
1323    dst_bo: ByteOrderMark,
1324) -> Vec<u8> {
1325    let mut out = Vec::with_capacity(data.len());
1326    match dtype {
1327        3 | 8 => {
1328            // 16-bit
1329            for i in 0..count {
1330                let v = read_u16_bo(data, i * 2, src_bo);
1331                match dst_bo {
1332                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
1333                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
1334                }
1335            }
1336        }
1337        4 | 9 | 11 | 13 => {
1338            // 32-bit
1339            for i in 0..count {
1340                let v = read_u32_bo(data, i * 4, src_bo);
1341                match dst_bo {
1342                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
1343                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
1344                }
1345            }
1346        }
1347        5 | 10 => {
1348            // Rational (two 32-bit)
1349            for i in 0..count {
1350                let n = read_u32_bo(data, i * 8, src_bo);
1351                let d = read_u32_bo(data, i * 8 + 4, src_bo);
1352                match dst_bo {
1353                    ByteOrderMark::LittleEndian => {
1354                        out.extend_from_slice(&n.to_le_bytes());
1355                        out.extend_from_slice(&d.to_le_bytes());
1356                    }
1357                    ByteOrderMark::BigEndian => {
1358                        out.extend_from_slice(&n.to_be_bytes());
1359                        out.extend_from_slice(&d.to_be_bytes());
1360                    }
1361                }
1362            }
1363        }
1364        12 => {
1365            // 64-bit double
1366            for i in 0..count {
1367                let mut bytes = [0u8; 8];
1368                bytes.copy_from_slice(&data[i * 8..i * 8 + 8]);
1369                if src_bo != dst_bo {
1370                    bytes.reverse();
1371                }
1372                out.extend_from_slice(&bytes);
1373            }
1374        }
1375        _ => out.extend_from_slice(data),
1376    }
1377    out
1378}
1379
1380fn read_u16_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u16 {
1381    if offset + 2 > data.len() { return 0; }
1382    match bo {
1383        ByteOrderMark::LittleEndian => u16::from_le_bytes([data[offset], data[offset + 1]]),
1384        ByteOrderMark::BigEndian => u16::from_be_bytes([data[offset], data[offset + 1]]),
1385    }
1386}
1387
1388fn read_u32_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u32 {
1389    if offset + 4 > data.len() { return 0; }
1390    match bo {
1391        ByteOrderMark::LittleEndian => u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]),
1392        ByteOrderMark::BigEndian => u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]),
1393    }
1394}
1395
1396/// Map tag name to numeric EXIF tag ID.
1397fn tag_name_to_id(name: &str) -> Option<u16> {
1398    encode_exif_tag(name, "", "", ByteOrderMark::BigEndian).map(|(id, _, _)| id)
1399}
1400
1401/// Convert a tag value to a safe filename.
1402fn value_to_filename(value: &str) -> String {
1403    value
1404        .chars()
1405        .map(|c| match c {
1406            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1407            c if c.is_control() => '_',
1408            c => c,
1409        })
1410        .collect::<String>()
1411        .trim()
1412        .to_string()
1413}
1414
1415/// Parse a date shift string like "+1:0:0" (add 1 hour) or "-0:30:0" (subtract 30 min).
1416/// Returns (sign, hours, minutes, seconds).
1417pub fn parse_date_shift(shift: &str) -> Option<(i32, u32, u32, u32)> {
1418    let (sign, rest) = if shift.starts_with('-') {
1419        (-1, &shift[1..])
1420    } else if shift.starts_with('+') {
1421        (1, &shift[1..])
1422    } else {
1423        (1, shift)
1424    };
1425
1426    let parts: Vec<&str> = rest.split(':').collect();
1427    match parts.len() {
1428        1 => {
1429            let h: u32 = parts[0].parse().ok()?;
1430            Some((sign, h, 0, 0))
1431        }
1432        2 => {
1433            let h: u32 = parts[0].parse().ok()?;
1434            let m: u32 = parts[1].parse().ok()?;
1435            Some((sign, h, m, 0))
1436        }
1437        3 => {
1438            let h: u32 = parts[0].parse().ok()?;
1439            let m: u32 = parts[1].parse().ok()?;
1440            let s: u32 = parts[2].parse().ok()?;
1441            Some((sign, h, m, s))
1442        }
1443        _ => None,
1444    }
1445}
1446
1447/// Shift a datetime string by the given amount.
1448/// Input format: "YYYY:MM:DD HH:MM:SS"
1449pub fn shift_datetime(datetime: &str, shift: &str) -> Option<String> {
1450    let (sign, hours, minutes, seconds) = parse_date_shift(shift)?;
1451
1452    // Parse date/time
1453    if datetime.len() < 19 {
1454        return None;
1455    }
1456    let year: i32 = datetime[0..4].parse().ok()?;
1457    let month: u32 = datetime[5..7].parse().ok()?;
1458    let day: u32 = datetime[8..10].parse().ok()?;
1459    let hour: u32 = datetime[11..13].parse().ok()?;
1460    let min: u32 = datetime[14..16].parse().ok()?;
1461    let sec: u32 = datetime[17..19].parse().ok()?;
1462
1463    // Convert to total seconds, shift, convert back
1464    let total_secs = (hour * 3600 + min * 60 + sec) as i64
1465        + sign as i64 * (hours * 3600 + minutes * 60 + seconds) as i64;
1466
1467    let days_shift = if total_secs < 0 {
1468        -1 - (-total_secs - 1) as i64 / 86400
1469    } else {
1470        total_secs / 86400
1471    };
1472
1473    let time_secs = ((total_secs % 86400) + 86400) % 86400;
1474    let new_hour = (time_secs / 3600) as u32;
1475    let new_min = ((time_secs % 3600) / 60) as u32;
1476    let new_sec = (time_secs % 60) as u32;
1477
1478    // Simple day shifting (doesn't handle month/year rollover perfectly for large shifts)
1479    let mut new_day = day as i32 + days_shift as i32;
1480    let mut new_month = month;
1481    let mut new_year = year;
1482
1483    let days_in_month = |m: u32, y: i32| -> i32 {
1484        match m {
1485            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1486            4 | 6 | 9 | 11 => 30,
1487            2 => if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 { 29 } else { 28 },
1488            _ => 30,
1489        }
1490    };
1491
1492    while new_day > days_in_month(new_month, new_year) {
1493        new_day -= days_in_month(new_month, new_year);
1494        new_month += 1;
1495        if new_month > 12 {
1496            new_month = 1;
1497            new_year += 1;
1498        }
1499    }
1500    while new_day < 1 {
1501        new_month = if new_month == 1 { 12 } else { new_month - 1 };
1502        if new_month == 12 {
1503            new_year -= 1;
1504        }
1505        new_day += days_in_month(new_month, new_year);
1506    }
1507
1508    Some(format!(
1509        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}",
1510        new_year, new_month, new_day, new_hour, new_min, new_sec
1511    ))
1512}
1513
1514fn unix_to_datetime(secs: i64) -> String {
1515    let days = secs / 86400;
1516    let time = secs % 86400;
1517    let h = time / 3600;
1518    let m = (time % 3600) / 60;
1519    let s = time % 60;
1520    let mut y = 1970i32;
1521    let mut rem = days;
1522    loop {
1523        let dy = if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 { 366 } else { 365 };
1524        if rem < dy { break; }
1525        rem -= dy;
1526        y += 1;
1527    }
1528    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
1529    let months = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1530    let mut mo = 1;
1531    for &dm in &months {
1532        if rem < dm { break; }
1533        rem -= dm;
1534        mo += 1;
1535    }
1536    format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", y, mo, rem + 1, h, m, s)
1537}
1538
1539fn format_file_size(bytes: u64) -> String {
1540    if bytes < 1024 {
1541        format!("{} bytes", bytes)
1542    } else if bytes < 1024 * 1024 {
1543        format!("{:.1} kB", bytes as f64 / 1024.0)
1544    } else if bytes < 1024 * 1024 * 1024 {
1545        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1546    } else {
1547        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
1548    }
1549}
1550
1551/// Check if a tag name is typically XMP.
1552fn is_xmp_tag(tag: &str) -> bool {
1553    matches!(
1554        tag.to_lowercase().as_str(),
1555        "title" | "description" | "subject" | "creator" | "rights"
1556        | "keywords" | "rating" | "label" | "hierarchicalsubject"
1557    )
1558}
1559
1560/// Encode an EXIF tag value to binary.
1561/// Returns (tag_id, format, encoded_data) or None if tag is unknown.
1562fn encode_exif_tag(
1563    tag_name: &str,
1564    value: &str,
1565    _group: &str,
1566    bo: ByteOrderMark,
1567) -> Option<(u16, exif_writer::ExifFormat, Vec<u8>)> {
1568    let tag_lower = tag_name.to_lowercase();
1569
1570    // Map common tag names to EXIF tag IDs and formats
1571    let (tag_id, format): (u16, exif_writer::ExifFormat) = match tag_lower.as_str() {
1572        // IFD0 string tags
1573        "imagedescription" => (0x010E, exif_writer::ExifFormat::Ascii),
1574        "make" => (0x010F, exif_writer::ExifFormat::Ascii),
1575        "model" => (0x0110, exif_writer::ExifFormat::Ascii),
1576        "software" => (0x0131, exif_writer::ExifFormat::Ascii),
1577        "modifydate" | "datetime" => (0x0132, exif_writer::ExifFormat::Ascii),
1578        "artist" => (0x013B, exif_writer::ExifFormat::Ascii),
1579        "copyright" => (0x8298, exif_writer::ExifFormat::Ascii),
1580        // IFD0 numeric tags
1581        "orientation" => (0x0112, exif_writer::ExifFormat::Short),
1582        "xresolution" => (0x011A, exif_writer::ExifFormat::Rational),
1583        "yresolution" => (0x011B, exif_writer::ExifFormat::Rational),
1584        "resolutionunit" => (0x0128, exif_writer::ExifFormat::Short),
1585        // ExifIFD tags
1586        "datetimeoriginal" => (0x9003, exif_writer::ExifFormat::Ascii),
1587        "createdate" | "datetimedigitized" => (0x9004, exif_writer::ExifFormat::Ascii),
1588        "usercomment" => (0x9286, exif_writer::ExifFormat::Undefined),
1589        "imageuniqueid" => (0xA420, exif_writer::ExifFormat::Ascii),
1590        "ownername" | "cameraownername" => (0xA430, exif_writer::ExifFormat::Ascii),
1591        "serialnumber" | "bodyserialnumber" => (0xA431, exif_writer::ExifFormat::Ascii),
1592        "lensmake" => (0xA433, exif_writer::ExifFormat::Ascii),
1593        "lensmodel" => (0xA434, exif_writer::ExifFormat::Ascii),
1594        "lensserialnumber" => (0xA435, exif_writer::ExifFormat::Ascii),
1595        _ => return None,
1596    };
1597
1598    let encoded = match format {
1599        exif_writer::ExifFormat::Ascii => exif_writer::encode_ascii(value),
1600        exif_writer::ExifFormat::Short => {
1601            let v: u16 = value.parse().ok()?;
1602            exif_writer::encode_u16(v, bo)
1603        }
1604        exif_writer::ExifFormat::Long => {
1605            let v: u32 = value.parse().ok()?;
1606            exif_writer::encode_u32(v, bo)
1607        }
1608        exif_writer::ExifFormat::Rational => {
1609            // Parse "N/D" or just "N"
1610            if let Some(slash) = value.find('/') {
1611                let num: u32 = value[..slash].trim().parse().ok()?;
1612                let den: u32 = value[slash + 1..].trim().parse().ok()?;
1613                exif_writer::encode_urational(num, den, bo)
1614            } else if let Ok(v) = value.parse::<f64>() {
1615                // Convert float to rational
1616                let den = 10000u32;
1617                let num = (v * den as f64).round() as u32;
1618                exif_writer::encode_urational(num, den, bo)
1619            } else {
1620                return None;
1621            }
1622        }
1623        exif_writer::ExifFormat::Undefined => {
1624            // UserComment: 8 bytes charset + data
1625            let mut data = vec![0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00]; // "ASCII\0\0\0"
1626            data.extend_from_slice(value.as_bytes());
1627            data
1628        }
1629        _ => return None,
1630    };
1631
1632    Some((tag_id, format, encoded))
1633}