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