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