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