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::{
17    exif_writer, iptc_writer, jpeg_writer, matroska_writer, mp4_writer, pdf_writer, png_writer,
18    psd_writer, tiff_writer, webp_writer, xmp_writer,
19};
20
21/// Processing options for metadata extraction.
22#[derive(Debug, Clone)]
23pub struct Options {
24    /// Include duplicate tags (different groups may have same tag name).
25    pub duplicates: bool,
26    /// Apply print conversions (human-readable values).
27    pub print_conv: bool,
28    /// Fast scan level: 0=normal, 1=skip composite, 2=skip maker notes, 3=skip thumbnails.
29    pub fast_scan: u8,
30    /// Only extract these tag names (empty = all).
31    pub requested_tags: Vec<String>,
32    /// Extract embedded documents/data (video frames, etc.). Level: 0=off, 1=-ee, 2=-ee2, 3=-ee3.
33    pub extract_embedded: u8,
34    /// Show unknown tags: 0=off, 1=-u (show unknown), 2=-U (show unknown + binary data).
35    pub show_unknown: u8,
36    /// Process compressed data in files (-z option).
37    pub process_compressed: bool,
38    /// Use MWG (Metadata Working Group) composite tags for reading/writing.
39    pub use_mwg: bool,
40    /// Reverse-geocode `Geolocation*` tags from GPS coordinates
41    /// (ExifTool's `Geolocation` API option). Off by default, like ExifTool.
42    pub geolocation: bool,
43}
44
45impl Default for Options {
46    fn default() -> Self {
47        Self {
48            duplicates: false,
49            print_conv: true,
50            fast_scan: 0,
51            requested_tags: Vec::new(),
52            extract_embedded: 0,
53            show_unknown: 0,
54            process_compressed: false,
55            use_mwg: false,
56            geolocation: false,
57        }
58    }
59}
60
61/// The main ExifTool struct. Create one and use it to extract metadata from files.
62///
63/// # Example
64/// ```no_run
65/// use exiftool_rs::ExifTool;
66///
67/// let mut et = ExifTool::new();
68/// let info = et.image_info("photo.jpg").unwrap();
69/// for (name, value) in &info {
70///     println!("{}: {}", name, value);
71/// }
72/// ```
73/// A queued tag change for writing.
74#[derive(Debug, Clone)]
75pub struct NewValue {
76    /// Tag name (e.g., "Artist", "Copyright", "XMP:Title")
77    pub tag: String,
78    /// Group prefix if specified (e.g., "EXIF", "XMP", "IPTC")
79    pub group: Option<String>,
80    /// New value (None = delete tag)
81    pub value: Option<String>,
82}
83
84/// The main ExifTool engine — read, write, and edit metadata.
85///
86/// # Reading metadata
87/// ```no_run
88/// use exiftool_rs::ExifTool;
89///
90/// let et = ExifTool::new();
91///
92/// // Full tag structs
93/// let tags = et.extract_info("photo.jpg").unwrap();
94/// for tag in &tags {
95///     println!("[{}] {}: {}", tag.group.family0, tag.name, tag.print_value);
96/// }
97///
98/// // Simple name→value map
99/// let info = et.image_info("photo.jpg").unwrap();
100/// println!("Camera: {}", info.get("Model").unwrap_or(&String::new()));
101/// ```
102///
103/// # Writing metadata
104/// ```no_run
105/// use exiftool_rs::ExifTool;
106///
107/// let mut et = ExifTool::new();
108/// et.set_new_value("Artist", Some("John Doe"));
109/// et.set_new_value("Copyright", Some("2024"));
110/// et.write_info("input.jpg", "output.jpg").unwrap();
111/// ```
112pub struct ExifTool {
113    options: Options,
114    new_values: Vec<NewValue>,
115}
116
117/// Result of metadata extraction: maps tag names to display values.
118pub type ImageInfo = HashMap<String, String>;
119
120impl ExifTool {
121    /// Create a new ExifTool instance with default options.
122    pub fn new() -> Self {
123        Self {
124            options: Options::default(),
125            new_values: Vec::new(),
126        }
127    }
128
129    /// Create a new ExifTool instance with custom options.
130    pub fn with_options(options: Options) -> Self {
131        Self {
132            options,
133            new_values: Vec::new(),
134        }
135    }
136
137    /// Get a mutable reference to the options.
138    pub fn options_mut(&mut self) -> &mut Options {
139        &mut self.options
140    }
141
142    /// Get a reference to the options.
143    pub fn options(&self) -> &Options {
144        &self.options
145    }
146
147    // ================================================================
148    // Writing API
149    // ================================================================
150
151    /// Queue a new tag value for writing.
152    ///
153    /// Call this one or more times, then call `write_info()` to apply changes.
154    ///
155    /// # Arguments
156    /// * `tag` - Tag name, optionally prefixed with group (e.g., "Artist", "XMP:Title", "EXIF:Copyright")
157    /// * `value` - New value, or None to delete the tag
158    ///
159    /// # Example
160    /// ```no_run
161    /// use exiftool_rs::ExifTool;
162    /// let mut et = ExifTool::new();
163    /// et.set_new_value("Artist", Some("John Doe"));
164    /// et.set_new_value("Copyright", Some("2024 John Doe"));
165    /// et.set_new_value("XMP:Title", Some("My Photo"));
166    /// et.write_info("photo.jpg", "photo_out.jpg").unwrap();
167    /// ```
168    pub fn set_new_value(&mut self, tag: &str, value: Option<&str>) {
169        let (group, tag_name) = if let Some(colon_pos) = tag.find(':') {
170            (
171                Some(tag[..colon_pos].to_string()),
172                tag[colon_pos + 1..].to_string(),
173            )
174        } else {
175            (None, tag.to_string())
176        };
177
178        self.new_values.push(NewValue {
179            tag: tag_name,
180            group,
181            value: value.map(|v| v.to_string()),
182        });
183    }
184
185    /// Clear all queued new values.
186    pub fn clear_new_values(&mut self) {
187        self.new_values.clear();
188    }
189
190    /// Copy tags from a source file, queuing them as new values.
191    ///
192    /// Reads all tags from `src_path` and queues them for writing.
193    /// Optionally filter by tag names.
194    pub fn set_new_values_from_file<P: AsRef<Path>>(
195        &mut self,
196        src_path: P,
197        tags_to_copy: Option<&[&str]>,
198    ) -> Result<u32> {
199        let src_tags = self.extract_info(src_path)?;
200        let mut count = 0u32;
201
202        for tag in &src_tags {
203            // Skip file-level tags that shouldn't be copied
204            if tag.group.family0 == "File" || tag.group.family0 == "Composite" {
205                continue;
206            }
207            // Skip binary/undefined data and empty values
208            if tag.print_value.starts_with("(Binary") || tag.print_value.starts_with("(Undefined") {
209                continue;
210            }
211            if tag.print_value.is_empty() {
212                continue;
213            }
214
215            // Filter by requested tags
216            if let Some(filter) = tags_to_copy {
217                let name_lower = tag.name.to_lowercase();
218                if !filter.iter().any(|f| f.to_lowercase() == name_lower) {
219                    continue;
220                }
221            }
222
223            let _full_tag = format!("{}:{}", tag.group.family0, tag.name);
224            self.new_values.push(NewValue {
225                tag: tag.name.clone(),
226                group: Some(tag.group.family0.clone()),
227                value: Some(tag.print_value.clone()),
228            });
229            count += 1;
230        }
231
232        Ok(count)
233    }
234
235    /// Set a file's name based on a tag value.
236    pub fn set_file_name_from_tag<P: AsRef<Path>>(
237        &self,
238        path: P,
239        tag_name: &str,
240        template: &str,
241    ) -> Result<String> {
242        let path = path.as_ref();
243        let tags = self.extract_info(path)?;
244
245        let tag_value = tags
246            .iter()
247            .find(|t| t.name.to_lowercase() == tag_name.to_lowercase())
248            .map(|t| &t.print_value)
249            .ok_or_else(|| Error::TagNotFound(tag_name.to_string()))?;
250
251        // Build new filename from template
252        // Template: "prefix%value%suffix.ext" or just use the tag value
253        let new_name = if template.contains('%') {
254            template.replace("%v", value_to_filename(tag_value).as_str())
255        } else {
256            // Default: use tag value as filename, keep extension
257            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
258            let clean = value_to_filename(tag_value);
259            if ext.is_empty() {
260                clean
261            } else {
262                format!("{}.{}", clean, ext)
263            }
264        };
265
266        let parent = path.parent().unwrap_or(Path::new(""));
267        let new_path = parent.join(&new_name);
268
269        fs::rename(path, &new_path).map_err(Error::Io)?;
270        Ok(new_path.to_string_lossy().to_string())
271    }
272
273    /// Write queued changes to a file.
274    ///
275    /// If `dst_path` is the same as `src_path`, the file is modified in-place
276    /// (via a temporary file).
277    pub fn write_info<P: AsRef<Path>, Q: AsRef<Path>>(
278        &self,
279        src_path: P,
280        dst_path: Q,
281    ) -> Result<u32> {
282        let src_path = src_path.as_ref();
283        let dst_path = dst_path.as_ref();
284        let data = fs::read(src_path).map_err(Error::Io)?;
285
286        let file_type = self.detect_file_type(&data, src_path)?;
287        let output = self.apply_changes(&data, file_type)?;
288
289        // Write to temp file first, then rename (atomic)
290        let temp_path = dst_path.with_extension("exiftool_tmp");
291        fs::write(&temp_path, &output).map_err(Error::Io)?;
292        fs::rename(&temp_path, dst_path).map_err(Error::Io)?;
293
294        Ok(self.new_values.len() as u32)
295    }
296
297    /// Apply queued changes to in-memory data.
298    fn apply_changes(&self, data: &[u8], file_type: FileType) -> Result<Vec<u8>> {
299        match file_type {
300            FileType::Jpeg => self.write_jpeg(data),
301            FileType::Png => self.write_png(data),
302            FileType::Tiff
303            | FileType::Dng
304            | FileType::Cr2
305            | FileType::Nef
306            | FileType::Arw
307            | FileType::Orf
308            | FileType::Pef => self.write_tiff(data),
309            FileType::WebP => self.write_webp(data),
310            FileType::Mp4
311            | FileType::QuickTime
312            | FileType::M4a
313            | FileType::ThreeGP
314            | FileType::F4v => self.write_mp4(data),
315            FileType::Psd => self.write_psd(data),
316            FileType::Pdf => self.write_pdf(data),
317            FileType::Heif | FileType::Avif => self.write_mp4(data),
318            FileType::Mkv | FileType::WebM => self.write_matroska(data),
319            FileType::Gif => {
320                let comment = self
321                    .new_values
322                    .iter()
323                    .find(|nv| nv.tag.to_lowercase() == "comment")
324                    .and_then(|nv| nv.value.clone());
325                crate::writer::gif_writer::write_gif(data, comment.as_deref())
326            }
327            FileType::Flac => {
328                let changes: Vec<(&str, &str)> = self
329                    .new_values
330                    .iter()
331                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
332                    .collect();
333                crate::writer::flac_writer::write_flac(data, &changes)
334            }
335            FileType::Mp3 | FileType::Aiff => {
336                let changes: Vec<(&str, &str)> = self
337                    .new_values
338                    .iter()
339                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
340                    .collect();
341                crate::writer::id3_writer::write_id3(data, &changes)
342            }
343            FileType::Jp2 | FileType::Jxl => {
344                let new_xmp = if self
345                    .new_values
346                    .iter()
347                    .any(|nv| nv.group.as_deref() == Some("XMP"))
348                {
349                    let refs: Vec<&NewValue> = self
350                        .new_values
351                        .iter()
352                        .filter(|nv| nv.group.as_deref() == Some("XMP"))
353                        .collect();
354                    Some(self.build_new_xmp(&refs))
355                } else {
356                    None
357                };
358                crate::writer::jp2_writer::write_jp2(data, new_xmp.as_deref(), None)
359            }
360            FileType::PostScript => {
361                let changes: Vec<(&str, &str)> = self
362                    .new_values
363                    .iter()
364                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
365                    .collect();
366                crate::writer::ps_writer::write_postscript(data, &changes)
367            }
368            FileType::Ogg | FileType::Opus => {
369                let changes: Vec<(&str, &str)> = self
370                    .new_values
371                    .iter()
372                    .filter_map(|nv| Some((nv.tag.as_str(), nv.value.as_deref()?)))
373                    .collect();
374                crate::writer::ogg_writer::write_ogg(data, &changes)
375            }
376            FileType::Xmp => {
377                let props: Vec<xmp_writer::XmpProperty> = self
378                    .new_values
379                    .iter()
380                    .filter_map(|nv| {
381                        let val = nv.value.as_deref()?;
382                        Some(xmp_writer::XmpProperty {
383                            namespace: nv.group.clone().unwrap_or_else(|| "dc".into()),
384                            property: nv.tag.clone(),
385                            values: vec![val.to_string()],
386                            prop_type: xmp_writer::XmpPropertyType::Simple,
387                        })
388                    })
389                    .collect();
390                Ok(crate::writer::xmp_sidecar_writer::write_xmp_sidecar(&props))
391            }
392            _ => Err(Error::UnsupportedFileType(format!(
393                "writing not yet supported for {}",
394                file_type
395            ))),
396        }
397    }
398
399    /// Returns the set of tag names (lowercase) that are writable for a given file type.
400    /// Returns `None` if any tag is writable (open-ended formats like PNG, FLAC, MKV).
401    /// Returns `Some(empty set)` if the format has no writer.
402    pub fn writable_tags(file_type: FileType) -> Option<std::collections::HashSet<&'static str>> {
403        use std::collections::HashSet;
404
405        // EXIF tags supported by exif_writer
406        const EXIF_TAGS: &[&str] = &[
407            "imagedescription",
408            "make",
409            "model",
410            "orientation",
411            "xresolution",
412            "yresolution",
413            "resolutionunit",
414            "software",
415            "modifydate",
416            "datetime",
417            "artist",
418            "copyright",
419            "datetimeoriginal",
420            "createdate",
421            "datetimedigitized",
422            "usercomment",
423            "imageuniqueid",
424            "ownername",
425            "cameraownername",
426            "serialnumber",
427            "bodyserialnumber",
428            "lensmake",
429            "lensmodel",
430            "lensserialnumber",
431        ];
432
433        // IPTC tags supported by iptc_writer
434        const IPTC_TAGS: &[&str] = &[
435            "objectname",
436            "title",
437            "urgency",
438            "category",
439            "supplementalcategories",
440            "keywords",
441            "specialinstructions",
442            "datecreated",
443            "timecreated",
444            "by-line",
445            "author",
446            "byline",
447            "by-linetitle",
448            "authorsposition",
449            "bylinetitle",
450            "city",
451            "sub-location",
452            "sublocation",
453            "province-state",
454            "state",
455            "provincestate",
456            "country-primarylocationcode",
457            "countrycode",
458            "country-primarylocationname",
459            "country",
460            "headline",
461            "credit",
462            "source",
463            "copyrightnotice",
464            "contact",
465            "caption-abstract",
466            "caption",
467            "description",
468            "writer-editor",
469            "captionwriter",
470        ];
471
472        // XMP auto-detected tags (no group prefix needed)
473        const XMP_AUTO_TAGS: &[&str] = &[
474            "title",
475            "description",
476            "subject",
477            "creator",
478            "rights",
479            "keywords",
480            "rating",
481            "label",
482            "hierarchicalsubject",
483        ];
484
485        // ID3 tags
486        const ID3_TAGS: &[&str] = &[
487            "title",
488            "artist",
489            "album",
490            "year",
491            "date",
492            "track",
493            "genre",
494            "comment",
495            "composer",
496            "albumartist",
497            "encoder",
498            "encodedby",
499            "publisher",
500            "copyright",
501            "bpm",
502            "lyrics",
503        ];
504
505        // MP4/MOV ilst tags
506        const MP4_TAGS: &[&str] = &[
507            "title",
508            "artist",
509            "album",
510            "year",
511            "date",
512            "comment",
513            "genre",
514            "composer",
515            "writer",
516            "encoder",
517            "encodedby",
518            "grouping",
519            "lyrics",
520            "description",
521            "albumartist",
522            "copyright",
523        ];
524
525        // PDF Info dict tags
526        const PDF_TAGS: &[&str] = &[
527            "title", "author", "subject", "keywords", "creator", "producer",
528        ];
529
530        // PostScript DSC tags
531        const PS_TAGS: &[&str] = &[
532            "title",
533            "creator",
534            "author",
535            "for",
536            "creationdate",
537            "createdate",
538        ];
539
540        match file_type {
541            // Open-ended: any tag name accepted
542            FileType::Png
543            | FileType::Flac
544            | FileType::Mkv
545            | FileType::WebM
546            | FileType::Ogg
547            | FileType::Opus
548            | FileType::Xmp => None,
549
550            // JPEG: EXIF + IPTC + XMP auto + comment
551            FileType::Jpeg => {
552                let mut set: HashSet<&str> = HashSet::new();
553                set.extend(EXIF_TAGS);
554                set.extend(IPTC_TAGS);
555                set.extend(XMP_AUTO_TAGS);
556                set.insert("comment");
557                Some(set)
558            }
559
560            // TIFF-based: EXIF only
561            FileType::Tiff
562            | FileType::Dng
563            | FileType::Cr2
564            | FileType::Nef
565            | FileType::Arw
566            | FileType::Orf
567            | FileType::Pef => {
568                let mut set: HashSet<&str> = HashSet::new();
569                set.extend(EXIF_TAGS);
570                Some(set)
571            }
572
573            // WebP: EXIF + XMP auto
574            FileType::WebP => {
575                let mut set: HashSet<&str> = HashSet::new();
576                set.extend(EXIF_TAGS);
577                set.extend(XMP_AUTO_TAGS);
578                Some(set)
579            }
580
581            // MP4/MOV/HEIF: ilst + XMP auto
582            FileType::Mp4
583            | FileType::QuickTime
584            | FileType::M4a
585            | FileType::ThreeGP
586            | FileType::F4v
587            | FileType::Heif
588            | FileType::Avif => {
589                let mut set: HashSet<&str> = HashSet::new();
590                set.extend(MP4_TAGS);
591                set.extend(XMP_AUTO_TAGS);
592                Some(set)
593            }
594
595            // PSD: IPTC + XMP auto
596            FileType::Psd => {
597                let mut set: HashSet<&str> = HashSet::new();
598                set.extend(IPTC_TAGS);
599                set.extend(XMP_AUTO_TAGS);
600                Some(set)
601            }
602
603            FileType::Pdf => Some(PDF_TAGS.iter().copied().collect()),
604            FileType::PostScript => Some(PS_TAGS.iter().copied().collect()),
605
606            FileType::Mp3 | FileType::Aiff => Some(ID3_TAGS.iter().copied().collect()),
607
608            FileType::Gif => {
609                let mut set: HashSet<&str> = HashSet::new();
610                set.insert("comment");
611                Some(set)
612            }
613
614            // JP2/JXL: XMP only (with group prefix)
615            FileType::Jp2 | FileType::Jxl => Some(XMP_AUTO_TAGS.iter().copied().collect()),
616
617            // No writer
618            _ => Some(HashSet::new()),
619        }
620    }
621
622    /// Write metadata changes to JPEG data.
623    fn write_jpeg(&self, data: &[u8]) -> Result<Vec<u8>> {
624        // Classify new values by target group
625        let mut exif_values: Vec<&NewValue> = Vec::new();
626        let mut xmp_values: Vec<&NewValue> = Vec::new();
627        let mut iptc_values: Vec<&NewValue> = Vec::new();
628        let mut comment_value: Option<&str> = None;
629        let mut remove_exif = false;
630        let mut remove_xmp = false;
631        let mut remove_iptc = false;
632        let mut remove_comment = false;
633
634        for nv in &self.new_values {
635            let group = nv.group.as_deref().unwrap_or("");
636            let group_upper = group.to_uppercase();
637
638            // Check for group deletion
639            if nv.value.is_none() && nv.tag == "*" {
640                match group_upper.as_str() {
641                    "EXIF" => {
642                        remove_exif = true;
643                        continue;
644                    }
645                    "XMP" => {
646                        remove_xmp = true;
647                        continue;
648                    }
649                    "IPTC" => {
650                        remove_iptc = true;
651                        continue;
652                    }
653                    _ => {}
654                }
655            }
656
657            match group_upper.as_str() {
658                "XMP" => xmp_values.push(nv),
659                "IPTC" => iptc_values.push(nv),
660                "EXIF" | "IFD0" | "EXIFIFD" | "GPS" => exif_values.push(nv),
661                "" => {
662                    // Auto-detect best group based on tag name
663                    if nv.tag.to_lowercase() == "comment" {
664                        if nv.value.is_none() {
665                            remove_comment = true;
666                        } else {
667                            comment_value = nv.value.as_deref();
668                        }
669                    } else if is_xmp_tag(&nv.tag) {
670                        xmp_values.push(nv);
671                    } else {
672                        exif_values.push(nv);
673                    }
674                }
675                _ => exif_values.push(nv), // default to EXIF
676            }
677        }
678
679        // Build new EXIF data
680        let new_exif = if !exif_values.is_empty() {
681            Some(self.build_new_exif(data, &exif_values)?)
682        } else {
683            None
684        };
685
686        // Build new XMP data
687        let new_xmp = if !xmp_values.is_empty() {
688            Some(self.build_new_xmp(&xmp_values))
689        } else {
690            None
691        };
692
693        // Build new IPTC data
694        let new_iptc_data = if !iptc_values.is_empty() {
695            let records: Vec<iptc_writer::IptcRecord> = iptc_values
696                .iter()
697                .filter_map(|nv| {
698                    let value = nv.value.as_deref()?;
699                    let (record, dataset) = iptc_writer::tag_name_to_iptc(&nv.tag)?;
700                    Some(iptc_writer::IptcRecord {
701                        record,
702                        dataset,
703                        data: value.as_bytes().to_vec(),
704                    })
705                })
706                .collect();
707            if records.is_empty() {
708                None
709            } else {
710                Some(iptc_writer::build_iptc(&records))
711            }
712        } else {
713            None
714        };
715
716        // Rewrite JPEG
717        jpeg_writer::write_jpeg(
718            data,
719            new_exif.as_deref(),
720            new_xmp.as_deref(),
721            new_iptc_data.as_deref(),
722            comment_value,
723            remove_exif,
724            remove_xmp,
725            remove_iptc,
726            remove_comment,
727        )
728    }
729
730    /// Build new EXIF data by merging existing EXIF with queued changes.
731    fn build_new_exif(&self, jpeg_data: &[u8], values: &[&NewValue]) -> Result<Vec<u8>> {
732        let bo = ByteOrderMark::BigEndian;
733        let mut ifd0_entries = Vec::new();
734        let mut exif_entries = Vec::new();
735        let mut gps_entries = Vec::new();
736
737        // Step 1: Extract existing EXIF entries from the JPEG
738        let existing = extract_existing_exif_entries(jpeg_data, bo);
739        for entry in &existing {
740            match classify_exif_tag(entry.tag) {
741                ExifIfdGroup::Ifd0 => ifd0_entries.push(entry.clone()),
742                ExifIfdGroup::ExifIfd => exif_entries.push(entry.clone()),
743                ExifIfdGroup::Gps => gps_entries.push(entry.clone()),
744            }
745        }
746
747        // Step 2: Apply queued changes (add/replace/delete)
748        let deleted_tags: Vec<u16> = values
749            .iter()
750            .filter(|nv| nv.value.is_none())
751            .filter_map(|nv| tag_name_to_id(&nv.tag))
752            .collect();
753
754        // Remove deleted tags
755        ifd0_entries.retain(|e| !deleted_tags.contains(&e.tag));
756        exif_entries.retain(|e| !deleted_tags.contains(&e.tag));
757        gps_entries.retain(|e| !deleted_tags.contains(&e.tag));
758
759        // Add/replace new values
760        for nv in values {
761            if nv.value.is_none() {
762                continue;
763            }
764            let value_str = nv.value.as_deref().unwrap_or("");
765            let group = nv.group.as_deref().unwrap_or("");
766
767            if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, value_str, group, bo)
768            {
769                let entry = exif_writer::IfdEntry {
770                    tag: tag_id,
771                    format,
772                    data: encoded,
773                };
774
775                let target = match group.to_uppercase().as_str() {
776                    "GPS" => &mut gps_entries,
777                    "EXIFIFD" => &mut exif_entries,
778                    _ => match classify_exif_tag(tag_id) {
779                        ExifIfdGroup::ExifIfd => &mut exif_entries,
780                        ExifIfdGroup::Gps => &mut gps_entries,
781                        ExifIfdGroup::Ifd0 => &mut ifd0_entries,
782                    },
783                };
784
785                // Replace existing or add new
786                if let Some(existing) = target.iter_mut().find(|e| e.tag == tag_id) {
787                    *existing = entry;
788                } else {
789                    target.push(entry);
790                }
791            }
792        }
793
794        // Remove sub-IFD pointers from entries (they'll be rebuilt by build_exif)
795        ifd0_entries.retain(|e| e.tag != 0x8769 && e.tag != 0x8825 && e.tag != 0xA005);
796
797        exif_writer::build_exif(&ifd0_entries, &exif_entries, &gps_entries, bo)
798    }
799
800    /// Write metadata changes to PNG data.
801    fn write_png(&self, data: &[u8]) -> Result<Vec<u8>> {
802        let mut new_text: Vec<(&str, &str)> = Vec::new();
803        let mut remove_text: Vec<&str> = Vec::new();
804
805        // Collect text-based changes
806        // We need to hold the strings in vectors that live long enough
807        let owned_pairs: Vec<(String, String)> = self
808            .new_values
809            .iter()
810            .filter(|nv| nv.value.is_some())
811            .map(|nv| (nv.tag.clone(), nv.value.clone().unwrap()))
812            .collect();
813
814        for (tag, value) in &owned_pairs {
815            new_text.push((tag.as_str(), value.as_str()));
816        }
817
818        for nv in &self.new_values {
819            if nv.value.is_none() {
820                remove_text.push(&nv.tag);
821            }
822        }
823
824        png_writer::write_png(data, &new_text, None, &remove_text)
825    }
826
827    /// Write metadata changes to PSD data.
828    fn write_psd(&self, data: &[u8]) -> Result<Vec<u8>> {
829        let mut iptc_values = Vec::new();
830        let mut xmp_values = Vec::new();
831
832        for nv in &self.new_values {
833            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
834            match group.as_str() {
835                "XMP" => xmp_values.push(nv),
836                "IPTC" => iptc_values.push(nv),
837                _ => {
838                    if is_xmp_tag(&nv.tag) {
839                        xmp_values.push(nv);
840                    } else {
841                        iptc_values.push(nv);
842                    }
843                }
844            }
845        }
846
847        let new_iptc = if !iptc_values.is_empty() {
848            let records: Vec<_> = iptc_values
849                .iter()
850                .filter_map(|nv| {
851                    let value = nv.value.as_deref()?;
852                    let (record, dataset) = iptc_writer::tag_name_to_iptc(&nv.tag)?;
853                    Some(iptc_writer::IptcRecord {
854                        record,
855                        dataset,
856                        data: value.as_bytes().to_vec(),
857                    })
858                })
859                .collect();
860            if records.is_empty() {
861                None
862            } else {
863                Some(iptc_writer::build_iptc(&records))
864            }
865        } else {
866            None
867        };
868
869        let new_xmp = if !xmp_values.is_empty() {
870            let refs: Vec<&NewValue> = xmp_values.to_vec();
871            Some(self.build_new_xmp(&refs))
872        } else {
873            None
874        };
875
876        psd_writer::write_psd(data, new_iptc.as_deref(), new_xmp.as_deref())
877    }
878
879    /// Write metadata changes to Matroska (MKV/WebM) data.
880    fn write_matroska(&self, data: &[u8]) -> Result<Vec<u8>> {
881        let changes: Vec<(&str, &str)> = self
882            .new_values
883            .iter()
884            .filter_map(|nv| {
885                let value = nv.value.as_deref()?;
886                Some((nv.tag.as_str(), value))
887            })
888            .collect();
889
890        matroska_writer::write_matroska(data, &changes)
891    }
892
893    /// Write metadata changes to PDF data.
894    fn write_pdf(&self, data: &[u8]) -> Result<Vec<u8>> {
895        let changes: Vec<(&str, &str)> = self
896            .new_values
897            .iter()
898            .filter_map(|nv| {
899                let value = nv.value.as_deref()?;
900                Some((nv.tag.as_str(), value))
901            })
902            .collect();
903
904        pdf_writer::write_pdf(data, &changes)
905    }
906
907    /// Write metadata changes to MP4/MOV data.
908    fn write_mp4(&self, data: &[u8]) -> Result<Vec<u8>> {
909        let mut ilst_tags: Vec<([u8; 4], String)> = Vec::new();
910        let mut xmp_values: Vec<&NewValue> = Vec::new();
911
912        for nv in &self.new_values {
913            if nv.value.is_none() {
914                continue;
915            }
916            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
917            if group == "XMP" {
918                xmp_values.push(nv);
919            } else if let Some(key) = mp4_writer::tag_to_ilst_key(&nv.tag) {
920                ilst_tags.push((key, nv.value.clone().unwrap()));
921            }
922        }
923
924        let tag_refs: Vec<(&[u8; 4], &str)> =
925            ilst_tags.iter().map(|(k, v)| (k, v.as_str())).collect();
926
927        let new_xmp = if !xmp_values.is_empty() {
928            let refs: Vec<&NewValue> = xmp_values.to_vec();
929            Some(self.build_new_xmp(&refs))
930        } else {
931            None
932        };
933
934        mp4_writer::write_mp4(data, &tag_refs, new_xmp.as_deref())
935    }
936
937    /// Write metadata changes to WebP data.
938    fn write_webp(&self, data: &[u8]) -> Result<Vec<u8>> {
939        let mut exif_values: Vec<&NewValue> = Vec::new();
940        let mut xmp_values: Vec<&NewValue> = Vec::new();
941        let mut remove_exif = false;
942        let mut remove_xmp = false;
943
944        for nv in &self.new_values {
945            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
946            if nv.value.is_none() && nv.tag == "*" {
947                if group == "EXIF" {
948                    remove_exif = true;
949                }
950                if group == "XMP" {
951                    remove_xmp = true;
952                }
953                continue;
954            }
955            match group.as_str() {
956                "XMP" => xmp_values.push(nv),
957                _ => exif_values.push(nv),
958            }
959        }
960
961        let new_exif = if !exif_values.is_empty() {
962            let bo = ByteOrderMark::BigEndian;
963            let mut entries = Vec::new();
964            for nv in &exif_values {
965                if let Some(ref v) = nv.value {
966                    let group = nv.group.as_deref().unwrap_or("");
967                    if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, v, group, bo)
968                    {
969                        entries.push(exif_writer::IfdEntry {
970                            tag: tag_id,
971                            format,
972                            data: encoded,
973                        });
974                    }
975                }
976            }
977            if !entries.is_empty() {
978                Some(exif_writer::build_exif(&entries, &[], &[], bo)?)
979            } else {
980                None
981            }
982        } else {
983            None
984        };
985
986        let new_xmp = if !xmp_values.is_empty() {
987            Some(self.build_new_xmp(&xmp_values.to_vec()))
988        } else {
989            None
990        };
991
992        webp_writer::write_webp(
993            data,
994            new_exif.as_deref(),
995            new_xmp.as_deref(),
996            remove_exif,
997            remove_xmp,
998        )
999    }
1000
1001    /// Write metadata changes to TIFF data.
1002    fn write_tiff(&self, data: &[u8]) -> Result<Vec<u8>> {
1003        let bo = if data.starts_with(b"II") {
1004            ByteOrderMark::LittleEndian
1005        } else {
1006            ByteOrderMark::BigEndian
1007        };
1008
1009        let mut changes: Vec<(u16, Vec<u8>)> = Vec::new();
1010        for nv in &self.new_values {
1011            if let Some(ref value) = nv.value {
1012                let group = nv.group.as_deref().unwrap_or("");
1013                if let Some((tag_id, _format, encoded)) = encode_exif_tag(&nv.tag, value, group, bo)
1014                {
1015                    changes.push((tag_id, encoded));
1016                }
1017            }
1018        }
1019
1020        tiff_writer::write_tiff(data, &changes)
1021    }
1022
1023    /// Build new XMP data from queued values.
1024    fn build_new_xmp(&self, values: &[&NewValue]) -> Vec<u8> {
1025        let mut properties = Vec::new();
1026
1027        for nv in values {
1028            let value_str = match &nv.value {
1029                Some(v) => v.clone(),
1030                None => continue,
1031            };
1032
1033            let ns = nv.group.as_deref().unwrap_or("dc").to_lowercase();
1034            let ns = if ns == "xmp" { "xmp".to_string() } else { ns };
1035
1036            let prop_type = match nv.tag.to_lowercase().as_str() {
1037                "title" | "description" | "rights" => xmp_writer::XmpPropertyType::LangAlt,
1038                "subject" | "keywords" => xmp_writer::XmpPropertyType::Bag,
1039                "creator" => xmp_writer::XmpPropertyType::Seq,
1040                _ => xmp_writer::XmpPropertyType::Simple,
1041            };
1042
1043            let values = if matches!(
1044                prop_type,
1045                xmp_writer::XmpPropertyType::Bag | xmp_writer::XmpPropertyType::Seq
1046            ) {
1047                value_str.split(',').map(|s| s.trim().to_string()).collect()
1048            } else {
1049                vec![value_str]
1050            };
1051
1052            properties.push(xmp_writer::XmpProperty {
1053                namespace: ns,
1054                property: nv.tag.clone(),
1055                values,
1056                prop_type,
1057            });
1058        }
1059
1060        xmp_writer::build_xmp(&properties).into_bytes()
1061    }
1062
1063    // ================================================================
1064    // Reading API
1065    // ================================================================
1066
1067    /// Extract metadata from a file and return a simple name→value map.
1068    ///
1069    /// This is the high-level one-shot API, equivalent to ExifTool's `ImageInfo()`.
1070    pub fn image_info<P: AsRef<Path>>(&self, path: P) -> Result<ImageInfo> {
1071        let tags = self.extract_info(path)?;
1072        Ok(self.get_info(&tags))
1073    }
1074
1075    /// Extract all metadata tags from a file.
1076    ///
1077    /// Returns the full `Tag` structs with groups, raw values, etc.
1078    pub fn extract_info<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Tag>> {
1079        let path = path.as_ref();
1080        // Memory-map the file instead of reading it fully into a Vec. Our format
1081        // readers walk container structures by offset (mp4/mov skip `mdat`, Matroska
1082        // stops at the first Cluster), so only the header pages are ever faulted in —
1083        // a multi-gigabyte video is parsed by touching a few MB, not by allocating
1084        // and reading the whole file. Falls back to a plain read when mapping fails.
1085        let data = map_file_for_read(path)?;
1086        self.extract_info_from_bytes(&data, path)
1087    }
1088
1089    /// Extract metadata from in-memory data.
1090    pub fn extract_info_from_bytes(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
1091        // Propagate show_unknown to EXIF/MakerNotes parsers via thread-local
1092        crate::metadata::exif::set_show_unknown(self.options.show_unknown);
1093        // Propagate process_compressed to format readers via thread-local
1094        crate::formats::pdf::set_process_compressed(self.options.process_compressed);
1095
1096        let file_type_result = self.detect_file_type(data, path);
1097        let (file_type, mut tags) = match file_type_result {
1098            Ok(ft) => {
1099                let t = self
1100                    .process_file(data, ft)
1101                    .or_else(|_| self.process_by_extension(data, path))?;
1102                (Some(ft), t)
1103            }
1104            Err(_) => {
1105                // File type unknown by magic/extension — try extension-based fallback
1106                let t = self.process_by_extension(data, path)?;
1107                (None, t)
1108            }
1109        };
1110        let file_type = file_type.unwrap_or(FileType::Zip); // placeholder for file-level tags
1111
1112        // Some types refine their FileType/MIMEType/extension from the content
1113        // (ExifTool SetFileType): e.g. EXE -> "Win32 EXE" / "ELF executable" / Mach-O.
1114        let default_tags = || {
1115            (
1116                file_type.code().to_string(),
1117                file_type.mime_type().to_string(),
1118                file_type
1119                    .extensions()
1120                    .first()
1121                    .copied()
1122                    .unwrap_or("")
1123                    .to_string(),
1124            )
1125        };
1126        let (ft_code, mime_str, ext_str): (String, String, String) = if file_type == FileType::Exe {
1127            exe_subtype(data)
1128                .map(|(ft, mime, ext)| (ft.to_string(), mime.to_string(), ext.to_string()))
1129                .unwrap_or_else(default_tags)
1130        } else if let Some((code, mime)) = refine_filetype_by_content(file_type, data) {
1131            let (_, _, ext) = default_tags();
1132            (code, mime, ext)
1133        } else {
1134            default_tags()
1135        };
1136
1137        // Add file-level tags
1138        tags.push(Tag {
1139            id: crate::tag::TagId::Text("FileType".into()),
1140            name: "FileType".into(),
1141            description: "File Type".into(),
1142            group: crate::tag::TagGroup {
1143                family0: "File".into(),
1144                family1: "File".into(),
1145                family2: "Other".into(),
1146            },
1147            raw_value: Value::String(format!("{:?}", file_type)),
1148            // ExifTool's FileType value is the short code ("JPEG"), not the
1149            // human-readable description ("JPEG image").
1150            print_value: ft_code.clone(),
1151            priority: 1,
1152        });
1153
1154        tags.push(Tag {
1155            id: crate::tag::TagId::Text("MIMEType".into()),
1156            name: "MIMEType".into(),
1157            description: "MIME Type".into(),
1158            group: crate::tag::TagGroup {
1159                family0: "File".into(),
1160                family1: "File".into(),
1161                family2: "Other".into(),
1162            },
1163            raw_value: Value::String(mime_str.clone()),
1164            print_value: mime_str.clone(),
1165            priority: 1,
1166        });
1167
1168        if let Ok(metadata) = fs::metadata(path) {
1169            tags.push(Tag {
1170                id: crate::tag::TagId::Text("FileSize".into()),
1171                name: "FileSize".into(),
1172                description: "File Size".into(),
1173                group: crate::tag::TagGroup {
1174                    family0: "File".into(),
1175                    family1: "File".into(),
1176                    family2: "Other".into(),
1177                },
1178                // String, not U32: a file may exceed 4 GB (`as u32` would silently
1179                // truncate). `-n` prints this verbatim, matching Perl's raw byte count.
1180                raw_value: Value::String(metadata.len().to_string()),
1181                print_value: format_file_size(metadata.len()),
1182                priority: 0,
1183            });
1184        }
1185
1186        // Add more file-level tags
1187        let file_tag = |name: &str, val: Value| -> Tag {
1188            Tag {
1189                id: crate::tag::TagId::Text(name.to_string()),
1190                name: name.to_string(),
1191                description: name.to_string(),
1192                group: crate::tag::TagGroup {
1193                    family0: "File".into(),
1194                    family1: "File".into(),
1195                    family2: "Other".into(),
1196                },
1197                raw_value: val.clone(),
1198                print_value: val.to_display_string(),
1199                priority: 1,
1200            }
1201        };
1202
1203        if let Some(fname) = path.file_name().and_then(|n| n.to_str()) {
1204            tags.push(file_tag("FileName", Value::String(fname.to_string())));
1205        }
1206        if let Some(dir) = path.parent().and_then(|p| p.to_str()) {
1207            tags.push(file_tag("Directory", Value::String(dir.to_string())));
1208        }
1209        // Use the canonical (first) extension from the FileType, matching Perl ExifTool behavior.
1210        // EXE subtypes emit FileTypeExtension even when empty (ExifTool sets ext='').
1211        if !ext_str.is_empty() || file_type == FileType::Exe {
1212            tags.push(file_tag(
1213                "FileTypeExtension",
1214                Value::String(ext_str.clone()),
1215            ));
1216        }
1217
1218        #[cfg(unix)]
1219        if let Ok(metadata) = fs::metadata(path) {
1220            use std::os::unix::fs::MetadataExt;
1221            let mode = metadata.mode();
1222            // Port of ExifTool's FilePermissions: ValueConv is the full mode in octal
1223            // (`sprintf "%.3o"`, includes the file-type bits), PrintConv is the ls-style
1224            // "-rw-rw-r--" string built from those same bits.
1225            tags.push(Tag {
1226                id: crate::tag::TagId::Text("FilePermissions".into()),
1227                name: "FilePermissions".into(),
1228                description: "FilePermissions".into(),
1229                group: crate::tag::TagGroup {
1230                    family0: "File".into(),
1231                    family1: "File".into(),
1232                    family2: "Other".into(),
1233                },
1234                raw_value: Value::String(format!("{:o}", mode)),
1235                print_value: format_file_permissions(mode),
1236                priority: 1,
1237            });
1238
1239            // Dates use ConvertUnixTime($val, 1): local time with a numeric TZ offset
1240            // (e.g. "2026:06:13 15:14:15+02:00"), same conversion as GZIP's ModifyDate.
1241            use crate::formats::gzip::gzip_unix_to_datetime;
1242            // FileModifyDate
1243            if let Ok(modified) = metadata.modified() {
1244                if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
1245                    let secs = dur.as_secs() as i64;
1246                    tags.push(file_tag(
1247                        "FileModifyDate",
1248                        Value::String(gzip_unix_to_datetime(secs)),
1249                    ));
1250                }
1251            }
1252            // FileAccessDate
1253            if let Ok(accessed) = metadata.accessed() {
1254                if let Ok(dur) = accessed.duration_since(std::time::UNIX_EPOCH) {
1255                    let secs = dur.as_secs() as i64;
1256                    tags.push(file_tag(
1257                        "FileAccessDate",
1258                        Value::String(gzip_unix_to_datetime(secs)),
1259                    ));
1260                }
1261            }
1262            // FileInodeChangeDate (ctime on Unix)
1263            let ctime = metadata.ctime();
1264            if ctime > 0 {
1265                tags.push(file_tag(
1266                    "FileInodeChangeDate",
1267                    Value::String(gzip_unix_to_datetime(ctime)),
1268                ));
1269            }
1270        }
1271
1272        // ExifByteOrder (from TIFF header)
1273        {
1274            let bo_str = if data.len() > 8 {
1275                // Check EXIF in JPEG or TIFF header or WebP/RIFF EXIF chunk
1276                let check: Option<&[u8]> = if data.starts_with(&[0xFF, 0xD8]) {
1277                    // JPEG: find APP1 EXIF header
1278                    data.windows(6)
1279                        .position(|w| w == b"Exif\0\0")
1280                        .map(|p| &data[p + 6..])
1281                } else if data.starts_with(b"FUJIFILMCCD-RAW") && data.len() >= 0x60 {
1282                    // RAF: look in the embedded JPEG for EXIF byte order
1283                    let jpeg_offset =
1284                        u32::from_be_bytes([data[0x54], data[0x55], data[0x56], data[0x57]])
1285                            as usize;
1286                    let jpeg_length =
1287                        u32::from_be_bytes([data[0x58], data[0x59], data[0x5A], data[0x5B]])
1288                            as usize;
1289                    if jpeg_offset > 0 && jpeg_offset + jpeg_length <= data.len() {
1290                        let jpeg = &data[jpeg_offset..jpeg_offset + jpeg_length];
1291                        jpeg.windows(6)
1292                            .position(|w| w == b"Exif\0\0")
1293                            .map(|p| &jpeg[p + 6..])
1294                    } else {
1295                        None
1296                    }
1297                } else if data.starts_with(b"RIFF") && data.len() >= 12 {
1298                    // RIFF/WebP: find EXIF chunk
1299                    let mut riff_bo: Option<&[u8]> = None;
1300                    let mut pos = 12usize;
1301                    while pos + 8 <= data.len() {
1302                        let cid = &data[pos..pos + 4];
1303                        let csz = u32::from_le_bytes([
1304                            data[pos + 4],
1305                            data[pos + 5],
1306                            data[pos + 6],
1307                            data[pos + 7],
1308                        ]) as usize;
1309                        let cstart = pos + 8;
1310                        let cend = (cstart + csz).min(data.len());
1311                        if cid == b"EXIF" && cend > cstart {
1312                            let exif_data = &data[cstart..cend];
1313                            let tiff = if exif_data.starts_with(b"Exif\0\0") {
1314                                &exif_data[6..]
1315                            } else {
1316                                exif_data
1317                            };
1318                            riff_bo = Some(tiff);
1319                            break;
1320                        }
1321                        // Also check LIST chunks
1322                        if cid == b"LIST" && cend >= cstart + 4 {
1323                            // recurse not needed for this simple scan - just advance
1324                        }
1325                        pos = cend + (csz & 1);
1326                    }
1327                    riff_bo
1328                } else if data.starts_with(&[0x00, 0x00, 0x00, 0x0C, b'J', b'X', b'L', b' ']) {
1329                    // JXL container: scan for brob Exif box and decompress to get byte order
1330                    let mut jxl_bo: Option<String> = None;
1331                    let mut jpos = 12usize; // skip JXL signature box
1332                    while jpos + 8 <= data.len() {
1333                        let bsize = u32::from_be_bytes([
1334                            data[jpos],
1335                            data[jpos + 1],
1336                            data[jpos + 2],
1337                            data[jpos + 3],
1338                        ]) as usize;
1339                        let btype = &data[jpos + 4..jpos + 8];
1340                        if bsize < 8 || jpos + bsize > data.len() {
1341                            break;
1342                        }
1343                        if btype == b"brob" && jpos + bsize > 12 {
1344                            let inner_type = &data[jpos + 8..jpos + 12];
1345                            if inner_type == b"Exif" || inner_type == b"exif" {
1346                                let brotli_payload = &data[jpos + 12..jpos + bsize];
1347                                use std::io::Cursor;
1348                                let mut inp = Cursor::new(brotli_payload);
1349                                let mut out: Vec<u8> = Vec::new();
1350                                if brotli::BrotliDecompress(&mut inp, &mut out).is_ok() {
1351                                    let exif_start = if out.len() > 4 { 4 } else { 0 };
1352                                    if exif_start < out.len() {
1353                                        if out[exif_start..].starts_with(b"MM") {
1354                                            jxl_bo = Some("Big-endian (Motorola, MM)".to_string());
1355                                        } else if out[exif_start..].starts_with(b"II") {
1356                                            jxl_bo = Some("Little-endian (Intel, II)".to_string());
1357                                        }
1358                                    }
1359                                }
1360                                break;
1361                            }
1362                        }
1363                        jpos += bsize;
1364                    }
1365                    if let Some(bo) = jxl_bo {
1366                        if !bo.is_empty() && file_type != FileType::Btf {
1367                            tags.push(file_tag("ExifByteOrder", Value::String(bo)));
1368                        }
1369                    }
1370                    // Return None to skip the generic byte order check below
1371                    None
1372                } else if data.starts_with(&[0x00, b'M', b'R', b'M']) {
1373                    // MRW: find TTW segment which contains TIFF/EXIF data
1374                    let mrw_data_offset = if data.len() >= 8 {
1375                        u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize + 8
1376                    } else {
1377                        0
1378                    };
1379                    let mut mrw_bo: Option<&[u8]> = None;
1380                    let mut mpos = 8usize;
1381                    while mpos + 8 <= mrw_data_offset.min(data.len()) {
1382                        let seg_tag = &data[mpos..mpos + 4];
1383                        let seg_len = u32::from_be_bytes([
1384                            data[mpos + 4],
1385                            data[mpos + 5],
1386                            data[mpos + 6],
1387                            data[mpos + 7],
1388                        ]) as usize;
1389                        if seg_tag == b"\x00TTW" && mpos + 8 + seg_len <= data.len() {
1390                            mrw_bo = Some(&data[mpos + 8..mpos + 8 + seg_len]);
1391                            break;
1392                        }
1393                        mpos += 8 + seg_len;
1394                    }
1395                    mrw_bo
1396                } else {
1397                    Some(data)
1398                };
1399                if let Some(tiff) = check {
1400                    if tiff.starts_with(b"II") {
1401                        "Little-endian (Intel, II)"
1402                    } else if tiff.starts_with(b"MM") {
1403                        "Big-endian (Motorola, MM)"
1404                    } else {
1405                        ""
1406                    }
1407                } else {
1408                    ""
1409                }
1410            } else {
1411                ""
1412            };
1413            // Suppress ExifByteOrder for BigTIFF, Canon VRD/DR4 (Perl doesn't output it for these)
1414            // Also skip if already emitted by ExifReader (TIFF-based formats)
1415            let already_has_exifbyteorder = tags.iter().any(|t| t.name == "ExifByteOrder");
1416            if !bo_str.is_empty()
1417                && !already_has_exifbyteorder
1418                && file_type != FileType::Btf
1419                && file_type != FileType::Dr4
1420                && file_type != FileType::Vrd
1421                && file_type != FileType::Crw
1422            {
1423                tags.push(file_tag("ExifByteOrder", Value::String(bo_str.to_string())));
1424            }
1425        }
1426
1427        tags.push(file_tag(
1428            "ExifToolVersion",
1429            Value::String(crate::VERSION.to_string()),
1430        ));
1431
1432        // Promote authoritative specialized-source tags before computing composites,
1433        // so derived tags (ShutterSpeed, LightValue, ...) use the primary value.
1434        {
1435            const SPECIAL_WINS: &[(&str, &str)] = &[
1436                ("Kodak", "FNumber"),
1437                ("Kodak", "ExposureTime"),
1438                ("MinoltaRaw", "Contrast"),
1439                ("MinoltaRaw", "Saturation"),
1440                ("MinoltaRaw", "Sharpness"),
1441                ("MinoltaRaw", "ISOSetting"),
1442                // Lytro's own FocalLength (full precision) is primary over embedded EXIF;
1443                // the 35efl/FOV/HyperfocalDistance composites must use it.
1444                ("Lytro", "FocalLength"),
1445            ];
1446            for (grp, name) in SPECIAL_WINS {
1447                if tags
1448                    .iter()
1449                    .any(|t| t.name == *name && t.group.family1 == *grp)
1450                {
1451                    tags.retain(|t| t.name != *name || t.group.family1 == *grp);
1452                }
1453            }
1454        }
1455
1456        // Compute composite tags
1457        let composite = crate::composite::compute_composite_tags(&tags);
1458        tags.extend(composite);
1459
1460        // ExifTool's Composite RedBalance/BlueBalance (computed from WB level tags)
1461        // is preferred over a manufacturer's own same-named tag. Drop the non-Composite
1462        // versions when a Composite one exists so the Composite value wins.
1463        for bal in ["RedBalance", "BlueBalance"] {
1464            let has_composite = tags
1465                .iter()
1466                .any(|t| t.name == bal && t.group.family0 == "Composite");
1467            if has_composite {
1468                tags.retain(|t| t.name != bal || t.group.family0 == "Composite");
1469            }
1470        }
1471
1472        // Geolocation is opt-in, matching ExifTool's `Geolocation` API option.
1473        if self.options.geolocation {
1474            if let Some(geo) = crate::composite::compute_geolocation(&tags) {
1475                tags.extend(geo);
1476            }
1477        }
1478
1479        // MWG (Metadata Working Group) composite tags
1480        if self.options.use_mwg {
1481            let mwg = crate::composite::compute_mwg_composites(&tags);
1482            tags.extend(mwg);
1483        }
1484
1485        // FLIR post-processing: remove LensID composite for FLIR cameras.
1486        // Perl's LensID composite requires LensType EXIF tag (not present in FLIR images),
1487        // and LensID-2 requires LensModel to match /(mm|\d\/F)/ (FLIR names like "FOL7"
1488        // don't match).  Our composite.rs uses a simpler fallback that picks up any non-empty
1489        // LensModel, so we remove LensID when the image is from a FLIR camera with FFF data.
1490        {
1491            let is_flir_fff = tags
1492                .iter()
1493                .any(|t| t.group.family0 == "APP1" && t.group.family1 == "FLIR");
1494            if is_flir_fff {
1495                tags.retain(|t| !(t.name == "LensID" && t.group.family0 == "Composite"));
1496            }
1497        }
1498
1499        // Olympus post-processing: remove the generic "Lens" composite for Olympus cameras.
1500        // In Perl, the "Lens" composite tag requires Canon:MinFocalLength (Canon namespace).
1501        // Our composite.rs generates Lens for any manufacturer that has MinFocalLength +
1502        // MaxFocalLength (e.g., Olympus Equipment sub-IFD).  Remove it for non-Canon cameras.
1503        {
1504            let make = tags
1505                .iter()
1506                .find(|t| t.name == "Make")
1507                .map(|t| t.print_value.clone())
1508                .unwrap_or_default();
1509            if !make.to_uppercase().contains("CANON") {
1510                tags.retain(|t| t.name != "Lens" || t.group.family0 != "Composite");
1511            }
1512        }
1513
1514        // Priority-based deduplication: when the same tag name appears from both RIFF (priority 0)
1515        // and MakerNotes/EXIF (priority 0 but higher-quality source), remove the RIFF copy.
1516        // Mirrors ExifTool's PRIORITY => 0 behavior for RIFF StreamHeader tags.
1517        {
1518            let riff_priority_zero_tags = ["Quality", "SampleSize", "StreamType"];
1519            for tag_name in &riff_priority_zero_tags {
1520                let has_makernotes = tags
1521                    .iter()
1522                    .any(|t| t.name == *tag_name && t.group.family0 != "RIFF");
1523                if has_makernotes {
1524                    tags.retain(|t| !(t.name == *tag_name && t.group.family0 == "RIFF"));
1525                }
1526            }
1527        }
1528
1529        // Priority-based deduplication: when the same tag name appears multiple times,
1530        // keep only the one with the highest priority (e.g., EXIF over JFIF, FFF over MakerNote).
1531        if !self.options.duplicates {
1532            // Specialized-source precedence: a few container/sidecar groups are
1533            // authoritative for specific tags and win over a generic EXIF copy
1534            // (ExifTool reports the GoPro GPMF value). Applied before the priority
1535            // dedup so the (priority-0) specialized tag isn't pruned first.
1536            {
1537                const SPECIAL_WINS: &[(&str, &str)] = &[
1538                    ("GoPro", "WhiteBalance"),
1539                    ("GoPro", "Sharpness"),
1540                    ("GoPro", "ExposureCompensation"),
1541                    // Embedded ID3 Comment overrides the native container's (AIFF/...).
1542                    ("ID3", "Comment"),
1543                    // Minolta RAW (.mrw PRD/native block) is authoritative for these
1544                    // over the embedded EXIF maker note copies.
1545                    ("MinoltaRaw", "Contrast"),
1546                    ("MinoltaRaw", "Saturation"),
1547                    ("MinoltaRaw", "Sharpness"),
1548                    ("MinoltaRaw", "ISOSetting"),
1549                    // Kodak maker note carries more precise Exposure/FNumber than EXIF.
1550                    ("Kodak", "FNumber"),
1551                    ("Kodak", "ExposureTime"),
1552                    // Sigma maker note X3FillLight (int) is primary over the X3F header.
1553                    ("Sigma", "X3FillLight"),
1554                ];
1555                for (grp, name) in SPECIAL_WINS {
1556                    if tags
1557                        .iter()
1558                        .any(|t| t.name == *name && t.group.family1 == *grp)
1559                    {
1560                        tags.retain(|t| t.name != *name || t.group.family1 == *grp);
1561                    }
1562                }
1563
1564                // QuickTime container/handler tags: ExifTool reports the LAST track's
1565                // value (e.g. the metadata-track HandlerType), unlike per-track TrackID
1566                // which keeps the first. Keep only the last instance of these.
1567                const QT_LAST_WINS: &[&str] = &[
1568                    "HandlerType",
1569                    "HandlerClass",
1570                    "HandlerVendorID",
1571                    "MediaTimeScale",
1572                    "SourceImageWidth",
1573                    "SourceImageHeight",
1574                    // Multiple 'mdat' boxes: ExifTool's RawConv overwrites, so the
1575                    // last box's offset/size is reported.
1576                    "MediaDataOffset",
1577                    "MediaDataSize",
1578                ];
1579                for name in QT_LAST_WINS {
1580                    let last = tags
1581                        .iter()
1582                        .rposition(|t| t.name == *name && t.group.family1 == "QuickTime");
1583                    if let Some(li) = last {
1584                        let mut i = 0usize;
1585                        tags.retain(|t| {
1586                            let keep =
1587                                !(t.name == *name && t.group.family1 == "QuickTime" && i != li);
1588                            i += 1;
1589                            keep
1590                        });
1591                    }
1592                }
1593            }
1594
1595            let mut best_priority: HashMap<String, i32> = HashMap::new();
1596            for tag in &tags {
1597                let entry = best_priority
1598                    .entry(tag.name.clone())
1599                    .or_insert(tag.priority);
1600                if tag.priority > *entry {
1601                    *entry = tag.priority;
1602                }
1603            }
1604            tags.retain(|t| t.priority >= *best_priority.get(&t.name).unwrap_or(&0));
1605
1606            // Document formats (PDF/PostScript/DjVu): their native Info metadata is the
1607            // LOWEST priority in ExifTool — XMP and embedded EXIF both win. Drop the
1608            // native copy when any non-native source provides the same tag.
1609            {
1610                let is_native_doc =
1611                    |g1: &str| matches!(g1, "PDF" | "PostScript" | "DjVu" | "DjVu-Meta");
1612                let other_names: std::collections::HashSet<String> = tags
1613                    .iter()
1614                    .filter(|t| !is_native_doc(&t.group.family1) && !t.print_value.is_empty())
1615                    .map(|t| t.name.clone())
1616                    .collect();
1617                tags.retain(|t| {
1618                    // Trapped keeps its native value ('Unknown' vs XMP's raw '/Unknown').
1619                    t.name == "Trapped"
1620                        || !is_native_doc(&t.group.family1)
1621                        || !other_names.contains(&t.name)
1622                });
1623            }
1624
1625            // MWG (Metadata Working Group) reconciliation: for these tags ExifTool
1626            // prefers XMP over the IPTC copy. Drop the IPTC version when XMP provides it.
1627            {
1628                const MWG_XMP_WINS: &[&str] = &["City", "DateCreated"];
1629                let xmp_has: std::collections::HashSet<String> = tags
1630                    .iter()
1631                    .filter(|t| t.group.family0 == "XMP" && !t.print_value.is_empty())
1632                    .map(|t| t.name.clone())
1633                    .collect();
1634                tags.retain(|t| {
1635                    !(MWG_XMP_WINS.contains(&t.name.as_str())
1636                        && t.group.family0 == "IPTC"
1637                        && xmp_has.contains(&t.name))
1638                });
1639            }
1640
1641            // EXIF/IPTC/MakerNotes outrank XMP for the same tag name (ExifTool default
1642            // group priority). Drop an XMP duplicate only when a non-XMP source at the
1643            // same (now-max) priority exists.
1644            let has_non_xmp: std::collections::HashSet<String> = tags
1645                .iter()
1646                .filter(|t| t.group.family0 != "XMP" && !t.print_value.is_empty())
1647                .map(|t| t.name.clone())
1648                .collect();
1649            tags.retain(|t| t.group.family0 != "XMP" || !has_non_xmp.contains(&t.name));
1650
1651            // Full-resolution-IFD precedence (TIFF-based RAW like NEF/DNG): the
1652            // structural image tags come from the IFD whose SubfileType is
1653            // "Full-resolution image" (NewSubfileType bit0 clear), not the
1654            // reduced-resolution IFD0/thumbnail. When such an IFD exists, its
1655            // structural tags are primary — mirrors ExifTool's directory priority.
1656            {
1657                let full_res_ifds: std::collections::HashSet<String> = tags
1658                    .iter()
1659                    .filter(|t| t.name == "SubfileType" && t.print_value == "Full-resolution image")
1660                    .map(|t| t.group.family1.clone())
1661                    .collect();
1662                if !full_res_ifds.is_empty() {
1663                    // Note: SubfileType itself is NOT promoted — ExifTool keeps each
1664                    // IFD's own SubfileType (DNG primary stays IFD0's reduced value).
1665                    const STRUCTURAL: &[&str] = &[
1666                        "ImageWidth",
1667                        "ImageHeight",
1668                        "BitsPerSample",
1669                        "Compression",
1670                        "PhotometricInterpretation",
1671                        "SamplesPerPixel",
1672                        "StripOffsets",
1673                        "StripByteCounts",
1674                        "RowsPerStrip",
1675                        "PlanarConfiguration",
1676                    ];
1677                    // For each structural tag that has a full-res instance, drop the
1678                    // instances from non-full-res IFDs.
1679                    let has_full: std::collections::HashSet<String> = tags
1680                        .iter()
1681                        .filter(|t| {
1682                            STRUCTURAL.contains(&t.name.as_str())
1683                                && full_res_ifds.contains(&t.group.family1)
1684                        })
1685                        .map(|t| t.name.clone())
1686                        .collect();
1687                    tags.retain(|t| {
1688                        !STRUCTURAL.contains(&t.name.as_str())
1689                            || !has_full.contains(&t.name)
1690                            || full_res_ifds.contains(&t.group.family1)
1691                    });
1692                }
1693            }
1694
1695            // SubfileType across the IFD0/SubIFD image pyramid: ExifTool extracts
1696            // each IFD in order and the LAST value wins (NEF → SubIFD1's
1697            // full-res 0; DNG → the trailing reduced SubIFD's 1). SubIFDs are
1698            // otherwise first-wins, so this is a targeted exception.
1699            {
1700                let last = tags.iter().rposition(|t| {
1701                    t.name == "SubfileType"
1702                        && (t.group.family1 == "IFD0" || t.group.family1.starts_with("SubIFD"))
1703                });
1704                if let Some(li) = last {
1705                    let mut i = 0usize;
1706                    tags.retain(|t| {
1707                        let drop = t.name == "SubfileType"
1708                            && (t.group.family1 == "IFD0" || t.group.family1.starts_with("SubIFD"))
1709                            && i != li;
1710                        i += 1;
1711                        !drop
1712                    });
1713                }
1714            }
1715
1716            // QuickTime container dates are primary over an embedded EXIF copy
1717            // (ExifTool reports the QuickTime CreateDate/ModifyDate for MOV/CR3/etc.).
1718            {
1719                for dname in ["CreateDate", "ModifyDate"] {
1720                    let has_qt = tags
1721                        .iter()
1722                        .any(|t| t.name == dname && t.group.family1 == "QuickTime");
1723                    if has_qt {
1724                        tags.retain(|t| t.name != dname || t.group.family1 == "QuickTime");
1725                    }
1726                }
1727            }
1728
1729            // ExifTool FoundTag rule (narrow, safe subset): among duplicates of the
1730            // same tag name that all live in the SAME main-document group (same
1731            // family1, not a sub-document like IFD1/SubIFD/PreviewIFD/Track2+/Doc2+),
1732            // the LAST extracted overrides the earlier ones. Keep only that last
1733            // instance so the primary value matches ExifTool's `-TAG` output.
1734            {
1735                fn is_sub_document(g1: &str) -> bool {
1736                    g1 == "IFD1"
1737                        || g1.starts_with("SubIFD")
1738                        || g1 == "PreviewIFD"
1739                        || g1.starts_with("Doc")
1740                        || (g1.starts_with("Track")
1741                            && g1 != "Track1"
1742                            && g1.len() > 5
1743                            && g1.as_bytes()[5].is_ascii_digit())
1744                    // VCard removed: within one vCard, duplicate tags are last-wins
1745                    // (TelephoneOtherVoice); the 2nd vCard is demoted to priority -1.
1746                }
1747                // Container groups where ExifTool keeps the FIRST duplicate for its
1748                // Priority-0 tags (e.g. QuickTime tkhd tags). Default-priority tags in
1749                // these same groups (priority >= 1, e.g. mdhd/hdlr) still follow the
1750                // normal last-wins rule, so they are NOT blanket-excluded here.
1751                fn is_first_wins_group(g1: &str) -> bool {
1752                    g1 == "QuickTime"
1753                        || g1 == "Track1"
1754                        || g1 == "JP2"
1755                        || g1 == "PhotoMechanic"
1756                        || g1 == "DjVu"
1757                }
1758                use std::collections::HashMap as HM;
1759                // group indices by name
1760                let mut by_name: HM<&str, Vec<usize>> = HM::new();
1761                for (i, t) in tags.iter().enumerate() {
1762                    by_name.entry(t.name.as_str()).or_default().push(i);
1763                }
1764                let mut drop: std::collections::HashSet<usize> = std::collections::HashSet::new();
1765                for idxs in by_name.values() {
1766                    if idxs.len() < 2 {
1767                        continue;
1768                    }
1769                    let g1 = &tags[idxs[0]].group.family1;
1770                    let priority = tags[idxs[0]].priority;
1771                    // all same family1, same priority, not a real sub-document group
1772                    let uniform = idxs
1773                        .iter()
1774                        .all(|&i| &tags[i].group.family1 == g1 && tags[i].priority == priority);
1775                    if !uniform || is_sub_document(g1) {
1776                        continue;
1777                    }
1778                    // In a first-wins container, Priority-0 duplicates keep the FIRST
1779                    // (ExifTool promotes the stored 0-priority tag), so drop the rest;
1780                    // default-priority (>= 1) tags fall through to last-wins below.
1781                    // Outside such containers, last-wins always.
1782                    if is_first_wins_group(g1) && priority == 0 {
1783                        // keep idxs[0], drop the later duplicates
1784                        for &i in &idxs[1..] {
1785                            drop.insert(i);
1786                        }
1787                        continue;
1788                    }
1789                    // drop all but the last (last extracted wins)
1790                    for &i in &idxs[..idxs.len() - 1] {
1791                        drop.insert(i);
1792                    }
1793                }
1794                if !drop.is_empty() {
1795                    let mut i = 0usize;
1796                    tags.retain(|_| {
1797                        let keep = !drop.contains(&i);
1798                        i += 1;
1799                        keep
1800                    });
1801                }
1802            }
1803        }
1804
1805        // Filter by requested tags if specified
1806        if !self.options.requested_tags.is_empty() {
1807            let requested: Vec<String> = self
1808                .options
1809                .requested_tags
1810                .iter()
1811                .map(|t| t.to_lowercase())
1812                .collect();
1813            tags.retain(|t| requested.contains(&t.name.to_lowercase()));
1814        }
1815
1816        Ok(tags)
1817    }
1818
1819    /// Format extracted tags into a simple name→value map.
1820    ///
1821    /// Handles duplicate tag names by appending group info.
1822    fn get_info(&self, tags: &[Tag]) -> ImageInfo {
1823        let mut info = ImageInfo::new();
1824        let mut seen: HashMap<String, (usize, i32)> = HashMap::new(); // (count, best priority)
1825
1826        for tag in tags {
1827            let value = if self.options.print_conv {
1828                &tag.print_value
1829            } else {
1830                &tag.raw_value.to_display_string()
1831            };
1832
1833            let entry = seen.entry(tag.name.clone()).or_insert((0, i32::MIN));
1834            entry.0 += 1;
1835
1836            if entry.0 == 1 {
1837                entry.1 = tag.priority;
1838                info.insert(tag.name.clone(), value.clone());
1839            } else if tag.priority > entry.1 {
1840                // Higher priority tag replaces the previous one
1841                entry.1 = tag.priority;
1842                info.insert(tag.name.clone(), value.clone());
1843            } else if self.options.duplicates {
1844                let key = format!("{} [{}:{}]", tag.name, tag.group.family0, tag.group.family1);
1845                info.insert(key, value.clone());
1846            }
1847        }
1848
1849        info
1850    }
1851
1852    /// Detect file type from magic bytes and extension.
1853    fn detect_file_type(&self, data: &[u8], path: &Path) -> Result<FileType> {
1854        // Try magic bytes first
1855        let header_len = data.len().min(256);
1856        if let Some(ft) = file_type::detect_from_magic(&data[..header_len]) {
1857            // Override ICO to Font if extension is .dfont (Mac resource fork)
1858            if ft == FileType::Ico {
1859                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1860                    if ext.eq_ignore_ascii_case("dfont") {
1861                        return Ok(FileType::Dfont);
1862                    }
1863                }
1864            }
1865            // Override JPEG to JPS if the file extension is .jps
1866            if ft == FileType::Jpeg {
1867                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1868                    if ext.eq_ignore_ascii_case("jps") {
1869                        return Ok(FileType::Jps);
1870                    }
1871                }
1872            }
1873            // Override PLIST to AAE if extension is .aae
1874            if ft == FileType::Plist {
1875                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1876                    if ext.eq_ignore_ascii_case("aae") {
1877                        return Ok(FileType::Aae);
1878                    }
1879                }
1880            }
1881            // Override XMP/XML to PLIST/AAE if extension is .plist or .aae
1882            if ft == FileType::Xmp || ft == FileType::Xml {
1883                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1884                    if ext.eq_ignore_ascii_case("plist") {
1885                        return Ok(FileType::Plist);
1886                    }
1887                    if ext.eq_ignore_ascii_case("aae") {
1888                        return Ok(FileType::Aae);
1889                    }
1890                }
1891            }
1892            // Override to PhotoCD if extension is .pcd (file starts with 0xFF padding)
1893            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1894                if ext.eq_ignore_ascii_case("pcd")
1895                    && data.len() >= 2056
1896                    && &data[2048..2055] == b"PCD_IPI"
1897                {
1898                    return Ok(FileType::PhotoCd);
1899                }
1900            }
1901            // Override MP3 to MPC/APE/WavPack if extension says otherwise
1902            if ft == FileType::Mp3 {
1903                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1904                    if ext.eq_ignore_ascii_case("mpc") {
1905                        return Ok(FileType::Mpc);
1906                    }
1907                    if ext.eq_ignore_ascii_case("ape") {
1908                        return Ok(FileType::Ape);
1909                    }
1910                    if ext.eq_ignore_ascii_case("wv") {
1911                        return Ok(FileType::WavPack);
1912                    }
1913                }
1914            }
1915            // ASF is the container for WMV (video) and WMA (audio); refine by extension.
1916            if ft == FileType::Asf {
1917                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1918                    if ext.eq_ignore_ascii_case("wmv") {
1919                        return Ok(FileType::Wmv);
1920                    }
1921                    if ext.eq_ignore_ascii_case("wma") {
1922                        return Ok(FileType::Wma);
1923                    }
1924                }
1925            }
1926            // Opus is an Ogg stream with the Opus codec.
1927            if ft == FileType::Ogg {
1928                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1929                    if ext.eq_ignore_ascii_case("opus") {
1930                        return Ok(FileType::Opus);
1931                    }
1932                }
1933            }
1934            // TIFF magic covers many RAW variants (DNG, NEF, ARW, …); ExifTool refines
1935            // the type by extension since they share the TIFF structure.
1936            if ft == FileType::Tiff {
1937                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1938                    if let Some(ext_ft) = file_type::detect_from_extension(ext) {
1939                        if ext_ft != FileType::Tiff && is_tiff_based(ext_ft) {
1940                            return Ok(ext_ft);
1941                        }
1942                    }
1943                }
1944            }
1945            // For ZIP files, check if it's an EIP (by extension) or OpenDocument format
1946            if ft == FileType::Zip {
1947                // Check extension first for EIP
1948                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1949                    if ext.eq_ignore_ascii_case("eip") {
1950                        return Ok(FileType::Eip);
1951                    }
1952                }
1953                // iWork (KEY/PAGES/NUMBERS): ExifTool keys on the file extension once an
1954                // iWork marker member is present (ZIP.pm Process_iWork).
1955                if let Some(iw) = detect_iwork_type(data, path) {
1956                    return Ok(iw);
1957                }
1958                if let Some(od_type) = detect_opendocument_type(data) {
1959                    return Ok(od_type);
1960                }
1961            }
1962            // OLE2 compound files (DOC/XLS/PPT/FlashPix) all share the D0CF11E0 magic;
1963            // refine by the UTF-16 stream names in the directory.
1964            if ft == FileType::Doc {
1965                if let Some(ole) = detect_ole2_type(data) {
1966                    return Ok(ole);
1967                }
1968            }
1969            return Ok(ft);
1970        }
1971
1972        // Fall back to extension
1973        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1974            if let Some(ft) = file_type::detect_from_extension(ext) {
1975                return Ok(ft);
1976            }
1977        }
1978
1979        let ext_str = path
1980            .extension()
1981            .and_then(|e| e.to_str())
1982            .unwrap_or("unknown");
1983        Err(Error::UnsupportedFileType(ext_str.to_string()))
1984    }
1985
1986    /// Dispatch to the appropriate format reader.
1987    fn process_file(&self, data: &[u8], file_type: FileType) -> Result<Vec<Tag>> {
1988        match file_type {
1989            FileType::Jpeg | FileType::Jps => formats::jpeg::read_jpeg(data),
1990            FileType::Png | FileType::Mng => formats::png::read_png(data),
1991            // All TIFF-based formats (TIFF + most RAW formats)
1992            FileType::Tiff
1993            | FileType::Btf
1994            | FileType::Dng
1995            | FileType::Cr2
1996            | FileType::Nef
1997            | FileType::Arw
1998            | FileType::Sr2
1999            | FileType::Orf
2000            | FileType::Pef
2001            | FileType::Erf
2002            | FileType::Fff
2003            | FileType::Rwl
2004            | FileType::Mef
2005            | FileType::Srw
2006            | FileType::Gpr
2007            | FileType::Arq
2008            | FileType::ThreeFR
2009            | FileType::Dcr
2010            | FileType::Rw2
2011            | FileType::Srf => formats::tiff::read_tiff(data),
2012            // Phase One IIQ: TIFF + PhaseOne maker note block
2013            FileType::Iiq => formats::iiq::read_iiq(data),
2014            // Image formats
2015            FileType::Gif => formats::gif::read_gif(data),
2016            FileType::Bmp => formats::bmp::read_bmp(data),
2017            FileType::WebP | FileType::Avi | FileType::Wav => formats::riff::read_riff(data),
2018            FileType::Psd => formats::psd::read_psd(data),
2019            // Audio formats
2020            FileType::Mp3 => formats::id3::read_mp3(data),
2021            FileType::Flac => formats::flac::read_flac(data),
2022            FileType::Ogg | FileType::Opus => formats::ogg::read_ogg(data),
2023            FileType::Aiff => formats::aiff::read_aiff(data),
2024            // Video formats
2025            FileType::Mp4
2026            | FileType::QuickTime
2027            | FileType::M4a
2028            | FileType::ThreeGP
2029            | FileType::Heif
2030            | FileType::Avif
2031            | FileType::Cr3
2032            | FileType::Crm
2033            | FileType::F4v
2034            | FileType::Mqv
2035            | FileType::Lrv => {
2036                formats::quicktime::read_quicktime_with_ee(data, self.options.extract_embedded)
2037            }
2038            FileType::Mkv | FileType::WebM => formats::matroska::read_matroska(data),
2039            FileType::Asf | FileType::Wmv | FileType::Wma => formats::asf::read_asf(data),
2040            FileType::Wtv => formats::wtv::read_wtv(data),
2041            // RAW formats with custom containers
2042            FileType::Crw => formats::canon_raw::read_crw(data),
2043            FileType::Raf => formats::raf::read_raf(data),
2044            FileType::Mrw => formats::mrw::read_mrw(data),
2045            FileType::Mrc => formats::mrc::read_mrc(data),
2046            // Image formats
2047            FileType::Jp2 => formats::jp2::read_jp2(data),
2048            FileType::J2c => formats::jp2::read_j2c(data),
2049            FileType::Jxl => formats::jp2::read_jxl(data),
2050            FileType::Ico => formats::ico::read_ico(data),
2051            FileType::Icc => formats::icc::read_icc(data),
2052            // Documents
2053            FileType::Pdf => formats::pdf::read_pdf(data),
2054            FileType::PostScript => {
2055                // PFA fonts start with %!PS-AdobeFont or %!FontType1
2056                if data.starts_with(b"%!PS-AdobeFont") || data.starts_with(b"%!FontType1") {
2057                    formats::font::read_pfa(data)
2058                        .or_else(|_| formats::postscript::read_postscript(data))
2059                } else {
2060                    formats::postscript::read_postscript(data)
2061                }
2062            }
2063            FileType::Eip => formats::capture_one::read_eip(data),
2064            FileType::Zip
2065            | FileType::Docx
2066            | FileType::Xlsx
2067            | FileType::Pptx
2068            | FileType::Doc
2069            | FileType::Xls
2070            | FileType::Ppt
2071            | FileType::Numbers
2072            | FileType::Pages
2073            | FileType::Key => formats::zip::read_zip(data),
2074            FileType::Rtf => formats::rtf::read_rtf(data),
2075            FileType::InDesign => formats::indesign::read_indesign(data),
2076            FileType::Pcap => formats::pcap::read_pcap(data),
2077            FileType::Pcapng => formats::pcap::read_pcapng(data),
2078            // Canon VRD / DR4
2079            FileType::Vrd => formats::canon_vrd::read_vrd(data).or_else(|_| Ok(Vec::new())),
2080            FileType::Dr4 => formats::canon_vrd::read_dr4(data).or_else(|_| Ok(Vec::new())),
2081            // Metadata / Other
2082            FileType::Xmp => formats::xmp_file::read_xmp(data),
2083            FileType::Svg => formats::svg::read_svg(data),
2084            FileType::Html => {
2085                // SVG files that weren't detected by magic (e.g., via extension fallback)
2086                let is_svg = data.windows(4).take(512).any(|w| w == b"<svg");
2087                if is_svg {
2088                    formats::svg::read_svg(data)
2089                } else {
2090                    formats::html::read_html(data)
2091                }
2092            }
2093            FileType::Exe => formats::exe::read_exe(data),
2094            FileType::Font => {
2095                // AFM: Adobe Font Metrics text file
2096                if data.starts_with(b"StartFontMetrics") {
2097                    return formats::font::read_afm(data);
2098                }
2099                // PFA: PostScript Type 1 ASCII font
2100                if data.starts_with(b"%!PS-AdobeFont") || data.starts_with(b"%!FontType1") {
2101                    return formats::font::read_pfa(data).or_else(|_| Ok(Vec::new()));
2102                }
2103                // PFB: PostScript Type 1 Binary font
2104                if data.len() >= 2 && data[0] == 0x80 && (data[1] == 0x01 || data[1] == 0x02) {
2105                    return formats::font::read_pfb(data).or_else(|_| Ok(Vec::new()));
2106                }
2107                formats::font::read_font(data)
2108            }
2109            // Audio with ID3
2110            FileType::WavPack | FileType::Dsf => formats::id3::read_mp3(data),
2111            FileType::Ape => formats::ape::read_ape(data),
2112            FileType::Mpc => formats::ape::read_mpc(data),
2113            FileType::Aac => formats::aac::read_aac(data),
2114            FileType::RealAudio => {
2115                formats::real_audio::read_real_audio(data).or_else(|_| Ok(Vec::new()))
2116            }
2117            FileType::RealMedia => {
2118                formats::real_media::read_real_media(data).or_else(|_| Ok(Vec::new()))
2119            }
2120            // Misc formats
2121            FileType::Czi => formats::czi::read_czi(data).or_else(|_| Ok(Vec::new())),
2122            FileType::PhotoCd => formats::photo_cd::read_photo_cd(data).or_else(|_| Ok(Vec::new())),
2123            FileType::Dicom => formats::dicom::read_dicom(data),
2124            FileType::Fits => formats::fits::read_fits(data),
2125            FileType::Flv => formats::flv::read_flv(data),
2126            FileType::Mxf => formats::mxf::read_mxf(data).or_else(|_| Ok(Vec::new())),
2127            FileType::Swf => formats::swf::read_swf(data),
2128            FileType::Hdr => formats::hdr::read_hdr(data),
2129            FileType::DjVu => formats::djvu::read_djvu(data),
2130            FileType::Xcf => formats::gimp::read_xcf(data),
2131            FileType::Mie => formats::mie::read_mie(data),
2132            FileType::Lfp => formats::lytro::read_lfp(data),
2133            // FileType::Miff dispatched via string extension below
2134            FileType::Fpf => formats::flir_fpf::read_fpf(data),
2135            FileType::Flif => formats::flif::read_flif(data),
2136            FileType::Bpg => formats::bpg::read_bpg(data),
2137            FileType::Pcx => formats::pcx::read_pcx(data),
2138            FileType::Pict => formats::pict::read_pict(data),
2139            FileType::Mpeg => formats::mpeg::read_mpeg(data),
2140            FileType::M2ts => formats::m2ts::read_m2ts(data, self.options.extract_embedded),
2141            FileType::Gzip => formats::gzip::read_gzip(data),
2142            FileType::Rar => formats::rar::read_rar(data),
2143            FileType::SevenZ => formats::sevenz::read_7z(data),
2144            FileType::Dss => formats::dss::read_dss(data),
2145            FileType::Moi => formats::moi::read_moi(data),
2146            FileType::MacOs => formats::macos::read_macos(data),
2147            FileType::Json => formats::json_format::read_json(data),
2148            // New formats
2149            FileType::Pgf => formats::pgf::read_pgf(data),
2150            FileType::Xisf => formats::xisf::read_xisf(data),
2151            FileType::Torrent => formats::torrent::read_torrent(data),
2152            FileType::Mobi => formats::palm::read_palm(data),
2153            FileType::Psp => formats::psp::read_psp(data),
2154            FileType::SonyPmp => formats::sony_pmp::read_sony_pmp(data),
2155            FileType::Audible => formats::audible::read_audible(data),
2156            FileType::Exr => formats::openexr::read_openexr(data),
2157            // New formats
2158            FileType::Plist => {
2159                if data.starts_with(b"bplist") {
2160                    formats::plist::read_binary_plist_tags(data)
2161                } else {
2162                    formats::plist::read_xml_plist(data)
2163                }
2164            }
2165            FileType::Aae => {
2166                if data.starts_with(b"bplist") {
2167                    formats::plist::read_binary_plist_tags(data)
2168                } else {
2169                    formats::plist::read_aae_plist(data)
2170                }
2171            }
2172            FileType::KyoceraRaw => formats::kyocera_raw::read_kyocera_raw(data),
2173            FileType::PortableFloatMap => formats::pfm::read_pfm(data),
2174            FileType::Ods
2175            | FileType::Odt
2176            | FileType::Odp
2177            | FileType::Odg
2178            | FileType::Odf
2179            | FileType::Odb
2180            | FileType::Odi
2181            | FileType::Odc => formats::zip::read_zip(data),
2182            FileType::Lif => formats::lif::read_lif(data),
2183            FileType::Rwz => formats::rawzor::read_rawzor(data),
2184            FileType::Jxr => formats::jxr::read_jxr(data),
2185            FileType::Miff => formats::miff::read_miff(data).or_else(|_| Ok(Vec::new())),
2186            FileType::Tnef => formats::tnef::read_tnef(data).or_else(|_| Ok(Vec::new())),
2187            FileType::Wpg => formats::wpg::read_wpg(data).or_else(|_| Ok(Vec::new())),
2188            FileType::Dv => {
2189                formats::dv::read_dv(data, data.len() as u64).or_else(|_| Ok(Vec::new()))
2190            }
2191            FileType::Itc => formats::itc::read_itc(data).or_else(|_| Ok(Vec::new())),
2192            FileType::Iso => formats::iso::read_iso(data).or_else(|_| Ok(Vec::new())),
2193            FileType::Afm => formats::font::read_afm(data).or_else(|_| Ok(Vec::new())),
2194            FileType::Pfa => formats::font::read_pfa(data).or_else(|_| Ok(Vec::new())),
2195            FileType::Pfb => formats::font::read_pfb(data).or_else(|_| Ok(Vec::new())),
2196            FileType::Dfont => formats::font::read_font(data).or_else(|_| Ok(Vec::new())),
2197            FileType::Xml | FileType::Inx => {
2198                formats::xmp_file::read_xmp(data).or_else(|_| Ok(Vec::new()))
2199            }
2200            FileType::Eps => formats::postscript::read_postscript(data),
2201            _ => Err(Error::UnsupportedFileType(format!("{}", file_type))),
2202        }
2203    }
2204
2205    /// Fallback: try to read file based on extension for formats without magic detection.
2206    fn process_by_extension(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
2207        let ext = path
2208            .extension()
2209            .and_then(|e| e.to_str())
2210            .unwrap_or("")
2211            .to_ascii_lowercase();
2212
2213        match ext.as_str() {
2214            "ppm" | "pgm" | "pbm" => formats::ppm::read_ppm(data),
2215            "pfm" => {
2216                // PFM can be Portable Float Map or Printer Font Metrics
2217                if data.len() >= 3 && data[0] == b'P' && (data[1] == b'f' || data[1] == b'F') {
2218                    formats::ppm::read_ppm(data)
2219                } else {
2220                    Ok(Vec::new()) // Printer Font Metrics
2221                }
2222            }
2223            "json" => formats::json_format::read_json(data),
2224            "svg" => formats::svg::read_svg(data),
2225            "ram" => formats::ram::read_ram(data).or_else(|_| Ok(Vec::new())),
2226            "txt" | "log" | "igc" => Ok(compute_text_tags(data, false)),
2227            "csv" => Ok(compute_text_tags(data, true)),
2228            "url" => formats::lnk::read_url(data).or_else(|_| Ok(Vec::new())),
2229            "lnk" => formats::lnk::read_lnk(data).or_else(|_| Ok(Vec::new())),
2230            "gpx" | "kml" | "xml" | "inx" => formats::xmp_file::read_xmp(data),
2231            "plist" => {
2232                if data.starts_with(b"bplist") {
2233                    formats::plist::read_binary_plist_tags(data).or_else(|_| Ok(Vec::new()))
2234                } else {
2235                    formats::plist::read_xml_plist(data).or_else(|_| Ok(Vec::new()))
2236                }
2237            }
2238            "aae" => {
2239                if data.starts_with(b"bplist") {
2240                    formats::plist::read_binary_plist_tags(data).or_else(|_| Ok(Vec::new()))
2241                } else {
2242                    formats::plist::read_aae_plist(data).or_else(|_| Ok(Vec::new()))
2243                }
2244            }
2245            "vcf" | "ics" | "vcard" => {
2246                let s = crate::encoding::decode_utf8_or_latin1(&data[..data.len().min(100)]);
2247                if s.contains("BEGIN:VCALENDAR") {
2248                    formats::vcard::read_ics(data).or_else(|_| Ok(Vec::new()))
2249                } else {
2250                    formats::vcard::read_vcf(data).or_else(|_| Ok(Vec::new()))
2251                }
2252            }
2253            "xcf" => Ok(Vec::new()), // GIMP
2254            "vrd" => formats::canon_vrd::read_vrd(data).or_else(|_| Ok(Vec::new())),
2255            "dr4" => formats::canon_vrd::read_dr4(data).or_else(|_| Ok(Vec::new())),
2256            "indd" | "indt" => Ok(Vec::new()), // InDesign
2257            "x3f" => formats::sigma_raw::read_x3f(data).or_else(|_| Ok(Vec::new())),
2258            "mie" => Ok(Vec::new()), // MIE
2259            "exr" => Ok(Vec::new()), // OpenEXR
2260            "wpg" => formats::wpg::read_wpg(data).or_else(|_| Ok(Vec::new())),
2261            "moi" => formats::moi::read_moi(data).or_else(|_| Ok(Vec::new())),
2262            "macos" => formats::macos::read_macos(data).or_else(|_| Ok(Vec::new())),
2263            "dpx" => formats::dpx::read_dpx(data).or_else(|_| Ok(Vec::new())),
2264            "r3d" => formats::red::read_r3d(data).or_else(|_| Ok(Vec::new())),
2265            "tnef" => formats::tnef::read_tnef(data).or_else(|_| Ok(Vec::new())),
2266            "ppt" | "fpx" => formats::flashpix::read_fpx(data).or_else(|_| Ok(Vec::new())),
2267            "fpf" => formats::flir_fpf::read_fpf(data).or_else(|_| Ok(Vec::new())),
2268            "itc" => formats::itc::read_itc(data).or_else(|_| Ok(Vec::new())),
2269            "mpg" | "mpeg" | "m1v" | "m2v" | "mpv" => {
2270                formats::mpeg::read_mpeg(data).or_else(|_| Ok(Vec::new()))
2271            }
2272            "dv" => formats::dv::read_dv(data, data.len() as u64).or_else(|_| Ok(Vec::new())),
2273            "czi" => formats::czi::read_czi(data).or_else(|_| Ok(Vec::new())),
2274            "miff" => formats::miff::read_miff(data).or_else(|_| Ok(Vec::new())),
2275            "lfp" | "mrc" | "dss" | "mobi" | "psp" | "pgf" | "raw" | "pmp" | "torrent" | "xisf"
2276            | "mxf" | "dfont" => Ok(Vec::new()),
2277            "iso" => formats::iso::read_iso(data).or_else(|_| Ok(Vec::new())),
2278            "afm" => formats::font::read_afm(data).or_else(|_| Ok(Vec::new())),
2279            "pfa" => formats::font::read_pfa(data).or_else(|_| Ok(Vec::new())),
2280            "pfb" => formats::font::read_pfb(data).or_else(|_| Ok(Vec::new())),
2281            _ => Err(Error::UnsupportedFileType(ext)),
2282        }
2283    }
2284}
2285
2286impl Default for ExifTool {
2287    fn default() -> Self {
2288        Self::new()
2289    }
2290}
2291
2292/// Detect OpenDocument file type by reading the `mimetype` entry from a ZIP.
2293/// Returns None if not an OpenDocument file.
2294/// Refine an EXE file's (FileType, MIMEType, FileTypeExtension) from its magic, mirroring
2295/// ExifTool's EXE SetFileType. MIME is always application/octet-stream for these.
2296fn exe_subtype(d: &[u8]) -> Option<(&'static str, &'static str, &'static str)> {
2297    const MIME: &str = "application/octet-stream";
2298    if d.len() < 8 {
2299        return None;
2300    }
2301    // ELF: 0x7F 'E' 'L' 'F'; data[5] endianness (1=LE,2=BE); e_type at offset 16 (2 bytes)
2302    if &d[0..4] == b"\x7fELF" && d.len() >= 18 {
2303        let le = d[5] == 1;
2304        let e_type = if le {
2305            u16::from_le_bytes([d[16], d[17]])
2306        } else {
2307            u16::from_be_bytes([d[16], d[17]])
2308        };
2309        return Some(match e_type {
2310            1 => ("ELF relocatable", MIME, "o"),
2311            2 => ("ELF executable", MIME, ""),
2312            3 => ("ELF shared library", MIME, "so"),
2313            4 => ("ELF core file", MIME, ""),
2314            _ => ("ELF", MIME, ""),
2315        });
2316    }
2317    // Mach-O thin binary: magic FEEDFACE/FEEDFACF (BE) or CEFAEDFE/CFFAEDFE (LE).
2318    let magic_be = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
2319    let macho = matches!(magic_be, 0xFEEDFACE | 0xFEEDFACF | 0xCEFAEDFE | 0xCFFAEDFE);
2320    if macho && d.len() >= 16 {
2321        let le = matches!(magic_be, 0xCEFAEDFE | 0xCFFAEDFE);
2322        let filetype = if le {
2323            u32::from_le_bytes([d[12], d[13], d[14], d[15]])
2324        } else {
2325            u32::from_be_bytes([d[12], d[13], d[14], d[15]])
2326        };
2327        return Some(match filetype {
2328            1 => ("Mach-O object file", MIME, "o"),
2329            6 => ("Mach-O dynamic link library", MIME, "dylib"),
2330            8 => ("Mach-O dynamic bound bundle", MIME, "dylib"),
2331            9 => ("Mach-O dynamic link library stub", MIME, "dylib"),
2332            _ => ("Mach-O executable", MIME, ""),
2333        });
2334    }
2335    // Mach-O fat binary: CAFEBABE / BEBAFECA
2336    if matches!(magic_be, 0xCAFEBABE | 0xBEBAFECA) {
2337        return Some(("Mach-O fat binary executable", MIME, ""));
2338    }
2339    // ar archive ("!<arch>\n"): static library (Mach-O if it contains Mach-O members).
2340    if d.starts_with(b"!<arch>\n") {
2341        let is_macho = d.windows(4).take(4096).any(|w| {
2342            let m = u32::from_be_bytes([w[0], w[1], w[2], w[3]]);
2343            matches!(
2344                m,
2345                0xFEEDFACE | 0xFEEDFACF | 0xCEFAEDFE | 0xCFFAEDFE | 0xCAFEBABE
2346            )
2347        });
2348        return Some(if is_macho {
2349            ("Mach-O static library", MIME, "a")
2350        } else {
2351            ("Static library", MIME, "a")
2352        });
2353    }
2354    // PE (Windows): "MZ" then PE header; machine field selects Win32/Win64.
2355    if &d[0..2] == b"MZ" && d.len() >= 0x40 {
2356        let pe_off = u32::from_le_bytes([d[0x3c], d[0x3d], d[0x3e], d[0x3f]]) as usize;
2357        if pe_off + 6 <= d.len() && &d[pe_off..pe_off + 4] == b"PE\0\0" {
2358            let machine = u16::from_le_bytes([d[pe_off + 4], d[pe_off + 5]]);
2359            return Some(match machine {
2360                0x8664 | 0xAA64 => ("Win64 EXE", MIME, "exe"),
2361                _ => ("Win32 EXE", MIME, "exe"),
2362            });
2363        }
2364    }
2365    None
2366}
2367
2368/// Whether a FileType is a TIFF-based RAW variant (shares TIFF magic, refined by extension).
2369fn is_tiff_based(ft: FileType) -> bool {
2370    matches!(
2371        ft,
2372        FileType::Dng
2373            | FileType::Cr2
2374            | FileType::Nef
2375            | FileType::Arw
2376            | FileType::Sr2
2377            | FileType::Orf
2378            | FileType::Pef
2379            | FileType::Erf
2380            | FileType::Rwl
2381            | FileType::Mef
2382            | FileType::Srw
2383            | FileType::Gpr
2384            | FileType::Arq
2385            | FileType::ThreeFR
2386            | FileType::Dcr
2387            | FileType::Rw2
2388            | FileType::Srf
2389            | FileType::Iiq
2390            | FileType::Btf
2391    )
2392}
2393
2394/// Refine an OLE2 compound document (DOC/XLS/PPT) by scanning the directory for
2395/// well-known UTF-16LE stream names. Returns None (→ keep DOC) when none match.
2396fn detect_ole2_type(data: &[u8]) -> Option<FileType> {
2397    fn has_utf16(data: &[u8], name: &str) -> bool {
2398        let needle: Vec<u8> = name.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
2399        data.windows(needle.len()).any(|w| w == needle.as_slice())
2400    }
2401    if has_utf16(data, "PowerPoint Document") {
2402        Some(FileType::Ppt)
2403    } else if has_utf16(data, "Workbook") || has_utf16(data, "Book") {
2404        Some(FileType::Xls)
2405    } else {
2406        None
2407    }
2408}
2409
2410/// Detect an iWork (KEY/PAGES/NUMBERS) ZIP. ExifTool recognises these by the
2411/// presence of an iWork marker member, then maps the file type from the
2412/// extension (ZIP.pm `%iWorkType` / Process_iWork).
2413fn detect_iwork_type(data: &[u8], path: &Path) -> Option<FileType> {
2414    const MARKERS: &[&[u8]] = &[
2415        b"index.xml",
2416        b"index.apxl",
2417        b"QuickLook/Thumbnail.jpg",
2418        b"Index/Document.iwa",
2419        b"Index/Slide.iwa",
2420        b"Index/Tables/DataList.iwa",
2421    ];
2422    let has_marker = MARKERS
2423        .iter()
2424        .any(|m| data.windows(m.len()).any(|w| w == *m));
2425    if !has_marker {
2426        return None;
2427    }
2428    let ext = path
2429        .extension()
2430        .and_then(|e| e.to_str())
2431        .unwrap_or("")
2432        .to_ascii_lowercase();
2433    match ext.as_str() {
2434        "numbers" | "nmbtemplate" => Some(FileType::Numbers),
2435        "pages" => Some(FileType::Pages),
2436        "key" | "kth" => Some(FileType::Key),
2437        _ => None,
2438    }
2439}
2440
2441/// Content-dependent FileType code / MIME refinements (ExifTool SetFileType with a
2442/// content test). Returns (code, mime); the extension keeps its default.
2443fn refine_filetype_by_content(file_type: FileType, data: &[u8]) -> Option<(String, String)> {
2444    match file_type {
2445        // Printer Font Metrics (font, starts 0x00 0x01/0x02) vs Portable Float Map (image, "PF").
2446        FileType::PortableFloatMap if data.len() >= 2 && data[0] == 0x00 && data[1] <= 0x02 => {
2447            Some(("PFM".into(), "application/x-font-type1".into()))
2448        }
2449        // XML property list → application/xml (binary plist keeps application/x-plist).
2450        FileType::Plist if !data.starts_with(b"bplist") => {
2451            Some(("PLIST".into(), "application/xml".into()))
2452        }
2453        // Naked JPEG XL codestream (FF 0A) vs the ISOBMFF container.
2454        FileType::Jxl if data.starts_with(&[0xFF, 0x0A]) => {
2455            Some(("JXL Codestream".into(), file_type.mime_type().to_string()))
2456        }
2457        // Extended WebP: VP8X chunk at offset 12.
2458        FileType::WebP if data.len() >= 16 && &data[12..16] == b"VP8X" => {
2459            Some(("Extended WEBP".into(), file_type.mime_type().to_string()))
2460        }
2461        // Multi-page DjVu: "DJVM" form type at offset 12.
2462        FileType::DjVu if data.len() >= 16 && &data[12..16] == b"DJVM" => Some((
2463            "DJVU (multi-page)".into(),
2464            file_type.mime_type().to_string(),
2465        )),
2466        _ => None,
2467    }
2468}
2469
2470fn detect_opendocument_type(data: &[u8]) -> Option<FileType> {
2471    // OpenDocument ZIPs have "mimetype" as the FIRST local file entry (uncompressed)
2472    if data.len() < 30 || data[0..4] != [0x50, 0x4B, 0x03, 0x04] {
2473        return None;
2474    }
2475    let compression = u16::from_le_bytes([data[8], data[9]]);
2476    let compressed_size = u32::from_le_bytes([data[18], data[19], data[20], data[21]]) as usize;
2477    let name_len = u16::from_le_bytes([data[26], data[27]]) as usize;
2478    let extra_len = u16::from_le_bytes([data[28], data[29]]) as usize;
2479    let name_start = 30;
2480    if name_start + name_len > data.len() {
2481        return None;
2482    }
2483    let filename = std::str::from_utf8(&data[name_start..name_start + name_len]).unwrap_or("");
2484    if filename != "mimetype" || compression != 0 {
2485        return None;
2486    }
2487    let content_start = name_start + name_len + extra_len;
2488    let content_end = (content_start + compressed_size).min(data.len());
2489    if content_start >= content_end {
2490        return None;
2491    }
2492    let mime = std::str::from_utf8(&data[content_start..content_end])
2493        .unwrap_or("")
2494        .trim();
2495    match mime {
2496        "application/vnd.oasis.opendocument.spreadsheet" => Some(FileType::Ods),
2497        "application/vnd.oasis.opendocument.text" => Some(FileType::Odt),
2498        "application/vnd.oasis.opendocument.presentation" => Some(FileType::Odp),
2499        "application/vnd.oasis.opendocument.graphics" => Some(FileType::Odg),
2500        "application/vnd.oasis.opendocument.formula" => Some(FileType::Odf),
2501        "application/vnd.oasis.opendocument.database" => Some(FileType::Odb),
2502        "application/vnd.oasis.opendocument.image" => Some(FileType::Odi),
2503        "application/vnd.oasis.opendocument.chart" => Some(FileType::Odc),
2504        _ => None,
2505    }
2506}
2507
2508/// Detect the file type of a file at the given path.
2509pub fn get_file_type<P: AsRef<Path>>(path: P) -> Result<FileType> {
2510    let path = path.as_ref();
2511    let mut file = fs::File::open(path).map_err(Error::Io)?;
2512    let mut header = [0u8; 256];
2513    use std::io::Read;
2514    let n = file.read(&mut header).map_err(Error::Io)?;
2515
2516    if let Some(ft) = file_type::detect_from_magic(&header[..n]) {
2517        return Ok(ft);
2518    }
2519
2520    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2521        if let Some(ft) = file_type::detect_from_extension(ext) {
2522            return Ok(ft);
2523        }
2524    }
2525
2526    Err(Error::UnsupportedFileType("unknown".into()))
2527}
2528
2529/// Classification of EXIF tags into IFD groups.
2530enum ExifIfdGroup {
2531    Ifd0,
2532    ExifIfd,
2533    Gps,
2534}
2535
2536/// Determine which IFD a tag belongs to based on its ID.
2537fn classify_exif_tag(tag_id: u16) -> ExifIfdGroup {
2538    match tag_id {
2539        // ExifIFD tags
2540        0x829A..=0x829D | 0x8822..=0x8827 | 0x8830 | 0x9000..=0x9292 | 0xA000..=0xA435 => {
2541            ExifIfdGroup::ExifIfd
2542        }
2543        // GPS tags
2544        0x0000..=0x001F if tag_id <= 0x001F => ExifIfdGroup::Gps,
2545        // Everything else → IFD0
2546        _ => ExifIfdGroup::Ifd0,
2547    }
2548}
2549
2550/// Extract existing EXIF entries from a JPEG file's APP1 segment.
2551fn extract_existing_exif_entries(
2552    jpeg_data: &[u8],
2553    target_bo: ByteOrderMark,
2554) -> Vec<exif_writer::IfdEntry> {
2555    let mut entries = Vec::new();
2556
2557    // Find EXIF APP1 segment
2558    let mut pos = 2; // Skip SOI
2559    while pos + 4 <= jpeg_data.len() {
2560        if jpeg_data[pos] != 0xFF {
2561            pos += 1;
2562            continue;
2563        }
2564        let marker = jpeg_data[pos + 1];
2565        pos += 2;
2566
2567        if marker == 0xDA || marker == 0xD9 {
2568            break; // SOS or EOI
2569        }
2570        if marker == 0xFF || marker == 0x00 || marker == 0xD8 || (0xD0..=0xD7).contains(&marker) {
2571            continue;
2572        }
2573
2574        if pos + 2 > jpeg_data.len() {
2575            break;
2576        }
2577        let seg_len = u16::from_be_bytes([jpeg_data[pos], jpeg_data[pos + 1]]) as usize;
2578        if seg_len < 2 || pos + seg_len > jpeg_data.len() {
2579            break;
2580        }
2581
2582        let seg_data = &jpeg_data[pos + 2..pos + seg_len];
2583
2584        // EXIF APP1
2585        if marker == 0xE1 && seg_data.len() > 14 && seg_data.starts_with(b"Exif\0\0") {
2586            let tiff_data = &seg_data[6..];
2587            extract_ifd_entries(tiff_data, target_bo, &mut entries);
2588            break;
2589        }
2590
2591        pos += seg_len;
2592    }
2593
2594    entries
2595}
2596
2597/// Extract IFD entries from TIFF data, re-encoding values in the target byte order.
2598fn extract_ifd_entries(
2599    tiff_data: &[u8],
2600    target_bo: ByteOrderMark,
2601    entries: &mut Vec<exif_writer::IfdEntry>,
2602) {
2603    use crate::metadata::exif::parse_tiff_header;
2604
2605    let header = match parse_tiff_header(tiff_data) {
2606        Ok(h) => h,
2607        Err(_) => return,
2608    };
2609
2610    let src_bo = header.byte_order;
2611
2612    // Read IFD0
2613    read_ifd_for_merge(
2614        tiff_data,
2615        header.ifd0_offset as usize,
2616        src_bo,
2617        target_bo,
2618        entries,
2619    );
2620
2621    // Find ExifIFD and GPS pointers
2622    let ifd0_offset = header.ifd0_offset as usize;
2623    if ifd0_offset + 2 > tiff_data.len() {
2624        return;
2625    }
2626    let count = read_u16_bo(tiff_data, ifd0_offset, src_bo) as usize;
2627    for i in 0..count {
2628        let eoff = ifd0_offset + 2 + i * 12;
2629        if eoff + 12 > tiff_data.len() {
2630            break;
2631        }
2632        let tag = read_u16_bo(tiff_data, eoff, src_bo);
2633        let value_off = read_u32_bo(tiff_data, eoff + 8, src_bo) as usize;
2634
2635        match tag {
2636            0x8769 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
2637            0x8825 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
2638            _ => {}
2639        }
2640    }
2641}
2642
2643/// Read a single IFD and extract entries for merge.
2644fn read_ifd_for_merge(
2645    data: &[u8],
2646    offset: usize,
2647    src_bo: ByteOrderMark,
2648    target_bo: ByteOrderMark,
2649    entries: &mut Vec<exif_writer::IfdEntry>,
2650) {
2651    if offset + 2 > data.len() {
2652        return;
2653    }
2654    let count = read_u16_bo(data, offset, src_bo) as usize;
2655
2656    for i in 0..count {
2657        let eoff = offset + 2 + i * 12;
2658        if eoff + 12 > data.len() {
2659            break;
2660        }
2661
2662        let tag = read_u16_bo(data, eoff, src_bo);
2663        let dtype = read_u16_bo(data, eoff + 2, src_bo);
2664        let count_val = read_u32_bo(data, eoff + 4, src_bo);
2665
2666        // Skip sub-IFD pointers and MakerNote
2667        if tag == 0x8769 || tag == 0x8825 || tag == 0xA005 || tag == 0x927C {
2668            continue;
2669        }
2670
2671        let type_size = match dtype {
2672            1 | 2 | 6 | 7 => 1usize,
2673            3 | 8 => 2,
2674            4 | 9 | 11 | 13 => 4,
2675            5 | 10 | 12 => 8,
2676            _ => continue,
2677        };
2678
2679        let total_size = type_size * count_val as usize;
2680        let raw_data = if total_size <= 4 {
2681            data[eoff + 8..eoff + 12].to_vec()
2682        } else {
2683            let voff = read_u32_bo(data, eoff + 8, src_bo) as usize;
2684            if voff + total_size > data.len() {
2685                continue;
2686            }
2687            data[voff..voff + total_size].to_vec()
2688        };
2689
2690        // Re-encode multi-byte values if byte orders differ
2691        let final_data = if src_bo != target_bo && type_size > 1 {
2692            reencode_bytes(&raw_data, dtype, count_val as usize, src_bo, target_bo)
2693        } else {
2694            raw_data[..total_size].to_vec()
2695        };
2696
2697        let format = match dtype {
2698            1 => exif_writer::ExifFormat::Byte,
2699            2 => exif_writer::ExifFormat::Ascii,
2700            3 => exif_writer::ExifFormat::Short,
2701            4 => exif_writer::ExifFormat::Long,
2702            5 => exif_writer::ExifFormat::Rational,
2703            6 => exif_writer::ExifFormat::SByte,
2704            7 => exif_writer::ExifFormat::Undefined,
2705            8 => exif_writer::ExifFormat::SShort,
2706            9 => exif_writer::ExifFormat::SLong,
2707            10 => exif_writer::ExifFormat::SRational,
2708            11 => exif_writer::ExifFormat::Float,
2709            12 => exif_writer::ExifFormat::Double,
2710            _ => continue,
2711        };
2712
2713        entries.push(exif_writer::IfdEntry {
2714            tag,
2715            format,
2716            data: final_data,
2717        });
2718    }
2719}
2720
2721/// Re-encode multi-byte values when converting between byte orders.
2722fn reencode_bytes(
2723    data: &[u8],
2724    dtype: u16,
2725    count: usize,
2726    src_bo: ByteOrderMark,
2727    dst_bo: ByteOrderMark,
2728) -> Vec<u8> {
2729    let mut out = Vec::with_capacity(data.len());
2730    match dtype {
2731        3 | 8 => {
2732            // 16-bit
2733            for i in 0..count {
2734                let v = read_u16_bo(data, i * 2, src_bo);
2735                match dst_bo {
2736                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
2737                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
2738                }
2739            }
2740        }
2741        4 | 9 | 11 | 13 => {
2742            // 32-bit
2743            for i in 0..count {
2744                let v = read_u32_bo(data, i * 4, src_bo);
2745                match dst_bo {
2746                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
2747                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
2748                }
2749            }
2750        }
2751        5 | 10 => {
2752            // Rational (two 32-bit)
2753            for i in 0..count {
2754                let n = read_u32_bo(data, i * 8, src_bo);
2755                let d = read_u32_bo(data, i * 8 + 4, src_bo);
2756                match dst_bo {
2757                    ByteOrderMark::LittleEndian => {
2758                        out.extend_from_slice(&n.to_le_bytes());
2759                        out.extend_from_slice(&d.to_le_bytes());
2760                    }
2761                    ByteOrderMark::BigEndian => {
2762                        out.extend_from_slice(&n.to_be_bytes());
2763                        out.extend_from_slice(&d.to_be_bytes());
2764                    }
2765                }
2766            }
2767        }
2768        12 => {
2769            // 64-bit double
2770            for i in 0..count {
2771                let mut bytes = [0u8; 8];
2772                bytes.copy_from_slice(&data[i * 8..i * 8 + 8]);
2773                if src_bo != dst_bo {
2774                    bytes.reverse();
2775                }
2776                out.extend_from_slice(&bytes);
2777            }
2778        }
2779        _ => out.extend_from_slice(data),
2780    }
2781    out
2782}
2783
2784fn read_u16_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u16 {
2785    if offset + 2 > data.len() {
2786        return 0;
2787    }
2788    match bo {
2789        ByteOrderMark::LittleEndian => u16::from_le_bytes([data[offset], data[offset + 1]]),
2790        ByteOrderMark::BigEndian => u16::from_be_bytes([data[offset], data[offset + 1]]),
2791    }
2792}
2793
2794fn read_u32_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u32 {
2795    if offset + 4 > data.len() {
2796        return 0;
2797    }
2798    match bo {
2799        ByteOrderMark::LittleEndian => u32::from_le_bytes([
2800            data[offset],
2801            data[offset + 1],
2802            data[offset + 2],
2803            data[offset + 3],
2804        ]),
2805        ByteOrderMark::BigEndian => u32::from_be_bytes([
2806            data[offset],
2807            data[offset + 1],
2808            data[offset + 2],
2809            data[offset + 3],
2810        ]),
2811    }
2812}
2813
2814/// Map tag name to numeric EXIF tag ID.
2815fn tag_name_to_id(name: &str) -> Option<u16> {
2816    encode_exif_tag(name, "", "", ByteOrderMark::BigEndian).map(|(id, _, _)| id)
2817}
2818
2819/// Convert a tag value to a safe filename.
2820fn value_to_filename(value: &str) -> String {
2821    value
2822        .chars()
2823        .map(|c| match c {
2824            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2825            c if c.is_control() => '_',
2826            c => c,
2827        })
2828        .collect::<String>()
2829        .trim()
2830        .to_string()
2831}
2832
2833/// Parse a date shift string like "+1:0:0" (add 1 hour) or "-0:30:0" (subtract 30 min).
2834/// Returns (sign, hours, minutes, seconds).
2835pub fn parse_date_shift(shift: &str) -> Option<(i32, u32, u32, u32)> {
2836    let (sign, rest) = if let Some(stripped) = shift.strip_prefix('-') {
2837        (-1, stripped)
2838    } else if let Some(stripped) = shift.strip_prefix('+') {
2839        (1, stripped)
2840    } else {
2841        (1, shift)
2842    };
2843
2844    let parts: Vec<&str> = rest.split(':').collect();
2845    match parts.len() {
2846        1 => {
2847            let h: u32 = parts[0].parse().ok()?;
2848            Some((sign, h, 0, 0))
2849        }
2850        2 => {
2851            let h: u32 = parts[0].parse().ok()?;
2852            let m: u32 = parts[1].parse().ok()?;
2853            Some((sign, h, m, 0))
2854        }
2855        3 => {
2856            let h: u32 = parts[0].parse().ok()?;
2857            let m: u32 = parts[1].parse().ok()?;
2858            let s: u32 = parts[2].parse().ok()?;
2859            Some((sign, h, m, s))
2860        }
2861        _ => None,
2862    }
2863}
2864
2865/// Shift a datetime string by the given amount.
2866/// Input format: "YYYY:MM:DD HH:MM:SS"
2867pub fn shift_datetime(datetime: &str, shift: &str) -> Option<String> {
2868    let (sign, hours, minutes, seconds) = parse_date_shift(shift)?;
2869
2870    // Parse date/time
2871    if datetime.len() < 19 {
2872        return None;
2873    }
2874    let year: i32 = datetime[0..4].parse().ok()?;
2875    let month: u32 = datetime[5..7].parse().ok()?;
2876    let day: u32 = datetime[8..10].parse().ok()?;
2877    let hour: u32 = datetime[11..13].parse().ok()?;
2878    let min: u32 = datetime[14..16].parse().ok()?;
2879    let sec: u32 = datetime[17..19].parse().ok()?;
2880
2881    // Convert to total seconds, shift, convert back
2882    let total_secs = (hour * 3600 + min * 60 + sec) as i64
2883        + sign as i64 * (hours * 3600 + minutes * 60 + seconds) as i64;
2884
2885    let days_shift = if total_secs < 0 {
2886        -1 - (-total_secs - 1) / 86400
2887    } else {
2888        total_secs / 86400
2889    };
2890
2891    let time_secs = ((total_secs % 86400) + 86400) % 86400;
2892    let new_hour = (time_secs / 3600) as u32;
2893    let new_min = ((time_secs % 3600) / 60) as u32;
2894    let new_sec = (time_secs % 60) as u32;
2895
2896    // Simple day shifting (doesn't handle month/year rollover perfectly for large shifts)
2897    let mut new_day = day as i32 + days_shift as i32;
2898    let mut new_month = month;
2899    let mut new_year = year;
2900
2901    let days_in_month = |m: u32, y: i32| -> i32 {
2902        match m {
2903            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2904            4 | 6 | 9 | 11 => 30,
2905            2 => {
2906                if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
2907                    29
2908                } else {
2909                    28
2910                }
2911            }
2912            _ => 30,
2913        }
2914    };
2915
2916    while new_day > days_in_month(new_month, new_year) {
2917        new_day -= days_in_month(new_month, new_year);
2918        new_month += 1;
2919        if new_month > 12 {
2920            new_month = 1;
2921            new_year += 1;
2922        }
2923    }
2924    while new_day < 1 {
2925        new_month = if new_month == 1 { 12 } else { new_month - 1 };
2926        if new_month == 12 {
2927            new_year -= 1;
2928        }
2929        new_day += days_in_month(new_month, new_year);
2930    }
2931
2932    Some(format!(
2933        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}",
2934        new_year, new_month, new_day, new_hour, new_min, new_sec
2935    ))
2936}
2937
2938// Only used by the `#[cfg(unix)]` File:System FilePermissions pseudo-tag above;
2939// not compiled on Windows (which lacks Unix mode bits).
2940//
2941// Port of ExifTool's FilePermissions PrintConv: a leading file-type character
2942// (`-` for a regular file, `d`, `l`, …) followed by nine r/w/x flags for
2943// owner/group/other, e.g. mode 0o100664 → "-rw-rw-r--".
2944#[cfg(unix)]
2945fn format_file_permissions(mode: u32) -> String {
2946    let type_char = match mode & 0o170000 {
2947        0o010000 => 'p', // FIFO
2948        0o020000 => 'c', // character special
2949        0o040000 => 'd', // directory
2950        0o060000 => 'b', // block special
2951        0o120000 => 'l', // symlink
2952        0o140000 => 's', // socket
2953        _ => '-',
2954    };
2955    let mut s = String::with_capacity(10);
2956    s.push(type_char);
2957    let mut mask = 0o400u32;
2958    while mask > 0 {
2959        for ch in ['r', 'w', 'x'] {
2960            s.push(if mode & mask != 0 { ch } else { '-' });
2961            mask >>= 1;
2962        }
2963    }
2964    s
2965}
2966
2967/// File contents exposed as a byte slice, backed either by a memory map or — when
2968/// mapping is unavailable (empty file, unsupported FS, mapping error) — an owned
2969/// buffer. Both `Deref` to `[u8]` so callers are agnostic to the backing store.
2970enum FileData {
2971    Mapped(memmap2::Mmap),
2972    Owned(Vec<u8>),
2973}
2974
2975impl std::ops::Deref for FileData {
2976    type Target = [u8];
2977    fn deref(&self) -> &[u8] {
2978        match self {
2979            FileData::Mapped(m) => m,
2980            FileData::Owned(v) => v,
2981        }
2982    }
2983}
2984
2985/// Map a file read-only for parsing. Zero-length files (which cannot be mapped)
2986/// and any mapping failure fall back to a plain `fs::read`.
2987fn map_file_for_read(path: &Path) -> Result<FileData> {
2988    let file = fs::File::open(path).map_err(Error::Io)?;
2989    let len = file.metadata().map_err(Error::Io)?.len();
2990    if len == 0 {
2991        return Ok(FileData::Owned(Vec::new()));
2992    }
2993    // SAFETY: the mapping is only ever read, never written, and the `Mmap` is
2994    // dropped before `extract_info` returns. If another process truncates the
2995    // file mid-parse the kernel may raise SIGBUS — the same exposure ExifTool's
2996    // own random-access reads have; acceptable for a read-only metadata tool.
2997    match unsafe { memmap2::Mmap::map(&file) } {
2998        Ok(m) => Ok(FileData::Mapped(m)),
2999        Err(_) => Ok(FileData::Owned(fs::read(path).map_err(Error::Io)?)),
3000    }
3001}
3002
3003/// Port of ExifTool ConvertFileSize (decimal units): %.1f below 10× a unit, %.0f above.
3004fn format_file_size(bytes: u64) -> String {
3005    let v = bytes as f64;
3006    if bytes < 2000 {
3007        format!("{} bytes", bytes)
3008    } else if bytes < 10_000 {
3009        format!("{:.1} kB", v / 1000.0)
3010    } else if bytes < 2_000_000 {
3011        format!("{:.0} kB", v / 1000.0)
3012    } else if bytes < 10_000_000 {
3013        format!("{:.1} MB", v / 1_000_000.0)
3014    } else if bytes < 2_000_000_000 {
3015        format!("{:.0} MB", v / 1_000_000.0)
3016    } else if bytes < 10_000_000_000 {
3017        format!("{:.1} GB", v / 1_000_000_000.0)
3018    } else {
3019        format!("{:.0} GB", v / 1_000_000_000.0)
3020    }
3021}
3022
3023/// Check if a tag name is typically XMP.
3024fn is_xmp_tag(tag: &str) -> bool {
3025    matches!(
3026        tag.to_lowercase().as_str(),
3027        "title"
3028            | "description"
3029            | "subject"
3030            | "creator"
3031            | "rights"
3032            | "keywords"
3033            | "rating"
3034            | "label"
3035            | "hierarchicalsubject"
3036    )
3037}
3038
3039/// Encode an EXIF tag value to binary.
3040/// Returns (tag_id, format, encoded_data) or None if tag is unknown.
3041fn encode_exif_tag(
3042    tag_name: &str,
3043    value: &str,
3044    _group: &str,
3045    bo: ByteOrderMark,
3046) -> Option<(u16, exif_writer::ExifFormat, Vec<u8>)> {
3047    let tag_lower = tag_name.to_lowercase();
3048
3049    // Map common tag names to EXIF tag IDs and formats
3050    let (tag_id, format): (u16, exif_writer::ExifFormat) = match tag_lower.as_str() {
3051        // IFD0 string tags
3052        "imagedescription" => (0x010E, exif_writer::ExifFormat::Ascii),
3053        "make" => (0x010F, exif_writer::ExifFormat::Ascii),
3054        "model" => (0x0110, exif_writer::ExifFormat::Ascii),
3055        "software" => (0x0131, exif_writer::ExifFormat::Ascii),
3056        "modifydate" | "datetime" => (0x0132, exif_writer::ExifFormat::Ascii),
3057        "artist" => (0x013B, exif_writer::ExifFormat::Ascii),
3058        "copyright" => (0x8298, exif_writer::ExifFormat::Ascii),
3059        // IFD0 numeric tags
3060        "orientation" => (0x0112, exif_writer::ExifFormat::Short),
3061        "xresolution" => (0x011A, exif_writer::ExifFormat::Rational),
3062        "yresolution" => (0x011B, exif_writer::ExifFormat::Rational),
3063        "resolutionunit" => (0x0128, exif_writer::ExifFormat::Short),
3064        // ExifIFD tags
3065        "datetimeoriginal" => (0x9003, exif_writer::ExifFormat::Ascii),
3066        "createdate" | "datetimedigitized" => (0x9004, exif_writer::ExifFormat::Ascii),
3067        "usercomment" => (0x9286, exif_writer::ExifFormat::Undefined),
3068        "imageuniqueid" => (0xA420, exif_writer::ExifFormat::Ascii),
3069        "ownername" | "cameraownername" => (0xA430, exif_writer::ExifFormat::Ascii),
3070        "serialnumber" | "bodyserialnumber" => (0xA431, exif_writer::ExifFormat::Ascii),
3071        "lensmake" => (0xA433, exif_writer::ExifFormat::Ascii),
3072        "lensmodel" => (0xA434, exif_writer::ExifFormat::Ascii),
3073        "lensserialnumber" => (0xA435, exif_writer::ExifFormat::Ascii),
3074        _ => return None,
3075    };
3076
3077    let encoded = match format {
3078        exif_writer::ExifFormat::Ascii => exif_writer::encode_ascii(value),
3079        exif_writer::ExifFormat::Short => {
3080            let v: u16 = value.parse().ok()?;
3081            exif_writer::encode_u16(v, bo)
3082        }
3083        exif_writer::ExifFormat::Long => {
3084            let v: u32 = value.parse().ok()?;
3085            exif_writer::encode_u32(v, bo)
3086        }
3087        exif_writer::ExifFormat::Rational => {
3088            // Parse "N/D" or just "N"
3089            if let Some(slash) = value.find('/') {
3090                let num: u32 = value[..slash].trim().parse().ok()?;
3091                let den: u32 = value[slash + 1..].trim().parse().ok()?;
3092                exif_writer::encode_urational(num, den, bo)
3093            } else if let Ok(v) = value.parse::<f64>() {
3094                // Convert float to rational
3095                let den = 10000u32;
3096                let num = (v * den as f64).round() as u32;
3097                exif_writer::encode_urational(num, den, bo)
3098            } else {
3099                return None;
3100            }
3101        }
3102        exif_writer::ExifFormat::Undefined => {
3103            // UserComment: 8 bytes charset + data
3104            let mut data = vec![0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00]; // "ASCII\0\0\0"
3105            data.extend_from_slice(value.as_bytes());
3106            data
3107        }
3108        _ => return None,
3109    };
3110
3111    Some((tag_id, format, encoded))
3112}
3113
3114/// Compute text file tags (from Perl Text.pm).
3115fn compute_text_tags(data: &[u8], is_csv: bool) -> Vec<Tag> {
3116    let mut tags = Vec::new();
3117    let mk = |name: &str, val: String| Tag {
3118        id: crate::tag::TagId::Text(name.into()),
3119        name: name.into(),
3120        description: name.into(),
3121        group: crate::tag::TagGroup {
3122            family0: "File".into(),
3123            family1: "File".into(),
3124            family2: "Other".into(),
3125        },
3126        raw_value: Value::String(val.clone()),
3127        print_value: val,
3128        priority: 0,
3129    };
3130
3131    // Detect encoding and BOM
3132    let is_ascii = data.iter().all(|&b| b < 128);
3133    let has_utf8_bom = data.starts_with(&[0xEF, 0xBB, 0xBF]);
3134    let has_utf16le_bom =
3135        data.starts_with(&[0xFF, 0xFE]) && !data.starts_with(&[0xFF, 0xFE, 0x00, 0x00]);
3136    let has_utf16be_bom = data.starts_with(&[0xFE, 0xFF]);
3137    let has_utf32le_bom = data.starts_with(&[0xFF, 0xFE, 0x00, 0x00]);
3138    let has_utf32be_bom = data.starts_with(&[0x00, 0x00, 0xFE, 0xFF]);
3139
3140    // Detect if file has weird non-text control characters (like multi-byte unicode without BOM)
3141    let has_weird_ctrl = data.iter().any(|&b| {
3142        (b <= 0x06) || (0x0e..=0x1a).contains(&b) || (0x1c..=0x1f).contains(&b) || b == 0x7f
3143    });
3144
3145    let (encoding, is_bom, is_utf16) = if has_utf32le_bom {
3146        ("utf-32le", true, false)
3147    } else if has_utf32be_bom {
3148        ("utf-32be", true, false)
3149    } else if has_utf16le_bom {
3150        ("utf-16le", true, true)
3151    } else if has_utf16be_bom {
3152        ("utf-16be", true, true)
3153    } else if has_weird_ctrl {
3154        // Not a text file (has binary-like control chars but no recognized multi-byte marker)
3155        return tags;
3156    } else if is_ascii {
3157        ("us-ascii", false, false)
3158    } else {
3159        // Check UTF-8
3160        let is_valid_utf8 = std::str::from_utf8(data).is_ok();
3161        if is_valid_utf8 {
3162            if has_utf8_bom {
3163                ("utf-8", true, false)
3164            } else {
3165                // Check if it has high bytes suggesting iso-8859-1 vs utf-8
3166                // Perl's IsUTF8: returns >0 if valid UTF-8 with multi-byte, 0 if ASCII, <0 if invalid
3167                // For simplicity: valid UTF-8 without BOM = utf-8
3168                ("utf-8", false, false)
3169            }
3170        } else if !data.iter().any(|&b| (0x80..=0x9f).contains(&b)) {
3171            ("iso-8859-1", false, false)
3172        } else {
3173            ("unknown-8bit", false, false)
3174        }
3175    };
3176
3177    tags.push(mk("MIMEEncoding", encoding.into()));
3178
3179    if is_bom {
3180        tags.push(mk("ByteOrderMark", "Yes".into()));
3181    }
3182
3183    // Count newlines and detect type
3184    let has_cr = data.contains(&b'\r');
3185    let has_lf = data.contains(&b'\n');
3186    let newline_type = if has_cr && has_lf {
3187        "Windows CRLF"
3188    } else if has_lf {
3189        "Unix LF"
3190    } else if has_cr {
3191        "Macintosh CR"
3192    } else {
3193        "(none)"
3194    };
3195    tags.push(mk("Newlines", newline_type.into()));
3196
3197    if is_csv {
3198        // CSV analysis: detect delimiter, quoting, column count, row count
3199        let text = crate::encoding::decode_utf8_or_latin1(data);
3200        let mut delim = "";
3201        let mut quot = "";
3202        let mut ncols = 1usize;
3203        let mut nrows = 0usize;
3204
3205        for line in text.lines() {
3206            if nrows == 0 {
3207                // Detect delimiter from first line
3208                let comma_count = line.matches(',').count();
3209                let semi_count = line.matches(';').count();
3210                let tab_count = line.matches('\t').count();
3211                if comma_count > semi_count && comma_count > tab_count {
3212                    delim = ",";
3213                    ncols = comma_count + 1;
3214                } else if semi_count > tab_count {
3215                    delim = ";";
3216                    ncols = semi_count + 1;
3217                } else if tab_count > 0 {
3218                    delim = "\t";
3219                    ncols = tab_count + 1;
3220                } else {
3221                    delim = "";
3222                    ncols = 1;
3223                }
3224                // Detect quoting
3225                if line.contains('"') {
3226                    quot = "\"";
3227                } else if line.contains('\'') {
3228                    quot = "'";
3229                }
3230            }
3231            nrows += 1;
3232            if nrows >= 1000 {
3233                break;
3234            }
3235        }
3236
3237        let delim_display = match delim {
3238            "," => "Comma",
3239            ";" => "Semicolon",
3240            "\t" => "Tab",
3241            _ => "(none)",
3242        };
3243        let quot_display = match quot {
3244            "\"" => "Double quotes",
3245            "'" => "Single quotes",
3246            _ => "(none)",
3247        };
3248
3249        tags.push(mk("Delimiter", delim_display.into()));
3250        tags.push(mk("Quoting", quot_display.into()));
3251        tags.push(mk("ColumnCount", ncols.to_string()));
3252        if nrows > 0 {
3253            tags.push(mk("RowCount", nrows.to_string()));
3254        }
3255    } else if !is_utf16 {
3256        // Line count and word count for plain text files (not UTF-16/32)
3257        // ExifTool counts each ReadLine, so trailing content without a final newline
3258        // still counts as a line.
3259        let nl_count = data.iter().filter(|&&b| b == b'\n').count();
3260        let line_count = if !data.is_empty() && data.last() != Some(&b'\n') {
3261            nl_count + 1
3262        } else {
3263            nl_count
3264        };
3265        tags.push(mk("LineCount", line_count.to_string()));
3266
3267        let text = crate::encoding::decode_utf8_or_latin1(data);
3268        let word_count = text.split_whitespace().count();
3269        tags.push(mk("WordCount", word_count.to_string()));
3270    }
3271
3272    tags
3273}
3274
3275#[cfg(test)]
3276mod tests {
3277    use super::*;
3278
3279    #[test]
3280    fn new_has_default_options() {
3281        let et = ExifTool::new();
3282        assert!(!et.options().duplicates);
3283        assert!(et.options().print_conv);
3284        assert_eq!(et.options().fast_scan, 0);
3285        assert!(et.options().requested_tags.is_empty());
3286        assert_eq!(et.options().extract_embedded, 0);
3287        assert_eq!(et.options().show_unknown, 0);
3288        assert!(!et.options().process_compressed);
3289        assert!(!et.options().use_mwg);
3290    }
3291
3292    #[test]
3293    fn with_options_preserves_custom() {
3294        let opts = Options {
3295            duplicates: true,
3296            print_conv: false,
3297            fast_scan: 2,
3298            requested_tags: vec!["Artist".to_string()],
3299            extract_embedded: 1,
3300            show_unknown: 1,
3301            process_compressed: true,
3302            use_mwg: true,
3303            geolocation: true,
3304        };
3305        let et = ExifTool::with_options(opts.clone());
3306        assert!(et.options().duplicates);
3307        assert!(!et.options().print_conv);
3308        assert_eq!(et.options().fast_scan, 2);
3309        assert_eq!(et.options().requested_tags, vec!["Artist".to_string()]);
3310        assert_eq!(et.options().extract_embedded, 1);
3311        assert_eq!(et.options().show_unknown, 1);
3312        assert!(et.options().process_compressed);
3313        assert!(et.options().use_mwg);
3314    }
3315
3316    #[test]
3317    fn set_new_value_simple_tag() {
3318        let mut et = ExifTool::new();
3319        et.set_new_value("Artist", Some("John"));
3320        assert_eq!(et.new_values.len(), 1);
3321        assert_eq!(et.new_values[0].tag, "Artist");
3322        assert_eq!(et.new_values[0].group, None);
3323        assert_eq!(et.new_values[0].value, Some("John".to_string()));
3324    }
3325
3326    #[test]
3327    fn set_new_value_with_group_prefix() {
3328        let mut et = ExifTool::new();
3329        et.set_new_value("XMP:Title", Some("Test"));
3330        assert_eq!(et.new_values.len(), 1);
3331        assert_eq!(et.new_values[0].tag, "Title");
3332        assert_eq!(et.new_values[0].group, Some("XMP".to_string()));
3333        assert_eq!(et.new_values[0].value, Some("Test".to_string()));
3334    }
3335
3336    #[test]
3337    fn set_new_value_delete() {
3338        let mut et = ExifTool::new();
3339        et.set_new_value("Comment", None);
3340        assert_eq!(et.new_values.len(), 1);
3341        assert_eq!(et.new_values[0].tag, "Comment");
3342        assert_eq!(et.new_values[0].value, None);
3343    }
3344
3345    #[test]
3346    fn clear_new_values_empties_queue() {
3347        let mut et = ExifTool::new();
3348        et.set_new_value("Artist", Some("A"));
3349        et.set_new_value("Copyright", Some("B"));
3350        assert_eq!(et.new_values.len(), 2);
3351        et.clear_new_values();
3352        assert!(et.new_values.is_empty());
3353    }
3354
3355    #[test]
3356    fn set_new_value_multiple() {
3357        let mut et = ExifTool::new();
3358        et.set_new_value("Artist", Some("John"));
3359        et.set_new_value("IPTC:Keywords", Some("test"));
3360        et.set_new_value("XMP:Subject", None);
3361        assert_eq!(et.new_values.len(), 3);
3362        assert_eq!(et.new_values[1].group, Some("IPTC".to_string()));
3363        assert_eq!(et.new_values[1].tag, "Keywords");
3364        assert_eq!(et.new_values[2].value, None);
3365    }
3366
3367    #[test]
3368    fn options_mut_modifies() {
3369        let mut et = ExifTool::new();
3370        et.options_mut().duplicates = true;
3371        et.options_mut().fast_scan = 3;
3372        assert!(et.options().duplicates);
3373        assert_eq!(et.options().fast_scan, 3);
3374    }
3375
3376    #[test]
3377    fn default_options() {
3378        let opts = Options::default();
3379        assert!(!opts.duplicates);
3380        assert!(opts.print_conv);
3381        assert_eq!(opts.fast_scan, 0);
3382    }
3383}