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