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, TagGroup, MAIN_DOCUMENT};
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                        // IPTC-IIM strings use the internal charset (Latin-1
704                        // by default); writing raw UTF-8 double-encodes
705                        // accented characters. See issue #6.
706                        data: crate::encoding::encode_latin1(value),
707                    })
708                })
709                .collect();
710            if records.is_empty() {
711                None
712            } else {
713                Some(iptc_writer::build_iptc(&records))
714            }
715        } else {
716            None
717        };
718
719        // Rewrite JPEG
720        jpeg_writer::write_jpeg(
721            data,
722            new_exif.as_deref(),
723            new_xmp.as_deref(),
724            new_iptc_data.as_deref(),
725            comment_value,
726            remove_exif,
727            remove_xmp,
728            remove_iptc,
729            remove_comment,
730        )
731    }
732
733    /// Build new EXIF data by merging existing EXIF with queued changes.
734    fn build_new_exif(&self, jpeg_data: &[u8], values: &[&NewValue]) -> Result<Vec<u8>> {
735        let bo = ByteOrderMark::BigEndian;
736        let mut ifd0_entries = Vec::new();
737        let mut exif_entries = Vec::new();
738        let mut gps_entries = Vec::new();
739
740        // Step 1: Extract existing EXIF entries from the JPEG
741        let existing = extract_existing_exif_entries(jpeg_data, bo);
742        for entry in &existing {
743            match classify_exif_tag(entry.tag) {
744                ExifIfdGroup::Ifd0 => ifd0_entries.push(entry.clone()),
745                ExifIfdGroup::ExifIfd => exif_entries.push(entry.clone()),
746                ExifIfdGroup::Gps => gps_entries.push(entry.clone()),
747            }
748        }
749
750        // Step 2: Apply queued changes (add/replace/delete)
751        let deleted_tags: Vec<u16> = values
752            .iter()
753            .filter(|nv| nv.value.is_none())
754            .filter_map(|nv| tag_name_to_id(&nv.tag))
755            .collect();
756
757        // Remove deleted tags
758        ifd0_entries.retain(|e| !deleted_tags.contains(&e.tag));
759        exif_entries.retain(|e| !deleted_tags.contains(&e.tag));
760        gps_entries.retain(|e| !deleted_tags.contains(&e.tag));
761
762        // Add/replace new values
763        for nv in values {
764            if nv.value.is_none() {
765                continue;
766            }
767            let value_str = nv.value.as_deref().unwrap_or("");
768            let group = nv.group.as_deref().unwrap_or("");
769
770            if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, value_str, group, bo)
771            {
772                let entry = exif_writer::IfdEntry {
773                    tag: tag_id,
774                    format,
775                    data: encoded,
776                };
777
778                let target = match group.to_uppercase().as_str() {
779                    "GPS" => &mut gps_entries,
780                    "EXIFIFD" => &mut exif_entries,
781                    _ => match classify_exif_tag(tag_id) {
782                        ExifIfdGroup::ExifIfd => &mut exif_entries,
783                        ExifIfdGroup::Gps => &mut gps_entries,
784                        ExifIfdGroup::Ifd0 => &mut ifd0_entries,
785                    },
786                };
787
788                // Replace existing or add new
789                if let Some(existing) = target.iter_mut().find(|e| e.tag == tag_id) {
790                    *existing = entry;
791                } else {
792                    target.push(entry);
793                }
794            }
795        }
796
797        // Remove sub-IFD pointers from entries (they'll be rebuilt by build_exif)
798        ifd0_entries.retain(|e| e.tag != 0x8769 && e.tag != 0x8825 && e.tag != 0xA005);
799
800        exif_writer::build_exif(&ifd0_entries, &exif_entries, &gps_entries, bo)
801    }
802
803    /// Write metadata changes to PNG data.
804    fn write_png(&self, data: &[u8]) -> Result<Vec<u8>> {
805        let mut new_text: Vec<(&str, &str)> = Vec::new();
806        let mut remove_text: Vec<&str> = Vec::new();
807
808        // Collect text-based changes
809        // We need to hold the strings in vectors that live long enough
810        let owned_pairs: Vec<(String, String)> = self
811            .new_values
812            .iter()
813            .filter(|nv| nv.value.is_some())
814            .map(|nv| (nv.tag.clone(), nv.value.clone().unwrap()))
815            .collect();
816
817        for (tag, value) in &owned_pairs {
818            new_text.push((tag.as_str(), value.as_str()));
819        }
820
821        for nv in &self.new_values {
822            if nv.value.is_none() {
823                remove_text.push(&nv.tag);
824            }
825        }
826
827        png_writer::write_png(data, &new_text, None, &remove_text)
828    }
829
830    /// Write metadata changes to PSD data.
831    fn write_psd(&self, data: &[u8]) -> Result<Vec<u8>> {
832        let mut iptc_values = Vec::new();
833        let mut xmp_values = Vec::new();
834
835        for nv in &self.new_values {
836            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
837            match group.as_str() {
838                "XMP" => xmp_values.push(nv),
839                "IPTC" => iptc_values.push(nv),
840                _ => {
841                    if is_xmp_tag(&nv.tag) {
842                        xmp_values.push(nv);
843                    } else {
844                        iptc_values.push(nv);
845                    }
846                }
847            }
848        }
849
850        let new_iptc = if !iptc_values.is_empty() {
851            let records: Vec<_> = iptc_values
852                .iter()
853                .filter_map(|nv| {
854                    let value = nv.value.as_deref()?;
855                    let (record, dataset) = iptc_writer::tag_name_to_iptc(&nv.tag)?;
856                    Some(iptc_writer::IptcRecord {
857                        record,
858                        dataset,
859                        // IPTC-IIM strings use the internal charset (Latin-1
860                        // by default); writing raw UTF-8 double-encodes
861                        // accented characters. See issue #6.
862                        data: crate::encoding::encode_latin1(value),
863                    })
864                })
865                .collect();
866            if records.is_empty() {
867                None
868            } else {
869                Some(iptc_writer::build_iptc(&records))
870            }
871        } else {
872            None
873        };
874
875        let new_xmp = if !xmp_values.is_empty() {
876            let refs: Vec<&NewValue> = xmp_values.to_vec();
877            Some(self.build_new_xmp(&refs))
878        } else {
879            None
880        };
881
882        psd_writer::write_psd(data, new_iptc.as_deref(), new_xmp.as_deref())
883    }
884
885    /// Write metadata changes to Matroska (MKV/WebM) data.
886    fn write_matroska(&self, data: &[u8]) -> Result<Vec<u8>> {
887        let changes: Vec<(&str, &str)> = self
888            .new_values
889            .iter()
890            .filter_map(|nv| {
891                let value = nv.value.as_deref()?;
892                Some((nv.tag.as_str(), value))
893            })
894            .collect();
895
896        matroska_writer::write_matroska(data, &changes)
897    }
898
899    /// Write metadata changes to PDF data.
900    fn write_pdf(&self, data: &[u8]) -> Result<Vec<u8>> {
901        let changes: Vec<(&str, &str)> = self
902            .new_values
903            .iter()
904            .filter_map(|nv| {
905                let value = nv.value.as_deref()?;
906                Some((nv.tag.as_str(), value))
907            })
908            .collect();
909
910        pdf_writer::write_pdf(data, &changes)
911    }
912
913    /// Write metadata changes to MP4/MOV data.
914    fn write_mp4(&self, data: &[u8]) -> Result<Vec<u8>> {
915        let mut ilst_tags: Vec<([u8; 4], String)> = Vec::new();
916        let mut xmp_values: Vec<&NewValue> = Vec::new();
917
918        for nv in &self.new_values {
919            if nv.value.is_none() {
920                continue;
921            }
922            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
923            if group == "XMP" {
924                xmp_values.push(nv);
925            } else if let Some(key) = mp4_writer::tag_to_ilst_key(&nv.tag) {
926                ilst_tags.push((key, nv.value.clone().unwrap()));
927            }
928        }
929
930        let tag_refs: Vec<(&[u8; 4], &str)> =
931            ilst_tags.iter().map(|(k, v)| (k, v.as_str())).collect();
932
933        let new_xmp = if !xmp_values.is_empty() {
934            let refs: Vec<&NewValue> = xmp_values.to_vec();
935            Some(self.build_new_xmp(&refs))
936        } else {
937            None
938        };
939
940        mp4_writer::write_mp4(data, &tag_refs, new_xmp.as_deref())
941    }
942
943    /// Write metadata changes to WebP data.
944    fn write_webp(&self, data: &[u8]) -> Result<Vec<u8>> {
945        let mut exif_values: Vec<&NewValue> = Vec::new();
946        let mut xmp_values: Vec<&NewValue> = Vec::new();
947        let mut remove_exif = false;
948        let mut remove_xmp = false;
949
950        for nv in &self.new_values {
951            let group = nv.group.as_deref().unwrap_or("").to_uppercase();
952            if nv.value.is_none() && nv.tag == "*" {
953                if group == "EXIF" {
954                    remove_exif = true;
955                }
956                if group == "XMP" {
957                    remove_xmp = true;
958                }
959                continue;
960            }
961            match group.as_str() {
962                "XMP" => xmp_values.push(nv),
963                _ => exif_values.push(nv),
964            }
965        }
966
967        let new_exif = if !exif_values.is_empty() {
968            let bo = ByteOrderMark::BigEndian;
969            let mut entries = Vec::new();
970            for nv in &exif_values {
971                if let Some(ref v) = nv.value {
972                    let group = nv.group.as_deref().unwrap_or("");
973                    if let Some((tag_id, format, encoded)) = encode_exif_tag(&nv.tag, v, group, bo)
974                    {
975                        entries.push(exif_writer::IfdEntry {
976                            tag: tag_id,
977                            format,
978                            data: encoded,
979                        });
980                    }
981                }
982            }
983            if !entries.is_empty() {
984                Some(exif_writer::build_exif(&entries, &[], &[], bo)?)
985            } else {
986                None
987            }
988        } else {
989            None
990        };
991
992        let new_xmp = if !xmp_values.is_empty() {
993            Some(self.build_new_xmp(&xmp_values.to_vec()))
994        } else {
995            None
996        };
997
998        webp_writer::write_webp(
999            data,
1000            new_exif.as_deref(),
1001            new_xmp.as_deref(),
1002            remove_exif,
1003            remove_xmp,
1004        )
1005    }
1006
1007    /// Write metadata changes to TIFF data.
1008    fn write_tiff(&self, data: &[u8]) -> Result<Vec<u8>> {
1009        let bo = if data.starts_with(b"II") {
1010            ByteOrderMark::LittleEndian
1011        } else {
1012            ByteOrderMark::BigEndian
1013        };
1014
1015        let mut changes: Vec<(u16, Vec<u8>)> = Vec::new();
1016        for nv in &self.new_values {
1017            if let Some(ref value) = nv.value {
1018                let group = nv.group.as_deref().unwrap_or("");
1019                if let Some((tag_id, _format, encoded)) = encode_exif_tag(&nv.tag, value, group, bo)
1020                {
1021                    changes.push((tag_id, encoded));
1022                }
1023            }
1024        }
1025
1026        tiff_writer::write_tiff(data, &changes)
1027    }
1028
1029    /// Build new XMP data from queued values.
1030    fn build_new_xmp(&self, values: &[&NewValue]) -> Vec<u8> {
1031        let mut properties = Vec::new();
1032
1033        for nv in values {
1034            let value_str = match &nv.value {
1035                Some(v) => v.clone(),
1036                None => continue,
1037            };
1038
1039            let ns = nv.group.as_deref().unwrap_or("dc").to_lowercase();
1040            let ns = if ns == "xmp" { "xmp".to_string() } else { ns };
1041
1042            let prop_type = match nv.tag.to_lowercase().as_str() {
1043                "title" | "description" | "rights" => xmp_writer::XmpPropertyType::LangAlt,
1044                "subject" | "keywords" => xmp_writer::XmpPropertyType::Bag,
1045                "creator" => xmp_writer::XmpPropertyType::Seq,
1046                _ => xmp_writer::XmpPropertyType::Simple,
1047            };
1048
1049            let values = if matches!(
1050                prop_type,
1051                xmp_writer::XmpPropertyType::Bag | xmp_writer::XmpPropertyType::Seq
1052            ) {
1053                value_str.split(',').map(|s| s.trim().to_string()).collect()
1054            } else {
1055                vec![value_str]
1056            };
1057
1058            properties.push(xmp_writer::XmpProperty {
1059                namespace: ns,
1060                property: nv.tag.clone(),
1061                values,
1062                prop_type,
1063            });
1064        }
1065
1066        xmp_writer::build_xmp(&properties).into_bytes()
1067    }
1068
1069    // ================================================================
1070    // Reading API
1071    // ================================================================
1072
1073    /// Extract metadata from a file and return a simple name→value map.
1074    ///
1075    /// This is the high-level one-shot API, equivalent to ExifTool's `ImageInfo()`.
1076    pub fn image_info<P: AsRef<Path>>(&self, path: P) -> Result<ImageInfo> {
1077        let tags = self.extract_info(path)?;
1078        Ok(self.get_info(&tags))
1079    }
1080
1081    /// Extract all metadata tags from a file.
1082    ///
1083    /// Returns the full `Tag` structs with groups, raw values, etc.
1084    pub fn extract_info<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Tag>> {
1085        let path = path.as_ref();
1086        // Memory-map the file instead of reading it fully into a Vec. Our format
1087        // readers walk container structures by offset (mp4/mov skip `mdat`, Matroska
1088        // stops at the first Cluster), so only the header pages are ever faulted in —
1089        // a multi-gigabyte video is parsed by touching a few MB, not by allocating
1090        // and reading the whole file. Falls back to a plain read when mapping fails.
1091        let data = map_file_for_read(path)?;
1092        self.extract_info_from_bytes(&data, path)
1093    }
1094
1095    /// Extract metadata from in-memory data.
1096    pub fn extract_info_from_bytes(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
1097        // Propagate show_unknown to EXIF/MakerNotes parsers via thread-local
1098        crate::metadata::exif::set_show_unknown(self.options.show_unknown);
1099        // Propagate the Duplicates option (see `collapse_duplicates` below) to the
1100        // EXIF/MakerNotes reader, whose name-level pruning must not run when every
1101        // instance has to be reported.
1102        crate::metadata::exif::set_keep_duplicates(
1103            self.options.duplicates || self.options.extract_embedded > 0,
1104        );
1105        // Propagate process_compressed to format readers via thread-local
1106        crate::formats::pdf::set_process_compressed(self.options.process_compressed);
1107
1108        let file_type_result = self.detect_file_type(data, path);
1109        let (file_type, mut tags) = match file_type_result {
1110            Ok(ft) => {
1111                let t = self
1112                    .process_file(data, ft)
1113                    .or_else(|_| self.process_by_extension(data, path))?;
1114                (Some(ft), t)
1115            }
1116            Err(_) => {
1117                // File type unknown by magic/extension — try extension-based fallback
1118                let t = self.process_by_extension(data, path)?;
1119                (None, t)
1120            }
1121        };
1122        let file_type = file_type.unwrap_or(FileType::Zip); // placeholder for file-level tags
1123
1124        // Some types refine their FileType/MIMEType/extension from the content
1125        // (ExifTool SetFileType): e.g. EXE -> "Win32 EXE" / "ELF executable" / Mach-O.
1126        let default_tags = || {
1127            (
1128                file_type.code().to_string(),
1129                file_type.mime_type().to_string(),
1130                file_type
1131                    .extensions()
1132                    .first()
1133                    .copied()
1134                    .unwrap_or("")
1135                    .to_string(),
1136            )
1137        };
1138        // Office Open XML (DOCX/XLSX/PPTX/…): ExifTool sub-detects the type from
1139        // [Content_Types].xml (ZIP.pm ProcessZIP -> OOXML.pm ProcessDOCX). The ZIP
1140        // member tags stay in the [ZIP] group; only the three File-group pseudo-tags
1141        // (FileType/MIMEType/FileTypeExtension) change from the generic ZIP identity.
1142        let ooxml = if file_type == FileType::Zip {
1143            crate::formats::zip::detect_ooxml_type(data, path.extension().and_then(|e| e.to_str()))
1144        } else {
1145            None
1146        };
1147        let (ft_code, mime_str, ext_str): (String, String, String) = if file_type == FileType::Exe {
1148            exe_subtype(data)
1149                .map(|(ft, mime, ext)| (ft.to_string(), mime.to_string(), ext.to_string()))
1150                .unwrap_or_else(default_tags)
1151        } else if let Some(triple) = ooxml {
1152            triple
1153        } else if let Some((code, mime)) = refine_filetype_by_content(file_type, data) {
1154            let (_, _, ext) = default_tags();
1155            (code, mime, ext)
1156        } else {
1157            default_tags()
1158        };
1159
1160        // File-level pseudo-tags, emitted in ExifTool's own order.
1161        //
1162        // `ExtractInfo` reports them before it hands the file to a format reader:
1163        // ExifToolVersion (ExifTool.pm:2779), FileName (:2824), Directory (:2830),
1164        // FileSize (:2904), FileModifyDate (:2907), FileAccessDate (:2908),
1165        // FileInodeChangeDate (:2910), FilePermissions (:2921). Every format handler
1166        // then opens with `SetFileType`, which emits FileType, FileTypeExtension and
1167        // MIMEType in that order (ExifTool.pm:9713-9715). ExifByteOrder follows,
1168        // reported while the EXIF block itself is read.
1169        //
1170        // The order is load-bearing, not cosmetic: FoundTag arbitrates duplicates by
1171        // priority and then last-wins, so a pseudo-tag emitted after the format tags
1172        // would take a name it has to lose. They are collected here and spliced in
1173        // front of the format tags.
1174        let mut pre: Vec<Tag> = Vec::new();
1175
1176        // The File/File/Other group below is only a default: the pseudo-tags that
1177        // ExifTool places elsewhere (FileName, Directory, the File*Date tags,
1178        // ExifToolVersion, …) have their groups resolved from `FILE_LEVEL_GROUPS`
1179        // at the end of extraction. Tags that genuinely belong to File:File, such
1180        // as FileTypeExtension and ExifByteOrder, keep this default.
1181
1182        let file_tag = |name: &str, val: Value| -> Tag {
1183            Tag {
1184                id: crate::tag::TagId::Text(name.to_string()),
1185                name: name.to_string(),
1186                description: name.to_string(),
1187                group: crate::tag::TagGroup {
1188                    family0: "File".into(),
1189                    family1: "File".into(),
1190                    family2: "Other".into(),
1191                    family3: "Main".into(),
1192                },
1193                raw_value: val.clone(),
1194                print_value: val.to_display_string(),
1195                priority: 1,
1196            }
1197        };
1198
1199        pre.push(file_tag(
1200            "ExifToolVersion",
1201            Value::String(crate::VERSION.to_string()),
1202        ));
1203
1204        if let Some(fname) = path.file_name().and_then(|n| n.to_str()) {
1205            pre.push(file_tag("FileName", Value::String(fname.to_string())));
1206        }
1207        if let Some(dir) = path.parent().and_then(|p| p.to_str()) {
1208            pre.push(file_tag("Directory", Value::String(dir.to_string())));
1209        }
1210
1211        if let Ok(metadata) = fs::metadata(path) {
1212            pre.push(Tag {
1213                id: crate::tag::TagId::Text("FileSize".into()),
1214                name: "FileSize".into(),
1215                description: "File Size".into(),
1216                group: crate::tag::TagGroup {
1217                    family0: "File".into(),
1218                    family1: "File".into(),
1219                    family2: "Other".into(),
1220                    family3: "Main".into(),
1221                },
1222                // String, not U32: a file may exceed 4 GB (`as u32` would silently
1223                // truncate). `-n` prints this verbatim, matching Perl's raw byte count.
1224                raw_value: Value::String(metadata.len().to_string()),
1225                print_value: format_file_size(metadata.len()),
1226                priority: 0,
1227            });
1228        }
1229
1230        #[cfg(unix)]
1231        if let Ok(metadata) = fs::metadata(path) {
1232            use std::os::unix::fs::MetadataExt;
1233            let mode = metadata.mode();
1234            // Dates use ConvertUnixTime($val, 1): local time with a numeric TZ offset
1235            // (e.g. "2026:06:13 15:14:15+02:00"), same conversion as GZIP's ModifyDate.
1236            use crate::formats::gzip::gzip_unix_to_datetime;
1237            // FileModifyDate
1238            if let Ok(modified) = metadata.modified() {
1239                if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
1240                    let secs = dur.as_secs() as i64;
1241                    pre.push(file_tag(
1242                        "FileModifyDate",
1243                        Value::String(gzip_unix_to_datetime(secs)),
1244                    ));
1245                }
1246            }
1247            // FileAccessDate
1248            if let Ok(accessed) = metadata.accessed() {
1249                if let Ok(dur) = accessed.duration_since(std::time::UNIX_EPOCH) {
1250                    let secs = dur.as_secs() as i64;
1251                    pre.push(file_tag(
1252                        "FileAccessDate",
1253                        Value::String(gzip_unix_to_datetime(secs)),
1254                    ));
1255                }
1256            }
1257            // FileInodeChangeDate (ctime on Unix)
1258            let ctime = metadata.ctime();
1259            if ctime > 0 {
1260                pre.push(file_tag(
1261                    "FileInodeChangeDate",
1262                    Value::String(gzip_unix_to_datetime(ctime)),
1263                ));
1264            }
1265
1266            // Port of ExifTool's FilePermissions: ValueConv is the full mode in octal
1267            // (`sprintf "%.3o"`, includes the file-type bits), PrintConv is the ls-style
1268            // "-rw-rw-r--" string built from those same bits.
1269            pre.push(Tag {
1270                id: crate::tag::TagId::Text("FilePermissions".into()),
1271                name: "FilePermissions".into(),
1272                description: "FilePermissions".into(),
1273                group: crate::tag::TagGroup {
1274                    family0: "File".into(),
1275                    family1: "File".into(),
1276                    family2: "Other".into(),
1277                    family3: "Main".into(),
1278                },
1279                raw_value: Value::String(format!("{:o}", mode)),
1280                print_value: format_file_permissions(mode),
1281                priority: 1,
1282            });
1283        }
1284
1285        pre.push(Tag {
1286            id: crate::tag::TagId::Text("FileType".into()),
1287            name: "FileType".into(),
1288            description: "File Type".into(),
1289            group: crate::tag::TagGroup {
1290                family0: "File".into(),
1291                family1: "File".into(),
1292                family2: "Other".into(),
1293                family3: "Main".into(),
1294            },
1295            raw_value: Value::String(format!("{:?}", file_type)),
1296            // ExifTool's FileType value is the short code ("JPEG"), not the
1297            // human-readable description ("JPEG image").
1298            print_value: ft_code.clone(),
1299            priority: 1,
1300        });
1301
1302        // Use the canonical (first) extension from the FileType, matching Perl ExifTool behavior.
1303        // EXE subtypes emit FileTypeExtension even when empty (ExifTool sets ext='').
1304        if !ext_str.is_empty() || file_type == FileType::Exe {
1305            pre.push(file_tag(
1306                "FileTypeExtension",
1307                Value::String(ext_str.clone()),
1308            ));
1309        }
1310
1311        pre.push(Tag {
1312            id: crate::tag::TagId::Text("MIMEType".into()),
1313            name: "MIMEType".into(),
1314            description: "MIME Type".into(),
1315            group: crate::tag::TagGroup {
1316                family0: "File".into(),
1317                family1: "File".into(),
1318                family2: "Other".into(),
1319                family3: "Main".into(),
1320            },
1321            raw_value: Value::String(mime_str.clone()),
1322            print_value: mime_str.clone(),
1323            priority: 1,
1324        });
1325
1326        // ExifByteOrder (from TIFF header)
1327        {
1328            let bo_str = if data.len() > 8 {
1329                // Check EXIF in JPEG or TIFF header or WebP/RIFF EXIF chunk
1330                let check: Option<&[u8]> = if data.starts_with(&[0xFF, 0xD8]) {
1331                    // JPEG: find APP1 EXIF header
1332                    data.windows(6)
1333                        .position(|w| w == b"Exif\0\0")
1334                        .map(|p| &data[p + 6..])
1335                } else if data.starts_with(b"FUJIFILMCCD-RAW") && data.len() >= 0x60 {
1336                    // RAF: look in the embedded JPEG for EXIF byte order
1337                    let jpeg_offset =
1338                        u32::from_be_bytes([data[0x54], data[0x55], data[0x56], data[0x57]])
1339                            as usize;
1340                    let jpeg_length =
1341                        u32::from_be_bytes([data[0x58], data[0x59], data[0x5A], data[0x5B]])
1342                            as usize;
1343                    if jpeg_offset > 0 && jpeg_offset + jpeg_length <= data.len() {
1344                        let jpeg = &data[jpeg_offset..jpeg_offset + jpeg_length];
1345                        jpeg.windows(6)
1346                            .position(|w| w == b"Exif\0\0")
1347                            .map(|p| &jpeg[p + 6..])
1348                    } else {
1349                        None
1350                    }
1351                } else if data.starts_with(b"RIFF") && data.len() >= 12 {
1352                    // RIFF/WebP: find EXIF chunk
1353                    let mut riff_bo: Option<&[u8]> = None;
1354                    let mut pos = 12usize;
1355                    while pos + 8 <= data.len() {
1356                        let cid = &data[pos..pos + 4];
1357                        let csz = u32::from_le_bytes([
1358                            data[pos + 4],
1359                            data[pos + 5],
1360                            data[pos + 6],
1361                            data[pos + 7],
1362                        ]) as usize;
1363                        let cstart = pos + 8;
1364                        let cend = (cstart + csz).min(data.len());
1365                        if cid == b"EXIF" && cend > cstart {
1366                            let exif_data = &data[cstart..cend];
1367                            let tiff = if exif_data.starts_with(b"Exif\0\0") {
1368                                &exif_data[6..]
1369                            } else {
1370                                exif_data
1371                            };
1372                            riff_bo = Some(tiff);
1373                            break;
1374                        }
1375                        // Also check LIST chunks
1376                        if cid == b"LIST" && cend >= cstart + 4 {
1377                            // recurse not needed for this simple scan - just advance
1378                        }
1379                        pos = cend + (csz & 1);
1380                    }
1381                    riff_bo
1382                } else if data.starts_with(&[0x00, 0x00, 0x00, 0x0C, b'J', b'X', b'L', b' ']) {
1383                    // JXL container: the Exif payload lives in an (optionally
1384                    // brotli-compressed) box that Jpeg2000.pm hands to ProcessTIFF,
1385                    // and ProcessTIFF is what raises ExifByteOrder — in box order,
1386                    // after the container's own tags. The JXL reader already does
1387                    // that, so no pre-scan value is contributed here.
1388                    None
1389                } else if data.starts_with(&[0x00, b'M', b'R', b'M']) {
1390                    // MRW: find TTW segment which contains TIFF/EXIF data
1391                    let mrw_data_offset = if data.len() >= 8 {
1392                        u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize + 8
1393                    } else {
1394                        0
1395                    };
1396                    let mut mrw_bo: Option<&[u8]> = None;
1397                    let mut mpos = 8usize;
1398                    while mpos + 8 <= mrw_data_offset.min(data.len()) {
1399                        let seg_tag = &data[mpos..mpos + 4];
1400                        let seg_len = u32::from_be_bytes([
1401                            data[mpos + 4],
1402                            data[mpos + 5],
1403                            data[mpos + 6],
1404                            data[mpos + 7],
1405                        ]) as usize;
1406                        if seg_tag == b"\x00TTW" && mpos + 8 + seg_len <= data.len() {
1407                            mrw_bo = Some(&data[mpos + 8..mpos + 8 + seg_len]);
1408                            break;
1409                        }
1410                        mpos += 8 + seg_len;
1411                    }
1412                    mrw_bo
1413                } else {
1414                    Some(data)
1415                };
1416                if let Some(tiff) = check {
1417                    if tiff.starts_with(b"II") {
1418                        "Little-endian (Intel, II)"
1419                    } else if tiff.starts_with(b"MM") {
1420                        "Big-endian (Motorola, MM)"
1421                    } else {
1422                        ""
1423                    }
1424                } else {
1425                    ""
1426                }
1427            } else {
1428                ""
1429            };
1430            // Suppress ExifByteOrder for BigTIFF, Canon VRD/DR4 (Perl doesn't output it for these)
1431            // Also skip if already emitted by ExifReader (TIFF-based formats)
1432            let already_has_exifbyteorder = tags.iter().any(|t| t.name == "ExifByteOrder");
1433            if !bo_str.is_empty()
1434                && !already_has_exifbyteorder
1435                && file_type != FileType::Btf
1436                && file_type != FileType::Dr4
1437                && file_type != FileType::Vrd
1438                && file_type != FileType::Crw
1439            {
1440                pre.push(file_tag("ExifByteOrder", Value::String(bo_str.to_string())));
1441            }
1442        }
1443
1444        // The pseudo-tags collected above precede every format tag.
1445        tags.splice(0..0, pre);
1446
1447        // A format reader that overrides the file's MIME type does it by
1448        // assignment in ExifTool -- `$$et{VALUE}{MIMEType} = $mimeTypes[0]`
1449        // (Real.pm:655) -- which replaces the value in place instead of adding
1450        // a second tag. Keep only the first File:MIMEType, carrying the last
1451        // value, so the count stays one even with the Duplicates option on.
1452        {
1453            // Only the main document's: an embedded image legitimately reports
1454            // its own MIMEType in its own document.
1455            let is_mime = |t: &Tag| {
1456                t.name == "MIMEType"
1457                    && t.group.family0 == "File"
1458                    && t.group.family3 == crate::tag::MAIN_DOCUMENT
1459            };
1460            if tags.iter().filter(|t| is_mime(t)).count() > 1 {
1461                let last = tags.iter().rposition(is_mime).unwrap();
1462                let (value, print) = (tags[last].raw_value.clone(), tags[last].print_value.clone());
1463                let first = tags.iter().position(is_mime).unwrap();
1464                tags[first].raw_value = value;
1465                tags[first].print_value = print;
1466                let mut seen = false;
1467                tags.retain(|t| {
1468                    !is_mime(t) || {
1469                        let keep = !seen;
1470                        seen = true;
1471                        keep
1472                    }
1473                });
1474            }
1475        }
1476
1477        // Promote authoritative specialized-source tags before computing composites,
1478        // so derived tags (ShutterSpeed, LightValue, ...) use the primary value.
1479        //
1480        // ExifTool builds composites from `$$self{VALUE}`, i.e. from the winner of
1481        // the duplicate arbitration, and Kodak's own ExposureTime/FNumber are found
1482        // after the ExifIFD ones, so they are what its Composite ShutterSpeed and
1483        // LightValue use. Here composites are computed before that arbitration, so
1484        // the winner has to be brought forward for these two names. The rest of the
1485        // list this used to hold (MinoltaRaw, Lytro) is gone: the group-blind
1486        // FoundTag pass now settles those by last-wins on its own.
1487        {
1488            const SPECIAL_WINS: &[(&str, &str)] =
1489                &[("Kodak", "FNumber"), ("Kodak", "ExposureTime")];
1490            //
1491            // Dropping the loser is only correct while duplicates are being
1492            // collapsed. With the Duplicates option on (which `-ee` turns on,
1493            // exiftool line 1030) ExifTool still lists BOTH — `-ee` on Kodak.jpg
1494            // prints ExifIFD 0x829a `ExposureTime: 1/180` and Kodak 0x0020
1495            // `ExposureTime: 1/216` — so there the promotion has to leave the
1496            // loser in place, in its original position, and only make the winner
1497            // the one the composites read.
1498            let keep_dups = self.options.duplicates || self.options.extract_embedded > 0;
1499            for (grp, name) in SPECIAL_WINS {
1500                if !tags
1501                    .iter()
1502                    .any(|t| t.name == *name && t.group.family1 == *grp)
1503                {
1504                    continue;
1505                }
1506                if keep_dups {
1507                    for t in tags.iter_mut() {
1508                        if t.name == *name && t.group.family1 == *grp {
1509                            t.priority = t.priority_rank() + 1;
1510                        }
1511                    }
1512                } else {
1513                    tags.retain(|t| t.name != *name || t.group.family1 == *grp);
1514                }
1515            }
1516        }
1517
1518        // GPS::Composite GPSLatitude/GPSLongitude first: the other composites
1519        // (GPSPosition) Require them, and ExifTool resolves inter-composite
1520        // dependencies the same way (BuildCompositeTags defers a composite whose
1521        // requirements are themselves composites).
1522        let gps = crate::composite::gps_coordinates(&tags);
1523        tags.extend(gps);
1524
1525        // Compute composite tags
1526        let composite = crate::composite::compute_composite_tags(&tags);
1527        tags.extend(composite);
1528
1529        // Composite GPSAltitude (GPS.pm:406) claims the "GPSAltitude" name whenever
1530        // its Desire GPSAltitudeRef is present — FoundTag stores the composite and
1531        // moves the plain GPS:GPSAltitude aside to "GPSAltitude (1)". When the plain
1532        // altitude is a 0/0 rational the composite's ValueConv yields undef, so it
1533        // prints nothing; the displaced plain tag is then visible only with the
1534        // Duplicates option on. Model that here: with duplicates collapsed and no
1535        // real Composite GPSAltitude built, drop the "undef" plain tag.
1536        if !(self.options.duplicates || self.options.extract_embedded > 0) {
1537            let has_composite_alt = tags
1538                .iter()
1539                .any(|t| t.name == "GPSAltitude" && t.group.family0 == "Composite");
1540            let has_alt_ref = tags.iter().any(|t| t.name == "GPSAltitudeRef");
1541            if !has_composite_alt && has_alt_ref {
1542                tags.retain(|t| {
1543                    !(t.name == "GPSAltitude"
1544                        && t.group.family0 == "EXIF"
1545                        && t.print_value == "undef")
1546                });
1547            }
1548        }
1549
1550        // No name filter for Composite RedBalance/BlueBalance. `%Exif::Composite`
1551        // declares them with `Desire` only and no `Priority` (Exif.pm:5235-5260),
1552        // so a manufacturer's own RedBalance is extracted too; the Composite is
1553        // built after the file has been read, and therefore wins the duplicate
1554        // competition by last-wins when duplicates are collapsed.
1555
1556        // Geolocation is opt-in, matching ExifTool's `Geolocation` API option.
1557        if self.options.geolocation {
1558            if let Some(geo) = crate::composite::compute_geolocation(&tags) {
1559                tags.extend(geo);
1560            }
1561        }
1562
1563        // MWG (Metadata Working Group) composite tags
1564        if self.options.use_mwg {
1565            let mwg = crate::composite::compute_mwg_composites(&tags);
1566            tags.extend(mwg);
1567        }
1568
1569        // FLIR post-processing: remove LensID composite for FLIR cameras.
1570        // Perl's LensID composite requires LensType EXIF tag (not present in FLIR images),
1571        // and LensID-2 requires LensModel to match /(mm|\d\/F)/ (FLIR names like "FOL7"
1572        // don't match).  Our composite.rs uses a simpler fallback that picks up any non-empty
1573        // LensModel, so we remove LensID when the image is from a FLIR camera with FFF data.
1574        {
1575            let is_flir_fff = tags
1576                .iter()
1577                .any(|t| t.group.family0 == "APP1" && t.group.family1 == "FLIR");
1578            if is_flir_fff {
1579                tags.retain(|t| !(t.name == "LensID" && t.group.family0 == "Composite"));
1580            }
1581        }
1582
1583        // Olympus post-processing: remove the generic "Lens" composite for Olympus cameras.
1584        // In Perl, the "Lens" composite tag requires Canon:MinFocalLength (Canon namespace).
1585        // Our composite.rs generates Lens for any manufacturer that has MinFocalLength +
1586        // MaxFocalLength (e.g., Olympus Equipment sub-IFD).  Remove it for non-Canon cameras.
1587        {
1588            let make = tags
1589                .iter()
1590                .find(|t| t.name == "Make")
1591                .map(|t| t.print_value.clone())
1592                .unwrap_or_default();
1593            if !make.to_uppercase().contains("CANON") {
1594                tags.retain(|t| t.name != "Lens" || t.group.family0 != "Composite");
1595            }
1596        }
1597
1598        // Priority-based deduplication: when the same tag name appears multiple times,
1599        // keep only the one with the highest priority (e.g., EXIF over JFIF, FFF over MakerNote).
1600        //
1601        // Every pass below collapses same-named tags, which ExifTool only does when
1602        // the Duplicates option is off (`CombineInfo`/`GetInfo`). The exiftool CLI
1603        // turns Duplicates on together with ExtractEmbedded (`$mt->Options(Duplicates
1604        // => 1)` in the -ee branch), so -ee must keep every instance: the IFD1 copy
1605        // of XResolution, each ZIP member's Zip* set, every GPX track point.
1606        let collapse_duplicates = !self.options.duplicates && self.options.extract_embedded == 0;
1607        if collapse_duplicates {
1608            // Perl keys its extracted-info hash on the tag NAME alone, so with the
1609            // Duplicates option off a tag found in a sub-document is dropped as
1610            // soon as the file already reported that name — whatever source
1611            // either of them came from, since `FoundTag` never lets a tag
1612            // carrying a DOC_NUM override one that does not. (Below, competition
1613            // is keyed on the family-0 source, which cannot express that.) This is
1614            // why a CR3 read without `-ee` shows the Canon Timed MetaData
1615            // FocalLength only when the main document has no FocalLength.
1616            {
1617                let mut seen: std::collections::HashSet<&str> = tags
1618                    .iter()
1619                    .filter(|t| t.group.family3 == MAIN_DOCUMENT)
1620                    .map(|t| t.name.as_str())
1621                    .collect();
1622                let mut keep = Vec::with_capacity(tags.len());
1623                for t in &tags {
1624                    keep.push(t.group.family3 == MAIN_DOCUMENT || seen.insert(t.name.as_str()));
1625                }
1626                let mut it = keep.into_iter();
1627                tags.retain(|_| it.next().unwrap_or(true));
1628            }
1629
1630            // Specialized-source precedence: a few container/sidecar groups are
1631            // authoritative for specific tags and win over a generic EXIF copy
1632            // (ExifTool reports the GoPro GPMF value). Applied before the priority
1633            // dedup so the (priority-0) specialized tag isn't pruned first.
1634            {
1635                const SPECIAL_WINS: &[(&str, &str)] = &[
1636                    ("GoPro", "WhiteBalance"),
1637                    ("GoPro", "Sharpness"),
1638                    ("GoPro", "ExposureCompensation"),
1639                    // Embedded ID3v2 Comment overrides the native container's
1640                    // (AIFF/...). ID3v1 is NOT in this list: its table is
1641                    // `PRIORITY => 0` (ID3.pm:338), so it loses to the
1642                    // container's tag instead of displacing it.
1643                    ("ID3v2_4", "Comment"),
1644                    ("ID3v2_3", "Comment"),
1645                    ("ID3v2_2", "Comment"),
1646                    // Minolta RAW (.mrw PRD/native block) is authoritative for these
1647                    // over the embedded EXIF maker note copies.
1648                    ("MinoltaRaw", "Contrast"),
1649                    ("MinoltaRaw", "Saturation"),
1650                    ("MinoltaRaw", "Sharpness"),
1651                    ("MinoltaRaw", "ISOSetting"),
1652                    // Kodak maker note carries more precise Exposure/FNumber than EXIF.
1653                    ("Kodak", "FNumber"),
1654                    ("Kodak", "ExposureTime"),
1655                    // Sigma maker note X3FillLight (int) is primary over the X3F header.
1656                    ("Sigma", "X3FillLight"),
1657                ];
1658                for (grp, name) in SPECIAL_WINS {
1659                    if tags
1660                        .iter()
1661                        .any(|t| t.name == *name && t.group.family1 == *grp)
1662                    {
1663                        tags.retain(|t| t.name != *name || t.group.family1 == *grp);
1664                    }
1665                }
1666            }
1667
1668            let mut best_priority: HashMap<String, i32> = HashMap::new();
1669            for tag in &tags {
1670                let entry = best_priority
1671                    .entry(tag.name.clone())
1672                    .or_insert_with(|| tag.priority_rank());
1673                if tag.priority_rank() > *entry {
1674                    *entry = tag.priority_rank();
1675                }
1676            }
1677            tags.retain(|t| t.priority_rank() >= *best_priority.get(&t.name).unwrap_or(&0));
1678
1679            // Document formats (PDF/PostScript/DjVu): their native Info metadata is the
1680            // LOWEST priority in ExifTool — XMP and embedded EXIF both win. Drop the
1681            // native copy when any non-native source provides the same tag.
1682            {
1683                // DjVu-Meta is deliberately absent: %Image::ExifTool::DjVu::Meta
1684                // (DjVu.pm line 132) declares no PRIORITY, so its tags meet XMP's
1685                // at the normal priority and FoundTag decides between them. The
1686                // DjVu INFO chunk is the low-priority one (`PRIORITY => 0, # first
1687                // INFO block takes priority`, DjVu.pm line 60).
1688                let is_native_doc = |g1: &str| matches!(g1, "PDF" | "PostScript" | "DjVu");
1689                let other_names: std::collections::HashSet<String> = tags
1690                    .iter()
1691                    .filter(|t| !is_native_doc(&t.group.family1) && !t.print_value.is_empty())
1692                    .map(|t| t.name.clone())
1693                    .collect();
1694                tags.retain(|t| {
1695                    // Trapped keeps its native value ('Unknown' vs XMP's raw '/Unknown').
1696                    t.name == "Trapped"
1697                        || !is_native_doc(&t.group.family1)
1698                        || !other_names.contains(&t.name)
1699                });
1700            }
1701
1702            // ExifTool FoundTag rule. Among duplicates of the same tag name,
1703            // ExifTool keeps one primary instance decided purely by priority --
1704            // the comparison is group-blind (ExifTool.pm `FoundTag`, "take tag
1705            // with highest priority"):
1706            //
1707            //   * the incoming tag replaces the stored one iff its priority is
1708            //     >= the stored tag's priority;
1709            //   * a stored priority of 0 is PROMOTED to 1 first ("promote
1710            //     existing 0-priority tag so it takes precedence over a new
1711            //     0-tag"), so a priority-0 duplicate never displaces anything.
1712            //
1713            // With ExifTool's two usual priorities that reduces to: the LAST
1714            // instance wins at default priority, the FIRST wins when every
1715            // instance is priority 0. That single rule replaces what used to be
1716            // a hand-maintained list of "first-wins" container groups -- those
1717            // groups were simply the places where priority-0 duplicates happened
1718            // to have been noticed.
1719            //
1720            // The promotion has three exemptions (ExifTool.pm:9541-9548), and all
1721            // three collapse into the unconditional `.max(1)` used below:
1722            //   * the incoming tag has a DOC_NUM,
1723            //   * the tag is `Warning` ("never override a Warning tag because
1724            //     they may be added by ValueConv"),
1725            //   * the stored tag has no G3, i.e. it belongs to the main document.
1726            // Only the remaining case skips the promotion -- a main-document tag
1727            // arriving on top of a stored SUB-document one, which is then allowed
1728            // to displace it at equal priority ("don't promote sub-document tag
1729            // over main document"). That case cannot reach here: the pass at the
1730            // top of this block already drops every sub-document tag whose name
1731            // the main document also reports, which is the same outcome, and it
1732            // is also what the main condition's `not $$self{DOC_NUM} or
1733            // ($$self{TAG_EXTRA}{$tag}{G3} and $$self{DOC_NUM} eq ...{G3})` does
1734            // in the other direction -- an incoming sub-document tag never takes
1735            // the primary key from the main document, nor from another document.
1736            //
1737            // Like every pass in this block, the rule only applies when duplicates
1738            // are being collapsed — see `collapse_duplicates` above.
1739            {
1740                // Sources ExifTool gives priority 0, so that a duplicate coming
1741                // from them never displaces an already-stored tag:
1742                //   * the directories it flags LOW_PRIORITY_DIR (PreviewIFD,
1743                //     IFD1) -- a thumbnail describes a different image, so it
1744                //     must not override the main one;
1745                //   * XMP.pm marks its TIFF/EXIF mirror tables `PRIORITY => 0`
1746                //     ("not as reliable as actual EXIF tags");
1747                //   * an XMP property with no table entry gets a generated
1748                //     `{ Name, IsDefault => 1, Priority => 0 }` tagInfo;
1749                //   * container tables (Jpeg2000, PhotoMechanic, ...) whose
1750                //     stored tags ExifTool keeps first. A QuickTime track is NOT
1751                //     one of them: only the tkhd fields carry `Priority => 0`,
1752                //     and they say so tag by tag (see `parse_tkhd`), while the
1753                //     mdhd/hdlr/stsd tags keep the normal priority and follow the
1754                //     usual last-wins rule.
1755                //
1756                // VCard is deliberately absent: within one vCard, duplicate tags
1757                // are last-wins (TelephoneOtherVoice); the 2nd vCard is demoted
1758                // to priority -1 instead.
1759                //
1760                // Individual tags carrying their own `Priority => 0`, keyed by
1761                // the family-1 group of the table holding them. Canon::ShotInfo
1762                // BaseISO is the CIFF case: a .crw stores the value twice, once
1763                // in CanonRaw and once in the Canon MakerNotes, and ExifTool
1764                // keeps the CanonRaw one.
1765                #[rustfmt::skip]
1766                const LOW_PRIORITY_TAGS: &[(&str, &str)] = &[
1767                    ("Canon", "BaseISO"),          // Canon.pm:2789
1768                    // Canon::ShotInfo 22/23 carry `Priority => 0` (Canon.pm:2959,
1769                    // 2973, 2986) so the ExifIFD copies win. Canon::ExposureInfo
1770                    // has the same two names at default priority, but it is CR3
1771                    // timed metadata, i.e. a sub-document only `-ee` reaches, and
1772                    // duplicates are never collapsed under `-ee`.
1773                    ("Canon", "FNumber"),
1774                    ("Canon", "ExposureTime"),
1775                    // Canon::FocalLength 1: "the EXIF FocalLength is more reliable,
1776                    // so set this priority to zero" (Canon.pm:2709-2710). A CIFF
1777                    // file reaches the same table through CanonRaw 0x1029.
1778                    ("Canon", "FocalLength"),
1779                    ("CIFF", "FocalLength"),
1780                    // Sigma.pm:324, 337, 350, 363, 376 — the MakerNotes copies of
1781                    // the X3F header's picture-adjustment values are `Priority => 0`,
1782                    // so SigmaRaw::HeaderExt (no PRIORITY) keeps them.
1783                    ("Sigma", "Contrast"),
1784                    ("Sigma", "Shadow"),
1785                    ("Sigma", "Highlight"),
1786                    ("Sigma", "Saturation"),
1787                    ("Sigma", "Sharpness"),
1788                ];
1789                // Tables ExifTool declares `PRIORITY => 0` wholesale, keyed by the
1790                // family-1 group their tags land in:
1791                //   * APP12.pm:27 `%APP12::PictureInfo` — the JPEG APP12 "Picture
1792                //     Info" segment never displaces an EXIF value;
1793                //   * SigmaRaw.pm:138 `%SigmaRaw::Properties` — "(because these
1794                //     aren't writable like the EXIF ones)". Only the PROP tags are
1795                //     demoted; SigmaRaw::HeaderExt keeps the default priority, which
1796                //     is why the X3F header still wins Contrast and friends above;
1797                //   * CaptureOne COS properties are invented on the fly, and XMP.pm:3595
1798                //     builds those tagInfos as `{ Name, IsDefault => 1, Priority => 0 }`.
1799                //     They land in family-1 group XML (CaptureOne.pm:26), whose table
1800                //     declares one static tag only.
1801                const LOW_PRIORITY_GROUPS1: &[&str] = &["PictureInfo", "XML"];
1802                // The tag names of `%SigmaRaw::Properties` (SigmaRaw.pm:135-...),
1803                // which shares its family-1 group with the normal-priority
1804                // SigmaRaw::Header* tables and so cannot be demoted group-wide.
1805                #[rustfmt::skip]
1806                const SIGMARAW_PROPERTIES: &[&str] = &[
1807                    "AFArea", "AFInFocus", "ApertureDisplayed", "BracketShot",
1808                    "BurstShot", "CameraName", "ColorSpace", "DateTimeOriginal",
1809                    "DriveMode", "EvalState", "ExposureCompensation",
1810                    "ExposureProgram", "ExposureTime", "FNumber", "FirmwareVersion",
1811                    "FlashExpComp", "FlashMode", "FlashPower", "FlashTTLMode",
1812                    "FlashType", "FocalLength", "FocalLengthIn35mmFormat", "Focus",
1813                    "FocusMode", "ISO", "ImageBoardID", "ImagerBoardID",
1814                    "IntegrationTime", "LensApertureRange", "LensFocalRange",
1815                    "LensType", "Make", "MeteringMode", "Model",
1816                    "NetExposureCompensation", "Quality", "SceneCaptureType",
1817                    "SensorID", "SensorTemperature", "SerialNumber",
1818                    "ShutterSpeedDisplayed", "VersionBF", "WhiteBalance",
1819                ];
1820                // SceneCaptureType above is also an X3F Header2 field
1821                // (SigmaRaw.pm:94) at the default priority. Listing it costs
1822                // nothing: the header copy is emitted before the PROP one, and a
1823                // priority-0 tie is first-wins, so the header value still leads.
1824                // ExifTool.pm:4368 initialises `LOW_PRIORITY_DIR = { PreviewIFD => 1 }`
1825                // and only two places ever add to it: ProcessJPEG (ExifTool.pm:7317,
1826                // `$$self{LOW_PRIORITY_DIR}{IFD1} = 1; # lower priority of IFD1 tags`)
1827                // and ProcessTIFF for ARW (ExifTool.pm:8685). So IFD1 is demoted in a
1828                // JPEG-family file and in an ARW, but NOT in a plain TIFF/RAW, where
1829                // IFD1 keeps the default priority of 1 and so wins by last-wins.
1830                // A live dump of `%{$et->{LOW_PRIORITY_DIR}}` over the corpus confirms
1831                // it: only the JPEG and JPS files list IFD1.
1832                let ifd1_low = matches!(ft_code.as_str(), "JPEG" | "JPS" | "MPO" | "ARW");
1833                let is_low_priority_source = |g: &TagGroup, name: &str| -> bool {
1834                    let g1 = g.family1.as_str();
1835                    // An invented `XMP::other` tag carries `Priority => 0`
1836                    // whatever family 0 it reports: XMP.pm builds such a tagInfo as
1837                    // `{ Name, IsDefault => 1, Priority => 0 }` (XMP.pm:3595).
1838                    if g.family2 == "Unknown" {
1839                        return true;
1840                    }
1841                    // A sub-document never displaces the main document's tag:
1842                    // ExifTool only lets the incoming tag override when it carries
1843                    // no DOC_NUM, or the same one as the tag already stored.
1844                    if g.family3 != MAIN_DOCUMENT {
1845                        return true;
1846                    }
1847                    if LOW_PRIORITY_TAGS.contains(&(g1, name))
1848                        || LOW_PRIORITY_GROUPS1.contains(&g1)
1849                        || (g1 == "SigmaRaw" && SIGMARAW_PROPERTIES.contains(&name))
1850                    {
1851                        return true;
1852                    }
1853                    match g.family0.as_str() {
1854                        // The XMP properties ExifTool stores at priority 0 — the
1855                        // `PRIORITY => 0` mirror tables and every `Avoid => 1`
1856                        // property, which FoundTag demotes at ExifTool.pm:9472.
1857                        // See `scripts/gen_priority0.pl`.
1858                        "XMP" => {
1859                            crate::tags::priority0_generated::xmp_is_priority0(g1, name)
1860                                || crate::tags::group2::xmp_property_is_unknown(g1, name)
1861                        }
1862                        // A QuickTime track is NOT a sub-document: ProcessMOV
1863                        // only sets `$$et{SET_GROUP1} = 'Track'.++$track`
1864                        // (QuickTime.pm:10354) and never touches DOC_NUM for a
1865                        // track, so a track tag carries the normal priority and
1866                        // the LAST track wins a duplicate. The sole
1867                        // `PRIORITY => 0` table in QuickTime.pm is Bitrate
1868                        // (:1162, "often filled with zeros"), whose three tags
1869                        // are named here.
1870                        "QuickTime" => {
1871                            g1 == "QuickTime"
1872                                && matches!(name, "AverageBitrate" | "BufferSize" | "MaxBitrate")
1873                        }
1874                        // ExifTool's LOW_PRIORITY_DIR. SubIFDs are deliberately
1875                        // absent: ExifTool never demotes them, and a NEF's
1876                        // full-resolution SubIFD1 must win StripOffsets by last-wins.
1877                        "EXIF" | "MakerNotes" => g1 == "PreviewIFD" || (ifd1_low && g1 == "IFD1"),
1878                        // FujiFilm.pm:1270 declares the RAF directory table
1879                        // `PRIORITY => 0, # so the first RAF directory takes
1880                        // precedence`: a RAF file can hold two directories
1881                        // (header slots 0x5c and 0x78, FujiFilm.pm:1964), whose
1882                        // tags land in family-1 groups RAF and RAF2, and the
1883                        // first one wins when duplicates are collapsed.
1884                        "RAF" => true,
1885                        // IPTC.pm:1100 sets `$$et{LOW_PRIORITY_DIR}{IPTC} = 1`
1886                        // for an IPTC directory found outside the format's
1887                        // standard location, right where it numbers its family-1
1888                        // group (IPTC2, IPTC3, ...). The standard directory keeps
1889                        // the plain `IPTC` group and its normal priority.
1890                        "IPTC" => g1 != "IPTC",
1891                        // Matroska and MXF number their tracks too, but keep them
1892                        // all in the main document, where last-wins applies.
1893                        _ => matches!(g1, "Jpeg2000" | "PhotoMechanic" | "DjVu"),
1894                    }
1895                };
1896                // ExifTool's PRIORITY_DIR: Exif.pm 0xfe (SubfileType) and 0xff
1897                // (OldSubfileType) call `$self->SetPriorityDir()` when the directory
1898                // holds the full-resolution image, and SetPriorityDir
1899                // (ExifTool.pm:9636) keeps the FIRST one: `$$self{PRIORITY_DIR} =
1900                // $$self{DIR_NAME} unless $$self{PRIORITY_DIR}`. DIR_NAME is the
1901                // family-1 group name, which a live dump confirms (DNG.dng → SubIFD,
1902                // Nikon.nef → SubIFD1).
1903                let priority_dir: Option<String> = tags
1904                    .iter()
1905                    .find(|t| {
1906                        t.group.family0 == "EXIF"
1907                            && matches!(t.name.as_str(), "SubfileType" | "OldSubfileType")
1908                            && t.print_value == "Full-resolution image"
1909                    })
1910                    .map(|t| t.group.family1.clone());
1911                // QuickTime.pm:10016 — ProcessMOV runs `$$et{PRIORITY_DIR} = 'XMP'
1912                // unless $fileType and $fileType eq 'HEIC'` ("have XMP take
1913                // priority except for HEIC") before reading any box, and
1914                // SetPriorityDir only fills PRIORITY_DIR when it is still empty
1915                // (ExifTool.pm:9636), so XMP stays the priority directory for the
1916                // whole movie. Its effect is to promote back to 1 every XMP tag
1917                // ExifTool would otherwise store at priority 0.
1918                let xmp_is_priority_dir = matches!(
1919                    file_type,
1920                    FileType::Mp4
1921                        | FileType::QuickTime
1922                        | FileType::M4a
1923                        | FileType::ThreeGP
1924                        | FileType::Avif
1925                        | FileType::Cr3
1926                        | FileType::Crm
1927                        | FileType::F4v
1928                        | FileType::Mqv
1929                        | FileType::Lrv
1930                ) || (file_type == FileType::Heif && ft_code != "HEIC");
1931                use std::collections::HashMap as HM;
1932                // The competition is group-blind, exactly as in Perl: `$$self{VALUE}`
1933                // holds ONE entry per tag NAME, and FoundTag arbitrates every
1934                // incoming instance against it whatever directory it came from
1935                // (ExifTool.pm:9545-9570). So EXIF:FNumber and MakerNotes:FNumber
1936                // genuinely compete, and the loser is only reachable as `FNumber (1)`
1937                // with the Duplicates option on. The key is the name alone.
1938                let mut by_name: HM<&str, Vec<usize>> = HM::new();
1939                for (i, t) in tags.iter().enumerate() {
1940                    by_name.entry(t.name.as_str()).or_default().push(i);
1941                }
1942                let mut drop: std::collections::HashSet<usize> = std::collections::HashSet::new();
1943                for idxs in by_name.values() {
1944                    if idxs.len() < 2 {
1945                        continue;
1946                    }
1947                    // Replay FoundTag's priority comparison over the instances in
1948                    // extraction order, then keep only the surviving one. A tag
1949                    // carries priority 0 only when its source table declares it;
1950                    // the struct default of 0 means "unspecified", i.e. ExifTool's
1951                    // normal priority of 1.
1952                    let eff = |i: usize| -> i32 {
1953                        let t = &tags[i];
1954                        // Perl's DIR_NAME is the directory's own name, `XMP` for
1955                        // every XMP directory whatever family-1 group its
1956                        // properties end up in (XMP-dc, XMP-xmpDM, ...).
1957                        let in_priority_dir = priority_dir.as_deref()
1958                            == Some(t.group.family1.as_str())
1959                            || (xmp_is_priority_dir && t.group.family0 == "XMP");
1960                        // A priority the source table stated itself — an explicit
1961                        // `Priority => 0` or the `Avoid => 1` FoundTag turns into
1962                        // one. It bypasses the LOW_PRIORITY_DIR default and is
1963                        // promoted back to 1 only inside the PRIORITY_DIR
1964                        // (ExifTool.pm:9552-9555). A sub-document still never
1965                        // displaces the main document's tag.
1966                        // `XMP-pdf:Keywords => { Priority => -1 }`
1967                        // (XMP.pm line 1238), the one XMP property ExifTool puts
1968                        // below 0. Perl only ever promotes a priority that is
1969                        // FALSE, so this one is neither raised to 1 as a stored
1970                        // value (ExifTool.pm:9544-9551) nor promoted inside the
1971                        // PRIORITY_DIR (:9554): it can never take a name.
1972                        if t.group.family0 == "XMP"
1973                            && crate::tags::priority0_generated::xmp_is_below_priority0(
1974                                &t.group.family1,
1975                                &t.name,
1976                            )
1977                        {
1978                            return -1;
1979                        }
1980                        if t.priority == crate::tag::PRIORITY_EXPLICIT_ZERO {
1981                            if t.group.family3 != MAIN_DOCUMENT {
1982                                return 0;
1983                            }
1984                            return i32::from(in_priority_dir);
1985                        }
1986                        if t.priority == 0 && is_low_priority_source(&t.group, &t.name) {
1987                            // Only a table-stated `Priority => 0` is promoted in
1988                            // the priority directory: a LOW_PRIORITY_DIR default
1989                            // comes from the `elsif` branch (ExifTool.pm:9557),
1990                            // which never looks at PRIORITY_DIR. XMP is the one
1991                            // demotion here that FoundTag reaches through the
1992                            // `defined $priority` branch.
1993                            i32::from(
1994                                in_priority_dir
1995                                    && t.group.family0 == "XMP"
1996                                    && t.group.family3 == MAIN_DOCUMENT,
1997                            )
1998                        } else {
1999                            t.priority.max(1)
2000                        }
2001                    };
2002                    // `unless ($oldPriority) { ... $oldPriority = 1 }`
2003                    // (ExifTool.pm:9544-9551): a stored priority is promoted only
2004                    // when it is FALSE, so 0 becomes 1 and a negative one stays.
2005                    let promoted = |p: i32| if p == 0 { 1 } else { p };
2006                    let mut winner = idxs[0];
2007                    for &i in &idxs[1..] {
2008                        if eff(i) >= promoted(eff(winner)) {
2009                            winner = i;
2010                        }
2011                    }
2012                    for &i in idxs {
2013                        if i != winner {
2014                            drop.insert(i);
2015                        }
2016                    }
2017                }
2018                if !drop.is_empty() {
2019                    let mut i = 0usize;
2020                    tags.retain(|_| {
2021                        let keep = !drop.contains(&i);
2022                        i += 1;
2023                        keep
2024                    });
2025                }
2026            }
2027        }
2028
2029        // Resolve the file-level pseudo-tags through their ExifTool table. This
2030        // runs last, after duplicate resolution, so re-grouping a tag can never
2031        // perturb which instance survives: the pass rewrites group assignment
2032        // only, never a tag's name or value. Family 3 is preserved, so a Warning
2033        // raised inside an embedded document stays in that document.
2034        for tag in &mut tags {
2035            if let Some((f0, f1, f2)) = file_level_group(&tag.name) {
2036                tag.group.family0 = f0.to_string();
2037                tag.group.family1 = f1.to_string();
2038                tag.group.family2 = f2.to_string();
2039            }
2040        }
2041
2042        // Then re-derive family 2 from ExifTool's own group tables. Each format
2043        // reader picks a category as it goes, from partial knowledge; this pass
2044        // corrects it against the generated tables, keyed on the family 0/1 the
2045        // reader assigned. It runs after the pseudo-tag pass so those keep their
2046        // hand-picked groups, and after duplicate resolution for the same reason
2047        // as above: family 2 plays no part in choosing which tag survives, so
2048        // rewriting it here cannot move a name or a value.
2049        for tag in &mut tags {
2050            if file_level_group(&tag.name).is_some() {
2051                continue;
2052            }
2053            if let Some(f2) = crate::tags::group2::family2_for(
2054                &tag.group.family0,
2055                &tag.group.family1,
2056                &tag.name,
2057                &tag.group.family2,
2058            ) {
2059                if f2 != tag.group.family2 {
2060                    tag.group.family2 = f2.to_string();
2061                }
2062            }
2063        }
2064
2065        // Filter by requested tags if specified. A request is either a bare
2066        // tag name (`By-line`) or group-qualified (`IPTC:By-line`); the group
2067        // prefix matches any family 0-2 and `*` is a tag wildcard (`IPTC:*`).
2068        if !self.options.requested_tags.is_empty() {
2069            tags.retain(|t| {
2070                self.options
2071                    .requested_tags
2072                    .iter()
2073                    .any(|req| Self::tag_matches_request(t, req))
2074            });
2075        }
2076
2077        Ok(tags)
2078    }
2079
2080    /// Match a tag against a `-TAG` or `-GROUP:TAG` request (case-insensitive).
2081    /// The optional group prefix matches any of the tag's group families
2082    /// (0-2); a `*` tag name matches every tag (in the group, if given).
2083    fn tag_matches_request(tag: &Tag, request: &str) -> bool {
2084        let req = request.to_lowercase();
2085        let (group, name) = match req.split_once(':') {
2086            Some((g, n)) => (Some(g), n),
2087            None => (None, req.as_str()),
2088        };
2089        if name != "*" && tag.name.to_lowercase() != name {
2090            return false;
2091        }
2092        match group {
2093            None => true,
2094            Some(g) => {
2095                let grp = &tag.group;
2096                grp.family0.to_lowercase() == g
2097                    || grp.family1.to_lowercase() == g
2098                    || grp.family2.to_lowercase() == g
2099            }
2100        }
2101    }
2102
2103    /// Format extracted tags into a simple name→value map.
2104    ///
2105    /// Handles duplicate tag names by appending group info.
2106    fn get_info(&self, tags: &[Tag]) -> ImageInfo {
2107        let mut info = ImageInfo::new();
2108        let mut seen: HashMap<String, (usize, i32)> = HashMap::new(); // (count, best priority)
2109
2110        for tag in tags {
2111            let value = if self.options.print_conv {
2112                &tag.print_value
2113            } else {
2114                &tag.raw_value.to_display_string()
2115            };
2116
2117            let entry = seen.entry(tag.name.clone()).or_insert((0, i32::MIN));
2118            entry.0 += 1;
2119
2120            if entry.0 == 1 {
2121                entry.1 = tag.priority_rank();
2122                info.insert(tag.name.clone(), value.clone());
2123            } else if tag.priority_rank() > entry.1 {
2124                // Higher priority tag replaces the previous one
2125                entry.1 = tag.priority_rank();
2126                info.insert(tag.name.clone(), value.clone());
2127            } else if self.options.duplicates {
2128                let key = format!("{} [{}:{}]", tag.name, tag.group.family0, tag.group.family1);
2129                info.insert(key, value.clone());
2130            }
2131        }
2132
2133        info
2134    }
2135
2136    /// Detect file type from magic bytes and extension.
2137    fn detect_file_type(&self, data: &[u8], path: &Path) -> Result<FileType> {
2138        // Try magic bytes first
2139        let header_len = data.len().min(256);
2140        if let Some(ft) = file_type::detect_from_magic(&data[..header_len]) {
2141            // Override ICO to Font if extension is .dfont (Mac resource fork)
2142            if ft == FileType::Ico {
2143                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2144                    if ext.eq_ignore_ascii_case("dfont") {
2145                        return Ok(FileType::Dfont);
2146                    }
2147                }
2148            }
2149            // Override JPEG to JPS if the file extension is .jps
2150            if ft == FileType::Jpeg {
2151                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2152                    if ext.eq_ignore_ascii_case("jps") {
2153                        return Ok(FileType::Jps);
2154                    }
2155                }
2156            }
2157            // Override PLIST to AAE if extension is .aae
2158            if ft == FileType::Plist {
2159                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2160                    if ext.eq_ignore_ascii_case("aae") {
2161                        return Ok(FileType::Aae);
2162                    }
2163                }
2164            }
2165            // Override XMP/XML to PLIST/AAE if extension is .plist or .aae
2166            if ft == FileType::Xmp || ft == FileType::Xml {
2167                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2168                    if ext.eq_ignore_ascii_case("plist") {
2169                        return Ok(FileType::Plist);
2170                    }
2171                    if ext.eq_ignore_ascii_case("aae") {
2172                        return Ok(FileType::Aae);
2173                    }
2174                }
2175            }
2176            // Override to PhotoCD if extension is .pcd (file starts with 0xFF padding)
2177            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2178                if ext.eq_ignore_ascii_case("pcd")
2179                    && data.len() >= 2056
2180                    && &data[2048..2055] == b"PCD_IPI"
2181                {
2182                    return Ok(FileType::PhotoCd);
2183                }
2184            }
2185            // Override MP3 to MPC/APE/WavPack if extension says otherwise
2186            if ft == FileType::Mp3 {
2187                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2188                    if ext.eq_ignore_ascii_case("mpc") {
2189                        return Ok(FileType::Mpc);
2190                    }
2191                    if ext.eq_ignore_ascii_case("ape") {
2192                        return Ok(FileType::Ape);
2193                    }
2194                    if ext.eq_ignore_ascii_case("wv") {
2195                        return Ok(FileType::WavPack);
2196                    }
2197                }
2198            }
2199            // ASF is the container for WMV (video) and WMA (audio); refine by extension.
2200            if ft == FileType::Asf {
2201                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2202                    if ext.eq_ignore_ascii_case("wmv") {
2203                        return Ok(FileType::Wmv);
2204                    }
2205                    if ext.eq_ignore_ascii_case("wma") {
2206                        return Ok(FileType::Wma);
2207                    }
2208                }
2209            }
2210            // Opus is an Ogg stream with the Opus codec.
2211            if ft == FileType::Ogg {
2212                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2213                    if ext.eq_ignore_ascii_case("opus") {
2214                        return Ok(FileType::Opus);
2215                    }
2216                }
2217            }
2218            // TIFF magic covers many RAW variants (DNG, NEF, ARW, …); ExifTool refines
2219            // the type by extension since they share the TIFF structure.
2220            if ft == FileType::Tiff {
2221                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2222                    if let Some(ext_ft) = file_type::detect_from_extension(ext) {
2223                        if ext_ft != FileType::Tiff && is_tiff_based(ext_ft) {
2224                            return Ok(ext_ft);
2225                        }
2226                    }
2227                }
2228            }
2229            // For ZIP files, check if it's an EIP (by extension) or OpenDocument format
2230            if ft == FileType::Zip {
2231                // Check extension first for EIP
2232                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2233                    if ext.eq_ignore_ascii_case("eip") {
2234                        return Ok(FileType::Eip);
2235                    }
2236                }
2237                // iWork (KEY/PAGES/NUMBERS): ExifTool keys on the file extension once an
2238                // iWork marker member is present (ZIP.pm Process_iWork).
2239                if let Some(iw) = detect_iwork_type(data, path) {
2240                    return Ok(iw);
2241                }
2242                if let Some(od_type) = detect_opendocument_type(data) {
2243                    return Ok(od_type);
2244                }
2245            }
2246            // OLE2 compound files (DOC/XLS/PPT/FlashPix) all share the D0CF11E0 magic;
2247            // refine by the UTF-16 stream names in the directory.
2248            if ft == FileType::Doc {
2249                if let Some(ole) = detect_ole2_type(data) {
2250                    return Ok(ole);
2251                }
2252            }
2253            return Ok(ft);
2254        }
2255
2256        // Fall back to extension
2257        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2258            if let Some(ft) = file_type::detect_from_extension(ext) {
2259                return Ok(ft);
2260            }
2261        }
2262
2263        let ext_str = path
2264            .extension()
2265            .and_then(|e| e.to_str())
2266            .unwrap_or("unknown");
2267        Err(Error::UnsupportedFileType(ext_str.to_string()))
2268    }
2269
2270    /// Dispatch to the appropriate format reader.
2271    fn process_file(&self, data: &[u8], file_type: FileType) -> Result<Vec<Tag>> {
2272        match file_type {
2273            FileType::Jpeg | FileType::Jps => {
2274                formats::jpeg::read_jpeg_with_ee(data, self.options.extract_embedded)
2275            }
2276            FileType::Png | FileType::Mng => formats::png::read_png(data),
2277            // All TIFF-based formats (TIFF + most RAW formats)
2278            FileType::Tiff
2279            | FileType::Btf
2280            | FileType::Dng
2281            | FileType::Cr2
2282            | FileType::Nef
2283            | FileType::Arw
2284            | FileType::Sr2
2285            | FileType::Orf
2286            | FileType::Pef
2287            | FileType::Erf
2288            | FileType::Fff
2289            | FileType::Rwl
2290            | FileType::Mef
2291            | FileType::Srw
2292            | FileType::Gpr
2293            | FileType::Arq
2294            | FileType::ThreeFR
2295            | FileType::Dcr
2296            | FileType::Rw2
2297            | FileType::Srf => formats::tiff::read_tiff(data),
2298            // Phase One IIQ: TIFF + PhaseOne maker note block
2299            FileType::Iiq => formats::iiq::read_iiq(
2300                data,
2301                !self.options.duplicates && self.options.extract_embedded == 0,
2302            ),
2303            // Image formats
2304            FileType::Gif => formats::gif::read_gif(data),
2305            FileType::Bmp => formats::bmp::read_bmp(data),
2306            FileType::WebP | FileType::Avi | FileType::Wav => formats::riff::read_riff(data),
2307            FileType::Psd => formats::psd::read_psd(data),
2308            // Audio formats
2309            FileType::Mp3 => formats::id3::read_mp3(data),
2310            FileType::Flac => formats::flac::read_flac(data),
2311            FileType::Ogg | FileType::Opus => formats::ogg::read_ogg(data),
2312            FileType::Aiff => formats::aiff::read_aiff(data),
2313            // Video formats
2314            FileType::Mp4
2315            | FileType::QuickTime
2316            | FileType::M4a
2317            | FileType::ThreeGP
2318            | FileType::Heif
2319            | FileType::Avif
2320            | FileType::Cr3
2321            | FileType::Crm
2322            | FileType::F4v
2323            | FileType::Mqv
2324            | FileType::Lrv => {
2325                formats::quicktime::read_quicktime_with_ee(data, self.options.extract_embedded)
2326            }
2327            FileType::Mkv | FileType::WebM => formats::matroska::read_matroska(data),
2328            FileType::Asf | FileType::Wmv | FileType::Wma => formats::asf::read_asf(data),
2329            FileType::Wtv => formats::wtv::read_wtv(data),
2330            // RAW formats with custom containers
2331            FileType::Crw => formats::canon_raw::read_crw(data),
2332            FileType::Raf => formats::raf::read_raf(data),
2333            FileType::Mrw => formats::mrw::read_mrw(data),
2334            FileType::Mrc => formats::mrc::read_mrc(data, self.options.extract_embedded),
2335            // Image formats
2336            FileType::Jp2 => formats::jp2::read_jp2(data),
2337            FileType::J2c => formats::jp2::read_j2c(data),
2338            FileType::Jxl => formats::jp2::read_jxl(data),
2339            FileType::Ico => formats::ico::read_ico(data),
2340            FileType::Icc => formats::icc::read_icc(data),
2341            // Documents
2342            FileType::Pdf => formats::pdf::read_pdf(data, self.options.extract_embedded),
2343            FileType::PostScript => {
2344                // PFA fonts start with %!PS-AdobeFont or %!FontType1
2345                if data.starts_with(b"%!PS-AdobeFont") || data.starts_with(b"%!FontType1") {
2346                    formats::font::read_pfa(data).or_else(|_| {
2347                        formats::postscript::read_postscript(data, self.options.extract_embedded)
2348                    })
2349                } else {
2350                    formats::postscript::read_postscript(data, self.options.extract_embedded)
2351                }
2352            }
2353            FileType::Eip => formats::capture_one::read_eip(data, self.options.extract_embedded),
2354            FileType::Zip
2355            | FileType::Docx
2356            | FileType::Xlsx
2357            | FileType::Pptx
2358            | FileType::Doc
2359            | FileType::Xls
2360            | FileType::Ppt
2361            | FileType::Numbers
2362            | FileType::Pages
2363            | FileType::Key => formats::zip::read_zip(data, self.options.extract_embedded),
2364            FileType::Rtf => formats::rtf::read_rtf(data),
2365            FileType::InDesign => formats::indesign::read_indesign(data),
2366            FileType::Pcap => formats::pcap::read_pcap(data),
2367            FileType::Pcapng => formats::pcap::read_pcapng(data),
2368            // Canon VRD / DR4
2369            FileType::Vrd => formats::canon_vrd::read_vrd(data).or_else(|_| Ok(Vec::new())),
2370            FileType::Dr4 => formats::canon_vrd::read_dr4(data).or_else(|_| Ok(Vec::new())),
2371            // Metadata / Other
2372            FileType::Xmp => formats::xmp_file::read_xmp(data),
2373            FileType::Svg => formats::svg::read_svg(data),
2374            FileType::Html => {
2375                // SVG files that weren't detected by magic (e.g., via extension fallback)
2376                let is_svg = data.windows(4).take(512).any(|w| w == b"<svg");
2377                if is_svg {
2378                    formats::svg::read_svg(data)
2379                } else {
2380                    formats::html::read_html(data)
2381                }
2382            }
2383            FileType::Exe => formats::exe::read_exe(data),
2384            FileType::Font => {
2385                // AFM: Adobe Font Metrics text file
2386                if data.starts_with(b"StartFontMetrics") {
2387                    return formats::font::read_afm(data);
2388                }
2389                // PFA: PostScript Type 1 ASCII font
2390                if data.starts_with(b"%!PS-AdobeFont") || data.starts_with(b"%!FontType1") {
2391                    return formats::font::read_pfa(data).or_else(|_| Ok(Vec::new()));
2392                }
2393                // PFB: PostScript Type 1 Binary font
2394                if data.len() >= 2 && data[0] == 0x80 && (data[1] == 0x01 || data[1] == 0x02) {
2395                    return formats::font::read_pfb(data).or_else(|_| Ok(Vec::new()));
2396                }
2397                formats::font::read_font(data)
2398            }
2399            // Audio with ID3
2400            FileType::WavPack | FileType::Dsf => formats::id3::read_mp3(data),
2401            FileType::Ape => formats::ape::read_ape(data),
2402            FileType::Mpc => formats::ape::read_mpc(data),
2403            FileType::Aac => formats::aac::read_aac(data),
2404            FileType::RealAudio => {
2405                formats::real_audio::read_real_audio(data).or_else(|_| Ok(Vec::new()))
2406            }
2407            FileType::RealMedia => {
2408                formats::real_media::read_real_media(data).or_else(|_| Ok(Vec::new()))
2409            }
2410            // Misc formats
2411            FileType::Czi => formats::czi::read_czi(data).or_else(|_| Ok(Vec::new())),
2412            FileType::PhotoCd => formats::photo_cd::read_photo_cd(data).or_else(|_| Ok(Vec::new())),
2413            FileType::Dicom => formats::dicom::read_dicom(data),
2414            FileType::Fits => formats::fits::read_fits(data),
2415            FileType::Fit => formats::fit::read_fit_with_ee(data, self.options.extract_embedded),
2416            FileType::Flv => formats::flv::read_flv(data),
2417            FileType::Mxf => formats::mxf::read_mxf(data, self.options.extract_embedded)
2418                .or_else(|_| Ok(Vec::new())),
2419            FileType::Swf => formats::swf::read_swf(data),
2420            FileType::Hdr => formats::hdr::read_hdr(data),
2421            FileType::DjVu => formats::djvu::read_djvu(data),
2422            FileType::Xcf => formats::gimp::read_xcf(data),
2423            FileType::Mie => formats::mie::read_mie(data),
2424            FileType::Lfp => formats::lytro::read_lfp(data),
2425            // FileType::Miff dispatched via string extension below
2426            FileType::Fpf => formats::flir_fpf::read_fpf(data),
2427            FileType::Flif => formats::flif::read_flif(data),
2428            FileType::Bpg => formats::bpg::read_bpg(data),
2429            FileType::Pcx => formats::pcx::read_pcx(data),
2430            FileType::Pict => formats::pict::read_pict(data),
2431            FileType::Mpeg => formats::mpeg::read_mpeg(data),
2432            FileType::M2ts => formats::m2ts::read_m2ts(data, self.options.extract_embedded),
2433            FileType::Gzip => formats::gzip::read_gzip(data),
2434            FileType::Rar => formats::rar::read_rar(data),
2435            FileType::SevenZ => formats::sevenz::read_7z(data),
2436            FileType::Dss => formats::dss::read_dss(data),
2437            FileType::Moi => formats::moi::read_moi(data),
2438            FileType::MacOs => formats::macos::read_macos(data),
2439            FileType::Json => formats::json_format::read_json(data),
2440            // New formats
2441            FileType::Pgf => formats::pgf::read_pgf(data),
2442            FileType::Xisf => formats::xisf::read_xisf(data),
2443            FileType::Torrent => formats::torrent::read_torrent(data),
2444            FileType::Mobi => formats::palm::read_palm(data),
2445            FileType::Psp => formats::psp::read_psp(data),
2446            FileType::SonyPmp => formats::sony_pmp::read_sony_pmp(data),
2447            FileType::Audible => formats::audible::read_audible(data),
2448            FileType::Exr => formats::openexr::read_openexr(data),
2449            // New formats
2450            FileType::Plist => {
2451                if data.starts_with(b"bplist") {
2452                    formats::plist::read_binary_plist_tags(data)
2453                } else {
2454                    formats::plist::read_xml_plist(data)
2455                }
2456            }
2457            FileType::Aae => {
2458                if data.starts_with(b"bplist") {
2459                    formats::plist::read_binary_plist_tags(data)
2460                } else {
2461                    formats::plist::read_aae_plist(data)
2462                }
2463            }
2464            FileType::KyoceraRaw => formats::kyocera_raw::read_kyocera_raw(data),
2465            FileType::PortableFloatMap => formats::pfm::read_pfm(data),
2466            FileType::Ods
2467            | FileType::Odt
2468            | FileType::Odp
2469            | FileType::Odg
2470            | FileType::Odf
2471            | FileType::Odb
2472            | FileType::Odi
2473            | FileType::Odc => formats::zip::read_zip(data, self.options.extract_embedded),
2474            FileType::Lif => formats::lif::read_lif(data),
2475            FileType::Rwz => formats::rawzor::read_rawzor(data),
2476            FileType::Jxr => formats::jxr::read_jxr(data),
2477            FileType::Miff => formats::miff::read_miff(data).or_else(|_| Ok(Vec::new())),
2478            FileType::Tnef => formats::tnef::read_tnef(data).or_else(|_| Ok(Vec::new())),
2479            FileType::Wpg => formats::wpg::read_wpg(data).or_else(|_| Ok(Vec::new())),
2480            FileType::Dv => {
2481                formats::dv::read_dv(data, data.len() as u64).or_else(|_| Ok(Vec::new()))
2482            }
2483            FileType::Itc => formats::itc::read_itc(data).or_else(|_| Ok(Vec::new())),
2484            FileType::Iso => formats::iso::read_iso(data).or_else(|_| Ok(Vec::new())),
2485            FileType::Afm => formats::font::read_afm(data).or_else(|_| Ok(Vec::new())),
2486            FileType::Pfa => formats::font::read_pfa(data).or_else(|_| Ok(Vec::new())),
2487            FileType::Pfb => formats::font::read_pfb(data).or_else(|_| Ok(Vec::new())),
2488            FileType::Dfont => formats::font::read_font(data).or_else(|_| Ok(Vec::new())),
2489            FileType::Xml | FileType::Inx => {
2490                formats::xmp_file::read_xmp(data).or_else(|_| Ok(Vec::new()))
2491            }
2492            FileType::Eps => {
2493                formats::postscript::read_postscript(data, self.options.extract_embedded)
2494            }
2495            _ => Err(Error::UnsupportedFileType(format!("{}", file_type))),
2496        }
2497    }
2498
2499    /// Fallback: try to read file based on extension for formats without magic detection.
2500    fn process_by_extension(&self, data: &[u8], path: &Path) -> Result<Vec<Tag>> {
2501        let ext = path
2502            .extension()
2503            .and_then(|e| e.to_str())
2504            .unwrap_or("")
2505            .to_ascii_lowercase();
2506
2507        match ext.as_str() {
2508            "ppm" | "pgm" | "pbm" => formats::ppm::read_ppm(data),
2509            "pfm" => {
2510                // PFM can be Portable Float Map or Printer Font Metrics
2511                if data.len() >= 3 && data[0] == b'P' && (data[1] == b'f' || data[1] == b'F') {
2512                    formats::ppm::read_ppm(data)
2513                } else {
2514                    Ok(Vec::new()) // Printer Font Metrics
2515                }
2516            }
2517            "json" => formats::json_format::read_json(data),
2518            "svg" => formats::svg::read_svg(data),
2519            "ram" => formats::ram::read_ram(data).or_else(|_| Ok(Vec::new())),
2520            "txt" | "log" | "igc" => Ok(compute_text_tags(data, false)),
2521            "csv" => Ok(compute_text_tags(data, true)),
2522            "url" => formats::lnk::read_url(data).or_else(|_| Ok(Vec::new())),
2523            "lnk" => formats::lnk::read_lnk(data).or_else(|_| Ok(Vec::new())),
2524            "gpx" | "kml" | "xml" | "inx" => formats::xmp_file::read_xmp(data),
2525            "plist" => {
2526                if data.starts_with(b"bplist") {
2527                    formats::plist::read_binary_plist_tags(data).or_else(|_| Ok(Vec::new()))
2528                } else {
2529                    formats::plist::read_xml_plist(data).or_else(|_| Ok(Vec::new()))
2530                }
2531            }
2532            "aae" => {
2533                if data.starts_with(b"bplist") {
2534                    formats::plist::read_binary_plist_tags(data).or_else(|_| Ok(Vec::new()))
2535                } else {
2536                    formats::plist::read_aae_plist(data).or_else(|_| Ok(Vec::new()))
2537                }
2538            }
2539            "vcf" | "ics" | "vcard" => {
2540                let s = crate::encoding::decode_utf8_or_latin1(&data[..data.len().min(100)]);
2541                if s.contains("BEGIN:VCALENDAR") {
2542                    formats::vcard::read_ics(data).or_else(|_| Ok(Vec::new()))
2543                } else {
2544                    formats::vcard::read_vcf(data).or_else(|_| Ok(Vec::new()))
2545                }
2546            }
2547            "xcf" => Ok(Vec::new()), // GIMP
2548            "vrd" => formats::canon_vrd::read_vrd(data).or_else(|_| Ok(Vec::new())),
2549            "dr4" => formats::canon_vrd::read_dr4(data).or_else(|_| Ok(Vec::new())),
2550            "indd" | "indt" => Ok(Vec::new()), // InDesign
2551            "x3f" => formats::sigma_raw::read_x3f(data).or_else(|_| Ok(Vec::new())),
2552            "mie" => Ok(Vec::new()), // MIE
2553            "exr" => Ok(Vec::new()), // OpenEXR
2554            "wpg" => formats::wpg::read_wpg(data).or_else(|_| Ok(Vec::new())),
2555            "moi" => formats::moi::read_moi(data).or_else(|_| Ok(Vec::new())),
2556            "macos" => formats::macos::read_macos(data).or_else(|_| Ok(Vec::new())),
2557            "dpx" => formats::dpx::read_dpx(data).or_else(|_| Ok(Vec::new())),
2558            "r3d" => formats::red::read_r3d(data).or_else(|_| Ok(Vec::new())),
2559            "tnef" => formats::tnef::read_tnef(data).or_else(|_| Ok(Vec::new())),
2560            "ppt" | "fpx" => formats::flashpix::read_fpx(data).or_else(|_| Ok(Vec::new())),
2561            "fpf" => formats::flir_fpf::read_fpf(data).or_else(|_| Ok(Vec::new())),
2562            "itc" => formats::itc::read_itc(data).or_else(|_| Ok(Vec::new())),
2563            "mpg" | "mpeg" | "m1v" | "m2v" | "mpv" => {
2564                formats::mpeg::read_mpeg(data).or_else(|_| Ok(Vec::new()))
2565            }
2566            "dv" => formats::dv::read_dv(data, data.len() as u64).or_else(|_| Ok(Vec::new())),
2567            "czi" => formats::czi::read_czi(data).or_else(|_| Ok(Vec::new())),
2568            "miff" => formats::miff::read_miff(data).or_else(|_| Ok(Vec::new())),
2569            "lfp" | "mrc" | "dss" | "mobi" | "psp" | "pgf" | "raw" | "pmp" | "torrent" | "xisf"
2570            | "mxf" | "dfont" => Ok(Vec::new()),
2571            "iso" => formats::iso::read_iso(data).or_else(|_| Ok(Vec::new())),
2572            "afm" => formats::font::read_afm(data).or_else(|_| Ok(Vec::new())),
2573            "pfa" => formats::font::read_pfa(data).or_else(|_| Ok(Vec::new())),
2574            "pfb" => formats::font::read_pfb(data).or_else(|_| Ok(Vec::new())),
2575            _ => Err(Error::UnsupportedFileType(ext)),
2576        }
2577    }
2578}
2579
2580impl Default for ExifTool {
2581    fn default() -> Self {
2582        Self::new()
2583    }
2584}
2585
2586/// Detect OpenDocument file type by reading the `mimetype` entry from a ZIP.
2587/// Returns None if not an OpenDocument file.
2588/// Refine an EXE file's (FileType, MIMEType, FileTypeExtension) from its magic, mirroring
2589/// ExifTool's EXE SetFileType. MIME is always application/octet-stream for these.
2590fn exe_subtype(d: &[u8]) -> Option<(&'static str, &'static str, &'static str)> {
2591    const MIME: &str = "application/octet-stream";
2592    if d.len() < 8 {
2593        return None;
2594    }
2595    // ELF: 0x7F 'E' 'L' 'F'; data[5] endianness (1=LE,2=BE); e_type at offset 16 (2 bytes)
2596    if &d[0..4] == b"\x7fELF" && d.len() >= 18 {
2597        let le = d[5] == 1;
2598        let e_type = if le {
2599            u16::from_le_bytes([d[16], d[17]])
2600        } else {
2601            u16::from_be_bytes([d[16], d[17]])
2602        };
2603        return Some(match e_type {
2604            1 => ("ELF relocatable", MIME, "o"),
2605            2 => ("ELF executable", MIME, ""),
2606            3 => ("ELF shared library", MIME, "so"),
2607            4 => ("ELF core file", MIME, ""),
2608            _ => ("ELF", MIME, ""),
2609        });
2610    }
2611    // Mach-O thin binary: magic FEEDFACE/FEEDFACF (BE) or CEFAEDFE/CFFAEDFE (LE).
2612    let magic_be = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
2613    let macho = matches!(magic_be, 0xFEEDFACE | 0xFEEDFACF | 0xCEFAEDFE | 0xCFFAEDFE);
2614    if macho && d.len() >= 16 {
2615        let le = matches!(magic_be, 0xCEFAEDFE | 0xCFFAEDFE);
2616        let filetype = if le {
2617            u32::from_le_bytes([d[12], d[13], d[14], d[15]])
2618        } else {
2619            u32::from_be_bytes([d[12], d[13], d[14], d[15]])
2620        };
2621        return Some(match filetype {
2622            1 => ("Mach-O object file", MIME, "o"),
2623            6 => ("Mach-O dynamic link library", MIME, "dylib"),
2624            8 => ("Mach-O dynamic bound bundle", MIME, "dylib"),
2625            9 => ("Mach-O dynamic link library stub", MIME, "dylib"),
2626            _ => ("Mach-O executable", MIME, ""),
2627        });
2628    }
2629    // Mach-O fat binary: CAFEBABE / BEBAFECA
2630    if matches!(magic_be, 0xCAFEBABE | 0xBEBAFECA) {
2631        return Some(("Mach-O fat binary executable", MIME, ""));
2632    }
2633    // ar archive ("!<arch>\n"): static library (Mach-O if it contains Mach-O members).
2634    if d.starts_with(b"!<arch>\n") {
2635        let is_macho = d.windows(4).take(4096).any(|w| {
2636            let m = u32::from_be_bytes([w[0], w[1], w[2], w[3]]);
2637            matches!(
2638                m,
2639                0xFEEDFACE | 0xFEEDFACF | 0xCEFAEDFE | 0xCFFAEDFE | 0xCAFEBABE
2640            )
2641        });
2642        return Some(if is_macho {
2643            ("Mach-O static library", MIME, "a")
2644        } else {
2645            ("Static library", MIME, "a")
2646        });
2647    }
2648    // PE (Windows): "MZ" then PE header; machine field selects Win32/Win64.
2649    if &d[0..2] == b"MZ" && d.len() >= 0x40 {
2650        let pe_off = u32::from_le_bytes([d[0x3c], d[0x3d], d[0x3e], d[0x3f]]) as usize;
2651        if pe_off + 6 <= d.len() && &d[pe_off..pe_off + 4] == b"PE\0\0" {
2652            let machine = u16::from_le_bytes([d[pe_off + 4], d[pe_off + 5]]);
2653            return Some(match machine {
2654                0x8664 | 0xAA64 => ("Win64 EXE", MIME, "exe"),
2655                _ => ("Win32 EXE", MIME, "exe"),
2656            });
2657        }
2658    }
2659    None
2660}
2661
2662/// Whether a FileType is a TIFF-based RAW variant (shares TIFF magic, refined by extension).
2663fn is_tiff_based(ft: FileType) -> bool {
2664    matches!(
2665        ft,
2666        FileType::Dng
2667            | FileType::Cr2
2668            | FileType::Nef
2669            | FileType::Arw
2670            | FileType::Sr2
2671            | FileType::Orf
2672            | FileType::Pef
2673            | FileType::Erf
2674            | FileType::Rwl
2675            | FileType::Mef
2676            | FileType::Srw
2677            | FileType::Gpr
2678            | FileType::Arq
2679            | FileType::ThreeFR
2680            | FileType::Dcr
2681            | FileType::Rw2
2682            | FileType::Srf
2683            | FileType::Iiq
2684            | FileType::Btf
2685    )
2686}
2687
2688/// Refine an OLE2 compound document (DOC/XLS/PPT) by scanning the directory for
2689/// well-known UTF-16LE stream names. Returns None (→ keep DOC) when none match.
2690fn detect_ole2_type(data: &[u8]) -> Option<FileType> {
2691    fn has_utf16(data: &[u8], name: &str) -> bool {
2692        let needle: Vec<u8> = name.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
2693        data.windows(needle.len()).any(|w| w == needle.as_slice())
2694    }
2695    if has_utf16(data, "PowerPoint Document") {
2696        Some(FileType::Ppt)
2697    } else if has_utf16(data, "Workbook") || has_utf16(data, "Book") {
2698        Some(FileType::Xls)
2699    } else {
2700        None
2701    }
2702}
2703
2704/// Detect an iWork (KEY/PAGES/NUMBERS) ZIP. ExifTool recognises these by the
2705/// presence of an iWork marker member, then maps the file type from the
2706/// extension (ZIP.pm `%iWorkType` / Process_iWork).
2707fn detect_iwork_type(data: &[u8], path: &Path) -> Option<FileType> {
2708    const MARKERS: &[&[u8]] = &[
2709        b"index.xml",
2710        b"index.apxl",
2711        b"QuickLook/Thumbnail.jpg",
2712        b"Index/Document.iwa",
2713        b"Index/Slide.iwa",
2714        b"Index/Tables/DataList.iwa",
2715    ];
2716    let has_marker = MARKERS
2717        .iter()
2718        .any(|m| data.windows(m.len()).any(|w| w == *m));
2719    if !has_marker {
2720        return None;
2721    }
2722    let ext = path
2723        .extension()
2724        .and_then(|e| e.to_str())
2725        .unwrap_or("")
2726        .to_ascii_lowercase();
2727    match ext.as_str() {
2728        "numbers" | "nmbtemplate" => Some(FileType::Numbers),
2729        "pages" => Some(FileType::Pages),
2730        "key" | "kth" => Some(FileType::Key),
2731        _ => None,
2732    }
2733}
2734
2735/// Content-dependent FileType code / MIME refinements (ExifTool SetFileType with a
2736/// content test). Returns (code, mime); the extension keeps its default.
2737fn refine_filetype_by_content(file_type: FileType, data: &[u8]) -> Option<(String, String)> {
2738    match file_type {
2739        // Printer Font Metrics (font, starts 0x00 0x01/0x02) vs Portable Float Map (image, "PF").
2740        FileType::PortableFloatMap if data.len() >= 2 && data[0] == 0x00 && data[1] <= 0x02 => {
2741            Some(("PFM".into(), "application/x-font-type1".into()))
2742        }
2743        // XML property list → application/xml (binary plist keeps application/x-plist).
2744        FileType::Plist if !data.starts_with(b"bplist") => {
2745            Some(("PLIST".into(), "application/xml".into()))
2746        }
2747        // Naked JPEG XL codestream (FF 0A) vs the ISOBMFF container.
2748        FileType::Jxl if data.starts_with(&[0xFF, 0x0A]) => {
2749            Some(("JXL Codestream".into(), file_type.mime_type().to_string()))
2750        }
2751        // Extended WebP: VP8X chunk at offset 12.
2752        FileType::WebP if data.len() >= 16 && &data[12..16] == b"VP8X" => {
2753            Some(("Extended WEBP".into(), file_type.mime_type().to_string()))
2754        }
2755        // Multi-page DjVu: "DJVM" form type at offset 12.
2756        FileType::DjVu if data.len() >= 16 && &data[12..16] == b"DJVM" => Some((
2757            "DJVU (multi-page)".into(),
2758            file_type.mime_type().to_string(),
2759        )),
2760        _ => None,
2761    }
2762}
2763
2764fn detect_opendocument_type(data: &[u8]) -> Option<FileType> {
2765    // OpenDocument ZIPs have "mimetype" as the FIRST local file entry (uncompressed)
2766    if data.len() < 30 || data[0..4] != [0x50, 0x4B, 0x03, 0x04] {
2767        return None;
2768    }
2769    let compression = u16::from_le_bytes([data[8], data[9]]);
2770    let compressed_size = u32::from_le_bytes([data[18], data[19], data[20], data[21]]) as usize;
2771    let name_len = u16::from_le_bytes([data[26], data[27]]) as usize;
2772    let extra_len = u16::from_le_bytes([data[28], data[29]]) as usize;
2773    let name_start = 30;
2774    if name_start + name_len > data.len() {
2775        return None;
2776    }
2777    let filename = std::str::from_utf8(&data[name_start..name_start + name_len]).unwrap_or("");
2778    if filename != "mimetype" || compression != 0 {
2779        return None;
2780    }
2781    let content_start = name_start + name_len + extra_len;
2782    let content_end = (content_start + compressed_size).min(data.len());
2783    if content_start >= content_end {
2784        return None;
2785    }
2786    let mime = std::str::from_utf8(&data[content_start..content_end])
2787        .unwrap_or("")
2788        .trim();
2789    match mime {
2790        "application/vnd.oasis.opendocument.spreadsheet" => Some(FileType::Ods),
2791        "application/vnd.oasis.opendocument.text" => Some(FileType::Odt),
2792        "application/vnd.oasis.opendocument.presentation" => Some(FileType::Odp),
2793        "application/vnd.oasis.opendocument.graphics" => Some(FileType::Odg),
2794        "application/vnd.oasis.opendocument.formula" => Some(FileType::Odf),
2795        "application/vnd.oasis.opendocument.database" => Some(FileType::Odb),
2796        "application/vnd.oasis.opendocument.image" => Some(FileType::Odi),
2797        "application/vnd.oasis.opendocument.chart" => Some(FileType::Odc),
2798        _ => None,
2799    }
2800}
2801
2802/// Detect the file type of a file at the given path.
2803pub fn get_file_type<P: AsRef<Path>>(path: P) -> Result<FileType> {
2804    let path = path.as_ref();
2805    let mut file = fs::File::open(path).map_err(Error::Io)?;
2806    let mut header = [0u8; 256];
2807    use std::io::Read;
2808    let n = file.read(&mut header).map_err(Error::Io)?;
2809
2810    if let Some(ft) = file_type::detect_from_magic(&header[..n]) {
2811        return Ok(ft);
2812    }
2813
2814    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2815        if let Some(ft) = file_type::detect_from_extension(ext) {
2816            return Ok(ft);
2817        }
2818    }
2819
2820    Err(Error::UnsupportedFileType("unknown".into()))
2821}
2822
2823/// Classification of EXIF tags into IFD groups.
2824enum ExifIfdGroup {
2825    Ifd0,
2826    ExifIfd,
2827    Gps,
2828}
2829
2830/// Determine which IFD a tag belongs to based on its ID.
2831fn classify_exif_tag(tag_id: u16) -> ExifIfdGroup {
2832    match tag_id {
2833        // ExifIFD tags
2834        0x829A..=0x829D | 0x8822..=0x8827 | 0x8830 | 0x9000..=0x9292 | 0xA000..=0xA435 => {
2835            ExifIfdGroup::ExifIfd
2836        }
2837        // GPS tags
2838        0x0000..=0x001F if tag_id <= 0x001F => ExifIfdGroup::Gps,
2839        // Everything else → IFD0
2840        _ => ExifIfdGroup::Ifd0,
2841    }
2842}
2843
2844/// Extract existing EXIF entries from a JPEG file's APP1 segment.
2845fn extract_existing_exif_entries(
2846    jpeg_data: &[u8],
2847    target_bo: ByteOrderMark,
2848) -> Vec<exif_writer::IfdEntry> {
2849    let mut entries = Vec::new();
2850
2851    // Find EXIF APP1 segment
2852    let mut pos = 2; // Skip SOI
2853    while pos + 4 <= jpeg_data.len() {
2854        if jpeg_data[pos] != 0xFF {
2855            pos += 1;
2856            continue;
2857        }
2858        let marker = jpeg_data[pos + 1];
2859        pos += 2;
2860
2861        if marker == 0xDA || marker == 0xD9 {
2862            break; // SOS or EOI
2863        }
2864        if marker == 0xFF || marker == 0x00 || marker == 0xD8 || (0xD0..=0xD7).contains(&marker) {
2865            continue;
2866        }
2867
2868        if pos + 2 > jpeg_data.len() {
2869            break;
2870        }
2871        let seg_len = u16::from_be_bytes([jpeg_data[pos], jpeg_data[pos + 1]]) as usize;
2872        if seg_len < 2 || pos + seg_len > jpeg_data.len() {
2873            break;
2874        }
2875
2876        let seg_data = &jpeg_data[pos + 2..pos + seg_len];
2877
2878        // EXIF APP1
2879        if marker == 0xE1 && seg_data.len() > 14 && seg_data.starts_with(b"Exif\0\0") {
2880            let tiff_data = &seg_data[6..];
2881            extract_ifd_entries(tiff_data, target_bo, &mut entries);
2882            break;
2883        }
2884
2885        pos += seg_len;
2886    }
2887
2888    entries
2889}
2890
2891/// Extract IFD entries from TIFF data, re-encoding values in the target byte order.
2892fn extract_ifd_entries(
2893    tiff_data: &[u8],
2894    target_bo: ByteOrderMark,
2895    entries: &mut Vec<exif_writer::IfdEntry>,
2896) {
2897    use crate::metadata::exif::parse_tiff_header;
2898
2899    let header = match parse_tiff_header(tiff_data) {
2900        Ok(h) => h,
2901        Err(_) => return,
2902    };
2903
2904    let src_bo = header.byte_order;
2905
2906    // Read IFD0
2907    read_ifd_for_merge(
2908        tiff_data,
2909        header.ifd0_offset as usize,
2910        src_bo,
2911        target_bo,
2912        entries,
2913    );
2914
2915    // Find ExifIFD and GPS pointers
2916    let ifd0_offset = header.ifd0_offset as usize;
2917    if ifd0_offset + 2 > tiff_data.len() {
2918        return;
2919    }
2920    let count = read_u16_bo(tiff_data, ifd0_offset, src_bo) as usize;
2921    for i in 0..count {
2922        let eoff = ifd0_offset + 2 + i * 12;
2923        if eoff + 12 > tiff_data.len() {
2924            break;
2925        }
2926        let tag = read_u16_bo(tiff_data, eoff, src_bo);
2927        let value_off = read_u32_bo(tiff_data, eoff + 8, src_bo) as usize;
2928
2929        match tag {
2930            0x8769 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
2931            0x8825 => read_ifd_for_merge(tiff_data, value_off, src_bo, target_bo, entries),
2932            _ => {}
2933        }
2934    }
2935}
2936
2937/// Read a single IFD and extract entries for merge.
2938fn read_ifd_for_merge(
2939    data: &[u8],
2940    offset: usize,
2941    src_bo: ByteOrderMark,
2942    target_bo: ByteOrderMark,
2943    entries: &mut Vec<exif_writer::IfdEntry>,
2944) {
2945    if offset + 2 > data.len() {
2946        return;
2947    }
2948    let count = read_u16_bo(data, offset, src_bo) as usize;
2949
2950    for i in 0..count {
2951        let eoff = offset + 2 + i * 12;
2952        if eoff + 12 > data.len() {
2953            break;
2954        }
2955
2956        let tag = read_u16_bo(data, eoff, src_bo);
2957        let dtype = read_u16_bo(data, eoff + 2, src_bo);
2958        let count_val = read_u32_bo(data, eoff + 4, src_bo);
2959
2960        // Skip sub-IFD pointers and MakerNote
2961        if tag == 0x8769 || tag == 0x8825 || tag == 0xA005 || tag == 0x927C {
2962            continue;
2963        }
2964
2965        let type_size = match dtype {
2966            1 | 2 | 6 | 7 => 1usize,
2967            3 | 8 => 2,
2968            4 | 9 | 11 | 13 => 4,
2969            5 | 10 | 12 => 8,
2970            _ => continue,
2971        };
2972
2973        let total_size = type_size * count_val as usize;
2974        let raw_data = if total_size <= 4 {
2975            data[eoff + 8..eoff + 12].to_vec()
2976        } else {
2977            let voff = read_u32_bo(data, eoff + 8, src_bo) as usize;
2978            if voff + total_size > data.len() {
2979                continue;
2980            }
2981            data[voff..voff + total_size].to_vec()
2982        };
2983
2984        // Re-encode multi-byte values if byte orders differ
2985        let final_data = if src_bo != target_bo && type_size > 1 {
2986            reencode_bytes(&raw_data, dtype, count_val as usize, src_bo, target_bo)
2987        } else {
2988            raw_data[..total_size].to_vec()
2989        };
2990
2991        let format = match dtype {
2992            1 => exif_writer::ExifFormat::Byte,
2993            2 => exif_writer::ExifFormat::Ascii,
2994            3 => exif_writer::ExifFormat::Short,
2995            4 => exif_writer::ExifFormat::Long,
2996            5 => exif_writer::ExifFormat::Rational,
2997            6 => exif_writer::ExifFormat::SByte,
2998            7 => exif_writer::ExifFormat::Undefined,
2999            8 => exif_writer::ExifFormat::SShort,
3000            9 => exif_writer::ExifFormat::SLong,
3001            10 => exif_writer::ExifFormat::SRational,
3002            11 => exif_writer::ExifFormat::Float,
3003            12 => exif_writer::ExifFormat::Double,
3004            _ => continue,
3005        };
3006
3007        entries.push(exif_writer::IfdEntry {
3008            tag,
3009            format,
3010            data: final_data,
3011        });
3012    }
3013}
3014
3015/// Re-encode multi-byte values when converting between byte orders.
3016fn reencode_bytes(
3017    data: &[u8],
3018    dtype: u16,
3019    count: usize,
3020    src_bo: ByteOrderMark,
3021    dst_bo: ByteOrderMark,
3022) -> Vec<u8> {
3023    let mut out = Vec::with_capacity(data.len());
3024    match dtype {
3025        3 | 8 => {
3026            // 16-bit
3027            for i in 0..count {
3028                let v = read_u16_bo(data, i * 2, src_bo);
3029                match dst_bo {
3030                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
3031                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
3032                }
3033            }
3034        }
3035        4 | 9 | 11 | 13 => {
3036            // 32-bit
3037            for i in 0..count {
3038                let v = read_u32_bo(data, i * 4, src_bo);
3039                match dst_bo {
3040                    ByteOrderMark::LittleEndian => out.extend_from_slice(&v.to_le_bytes()),
3041                    ByteOrderMark::BigEndian => out.extend_from_slice(&v.to_be_bytes()),
3042                }
3043            }
3044        }
3045        5 | 10 => {
3046            // Rational (two 32-bit)
3047            for i in 0..count {
3048                let n = read_u32_bo(data, i * 8, src_bo);
3049                let d = read_u32_bo(data, i * 8 + 4, src_bo);
3050                match dst_bo {
3051                    ByteOrderMark::LittleEndian => {
3052                        out.extend_from_slice(&n.to_le_bytes());
3053                        out.extend_from_slice(&d.to_le_bytes());
3054                    }
3055                    ByteOrderMark::BigEndian => {
3056                        out.extend_from_slice(&n.to_be_bytes());
3057                        out.extend_from_slice(&d.to_be_bytes());
3058                    }
3059                }
3060            }
3061        }
3062        12 => {
3063            // 64-bit double
3064            for i in 0..count {
3065                let mut bytes = [0u8; 8];
3066                bytes.copy_from_slice(&data[i * 8..i * 8 + 8]);
3067                if src_bo != dst_bo {
3068                    bytes.reverse();
3069                }
3070                out.extend_from_slice(&bytes);
3071            }
3072        }
3073        _ => out.extend_from_slice(data),
3074    }
3075    out
3076}
3077
3078fn read_u16_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u16 {
3079    if offset + 2 > data.len() {
3080        return 0;
3081    }
3082    match bo {
3083        ByteOrderMark::LittleEndian => u16::from_le_bytes([data[offset], data[offset + 1]]),
3084        ByteOrderMark::BigEndian => u16::from_be_bytes([data[offset], data[offset + 1]]),
3085    }
3086}
3087
3088fn read_u32_bo(data: &[u8], offset: usize, bo: ByteOrderMark) -> u32 {
3089    if offset + 4 > data.len() {
3090        return 0;
3091    }
3092    match bo {
3093        ByteOrderMark::LittleEndian => u32::from_le_bytes([
3094            data[offset],
3095            data[offset + 1],
3096            data[offset + 2],
3097            data[offset + 3],
3098        ]),
3099        ByteOrderMark::BigEndian => u32::from_be_bytes([
3100            data[offset],
3101            data[offset + 1],
3102            data[offset + 2],
3103            data[offset + 3],
3104        ]),
3105    }
3106}
3107
3108/// Map tag name to numeric EXIF tag ID.
3109fn tag_name_to_id(name: &str) -> Option<u16> {
3110    encode_exif_tag(name, "", "", ByteOrderMark::BigEndian).map(|(id, _, _)| id)
3111}
3112
3113/// Convert a tag value to a safe filename.
3114fn value_to_filename(value: &str) -> String {
3115    value
3116        .chars()
3117        .map(|c| match c {
3118            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
3119            c if c.is_control() => '_',
3120            c => c,
3121        })
3122        .collect::<String>()
3123        .trim()
3124        .to_string()
3125}
3126
3127/// Parse a date shift string like "+1:0:0" (add 1 hour) or "-0:30:0" (subtract 30 min).
3128/// Returns (sign, hours, minutes, seconds).
3129pub fn parse_date_shift(shift: &str) -> Option<(i32, u32, u32, u32)> {
3130    let (sign, rest) = if let Some(stripped) = shift.strip_prefix('-') {
3131        (-1, stripped)
3132    } else if let Some(stripped) = shift.strip_prefix('+') {
3133        (1, stripped)
3134    } else {
3135        (1, shift)
3136    };
3137
3138    let parts: Vec<&str> = rest.split(':').collect();
3139    match parts.len() {
3140        1 => {
3141            let h: u32 = parts[0].parse().ok()?;
3142            Some((sign, h, 0, 0))
3143        }
3144        2 => {
3145            let h: u32 = parts[0].parse().ok()?;
3146            let m: u32 = parts[1].parse().ok()?;
3147            Some((sign, h, m, 0))
3148        }
3149        3 => {
3150            let h: u32 = parts[0].parse().ok()?;
3151            let m: u32 = parts[1].parse().ok()?;
3152            let s: u32 = parts[2].parse().ok()?;
3153            Some((sign, h, m, s))
3154        }
3155        _ => None,
3156    }
3157}
3158
3159/// Shift a datetime string by the given amount.
3160/// Input format: "YYYY:MM:DD HH:MM:SS"
3161pub fn shift_datetime(datetime: &str, shift: &str) -> Option<String> {
3162    let (sign, hours, minutes, seconds) = parse_date_shift(shift)?;
3163
3164    // Parse date/time
3165    if datetime.len() < 19 {
3166        return None;
3167    }
3168    let year: i32 = datetime[0..4].parse().ok()?;
3169    let month: u32 = datetime[5..7].parse().ok()?;
3170    let day: u32 = datetime[8..10].parse().ok()?;
3171    let hour: u32 = datetime[11..13].parse().ok()?;
3172    let min: u32 = datetime[14..16].parse().ok()?;
3173    let sec: u32 = datetime[17..19].parse().ok()?;
3174
3175    // Convert to total seconds, shift, convert back
3176    let total_secs = (hour * 3600 + min * 60 + sec) as i64
3177        + sign as i64 * (hours * 3600 + minutes * 60 + seconds) as i64;
3178
3179    let days_shift = if total_secs < 0 {
3180        -1 - (-total_secs - 1) / 86400
3181    } else {
3182        total_secs / 86400
3183    };
3184
3185    let time_secs = ((total_secs % 86400) + 86400) % 86400;
3186    let new_hour = (time_secs / 3600) as u32;
3187    let new_min = ((time_secs % 3600) / 60) as u32;
3188    let new_sec = (time_secs % 60) as u32;
3189
3190    // Simple day shifting (doesn't handle month/year rollover perfectly for large shifts)
3191    let mut new_day = day as i32 + days_shift as i32;
3192    let mut new_month = month;
3193    let mut new_year = year;
3194
3195    let days_in_month = |m: u32, y: i32| -> i32 {
3196        match m {
3197            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
3198            4 | 6 | 9 | 11 => 30,
3199            2 => {
3200                if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
3201                    29
3202                } else {
3203                    28
3204                }
3205            }
3206            _ => 30,
3207        }
3208    };
3209
3210    while new_day > days_in_month(new_month, new_year) {
3211        new_day -= days_in_month(new_month, new_year);
3212        new_month += 1;
3213        if new_month > 12 {
3214            new_month = 1;
3215            new_year += 1;
3216        }
3217    }
3218    while new_day < 1 {
3219        new_month = if new_month == 1 { 12 } else { new_month - 1 };
3220        if new_month == 12 {
3221            new_year -= 1;
3222        }
3223        new_day += days_in_month(new_month, new_year);
3224    }
3225
3226    Some(format!(
3227        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}",
3228        new_year, new_month, new_day, new_hour, new_min, new_sec
3229    ))
3230}
3231
3232/// Group assignment of the file-level pseudo-tags, ported from
3233/// `%Image::ExifTool::System` and the `%allGroupsExifTool` entries of
3234/// `%Image::ExifTool::Extra` in `ExifTool.pm`.
3235///
3236/// ExifTool resolves these tags through a single table, so their groups do not
3237/// depend on which parser produced them: `Warning` is reported in the `ExifTool`
3238/// group whether it was raised by the PNG, QuickTime or MRC reader. Only family 1
3239/// splits the System tags out of `File` — their family 0 stays `File`, matching
3240/// `exiftool -G0`. The ExifTool pseudo-tags sit in `ExifTool` for all three
3241/// families.
3242///
3243/// Entries are `(name, family0, family1, family2)`.
3244const FILE_LEVEL_GROUPS: &[(&str, &str, &str, &str)] = &[
3245    // ExifTool computes CurrentIPTCDigest with `FoundTag`, so it lands in the
3246    // Extra table (`GROUPS => { 0 => 'File', 1 => 'File', 2 => 'Image' }`,
3247    // ExifTool.pm line 1285/1771), never a format reader's own group.
3248    ("CurrentIPTCDigest", "File", "File", "Image"),
3249    ("Directory", "File", "System", "Other"),
3250    ("Error", "ExifTool", "ExifTool", "ExifTool"),
3251    ("ExifToolVersion", "ExifTool", "ExifTool", "ExifTool"),
3252    ("FileAccessDate", "File", "System", "Time"),
3253    ("FileCreateDate", "File", "System", "Time"),
3254    ("FileInodeChangeDate", "File", "System", "Time"),
3255    ("FileModifyDate", "File", "System", "Time"),
3256    ("FileName", "File", "System", "Other"),
3257    ("FilePermissions", "File", "System", "Other"),
3258    ("FileSize", "File", "System", "Other"),
3259    ("Warning", "ExifTool", "ExifTool", "ExifTool"),
3260];
3261
3262/// The `(family0, family1, family2)` groups [`FILE_LEVEL_GROUPS`] assigns to
3263/// `name`, or `None` if `name` is not a file-level pseudo-tag.
3264fn file_level_group(name: &str) -> Option<(&'static str, &'static str, &'static str)> {
3265    FILE_LEVEL_GROUPS
3266        .iter()
3267        .find(|(n, ..)| *n == name)
3268        .map(|&(_, f0, f1, f2)| (f0, f1, f2))
3269}
3270
3271// Only used by the `#[cfg(unix)]` File:System FilePermissions pseudo-tag above;
3272// not compiled on Windows (which lacks Unix mode bits).
3273//
3274// Port of ExifTool's FilePermissions PrintConv: a leading file-type character
3275// (`-` for a regular file, `d`, `l`, …) followed by nine r/w/x flags for
3276// owner/group/other, e.g. mode 0o100664 → "-rw-rw-r--".
3277#[cfg(unix)]
3278fn format_file_permissions(mode: u32) -> String {
3279    let type_char = match mode & 0o170000 {
3280        0o010000 => 'p', // FIFO
3281        0o020000 => 'c', // character special
3282        0o040000 => 'd', // directory
3283        0o060000 => 'b', // block special
3284        0o120000 => 'l', // symlink
3285        0o140000 => 's', // socket
3286        _ => '-',
3287    };
3288    let mut s = String::with_capacity(10);
3289    s.push(type_char);
3290    let mut mask = 0o400u32;
3291    while mask > 0 {
3292        for ch in ['r', 'w', 'x'] {
3293            s.push(if mode & mask != 0 { ch } else { '-' });
3294            mask >>= 1;
3295        }
3296    }
3297    s
3298}
3299
3300/// File contents exposed as a byte slice, backed either by a memory map or — when
3301/// mapping is unavailable (empty file, unsupported FS, mapping error) — an owned
3302/// buffer. Both `Deref` to `[u8]` so callers are agnostic to the backing store.
3303enum FileData {
3304    Mapped(memmap2::Mmap),
3305    Owned(Vec<u8>),
3306}
3307
3308impl std::ops::Deref for FileData {
3309    type Target = [u8];
3310    fn deref(&self) -> &[u8] {
3311        match self {
3312            FileData::Mapped(m) => m,
3313            FileData::Owned(v) => v,
3314        }
3315    }
3316}
3317
3318/// Map a file read-only for parsing. Zero-length files (which cannot be mapped)
3319/// and any mapping failure fall back to a plain `fs::read`.
3320fn map_file_for_read(path: &Path) -> Result<FileData> {
3321    let file = fs::File::open(path).map_err(Error::Io)?;
3322    let len = file.metadata().map_err(Error::Io)?.len();
3323    if len == 0 {
3324        return Ok(FileData::Owned(Vec::new()));
3325    }
3326    // SAFETY: the mapping is only ever read, never written, and the `Mmap` is
3327    // dropped before `extract_info` returns. If another process truncates the
3328    // file mid-parse the kernel may raise SIGBUS — the same exposure ExifTool's
3329    // own random-access reads have; acceptable for a read-only metadata tool.
3330    match unsafe { memmap2::Mmap::map(&file) } {
3331        Ok(m) => Ok(FileData::Mapped(m)),
3332        Err(_) => Ok(FileData::Owned(fs::read(path).map_err(Error::Io)?)),
3333    }
3334}
3335
3336/// Port of ExifTool ConvertFileSize (decimal units): %.1f below 10× a unit, %.0f above.
3337fn format_file_size(bytes: u64) -> String {
3338    let v = bytes as f64;
3339    if bytes < 2000 {
3340        format!("{} bytes", bytes)
3341    } else if bytes < 10_000 {
3342        format!("{:.1} kB", v / 1000.0)
3343    } else if bytes < 2_000_000 {
3344        format!("{:.0} kB", v / 1000.0)
3345    } else if bytes < 10_000_000 {
3346        format!("{:.1} MB", v / 1_000_000.0)
3347    } else if bytes < 2_000_000_000 {
3348        format!("{:.0} MB", v / 1_000_000.0)
3349    } else if bytes < 10_000_000_000 {
3350        format!("{:.1} GB", v / 1_000_000_000.0)
3351    } else {
3352        format!("{:.0} GB", v / 1_000_000_000.0)
3353    }
3354}
3355
3356/// Check if a tag name is typically XMP.
3357fn is_xmp_tag(tag: &str) -> bool {
3358    matches!(
3359        tag.to_lowercase().as_str(),
3360        "title"
3361            | "description"
3362            | "subject"
3363            | "creator"
3364            | "rights"
3365            | "keywords"
3366            | "rating"
3367            | "label"
3368            | "hierarchicalsubject"
3369    )
3370}
3371
3372/// Encode an EXIF tag value to binary.
3373/// Returns (tag_id, format, encoded_data) or None if tag is unknown.
3374fn encode_exif_tag(
3375    tag_name: &str,
3376    value: &str,
3377    _group: &str,
3378    bo: ByteOrderMark,
3379) -> Option<(u16, exif_writer::ExifFormat, Vec<u8>)> {
3380    let tag_lower = tag_name.to_lowercase();
3381
3382    // Map common tag names to EXIF tag IDs and formats
3383    let (tag_id, format): (u16, exif_writer::ExifFormat) = match tag_lower.as_str() {
3384        // IFD0 string tags
3385        "imagedescription" => (0x010E, exif_writer::ExifFormat::Ascii),
3386        "make" => (0x010F, exif_writer::ExifFormat::Ascii),
3387        "model" => (0x0110, exif_writer::ExifFormat::Ascii),
3388        "software" => (0x0131, exif_writer::ExifFormat::Ascii),
3389        "modifydate" | "datetime" => (0x0132, exif_writer::ExifFormat::Ascii),
3390        "artist" => (0x013B, exif_writer::ExifFormat::Ascii),
3391        "copyright" => (0x8298, exif_writer::ExifFormat::Ascii),
3392        // IFD0 numeric tags
3393        "orientation" => (0x0112, exif_writer::ExifFormat::Short),
3394        "xresolution" => (0x011A, exif_writer::ExifFormat::Rational),
3395        "yresolution" => (0x011B, exif_writer::ExifFormat::Rational),
3396        "resolutionunit" => (0x0128, exif_writer::ExifFormat::Short),
3397        // ExifIFD tags
3398        "datetimeoriginal" => (0x9003, exif_writer::ExifFormat::Ascii),
3399        "createdate" | "datetimedigitized" => (0x9004, exif_writer::ExifFormat::Ascii),
3400        "usercomment" => (0x9286, exif_writer::ExifFormat::Undefined),
3401        "imageuniqueid" => (0xA420, exif_writer::ExifFormat::Ascii),
3402        "ownername" | "cameraownername" => (0xA430, exif_writer::ExifFormat::Ascii),
3403        "serialnumber" | "bodyserialnumber" => (0xA431, exif_writer::ExifFormat::Ascii),
3404        "lensmake" => (0xA433, exif_writer::ExifFormat::Ascii),
3405        "lensmodel" => (0xA434, exif_writer::ExifFormat::Ascii),
3406        "lensserialnumber" => (0xA435, exif_writer::ExifFormat::Ascii),
3407        _ => return None,
3408    };
3409
3410    let encoded = match format {
3411        exif_writer::ExifFormat::Ascii => exif_writer::encode_ascii(value),
3412        exif_writer::ExifFormat::Short => {
3413            let v: u16 = value.parse().ok()?;
3414            exif_writer::encode_u16(v, bo)
3415        }
3416        exif_writer::ExifFormat::Long => {
3417            let v: u32 = value.parse().ok()?;
3418            exif_writer::encode_u32(v, bo)
3419        }
3420        exif_writer::ExifFormat::Rational => {
3421            // Parse "N/D" or just "N"
3422            if let Some(slash) = value.find('/') {
3423                let num: u32 = value[..slash].trim().parse().ok()?;
3424                let den: u32 = value[slash + 1..].trim().parse().ok()?;
3425                exif_writer::encode_urational(num, den, bo)
3426            } else if let Ok(v) = value.parse::<f64>() {
3427                // Convert float to rational
3428                let den = 10000u32;
3429                let num = (v * den as f64).round() as u32;
3430                exif_writer::encode_urational(num, den, bo)
3431            } else {
3432                return None;
3433            }
3434        }
3435        exif_writer::ExifFormat::Undefined => {
3436            // UserComment: 8 bytes charset + data
3437            let mut data = vec![0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00]; // "ASCII\0\0\0"
3438            data.extend_from_slice(value.as_bytes());
3439            data
3440        }
3441        _ => return None,
3442    };
3443
3444    Some((tag_id, format, encoded))
3445}
3446
3447/// Compute text file tags (from Perl Text.pm).
3448fn compute_text_tags(data: &[u8], is_csv: bool) -> Vec<Tag> {
3449    let mut tags = Vec::new();
3450    let mk = |name: &str, val: String| Tag {
3451        id: crate::tag::TagId::Text(name.into()),
3452        name: name.into(),
3453        description: name.into(),
3454        group: crate::tag::TagGroup {
3455            family0: "File".into(),
3456            family1: "File".into(),
3457            family2: "Other".into(),
3458            family3: "Main".into(),
3459        },
3460        raw_value: Value::String(val.clone()),
3461        print_value: val,
3462        priority: 0,
3463    };
3464
3465    // Detect encoding and BOM
3466    let is_ascii = data.iter().all(|&b| b < 128);
3467    let has_utf8_bom = data.starts_with(&[0xEF, 0xBB, 0xBF]);
3468    let has_utf16le_bom =
3469        data.starts_with(&[0xFF, 0xFE]) && !data.starts_with(&[0xFF, 0xFE, 0x00, 0x00]);
3470    let has_utf16be_bom = data.starts_with(&[0xFE, 0xFF]);
3471    let has_utf32le_bom = data.starts_with(&[0xFF, 0xFE, 0x00, 0x00]);
3472    let has_utf32be_bom = data.starts_with(&[0x00, 0x00, 0xFE, 0xFF]);
3473
3474    // Detect if file has weird non-text control characters (like multi-byte unicode without BOM)
3475    let has_weird_ctrl = data.iter().any(|&b| {
3476        (b <= 0x06) || (0x0e..=0x1a).contains(&b) || (0x1c..=0x1f).contains(&b) || b == 0x7f
3477    });
3478
3479    let (encoding, is_bom, is_utf16) = if has_utf32le_bom {
3480        ("utf-32le", true, false)
3481    } else if has_utf32be_bom {
3482        ("utf-32be", true, false)
3483    } else if has_utf16le_bom {
3484        ("utf-16le", true, true)
3485    } else if has_utf16be_bom {
3486        ("utf-16be", true, true)
3487    } else if has_weird_ctrl {
3488        // Not a text file (has binary-like control chars but no recognized multi-byte marker)
3489        return tags;
3490    } else if is_ascii {
3491        ("us-ascii", false, false)
3492    } else {
3493        // Check UTF-8
3494        let is_valid_utf8 = std::str::from_utf8(data).is_ok();
3495        if is_valid_utf8 {
3496            if has_utf8_bom {
3497                ("utf-8", true, false)
3498            } else {
3499                // Check if it has high bytes suggesting iso-8859-1 vs utf-8
3500                // Perl's IsUTF8: returns >0 if valid UTF-8 with multi-byte, 0 if ASCII, <0 if invalid
3501                // For simplicity: valid UTF-8 without BOM = utf-8
3502                ("utf-8", false, false)
3503            }
3504        } else if !data.iter().any(|&b| (0x80..=0x9f).contains(&b)) {
3505            ("iso-8859-1", false, false)
3506        } else {
3507            ("unknown-8bit", false, false)
3508        }
3509    };
3510
3511    tags.push(mk("MIMEEncoding", encoding.into()));
3512
3513    if is_bom {
3514        tags.push(mk("ByteOrderMark", "Yes".into()));
3515    }
3516
3517    // Count newlines and detect type
3518    let has_cr = data.contains(&b'\r');
3519    let has_lf = data.contains(&b'\n');
3520    let newline_type = if has_cr && has_lf {
3521        "Windows CRLF"
3522    } else if has_lf {
3523        "Unix LF"
3524    } else if has_cr {
3525        "Macintosh CR"
3526    } else {
3527        "(none)"
3528    };
3529    tags.push(mk("Newlines", newline_type.into()));
3530
3531    if is_csv {
3532        // CSV analysis: detect delimiter, quoting, column count, row count
3533        let text = crate::encoding::decode_utf8_or_latin1(data);
3534        let mut delim = "";
3535        let mut quot = "";
3536        let mut ncols = 1usize;
3537        let mut nrows = 0usize;
3538
3539        for line in text.lines() {
3540            if nrows == 0 {
3541                // Detect delimiter from first line
3542                let comma_count = line.matches(',').count();
3543                let semi_count = line.matches(';').count();
3544                let tab_count = line.matches('\t').count();
3545                if comma_count > semi_count && comma_count > tab_count {
3546                    delim = ",";
3547                    ncols = comma_count + 1;
3548                } else if semi_count > tab_count {
3549                    delim = ";";
3550                    ncols = semi_count + 1;
3551                } else if tab_count > 0 {
3552                    delim = "\t";
3553                    ncols = tab_count + 1;
3554                } else {
3555                    delim = "";
3556                    ncols = 1;
3557                }
3558                // Detect quoting
3559                if line.contains('"') {
3560                    quot = "\"";
3561                } else if line.contains('\'') {
3562                    quot = "'";
3563                }
3564            }
3565            nrows += 1;
3566            if nrows >= 1000 {
3567                break;
3568            }
3569        }
3570
3571        let delim_display = match delim {
3572            "," => "Comma",
3573            ";" => "Semicolon",
3574            "\t" => "Tab",
3575            _ => "(none)",
3576        };
3577        let quot_display = match quot {
3578            "\"" => "Double quotes",
3579            "'" => "Single quotes",
3580            _ => "(none)",
3581        };
3582
3583        tags.push(mk("Delimiter", delim_display.into()));
3584        tags.push(mk("Quoting", quot_display.into()));
3585        tags.push(mk("ColumnCount", ncols.to_string()));
3586        if nrows > 0 {
3587            tags.push(mk("RowCount", nrows.to_string()));
3588        }
3589    } else if !is_utf16 {
3590        // Line count and word count for plain text files (not UTF-16/32)
3591        // ExifTool counts each ReadLine, so trailing content without a final newline
3592        // still counts as a line.
3593        let nl_count = data.iter().filter(|&&b| b == b'\n').count();
3594        let line_count = if !data.is_empty() && data.last() != Some(&b'\n') {
3595            nl_count + 1
3596        } else {
3597            nl_count
3598        };
3599        tags.push(mk("LineCount", line_count.to_string()));
3600
3601        let text = crate::encoding::decode_utf8_or_latin1(data);
3602        let word_count = text.split_whitespace().count();
3603        tags.push(mk("WordCount", word_count.to_string()));
3604    }
3605
3606    tags
3607}
3608
3609#[cfg(test)]
3610mod tests {
3611    use super::*;
3612
3613    #[test]
3614    fn new_has_default_options() {
3615        let et = ExifTool::new();
3616        assert!(!et.options().duplicates);
3617        assert!(et.options().print_conv);
3618        assert_eq!(et.options().fast_scan, 0);
3619        assert!(et.options().requested_tags.is_empty());
3620        assert_eq!(et.options().extract_embedded, 0);
3621        assert_eq!(et.options().show_unknown, 0);
3622        assert!(!et.options().process_compressed);
3623        assert!(!et.options().use_mwg);
3624    }
3625
3626    #[test]
3627    fn tag_matches_request_group_qualified() {
3628        let tag = Tag {
3629            id: crate::tag::TagId::Text("By-line".into()),
3630            name: "By-line".into(),
3631            description: "By-line".into(),
3632            group: crate::tag::TagGroup {
3633                family0: "IPTC".into(),
3634                family1: "IPTC".into(),
3635                family2: "Author".into(),
3636                family3: "Main".into(),
3637            },
3638            raw_value: Value::String("Martín".into()),
3639            print_value: "Martín".into(),
3640            priority: 1,
3641        };
3642        // Bare name (case-insensitive).
3643        assert!(ExifTool::tag_matches_request(&tag, "By-line"));
3644        assert!(ExifTool::tag_matches_request(&tag, "by-line"));
3645        // Group-qualified against families 0 and 2.
3646        assert!(ExifTool::tag_matches_request(&tag, "IPTC:By-line"));
3647        assert!(ExifTool::tag_matches_request(&tag, "Author:By-line"));
3648        // Wildcards.
3649        assert!(ExifTool::tag_matches_request(&tag, "IPTC:*"));
3650        assert!(ExifTool::tag_matches_request(&tag, "*"));
3651        // Wrong group / wrong name → no match.
3652        assert!(!ExifTool::tag_matches_request(&tag, "EXIF:By-line"));
3653        assert!(!ExifTool::tag_matches_request(&tag, "IPTC:Make"));
3654        assert!(!ExifTool::tag_matches_request(&tag, "Headline"));
3655    }
3656
3657    #[test]
3658    fn with_options_preserves_custom() {
3659        let opts = Options {
3660            duplicates: true,
3661            print_conv: false,
3662            fast_scan: 2,
3663            requested_tags: vec!["Artist".to_string()],
3664            extract_embedded: 1,
3665            show_unknown: 1,
3666            process_compressed: true,
3667            use_mwg: true,
3668            geolocation: true,
3669        };
3670        let et = ExifTool::with_options(opts.clone());
3671        assert!(et.options().duplicates);
3672        assert!(!et.options().print_conv);
3673        assert_eq!(et.options().fast_scan, 2);
3674        assert_eq!(et.options().requested_tags, vec!["Artist".to_string()]);
3675        assert_eq!(et.options().extract_embedded, 1);
3676        assert_eq!(et.options().show_unknown, 1);
3677        assert!(et.options().process_compressed);
3678        assert!(et.options().use_mwg);
3679    }
3680
3681    #[test]
3682    fn set_new_value_simple_tag() {
3683        let mut et = ExifTool::new();
3684        et.set_new_value("Artist", Some("John"));
3685        assert_eq!(et.new_values.len(), 1);
3686        assert_eq!(et.new_values[0].tag, "Artist");
3687        assert_eq!(et.new_values[0].group, None);
3688        assert_eq!(et.new_values[0].value, Some("John".to_string()));
3689    }
3690
3691    #[test]
3692    fn set_new_value_with_group_prefix() {
3693        let mut et = ExifTool::new();
3694        et.set_new_value("XMP:Title", Some("Test"));
3695        assert_eq!(et.new_values.len(), 1);
3696        assert_eq!(et.new_values[0].tag, "Title");
3697        assert_eq!(et.new_values[0].group, Some("XMP".to_string()));
3698        assert_eq!(et.new_values[0].value, Some("Test".to_string()));
3699    }
3700
3701    #[test]
3702    fn set_new_value_delete() {
3703        let mut et = ExifTool::new();
3704        et.set_new_value("Comment", None);
3705        assert_eq!(et.new_values.len(), 1);
3706        assert_eq!(et.new_values[0].tag, "Comment");
3707        assert_eq!(et.new_values[0].value, None);
3708    }
3709
3710    #[test]
3711    fn clear_new_values_empties_queue() {
3712        let mut et = ExifTool::new();
3713        et.set_new_value("Artist", Some("A"));
3714        et.set_new_value("Copyright", Some("B"));
3715        assert_eq!(et.new_values.len(), 2);
3716        et.clear_new_values();
3717        assert!(et.new_values.is_empty());
3718    }
3719
3720    #[test]
3721    fn set_new_value_multiple() {
3722        let mut et = ExifTool::new();
3723        et.set_new_value("Artist", Some("John"));
3724        et.set_new_value("IPTC:Keywords", Some("test"));
3725        et.set_new_value("XMP:Subject", None);
3726        assert_eq!(et.new_values.len(), 3);
3727        assert_eq!(et.new_values[1].group, Some("IPTC".to_string()));
3728        assert_eq!(et.new_values[1].tag, "Keywords");
3729        assert_eq!(et.new_values[2].value, None);
3730    }
3731
3732    #[test]
3733    fn options_mut_modifies() {
3734        let mut et = ExifTool::new();
3735        et.options_mut().duplicates = true;
3736        et.options_mut().fast_scan = 3;
3737        assert!(et.options().duplicates);
3738        assert_eq!(et.options().fast_scan, 3);
3739    }
3740
3741    #[test]
3742    fn default_options() {
3743        let opts = Options::default();
3744        assert!(!opts.duplicates);
3745        assert!(opts.print_conv);
3746        assert_eq!(opts.fast_scan, 0);
3747    }
3748}