Skip to main content

exiftool_rs/metadata/
exif.rs

1//! EXIF/TIFF IFD metadata reader.
2//!
3//! Implements reading of TIFF IFD structures used in EXIF, GPS, and Interop metadata.
4//! Mirrors the core logic of ExifTool's Exif.pm ProcessExif function.
5
6use byteorder::{BigEndian, ByteOrder, LittleEndian};
7
8use std::cell::Cell;
9
10use crate::error::{Error, Result};
11use crate::tag::{Tag, TagGroup, TagId};
12use crate::tags::exif as exif_tags;
13use crate::value::Value;
14
15thread_local! {
16    static SHOW_UNKNOWN: Cell<u8> = const { Cell::new(0) };
17    /// Whether every instance of a tag name must be reported. ExifTool's CLI turns
18    /// the Duplicates option on together with -ee, and `FoundTag` then keeps each
19    /// instance under its own key; the name-level pruning in this module reproduces
20    /// the Duplicates-off collapse and must be skipped in that case.
21    static KEEP_DUPLICATES: Cell<bool> = const { Cell::new(false) };
22}
23
24/// Set whether duplicate tag names must all be kept (ExifTool's Duplicates option).
25pub fn set_keep_duplicates(keep: bool) {
26    KEEP_DUPLICATES.with(|s| s.set(keep));
27}
28
29/// Whether duplicate tag names must all be kept.
30pub fn keep_duplicates() -> bool {
31    KEEP_DUPLICATES.with(|s| s.get())
32}
33
34/// Set the show_unknown level for the current thread (used by MakerNotes).
35pub fn set_show_unknown(level: u8) {
36    SHOW_UNKNOWN.with(|s| s.set(level));
37}
38
39/// Get the show_unknown level for the current thread (used by MakerNotes).
40pub fn get_show_unknown() -> u8 {
41    SHOW_UNKNOWN.with(|s| s.get())
42}
43
44/// Byte order of the TIFF data.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ByteOrderMark {
47    LittleEndian,
48    BigEndian,
49}
50
51/// Parsed TIFF header.
52#[derive(Debug)]
53pub struct TiffHeader {
54    pub byte_order: ByteOrderMark,
55    pub ifd0_offset: u32,
56}
57
58/// EXIF IFD entry as read from the file.
59#[derive(Debug)]
60struct IfdEntry {
61    tag: u16,
62    data_type: u16,
63    count: u32,
64    value_offset: u32,
65    /// For values that fit in 4 bytes, the raw 4 bytes
66    inline_data: [u8; 4],
67}
68
69/// Size in bytes for each TIFF data type.
70fn type_size(data_type: u16) -> Option<usize> {
71    match data_type {
72        1 => Some(1),  // BYTE
73        2 => Some(1),  // ASCII
74        3 => Some(2),  // SHORT
75        4 => Some(4),  // LONG
76        5 => Some(8),  // RATIONAL
77        6 => Some(1),  // SBYTE
78        7 => Some(1),  // UNDEFINED
79        8 => Some(2),  // SSHORT
80        9 => Some(4),  // SLONG
81        10 => Some(8), // SRATIONAL
82        11 => Some(4), // FLOAT
83        12 => Some(8), // DOUBLE
84        13 => Some(4), // IFD
85        _ => None,
86    }
87}
88
89/// Parse a TIFF header from raw bytes.
90pub fn parse_tiff_header(data: &[u8]) -> Result<TiffHeader> {
91    if data.len() < 8 {
92        return Err(Error::InvalidTiffHeader);
93    }
94
95    let byte_order = match (data[0], data[1]) {
96        (b'I', b'I') => ByteOrderMark::LittleEndian,
97        (b'M', b'M') => ByteOrderMark::BigEndian,
98        _ => return Err(Error::InvalidTiffHeader),
99    };
100
101    let magic = match byte_order {
102        ByteOrderMark::LittleEndian => LittleEndian::read_u16(&data[2..4]),
103        ByteOrderMark::BigEndian => BigEndian::read_u16(&data[2..4]),
104    };
105
106    if magic != 42 {
107        return Err(Error::InvalidTiffHeader);
108    }
109
110    let ifd0_offset = match byte_order {
111        ByteOrderMark::LittleEndian => LittleEndian::read_u32(&data[4..8]),
112        ByteOrderMark::BigEndian => BigEndian::read_u32(&data[4..8]),
113    };
114
115    Ok(TiffHeader {
116        byte_order,
117        ifd0_offset,
118    })
119}
120
121/// Tags where the EXIF IFD value takes priority over a same-named MakerNotes tag
122/// (structural/authoritative EXIF). For all other duplicates, MakerNotes wins —
123/// matching ExifTool's group priority.
124pub(crate) const EXIF_PRIMARY_TAGS: &[&str] = &[
125    "ThumbnailOffset",
126    "ThumbnailLength",
127    "ThumbnailImage",
128    "StripOffsets",
129    "StripByteCounts",
130    "PreviewImageStart",
131    "PreviewImageLength",
132    "PreviewImage",
133    "ImageWidth",
134    "ImageHeight",
135    "BitsPerSample",
136    "Compression",
137    "PhotometricInterpretation",
138    "SamplesPerPixel",
139    "RowsPerStrip",
140    "PlanarConfiguration",
141    "XResolution",
142    "YResolution",
143    "ResolutionUnit",
144    "Orientation",
145    "Make",
146    "Model",
147    "Software",
148    "ExifByteOrder",
149    "CR2CFAPattern",
150    "RawImageSegmentation",
151    "ColorSpace",
152    "ExifVersion",
153    "FlashpixVersion",
154    "ExifImageWidth",
155    "ExifImageHeight",
156    "InteropIndex",
157    "InteropVersion",
158    "DateTimeOriginal",
159    "CreateDate",
160    "ModifyDate",
161    "DateTime",
162    "FocalPlaneXResolution",
163    "FocalPlaneYResolution",
164    "FocalPlaneResolutionUnit",
165    "CustomRendered",
166    "ExposureMode",
167    "SceneCaptureType",
168    // IFD0 PrintIM wins over a MakerNotes PrintIM copy.
169    "PrintIMVersion",
170    "Flash",
171    "FocalLength",
172    "ExposureTime",
173    "ExposureProgram",
174    "FNumber",
175    "ShutterSpeedValue",
176    "ApertureValue",
177    "ComponentsConfiguration",
178    "UserComment",
179    // Standard EXIF image-parameter tags (0xa408-0xa40c) take priority over the
180    // manufacturer's MakerNote duplicate when both are present (ExifTool default).
181    "Contrast",
182    "Saturation",
183    "Sharpness",
184];
185
186/// EXIF metadata reader.
187pub struct ExifReader;
188
189impl ExifReader {
190    /// Parse EXIF data from a byte slice (starting at the TIFF header).
191    pub fn read(data: &[u8]) -> Result<Vec<Tag>> {
192        Self::read_with_base(data, 0)
193    }
194
195    /// Parse EXIF data, adding `base` (the TIFF header's offset within the file) to
196    /// offset-type tags so they read as absolute file offsets, matching ExifTool.
197    pub fn read_with_base(data: &[u8], base: usize) -> Result<Vec<Tag>> {
198        let mut tags = Self::read_inner(data, base)?;
199        if base != 0 {
200            // ExifTool reports these IsOffset tags relative to the start of the file.
201            const OFFSET_TAGS: &[&str] = &[
202                "ThumbnailOffset",
203                "PreviewImageStart",
204                "JpgFromRawStart",
205                "OtherImageStart",
206                "StripOffsets",
207            ];
208            for t in tags.iter_mut() {
209                if OFFSET_TAGS.contains(&t.name.as_str()) {
210                    if let Some(off) = t.raw_value.as_u64() {
211                        let abs = off + base as u64;
212                        t.raw_value = Value::U32(abs as u32);
213                        t.print_value = abs.to_string();
214                    }
215                }
216            }
217        }
218        Ok(tags)
219    }
220
221    /// ExifTool raises ExifByteOrder at file level, not from IFD0.
222    fn exif_byte_order_tag(byte_order: ByteOrderMark) -> Tag {
223        let bo_str = match byte_order {
224            ByteOrderMark::LittleEndian => "Little-endian (Intel, II)",
225            ByteOrderMark::BigEndian => "Big-endian (Motorola, MM)",
226        };
227        Tag {
228            id: TagId::Text("ExifByteOrder".to_string()),
229            name: "ExifByteOrder".to_string(),
230            description: "Exif Byte Order".to_string(),
231            group: TagGroup {
232                family0: "File".to_string(),
233                family1: "File".to_string(),
234                family2: "Image".to_string(),
235                family3: "Main".into(),
236            },
237            raw_value: Value::String(bo_str.to_string()),
238            print_value: bo_str.to_string(),
239            priority: 0,
240        }
241    }
242
243    fn read_inner(data: &[u8], exif_base: usize) -> Result<Vec<Tag>> {
244        let header = parse_tiff_header(data)?;
245        let mut tags = Vec::new();
246
247        // Emit ExifByteOrder tag
248        tags.push(Self::exif_byte_order_tag(header.byte_order));
249
250        // Detect CR2: "CR" at offset 8 in TIFF data
251        let is_cr2 = data.len() > 10 && &data[8..10] == b"CR";
252
253        // Read IFD0 (main image)
254        Self::read_ifd(data, &header, header.ifd0_offset, "IFD0", &mut tags)?;
255
256        // For CR2 files, rename IFD0 StripOffsets→PreviewImageStart and
257        // StripByteCounts→PreviewImageLength, then construct PreviewImage.
258        if is_cr2 {
259            // Rename tags in-place
260            for tag in tags.iter_mut() {
261                if tag.group.family1 == "IFD0" {
262                    if tag.name == "StripOffsets" {
263                        tag.name = "PreviewImageStart".to_string();
264                        tag.description = "Preview Image Start".to_string();
265                        tag.id = TagId::Text("PreviewImageStart".to_string());
266                    } else if tag.name == "StripByteCounts" {
267                        tag.name = "PreviewImageLength".to_string();
268                        tag.description = "Preview Image Length".to_string();
269                        tag.id = TagId::Text("PreviewImageLength".to_string());
270                    }
271                }
272            }
273            // Construct PreviewImage from PreviewImageStart + PreviewImageLength
274            let preview_start = tags
275                .iter()
276                .find(|t| t.name == "PreviewImageStart" && t.group.family1 == "IFD0")
277                .and_then(|t| t.raw_value.as_u64())
278                .map(|v| v as usize);
279            let preview_len = tags
280                .iter()
281                .find(|t| t.name == "PreviewImageLength" && t.group.family1 == "IFD0")
282                .and_then(|t| t.raw_value.as_u64())
283                .map(|v| v as usize);
284            if let (Some(start), Some(len)) = (preview_start, preview_len) {
285                if len > 0 && start + len <= data.len() {
286                    let img_data = data[start..start + len].to_vec();
287                    let pv = format!("(Binary data {} bytes, use -b option to extract)", len);
288                    tags.push(Tag {
289                        id: TagId::Text("PreviewImage".to_string()),
290                        name: "PreviewImage".to_string(),
291                        description: "Preview Image".to_string(),
292                        group: TagGroup {
293                            family0: "EXIF".to_string(),
294                            family1: "IFD0".to_string(),
295                            family2: "Preview".to_string(),
296                            family3: "Main".into(),
297                        },
298                        raw_value: Value::Binary(img_data),
299                        print_value: pv,
300                        priority: 0,
301                    });
302                }
303            }
304        }
305
306        // Extract Make + Model for MakerNotes detection and sub-table dispatch
307        let make = tags
308            .iter()
309            .find(|t| t.name == "Make")
310            .map(|t| t.print_value.clone())
311            .unwrap_or_default();
312
313        let model = tags
314            .iter()
315            .find(|t| t.name == "Model")
316            .map(|t| t.print_value.clone())
317            .unwrap_or_default();
318
319        // Store model for sub-table dispatch
320        let make_and_model = if model.is_empty() {
321            make.clone()
322        } else {
323            model
324        };
325
326        // Find and parse MakerNotes
327        // Look for the MakerNote tag (0x927C) that was stored as Undefined
328        let mn_info: Option<(usize, usize)> = {
329            // Re-scan ExifIFD for MakerNote offset/size
330            let mut result = None;
331            Self::find_makernote(data, &header, &mut result);
332            result
333        };
334
335        if let Some((mn_offset, mn_size)) = mn_info {
336            let mn_tags = crate::metadata::makernotes::parse_makernotes_exif_base(
337                data,
338                mn_offset,
339                mn_size,
340                &make,
341                &make_and_model,
342                header.byte_order,
343                exif_base,
344            );
345            // The parsed tags take the raw MakerNote tag's place in the stream.
346            // ExifTool descends into the maker note the moment ProcessExif reaches
347            // 0x927c inside the ExifIFD (Exif.pm:6113 `ProcessDirectory` on the
348            // SubDirectory), so every maker-note tag is stored BEFORE the walk
349            // resumes and reaches IFD1. That order decides every priority-0 tie:
350            // in a JPEG both `PreviewIFD` and `IFD1` are in LOW_PRIORITY_DIR
351            // (ExifTool.pm:4368 and ProcessJPEG :7317), so the tie is first-wins
352            // and the PreviewIFD copy has to be seen first.
353            // In Perl ExifTool, MakerNotes tags with equal/higher priority overwrite EXIF tags.
354            // Tags in the EXIF-primary list: EXIF wins (skip MakerNotes duplicate).
355            // Other tags: MakerNotes wins (remove EXIF version, add MakerNotes version).
356            // The placeholder is still in `tags` here, so its index is looked up
357            // at splice time — the EXIF-duplicate pruning below runs first and
358            // would invalidate an index taken any earlier.
359            let splice_in = |tags: &mut Vec<Tag>, new: Vec<Tag>| match tags
360                .iter()
361                .position(|t| t.name == "MakerNote")
362            {
363                Some(i) => {
364                    tags.splice(i..=i, new);
365                }
366                None => tags.extend(new),
367            };
368            if keep_duplicates() {
369                // Duplicates are kept: ExifTool reports the EXIF and the maker-note
370                // copy of a tag side by side (e.g. FujiFilm Contrast/Saturation/
371                // Sharpness or Panasonic TextStamp under -ee).
372                splice_in(&mut tags, mn_tags);
373            } else {
374                // Tags where EXIF takes priority over MakerNotes (structural/authoritative EXIF)
375                let exif_primary: &[&str] = EXIF_PRIMARY_TAGS;
376                // Only a maker-note tag ExifTool would let win outright removes
377                // the EXIF duplicate here. A tag whose source table states
378                // `PRIORITY => 0` — Minolta::CameraSettings (Minolta.pm:974) and
379                // its siblings — does not: FoundTag promotes the stored EXIF tag
380                // to 1 first (ExifTool.pm:9544-9551), so it keeps the name unless
381                // it is itself demoted, and only the central arbitration knows
382                // that. Leaving both instances in place is what lets ExifIFD keep
383                // MeteringMode while the maker note takes WhiteBalance, whose
384                // 0xa403 carries `Priority => 0` "to keep this WhiteBalance from
385                // overriding the MakerNotes WhiteBalance" (Exif.pm:2877-2880).
386                // A `PreviewIFD` tag cannot either: ExifTool.pm:4368 initialises
387                // `LOW_PRIORITY_DIR = { PreviewIFD => 1 }`, and `Nikon::PreviewIFD`
388                // says so itself (Nikon.pm:5391, "these tags are priority 0 by
389                // default because PreviewIFD is flagged in LOW_PRIORITY_DIR").
390                let mn_name_set: std::collections::HashSet<String> = mn_tags
391                    .iter()
392                    .filter(|t| {
393                        t.priority_rank() >= 0
394                            && t.priority != crate::tag::PRIORITY_EXPLICIT_ZERO
395                            && t.group.family1 != "PreviewIFD"
396                    })
397                    .map(|t| t.name.clone())
398                    .collect();
399                let exif_has: std::collections::HashSet<String> =
400                    tags.iter().map(|t| t.name.clone()).collect();
401                // Remove EXIF non-primary tags when MakerNotes provides them (MakerNotes wins)
402                tags.retain(|t| {
403                    !mn_name_set.contains(&t.name) || exif_primary.contains(&t.name.as_str())
404                });
405                // Add MakerNotes tags, but skip EXIF-primary tags that EXIF already provides.
406                // Exception: a few maker notes carry a more precise authoritative value
407                // (e.g. Kodak FNumber/ExposureTime) that ExifTool reports over EXIF — keep
408                // those so the later precedence pass can promote them.
409                let kept: Vec<Tag> = mn_tags
410                    .into_iter()
411                    .filter(|mn_tag| {
412                        let authoritative = mn_tag.group.family1 == "Kodak"
413                            && matches!(mn_tag.name.as_str(), "FNumber" | "ExposureTime");
414                        // EXIF wins — don't add the MakerNotes version. A
415                        // `PreviewIFD` tag is exempt in both directions: it is
416                        // priority 0 (ExifTool.pm:4368), so the arbitration pass
417                        // in `exiftool` already lets IFD0 beat it, while against
418                        // the equally-demoted IFD1 of a JPEG (ProcessJPEG,
419                        // ExifTool.pm:7317) the tie is first-wins and the
420                        // PreviewIFD copy is the one ExifTool keeps.
421                        authoritative
422                            || mn_tag.group.family1 == "PreviewIFD"
423                            || !exif_primary.contains(&mn_tag.name.as_str())
424                            || !exif_has.contains(&mn_tag.name)
425                    })
426                    .collect();
427                splice_in(&mut tags, kept);
428            }
429        }
430
431        // DNG PrivateData (0xC634): parse Adobe MakN for MakerNotes if no MakerNote found
432        if mn_info.is_none() {
433            // Scan for DNGPrivateData in tags — look for "Adobe\0" header
434            // Find the offset from the tag value (stored as binary/undefined)
435            Self::parse_dng_private_data(data, &header, &make, &make_and_model, &mut tags);
436        }
437
438        // Parse IPTC data embedded in TIFF (tag 0x83BB "IPTC-NAA")
439        // The raw tag stores IPTC data as undefined bytes or a list of u32 values
440        {
441            let iptc_data: Option<Vec<u8>> =
442                tags.iter().find(|t| t.name == "IPTC-NAA").and_then(|t| {
443                    match &t.raw_value {
444                        Value::Undefined(bytes) => Some(bytes.clone()),
445                        Value::Binary(bytes) => Some(bytes.clone()),
446                        Value::List(items) => {
447                            // IPTC-NAA stored as uint32 list - convert back to bytes (big-endian)
448                            let mut bytes = Vec::with_capacity(items.len() * 4);
449                            for item in items {
450                                if let Value::U32(v) = item {
451                                    bytes.extend_from_slice(&v.to_be_bytes())
452                                }
453                            }
454                            if bytes.is_empty() {
455                                None
456                            } else {
457                                Some(bytes)
458                            }
459                        }
460                        _ => None,
461                    }
462                });
463
464            if let Some(iptc_bytes) = iptc_data {
465                // Compute MD5 of the raw IPTC data for CurrentIPTCDigest
466                let md5_hex = crate::md5::md5_hex(&iptc_bytes);
467
468                if let Ok(iptc_tags) = crate::metadata::IptcReader::read(&iptc_bytes) {
469                    // Replace raw IPTC-NAA tag with parsed IPTC tags
470                    tags.retain(|t| t.name != "IPTC-NAA");
471                    tags.extend(iptc_tags);
472                }
473
474                // Add CurrentIPTCDigest tag
475                tags.push(crate::tag::Tag {
476                    id: crate::tag::TagId::Text("CurrentIPTCDigest".into()),
477                    name: "CurrentIPTCDigest".into(),
478                    description: "Current IPTC Digest".into(),
479                    group: crate::tag::TagGroup {
480                        family0: "IPTC".into(),
481                        family1: "IPTC".into(),
482                        family2: "Other".into(),
483                        family3: "Main".into(),
484                    },
485                    raw_value: Value::String(md5_hex.clone()),
486                    print_value: md5_hex,
487                    priority: 0,
488                });
489            }
490        }
491
492        // Parse ICC_Profile data embedded in TIFF (tag 0x8773)
493        {
494            let icc_data: Option<Vec<u8>> =
495                tags.iter()
496                    .find(|t| t.name == "ICC_Profile")
497                    .and_then(|t| match &t.raw_value {
498                        Value::Undefined(bytes) => Some(bytes.clone()),
499                        Value::Binary(bytes) => Some(bytes.clone()),
500                        _ => None,
501                    });
502
503            if let Some(icc_bytes) = icc_data {
504                if let Ok(icc_tags) = crate::formats::icc::read_icc(&icc_bytes) {
505                    // Replace raw ICC_Profile tag with parsed ICC tags
506                    tags.retain(|t| t.name != "ICC_Profile");
507                    tags.extend(icc_tags);
508                }
509            }
510        }
511
512        // Process GeoTIFF key directory if present
513        process_geotiff_keys(&mut tags);
514
515        // Final deduplication: within MakerNotes, if the same tag name appears multiple times
516        // (e.g., from different sub-tables), keep the last occurrence.
517        // Only deduplicate MakerNotes tags (family0 == "MakerNotes") to avoid affecting
518        // structural EXIF/IFD tags.
519        //
520        // This is ExifTool collapsing its name-keyed VALUE hash, so it must not run
521        // when the Duplicates option is on: a maker note that genuinely defines the
522        // same name at several tag IDs (Panasonic TextStamp at 0x3b, 0x3e, 0x8008
523        // and 0x8009, BabyAge at 0x33 and 0x8010 — Panasonic.pm:727, 789, 811,
524        // 1570, 1576, 1581) is then reported once per ID.
525        {
526            // With duplicates kept, a name is only collapsed when the SAME tag ID
527            // produced it twice — that is our own sub-table reading a tag the main
528            // table already read, never two IDs the maker note really defines
529            // under one name.
530            let by_id = keep_duplicates();
531            if tags.iter().any(|t| t.group.family0 == "MakerNotes") {
532                // Replay FoundTag's comparison rather than simply keeping the
533                // last occurrence: an incoming tag takes the name only when its
534                // priority is >= the stored one's, and a stored 0 is promoted to
535                // 1 first (ExifTool.pm:9544-9560). A sub-table that states
536                // `PRIORITY => 0` — every Canon::CameraInfo* (Canon.pm:3162 and
537                // siblings) — therefore does not displace the value an earlier
538                // sub-table stored, which is how Canon::ShotInfo keeps
539                // WhiteBalance against CameraInfo1DmkIII's.
540                let eff = |t: &Tag| -> i32 {
541                    if t.priority == crate::tag::PRIORITY_EXPLICIT_ZERO {
542                        0
543                    } else if t.priority == 0 {
544                        1
545                    } else {
546                        t.priority
547                    }
548                };
549                let promoted = |p: i32| if p == 0 { 1 } else { p };
550                let mut winner: std::collections::HashMap<(&str, Option<&TagId>), usize> =
551                    std::collections::HashMap::new();
552                let mut keep = vec![true; tags.len()];
553                for (i, t) in tags.iter().enumerate() {
554                    if t.group.family0 != "MakerNotes" {
555                        continue;
556                    }
557                    let key = (t.name.as_str(), if by_id { Some(&t.id) } else { None });
558                    match winner.get(&key).copied() {
559                        None => {
560                            winner.insert(key, i);
561                        }
562                        Some(w) => {
563                            if eff(t) >= promoted(eff(&tags[w])) {
564                                keep[w] = false;
565                                winner.insert(key, i);
566                            } else {
567                                keep[i] = false;
568                            }
569                        }
570                    }
571                }
572                let mut iter = keep.iter();
573                tags.retain(|_| *iter.next().unwrap_or(&true));
574            }
575        }
576
577        // GPS.pm:17-21 `%coordConv` — GPSLatitude and GPSLongitude print through
578        // `ToDMS($self, $val, 1)`, which normalises fractional minutes into
579        // seconds and does NOT append the hemisphere reference. The ref belongs to
580        // the Composite of the same name (GPS.pm:367-405), whose PrintConv is
581        // `ToDMS($self, $val, 1, "N"/"E")`; see `composite::gps_coordinates`.
582        for coord in ["GPSLatitude", "GPSLongitude"] {
583            if let Some(t) = tags.iter_mut().find(|t| t.name == coord) {
584                let parts: Vec<f64> = t
585                    .raw_value
586                    .to_display_string()
587                    .split_whitespace()
588                    .filter_map(|s| s.parse::<f64>().ok())
589                    .collect();
590                if parts.len() == 3 {
591                    let dec = parts[0] + parts[1] / 60.0 + parts[2] / 3600.0;
592                    let deg = dec.floor();
593                    let rem = (dec - deg) * 60.0;
594                    let min = rem.floor();
595                    let sec = (rem - min) * 60.0;
596                    t.print_value = format!("{} deg {}' {:.2}\"", deg as i64, min as i64, sec);
597                }
598            }
599            // Undefined coordinate rationals (0/0 0/0 0/0) yield an empty value.
600            if let Some(t) = tags.iter_mut().find(|t| t.name == coord) {
601                if t.print_value.split_whitespace().all(|p| p == "undef") {
602                    t.print_value = String::new();
603                }
604            }
605        }
606
607        // ExifTool uses the full-resolution sub-IFD (SubfileType = "Full-resolution
608        // image") for the primary image dimensions. When such a sub-IFD exists (e.g. the
609        // real raw image in a DNG/NEF whose IFD0 is a small reduced-resolution preview),
610        // promote its ImageWidth/ImageHeight to the front so they win first-by-name and
611        // feed the ImageSize/Megapixels composites.
612        let fullres_group = tags
613            .iter()
614            .find(|t| {
615                t.name == "SubfileType"
616                    && t.print_value == "Full-resolution image"
617                    && t.group.family1 != "IFD0"
618            })
619            .map(|t| t.group.family1.clone());
620        if let Some(group) = fullres_group {
621            for dim in ["ImageHeight", "ImageWidth"] {
622                if let Some(pos) = tags
623                    .iter()
624                    .position(|t| t.name == dim && t.group.family1 == group)
625                {
626                    let t = tags.remove(pos);
627                    tags.insert(0, t);
628                }
629            }
630        }
631
632        Ok(tags)
633    }
634
635    /// Find MakerNote (tag 0x927C) offset and size in ExifIFD.
636    /// Parse DNG PrivateData (0xC634) to extract embedded MakerNotes
637    fn parse_dng_private_data(
638        data: &[u8],
639        header: &TiffHeader,
640        make: &str,
641        model: &str,
642        tags: &mut Vec<Tag>,
643    ) {
644        // Scan IFD0 for tag 0xC634
645        let ifd0_offset = header.ifd0_offset as usize;
646        if ifd0_offset + 2 > data.len() {
647            return;
648        }
649        let entry_count = read_u16(data, ifd0_offset, header.byte_order) as usize;
650        let entries_start = ifd0_offset + 2;
651        for i in 0..entry_count {
652            let eoff = entries_start + i * 12;
653            if eoff + 12 > data.len() {
654                break;
655            }
656            let tag = read_u16(data, eoff, header.byte_order);
657            if tag == 0xC634 {
658                let dtype = read_u16(data, eoff + 2, header.byte_order);
659                let count = read_u32(data, eoff + 4, header.byte_order) as usize;
660                let elem_size = match dtype {
661                    1 | 7 => 1,
662                    _ => 0,
663                };
664                let total = elem_size * count;
665                if total < 14 {
666                    continue;
667                }
668                let off = read_u32(data, eoff + 8, header.byte_order) as usize;
669                if off + total > data.len() {
670                    continue;
671                }
672                let pdata = &data[off..off + total];
673                // Parse Adobe DNGPrivateData: "Adobe\0" + blocks
674                if !pdata.starts_with(b"Adobe\0") {
675                    continue;
676                }
677                let mut bpos = 6;
678                while bpos + 8 <= pdata.len() {
679                    let btag = &pdata[bpos..bpos + 4];
680                    let bsize = u32::from_be_bytes([
681                        pdata[bpos + 4],
682                        pdata[bpos + 5],
683                        pdata[bpos + 6],
684                        pdata[bpos + 7],
685                    ]) as usize;
686                    bpos += 8;
687                    if bpos + bsize > pdata.len() {
688                        break;
689                    }
690                    if btag == b"MakN" && bsize > 6 {
691                        let mn_block = &pdata[bpos..bpos + bsize];
692                        let mn_bo = if &mn_block[0..2] == b"II" {
693                            ByteOrderMark::LittleEndian
694                        } else {
695                            ByteOrderMark::BigEndian
696                        };
697                        let mut mn_start = 6; // skip byte order + original offset
698                                              // Hack for extra 12 bytes in MakN header (Adobe Camera Raw bug)
699                        if bsize >= 18 && &mn_block[6..10] == b"\0\0\0\x01" {
700                            mn_start += 12;
701                        }
702                        if mn_start < bsize {
703                            // Emit MakerNoteByteOrder
704                            let mn_bo_str = if mn_bo == ByteOrderMark::LittleEndian {
705                                "Little-endian (Intel, II)"
706                            } else {
707                                "Big-endian (Motorola, MM)"
708                            };
709                            tags.push(Tag {
710                                id: TagId::Text("MakerNoteByteOrder".into()),
711                                name: "MakerNoteByteOrder".into(),
712                                description: "Maker Note Byte Order".into(),
713                                group: TagGroup {
714                                    family0: "File".into(),
715                                    family1: "File".into(),
716                                    family2: "Image".into(),
717                                    family3: "Main".into(),
718                                },
719                                raw_value: Value::String(mn_bo_str.into()),
720                                print_value: mn_bo_str.into(),
721                                priority: 0,
722                            });
723                            // Canon MakerNotes have a TIFF footer with the original offset.
724                            // Sub-table value_offsets are relative to the original file.
725                            // We need to pass the full DNG data so offsets resolve correctly.
726                            let mn_data_in_block = &mn_block[mn_start..];
727                            let mn_abs_offset = off + (bpos - 8 + 8) + mn_start; // absolute offset in DNG file
728                                                                                 // Check for Canon TIFF footer (last 8 bytes)
729                            let fix_base = if mn_data_in_block.len() > 8 {
730                                let footer = &mn_data_in_block[mn_data_in_block.len() - 8..];
731                                if (footer[0..2] == *b"II" || footer[0..2] == *b"MM")
732                                    && (footer[2..4] == *b"\x2a\x00"
733                                        || footer[2..4] == *b"\x00\x2a")
734                                {
735                                    let old_off = if footer[0] == b'I' {
736                                        u32::from_le_bytes([
737                                            footer[4], footer[5], footer[6], footer[7],
738                                        ])
739                                    } else {
740                                        u32::from_be_bytes([
741                                            footer[4], footer[5], footer[6], footer[7],
742                                        ])
743                                    } as usize;
744                                    if old_off > 0 && mn_abs_offset > old_off {
745                                        mn_abs_offset as isize - old_off as isize
746                                    } else {
747                                        0
748                                    }
749                                } else {
750                                    0
751                                }
752                            } else {
753                                0
754                            };
755
756                            let mn_tags = if fix_base != 0 {
757                                // Pass full DNG data with corrected offset and base fix
758                                crate::metadata::makernotes::parse_makernotes_with_base(
759                                    data,
760                                    mn_abs_offset,
761                                    mn_data_in_block.len(),
762                                    make,
763                                    model,
764                                    mn_bo,
765                                    fix_base,
766                                )
767                            } else {
768                                crate::metadata::makernotes::parse_makernotes(
769                                    mn_data_in_block,
770                                    0,
771                                    mn_data_in_block.len(),
772                                    make,
773                                    model,
774                                    mn_bo,
775                                )
776                            };
777                            // DNG MakerNote tags that Perl doesn't emit (conditions/Unknown/offset issues)
778                            let dng_suppress = [
779                                "AESetting",
780                                "CameraISO",
781                                "ImageStabilization",
782                                "SpotMeteringMode",
783                                "RawJpgSize",
784                                "Warning",
785                            ];
786                            for mn_tag in mn_tags {
787                                if dng_suppress.contains(&mn_tag.name.as_str()) {
788                                    continue;
789                                }
790                                // No name-collision filtering here: ExifTool
791                                // extracts the maker note in full and lets the
792                                // duplicate arbitration pick a winner later,
793                                // which is the only step the Duplicates option
794                                // (and therefore -ee) turns off.
795                                tags.push(mn_tag);
796                            }
797                        }
798                    }
799                    bpos += bsize;
800                }
801                break;
802            }
803        }
804    }
805
806    fn find_makernote(data: &[u8], header: &TiffHeader, result: &mut Option<(usize, usize)>) {
807        // First find ExifIFD offset from IFD0
808        let ifd0_offset = header.ifd0_offset as usize;
809        if ifd0_offset + 2 > data.len() {
810            return;
811        }
812        let entry_count = read_u16(data, ifd0_offset, header.byte_order) as usize;
813        let entries_start = ifd0_offset + 2;
814
815        for i in 0..entry_count {
816            let eoff = entries_start + i * 12;
817            if eoff + 12 > data.len() {
818                break;
819            }
820            let tag = read_u16(data, eoff, header.byte_order);
821            if tag == 0x8769 {
822                // ExifIFD pointer
823                let exif_offset = read_u32(data, eoff + 8, header.byte_order) as usize;
824                Self::find_makernote_in_ifd(data, header, exif_offset, result);
825                break;
826            }
827        }
828    }
829
830    fn find_makernote_in_ifd(
831        data: &[u8],
832        header: &TiffHeader,
833        ifd_offset: usize,
834        result: &mut Option<(usize, usize)>,
835    ) {
836        if ifd_offset + 2 > data.len() {
837            return;
838        }
839        let entry_count = read_u16(data, ifd_offset, header.byte_order) as usize;
840        let entries_start = ifd_offset + 2;
841
842        for i in 0..entry_count {
843            let eoff = entries_start + i * 12;
844            if eoff + 12 > data.len() {
845                break;
846            }
847            let tag = read_u16(data, eoff, header.byte_order);
848            if tag == 0x927C {
849                let data_type = read_u16(data, eoff + 2, header.byte_order);
850                let count = read_u32(data, eoff + 4, header.byte_order) as usize;
851                let type_size = match data_type {
852                    1 | 2 | 6 | 7 => 1,
853                    3 | 8 => 2,
854                    4 | 9 | 11 | 13 => 4,
855                    5 | 10 | 12 => 8,
856                    _ => 1,
857                };
858                let total_size = type_size * count;
859
860                if total_size <= 4 {
861                    // Inline - too small for real MakerNotes
862                    break;
863                }
864                let offset = read_u32(data, eoff + 8, header.byte_order) as usize;
865                if offset + total_size <= data.len() {
866                    *result = Some((offset, total_size));
867                }
868                break;
869            }
870        }
871    }
872
873    /// Parse EXIF data from a byte slice with an explicit byte order and offset.
874    fn read_ifd(
875        data: &[u8],
876        header: &TiffHeader,
877        offset: u32,
878        ifd_name: &str,
879        tags: &mut Vec<Tag>,
880    ) -> Result<Option<u32>> {
881        let offset = offset as usize;
882        if offset + 2 > data.len() {
883            return Err(Error::InvalidExif(format!(
884                "{} offset {} beyond data length {}",
885                ifd_name,
886                offset,
887                data.len()
888            )));
889        }
890
891        let entry_count = read_u16(data, offset, header.byte_order) as usize;
892        let entries_start = offset + 2;
893        let _entries_end = entries_start + entry_count * 12;
894
895        // Validate: at minimum, first entry must fit
896        if entries_start + 12 > data.len() && entry_count > 0 {
897            return Err(Error::InvalidExif(format!(
898                "{} entries extend beyond data (need {}, have {})",
899                ifd_name,
900                entries_start + 12,
901                data.len()
902            )));
903        }
904        // Clamp entry count if IFD extends beyond data
905        let entry_count = entry_count.min((data.len().saturating_sub(entries_start)) / 12);
906        let entries_end = entries_start + entry_count * 12;
907
908        for i in 0..entry_count {
909            let entry_offset = entries_start + i * 12;
910            let entry = parse_ifd_entry(data, entry_offset, header.byte_order);
911
912            // Check for sub-IFDs (ExifIFD, GPS, Interop)
913            match entry.tag {
914                0x8769 => {
915                    // ExifIFD
916                    let sub_offset = entry.value_offset;
917                    if (sub_offset as usize) < data.len() {
918                        let _ = Self::read_ifd(data, header, sub_offset, "ExifIFD", tags);
919                    }
920                    continue;
921                }
922                0x8825 => {
923                    // GPS IFD
924                    let sub_offset = entry.value_offset;
925                    if (sub_offset as usize) < data.len() {
926                        let _ = Self::read_ifd(data, header, sub_offset, "GPS", tags);
927                    }
928                    continue;
929                }
930                0xA005 => {
931                    // Interop IFD
932                    let sub_offset = entry.value_offset;
933                    if (sub_offset as usize) < data.len() {
934                        let _ = Self::read_ifd(data, header, sub_offset, "InteropIFD", tags);
935                    }
936                    continue;
937                }
938                // PrintIM tag: extract version from "PrintIM" + 4-byte version
939                0xC4A5 => {
940                    let total_size = match entry.data_type {
941                        1 | 2 | 6 | 7 => entry.count as usize,
942                        _ => 0,
943                    };
944                    if total_size >= 12 {
945                        let off = entry.value_offset as usize;
946                        if off + 12 <= data.len() && &data[off..off + 7] == b"PrintIM" {
947                            // "PrintIM\0" is 8 bytes; the 4-byte version follows.
948                            let ver =
949                                crate::encoding::decode_utf8_or_latin1(&data[off + 8..off + 12])
950                                    .trim_end_matches('\0')
951                                    .to_string();
952                            tags.push(Tag {
953                                id: TagId::Text("PrintIMVersion".into()),
954                                name: "PrintIMVersion".into(),
955                                description: "PrintIM Version".into(),
956                                group: TagGroup {
957                                    family0: "PrintIM".into(),
958                                    family1: "PrintIM".into(),
959                                    family2: "Printing".into(),
960                                    family3: "Main".into(),
961                                },
962                                raw_value: Value::String(ver.clone()),
963                                print_value: ver,
964                                priority: 0,
965                            });
966                        }
967                    }
968                    continue; // Suppress raw PrintIM tag
969                }
970                // GPSAltitude with a 0/0 rational is NOT suppressed: GPS.pm gives a
971                // zero-denominator rational the ValueConv string "undef", which the
972                // PrintConv `$val =~ /^(inf|undef)$/ ? $val : "$val m"` passes
973                // through unchanged (GPS.pm:119-125). So it prints "undef", exactly
974                // like the sibling GPSSpeed rational in the same file.
975                // In SubIFD, tag 0x0201 = JpgFromRawStart (JPEG preview offset)
976                0x0201 if ifd_name.starts_with("SubIFD") => {
977                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
978                        let pv = val.to_display_string();
979                        tags.push(Tag {
980                            id: TagId::Numeric(entry.tag),
981                            name: "JpgFromRawStart".into(),
982                            description: "Jpg From Raw Start".into(),
983                            group: TagGroup {
984                                family0: "EXIF".into(),
985                                family1: ifd_name.to_string(),
986                                family2: "Image".into(),
987                                family3: "Main".into(),
988                            },
989                            raw_value: val,
990                            print_value: pv,
991                            priority: 0,
992                        });
993                    }
994                    continue;
995                }
996                // In SubIFD, tag 0x0202 = JpgFromRawLength (JPEG preview byte count)
997                0x0202 if ifd_name.starts_with("SubIFD") => {
998                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
999                        let pv = val.to_display_string();
1000                        tags.push(Tag {
1001                            id: TagId::Numeric(entry.tag),
1002                            name: "JpgFromRawLength".into(),
1003                            description: "Jpg From Raw Length".into(),
1004                            group: TagGroup {
1005                                family0: "EXIF".into(),
1006                                family1: ifd_name.to_string(),
1007                                family2: "Image".into(),
1008                                family3: "Main".into(),
1009                            },
1010                            raw_value: val,
1011                            print_value: pv,
1012                            priority: 0,
1013                        });
1014                    }
1015                    continue;
1016                }
1017                // SubIFD pointer (0x014A): follow to read SubIFD entries
1018                0x014A if ifd_name == "IFD0" => {
1019                    // Read SubIFD offset(s) — may be a single uint32 or array
1020                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
1021                        let offsets: Vec<u32> = match &val {
1022                            Value::U32(v) => vec![*v],
1023                            Value::List(items) => items
1024                                .iter()
1025                                .filter_map(|v| {
1026                                    if let Value::U32(o) = v {
1027                                        Some(*o)
1028                                    } else {
1029                                        None
1030                                    }
1031                                })
1032                                .collect(),
1033                            _ => vec![],
1034                        };
1035                        for (idx, &off) in offsets.iter().enumerate() {
1036                            if (off as usize) < data.len() {
1037                                // ExifTool leaves the first SubIFD unnumbered and
1038                                // numbers the rest from 1.
1039                                let sub_name = if idx == 0 {
1040                                    "SubIFD".to_string()
1041                                } else {
1042                                    format!("SubIFD{}", idx)
1043                                };
1044                                let before_idx = tags.len();
1045                                let _ = Self::read_ifd(data, header, off, &sub_name, tags);
1046
1047                                // Check if this SubIFD has JPEG compression
1048                                let is_jpeg = tags[before_idx..].iter().any(|t| {
1049                                    t.name == "Compression"
1050                                        && (t.print_value.contains("JPEG")
1051                                            || t.raw_value.as_u64() == Some(6))
1052                                });
1053
1054                                // Exif.pm makes 0x111 and 0x117 conditional arrays: the
1055                                // StripOffsets / StripByteCounts branch (Exif.pm:638 and
1056                                // Exif.pm:738) is skipped when
1057                                //   `$$self{TIFF_TYPE} =~ /^(DNG|TIFF)$/ and
1058                                //    $$self{Compression} eq '7' and $$self{SubfileType} ne '0'`
1059                                // and the next branch names the tag PreviewImageStart /
1060                                // PreviewImageLength -- or, in SubIFD2, JpgFromRawStart /
1061                                // JpgFromRawLength (Exif.pm:664/761, 675/771). It is a RENAME: such
1062                                // a directory has no StripOffsets at all, which is why
1063                                // ExifTool reports DNG.dng's IFD0 StripOffsets (13470) as
1064                                // primary even though SubIFD2 comes later in the file.
1065                                let is_renamed_offset_pair = tags[before_idx..].iter().any(|t| {
1066                                    t.name == "Compression" && t.raw_value.as_u64() == Some(7)
1067                                }) && tags[before_idx..].iter().any(
1068                                    |t| t.name == "SubfileType" && t.raw_value.as_u64() != Some(0),
1069                                );
1070
1071                                if is_jpeg {
1072                                    // Rename StripOffsets/StripByteCounts based on SubIFD index
1073                                    // Perl: SubIFD2 → JpgFromRaw*, others → PreviewImage*
1074                                    let (start_name, len_name, img_name) = if idx == 2 {
1075                                        ("JpgFromRawStart", "JpgFromRawLength", "JpgFromRaw")
1076                                    } else {
1077                                        ("PreviewImageStart", "PreviewImageLength", "PreviewImage")
1078                                    };
1079                                    // Find StripOffsets and StripByteCounts in this SubIFD
1080                                    let strip_off = tags[before_idx..]
1081                                        .iter()
1082                                        .find(|t| t.name == "StripOffsets")
1083                                        .and_then(|t| t.raw_value.as_u64());
1084                                    let strip_len = tags[before_idx..]
1085                                        .iter()
1086                                        .find(|t| t.name == "StripByteCounts")
1087                                        .and_then(|t| t.raw_value.as_u64());
1088                                    if let (Some(s), Some(l)) = (strip_off, strip_len) {
1089                                        tags.push(Tag {
1090                                            id: TagId::Text(start_name.into()),
1091                                            name: start_name.into(),
1092                                            description: start_name.into(),
1093                                            group: TagGroup {
1094                                                family0: "EXIF".into(),
1095                                                family1: sub_name.clone(),
1096                                                family2: "Preview".into(),
1097                                                family3: "Main".into(),
1098                                            },
1099                                            raw_value: Value::U32(s as u32),
1100                                            print_value: s.to_string(),
1101                                            priority: 0,
1102                                        });
1103                                        tags.push(Tag {
1104                                            id: TagId::Text(len_name.into()),
1105                                            name: len_name.into(),
1106                                            description: len_name.into(),
1107                                            group: TagGroup {
1108                                                family0: "EXIF".into(),
1109                                                family1: sub_name.clone(),
1110                                                family2: "Preview".into(),
1111                                                family3: "Main".into(),
1112                                            },
1113                                            raw_value: Value::U32(l as u32),
1114                                            print_value: l.to_string(),
1115                                            priority: 0,
1116                                        });
1117                                        // Extract binary image data
1118                                        let s = s as usize;
1119                                        let l = l as usize;
1120                                        if l > 0 && s + l <= data.len() {
1121                                            let pv = format!(
1122                                                "(Binary data {} bytes, use -b option to extract)",
1123                                                l
1124                                            );
1125                                            tags.push(Tag {
1126                                                id: TagId::Text(img_name.into()),
1127                                                name: img_name.into(),
1128                                                description: img_name.into(),
1129                                                group: TagGroup {
1130                                                    family0: "EXIF".into(),
1131                                                    family1: sub_name.clone(),
1132                                                    family2: "Preview".into(),
1133                                                    family3: "Main".into(),
1134                                                },
1135                                                raw_value: Value::Binary(data[s..s + l].to_vec()),
1136                                                print_value: pv,
1137                                                priority: 0,
1138                                            });
1139                                        }
1140                                    }
1141                                }
1142
1143                                if is_renamed_offset_pair {
1144                                    let mut i = 0usize;
1145                                    tags.retain(|t| {
1146                                        let keep = i < before_idx
1147                                            || !matches!(
1148                                                t.name.as_str(),
1149                                                "StripOffsets" | "StripByteCounts"
1150                                            );
1151                                        i += 1;
1152                                        keep
1153                                    });
1154                                }
1155
1156                                // Also handle a SubIFD whose 0x0201/0x0202 pair was
1157                                // already named JpgFromRawStart/Length when it was read
1158                                // -- but only if the rename branch above has not already
1159                                // produced the image, or it would be reported twice.
1160                                let jpg_done =
1161                                    tags[before_idx..].iter().any(|t| t.name == "JpgFromRaw");
1162                                let jpg_start = tags[before_idx..]
1163                                    .iter()
1164                                    .find(|t| t.name == "JpgFromRawStart")
1165                                    .and_then(|t| t.raw_value.as_u64());
1166                                let jpg_len = tags[before_idx..]
1167                                    .iter()
1168                                    .find(|t| t.name == "JpgFromRawLength")
1169                                    .and_then(|t| t.raw_value.as_u64());
1170                                if let (false, Some(start), Some(len)) =
1171                                    (jpg_done, jpg_start, jpg_len)
1172                                {
1173                                    let start = start as usize;
1174                                    let len = len as usize;
1175                                    if len > 0 && start + len <= data.len() {
1176                                        let pv = format!(
1177                                            "(Binary data {} bytes, use -b option to extract)",
1178                                            len
1179                                        );
1180                                        tags.push(Tag {
1181                                            id: TagId::Text("JpgFromRaw".into()),
1182                                            name: "JpgFromRaw".into(),
1183                                            description: "Jpg From Raw".into(),
1184                                            group: TagGroup {
1185                                                family0: "EXIF".into(),
1186                                                family1: sub_name,
1187                                                family2: "Preview".into(),
1188                                                family3: "Main".into(),
1189                                            },
1190                                            raw_value: Value::Binary(
1191                                                data[start..start + len].to_vec(),
1192                                            ),
1193                                            print_value: pv,
1194                                            priority: 0,
1195                                        });
1196                                    }
1197                                }
1198                            }
1199                        }
1200                    }
1201                    continue;
1202                }
1203                // CR2 IFD2 (preview JPEG) and IFD3 (raw data) repeat names IFD0
1204                // already defines. ExifTool reads them all and lets the name-keyed
1205                // collapse pick IFD0's ImageWidth/ImageHeight/BitsPerSample/
1206                // Compression and IFD3's StripOffsets/StripByteCounts; with the
1207                // Duplicates option on it reports every copy, so only skip them
1208                // when we are collapsing.
1209                0x0100 | 0x0101 | 0x0102 | 0x0103 | 0x0111 | 0x0117
1210                    if ifd_name == "IFD2" && !keep_duplicates() =>
1211                {
1212                    continue;
1213                }
1214                0x0103 if ifd_name == "IFD3" && !keep_duplicates() => {
1215                    continue;
1216                }
1217                _ => {}
1218            }
1219
1220            if let Some(mut value) = read_ifd_value(data, &entry, header.byte_order) {
1221                // GPS TimeStamp (0x0007): convert 0/0 rationals to 0/1 so it displays as "0, 0, 0"
1222                // (Perl treats 0/0 as 0 for GPS time, enabling GPSDateTime composite)
1223                if ifd_name == "GPS" && entry.tag == 0x0007 {
1224                    if let Value::List(ref mut items) = value {
1225                        for item in items.iter_mut() {
1226                            if matches!(item, Value::URational(0, 0)) {
1227                                *item = Value::URational(0, 1);
1228                            }
1229                        }
1230                    }
1231                }
1232                let tag_info = exif_tags::lookup(ifd_name, entry.tag);
1233                let (name, description, family2) = match tag_info {
1234                    Some(info) => (
1235                        info.name.to_string(),
1236                        info.description.to_string(),
1237                        info.family2.to_string(),
1238                    ),
1239                    None => {
1240                        // Skip known SubDirectory/internal tags that Perl doesn't emit
1241                        if matches!(
1242                            entry.tag,
1243                            // 0x014A handled above (SubIFD traversal)
1244                            // 0x02BC (ApplicationNotes) now parsed as XMP above
1245                            0xC634 // DNG PrivateData — processed after IFD scan
1246                        ) {
1247                            continue;
1248                        }
1249                        // Fallback to generated tags
1250                        match exif_tags::lookup_generated(entry.tag) {
1251                            Some((n, d)) => (n.to_string(), d.to_string(), "Other".to_string()),
1252                            None => {
1253                                // Perl doesn't emit unknown EXIF tags by default
1254                                continue;
1255                            }
1256                        }
1257                    }
1258                };
1259
1260                // Per-tag RawConv that trims trailing blanks. In Exif.pm only a
1261                // handful of string tags do this: Make (0x010f), Model (0x0110),
1262                // Software (0x0131) and Artist (0x013b) each carry
1263                // `RawConv => '$val =~ s/\s+$//'`, and Copyright (0x8298) strips the
1264                // blanks preceding its NUL separator (`s/ *\0/\n/; ...; s/\n$//`),
1265                // which reduces to a trailing-blank trim for the single-part values.
1266                // Every other EXIF string keeps its fixed-width space padding (the
1267                // generic 'string' reader only does `s/\0.*//s`); the padding is
1268                // dropped from text output later by Printable, not from the value.
1269                if matches!(
1270                    name.as_str(),
1271                    "Make" | "Model" | "Software" | "Artist" | "Copyright"
1272                ) {
1273                    if let Value::String(ref s) = value {
1274                        let trimmed = s.trim_end();
1275                        if trimmed.len() != s.len() {
1276                            value = Value::String(trimmed.to_string());
1277                        }
1278                    }
1279                }
1280
1281                // Parse ApplicationNotes (0x02BC) as XMP
1282                if name == "ApplicationNotes" {
1283                    if let Value::Binary(ref xmp_bytes) = value {
1284                        if let Ok(xmp_tags) = crate::metadata::XmpReader::read(xmp_bytes) {
1285                            tags.extend(xmp_tags);
1286                        }
1287                    }
1288                    continue;
1289                }
1290                // Suppress known SubDirectory/internal tags
1291                if matches!(
1292                    name.as_str(),
1293                    "MinSampleValue" | "MaxSampleValue" | // Not emitted by Perl for raw formats
1294                    "ProcessingSoftware" | // Protected tag, not always emitted
1295                    "PanasonicTitle" | "PanasonicTitle2" // DNG tags, wrong match for RW2
1296                ) {
1297                    continue;
1298                }
1299
1300                let print_value = if name.starts_with("Tag0x") && get_show_unknown() >= 2 {
1301                    // -U mode: show binary data for unknown tags
1302                    match &value {
1303                        Value::Binary(bytes) | Value::Undefined(bytes) => bytes
1304                            .iter()
1305                            .map(|b| format!("{:02x}", b))
1306                            .collect::<Vec<_>>()
1307                            .join(" "),
1308                        _ => value.to_display_string(),
1309                    }
1310                } else if name.starts_with("Tag0x") {
1311                    // -u mode: show unknown tags but use standard display for values
1312                    value.to_display_string()
1313                } else {
1314                    exif_tags::print_conv(ifd_name, entry.tag, &value)
1315                        .or_else(|| {
1316                            // Fallback to generated print conversions
1317                            value
1318                                .as_u64()
1319                                .and_then(|v| {
1320                                    crate::tags::print_conv_generated::print_conv_by_name(
1321                                        &name, v as i64,
1322                                    )
1323                                })
1324                                .map(|s| s.to_string())
1325                        })
1326                        .unwrap_or_else(|| value.to_display_string())
1327                };
1328
1329                // Priority the Exif tables state themselves, whether as an
1330                // explicit `Priority => 0` or as the `Avoid => 1` that FoundTag
1331                // resolves to one (ExifTool.pm:9469-9472). Only the IFD tag
1332                // number identifies it: 0xfe54 Contrast is priority 0 while
1333                // 0xa408 Contrast is not, so the name alone would be ambiguous.
1334                let priority =
1335                    if crate::tags::priority0_generated::exif_is_priority0(entry.tag, &name) {
1336                        crate::tag::PRIORITY_EXPLICIT_ZERO
1337                    } else {
1338                        0
1339                    };
1340                tags.push(Tag {
1341                    id: TagId::Numeric(entry.tag),
1342                    name,
1343                    description,
1344                    group: TagGroup {
1345                        family0: "EXIF".to_string(),
1346                        family1: ifd_name.to_string(),
1347                        family2,
1348                        family3: "Main".into(),
1349                    },
1350                    raw_value: value,
1351                    print_value,
1352                    priority,
1353                });
1354            }
1355        }
1356
1357        // Read next IFD offset
1358        let next_ifd_offset = if entries_end + 4 <= data.len() {
1359            read_u32(data, entries_end, header.byte_order)
1360        } else {
1361            0
1362        };
1363        if next_ifd_offset != 0 && ifd_name == "IFD0" {
1364            // IFD1 = thumbnail
1365            let ifd1_start_idx = tags.len();
1366            let ifd1_next = Self::read_ifd(data, header, next_ifd_offset, "IFD1", tags)
1367                .ok()
1368                .flatten();
1369            // IFD1 (the thumbnail IFD) repeats several IFD0 tags — XResolution,
1370            // YResolution, ResolutionUnit, Orientation… Collapsing them here would
1371            // be wrong: ExifTool keeps every instance when the Duplicates option is
1372            // on, which the CLI turns on together with -ee. IFD1 is a
1373            // LOW_PRIORITY_DIR, so the general FoundTag pass in exiftool.rs keeps
1374            // IFD0's copy in the default mode and every copy under -ee. Nothing to
1375            // suppress at parse time.
1376            let _ = ifd1_start_idx;
1377
1378            // Create ThumbnailImage tag if offset+length are present
1379            let thumb_offset = tags
1380                .iter()
1381                .find(|t| t.name == "ThumbnailOffset" && t.group.family1 == "IFD1")
1382                .and_then(|t| t.raw_value.as_u64());
1383            let thumb_length = tags
1384                .iter()
1385                .find(|t| t.name == "ThumbnailLength" && t.group.family1 == "IFD1")
1386                .and_then(|t| t.raw_value.as_u64());
1387
1388            if let (Some(off), Some(len)) = (thumb_offset, thumb_length) {
1389                let off = off as usize;
1390                let len = len as usize;
1391                if off + len <= data.len() && len > 0 {
1392                    tags.push(Tag {
1393                        id: TagId::Text("ThumbnailImage".into()),
1394                        name: "ThumbnailImage".into(),
1395                        description: "Thumbnail Image".into(),
1396                        group: TagGroup {
1397                            family0: "EXIF".into(),
1398                            family1: "IFD1".into(),
1399                            family2: "Image".into(),
1400                            family3: "Main".into(),
1401                        },
1402                        raw_value: Value::Binary(data[off..off + len].to_vec()),
1403                        print_value: format!(
1404                            "(Binary data {} bytes, use -b option to extract)",
1405                            len
1406                        ),
1407                        priority: 0,
1408                    });
1409                }
1410            }
1411
1412            // CR2 files have additional IFDs (IFD2, IFD3) following IFD1 in the chain.
1413            // CR2 is identified by "CR" bytes at offset 8 in the TIFF data.
1414            let is_cr2 = data.len() > 10 && &data[8..10] == b"CR";
1415            if is_cr2 {
1416                if let Some(ifd2_offset) = ifd1_next {
1417                    // IFD2 = preview JPEG image data (emit selected tags)
1418                    let ifd2_next = Self::read_ifd(data, header, ifd2_offset, "IFD2", tags)
1419                        .ok()
1420                        .flatten();
1421                    // IFD3 = raw image data (emit CR2CFAPattern, RawImageSegmentation, StripOffsets, StripByteCounts)
1422                    if let Some(ifd3_offset) = ifd2_next {
1423                        let _ = Self::read_ifd(data, header, ifd3_offset, "IFD3", tags);
1424                    }
1425                }
1426            }
1427        }
1428
1429        Ok(if next_ifd_offset != 0 {
1430            Some(next_ifd_offset)
1431        } else {
1432            None
1433        })
1434    }
1435
1436    /// Parse a TIFF where IFD0 is treated as a named IFD (e.g. "GPS", "ExifIFD").
1437    /// Used for CR3 CMT4 (GPS-only TIFF) and CMT2 (ExifIFD-only TIFF).
1438    /// Does no MakerNote/IFD1 processing.
1439    pub fn read_as_named_ifd(data: &[u8], ifd_name: &str) -> Vec<Tag> {
1440        let header = match parse_tiff_header(data) {
1441            Ok(h) => h,
1442            Err(_) => return Vec::new(),
1443        };
1444        let mut tags = Vec::new();
1445        // Each of these boxes is a TIFF file of its own, and ExifTool's
1446        // ProcessTIFF raises ExifByteOrder once per TIFF it processes — a CR3
1447        // therefore reports it for CMT1, CMT2 and CMT4 (verified with -v2 on
1448        // CanonRaw.cr3; CMT3 goes through the maker-note path and raises none).
1449        tags.push(Self::exif_byte_order_tag(header.byte_order));
1450        let _ = Self::read_ifd(data, &header, header.ifd0_offset, ifd_name, &mut tags);
1451        tags
1452    }
1453
1454    /// Parse a single IFD located at `offset` inside a TIFF-like container whose
1455    /// header this reader cannot parse itself (RW2 uses magic 0x55 instead of
1456    /// 0x2A). Offsets inside the IFD stay relative to the start of `data`, which
1457    /// is what a TIFF IFD always uses. No ExifByteOrder and no IFD1 chaining.
1458    pub fn read_ifd_at(data: &[u8], little_endian: bool, offset: u32, ifd_name: &str) -> Vec<Tag> {
1459        let header = TiffHeader {
1460            byte_order: if little_endian {
1461                ByteOrderMark::LittleEndian
1462            } else {
1463                ByteOrderMark::BigEndian
1464            },
1465            ifd0_offset: offset,
1466        };
1467        let mut tags = Vec::new();
1468        let _ = Self::read_ifd(data, &header, offset, ifd_name, &mut tags);
1469        tags
1470    }
1471}
1472
1473fn parse_ifd_entry(data: &[u8], offset: usize, byte_order: ByteOrderMark) -> IfdEntry {
1474    let tag = read_u16(data, offset, byte_order);
1475    let data_type = read_u16(data, offset + 2, byte_order);
1476    let count = read_u32(data, offset + 4, byte_order);
1477    let value_offset = read_u32(data, offset + 8, byte_order);
1478    let mut inline_data = [0u8; 4];
1479    inline_data.copy_from_slice(&data[offset + 8..offset + 12]);
1480
1481    IfdEntry {
1482        tag,
1483        data_type,
1484        count,
1485        value_offset,
1486        inline_data,
1487    }
1488}
1489
1490fn read_ifd_value(data: &[u8], entry: &IfdEntry, byte_order: ByteOrderMark) -> Option<Value> {
1491    let elem_size = type_size(entry.data_type)?;
1492    let total_size = elem_size * entry.count as usize;
1493
1494    let value_data = if total_size <= 4 {
1495        &entry.inline_data[..total_size]
1496    } else {
1497        let offset = entry.value_offset as usize;
1498        if offset + total_size > data.len() {
1499            return None;
1500        }
1501        &data[offset..offset + total_size]
1502    };
1503
1504    // IPTC-NAA (0x83BB): always read as raw binary regardless of declared type
1505    if entry.tag == 0x83BB {
1506        return Some(Value::Binary(value_data.to_vec()));
1507    }
1508
1509    // ApplicationNotes (0x02BC): always read as raw binary (XMP data)
1510    if entry.tag == 0x02BC {
1511        return Some(Value::Binary(value_data.to_vec()));
1512    }
1513
1514    match entry.data_type {
1515        // BYTE
1516        1 => {
1517            if entry.count == 1 {
1518                Some(Value::U8(value_data[0]))
1519            } else {
1520                Some(Value::List(
1521                    value_data.iter().map(|&b| Value::U8(b)).collect(),
1522                ))
1523            }
1524        }
1525        // ASCII
1526        2 => {
1527            let s = crate::encoding::decode_utf8_or_latin1(value_data);
1528            // ExifTool truncates at the first null only (ExifTool.pm:10038
1529            // `$val =~ s/\0.*//s`). Trailing blanks that pad a fixed-width field
1530            // (e.g. "OLYMPUS DIGITAL CAMERA         ") are PRESERVED in the stored
1531            // value; they are stripped only at text-output time by Printable
1532            // (exiftool:3009 `$val =~ s/\s+$//`), which our text path mirrors in
1533            // sanitize_display_value. JSON output keeps them, matching ExifTool.
1534            let s = s.split('\0').next().unwrap_or("").to_string();
1535            Some(Value::String(s))
1536        }
1537        // SHORT
1538        3 => {
1539            if entry.count == 1 {
1540                Some(Value::U16(read_u16(value_data, 0, byte_order)))
1541            } else {
1542                let vals: Vec<Value> = (0..entry.count as usize)
1543                    .map(|i| Value::U16(read_u16(value_data, i * 2, byte_order)))
1544                    .collect();
1545                Some(Value::List(vals))
1546            }
1547        }
1548        // LONG
1549        4 | 13 => {
1550            if entry.count == 1 {
1551                Some(Value::U32(read_u32(value_data, 0, byte_order)))
1552            } else {
1553                let vals: Vec<Value> = (0..entry.count as usize)
1554                    .map(|i| Value::U32(read_u32(value_data, i * 4, byte_order)))
1555                    .collect();
1556                Some(Value::List(vals))
1557            }
1558        }
1559        // RATIONAL (unsigned)
1560        5 => {
1561            if entry.count == 1 {
1562                let n = read_u32(value_data, 0, byte_order);
1563                let d = read_u32(value_data, 4, byte_order);
1564                Some(Value::URational(n, d))
1565            } else {
1566                let vals: Vec<Value> = (0..entry.count as usize)
1567                    .map(|i| {
1568                        let n = read_u32(value_data, i * 8, byte_order);
1569                        let d = read_u32(value_data, i * 8 + 4, byte_order);
1570                        Value::URational(n, d)
1571                    })
1572                    .collect();
1573                Some(Value::List(vals))
1574            }
1575        }
1576        // SBYTE
1577        6 => {
1578            if entry.count == 1 {
1579                Some(Value::I16(value_data[0] as i8 as i16))
1580            } else {
1581                let vals: Vec<Value> = value_data
1582                    .iter()
1583                    .map(|&b| Value::I16(b as i8 as i16))
1584                    .collect();
1585                Some(Value::List(vals))
1586            }
1587        }
1588        // UNDEFINED
1589        7 => Some(Value::Undefined(value_data.to_vec())),
1590        // SSHORT
1591        8 => {
1592            if entry.count == 1 {
1593                Some(Value::I16(read_i16(value_data, 0, byte_order)))
1594            } else {
1595                let vals: Vec<Value> = (0..entry.count as usize)
1596                    .map(|i| Value::I16(read_i16(value_data, i * 2, byte_order)))
1597                    .collect();
1598                Some(Value::List(vals))
1599            }
1600        }
1601        // SLONG
1602        9 => {
1603            if entry.count == 1 {
1604                Some(Value::I32(read_i32(value_data, 0, byte_order)))
1605            } else {
1606                let vals: Vec<Value> = (0..entry.count as usize)
1607                    .map(|i| Value::I32(read_i32(value_data, i * 4, byte_order)))
1608                    .collect();
1609                Some(Value::List(vals))
1610            }
1611        }
1612        // SRATIONAL
1613        10 => {
1614            if entry.count == 1 {
1615                let n = read_i32(value_data, 0, byte_order);
1616                let d = read_i32(value_data, 4, byte_order);
1617                Some(Value::IRational(n, d))
1618            } else {
1619                let vals: Vec<Value> = (0..entry.count as usize)
1620                    .map(|i| {
1621                        let n = read_i32(value_data, i * 8, byte_order);
1622                        let d = read_i32(value_data, i * 8 + 4, byte_order);
1623                        Value::IRational(n, d)
1624                    })
1625                    .collect();
1626                Some(Value::List(vals))
1627            }
1628        }
1629        // FLOAT
1630        11 => {
1631            if entry.count == 1 {
1632                let bits = read_u32(value_data, 0, byte_order);
1633                Some(Value::F32(f32::from_bits(bits)))
1634            } else {
1635                let vals: Vec<Value> = (0..entry.count as usize)
1636                    .map(|i| {
1637                        let bits = read_u32(value_data, i * 4, byte_order);
1638                        Value::F32(f32::from_bits(bits))
1639                    })
1640                    .collect();
1641                Some(Value::List(vals))
1642            }
1643        }
1644        // DOUBLE
1645        12 => {
1646            if entry.count == 1 {
1647                let bits = read_u64(value_data, 0, byte_order);
1648                Some(Value::F64(f64::from_bits(bits)))
1649            } else {
1650                let vals: Vec<Value> = (0..entry.count as usize)
1651                    .map(|i| {
1652                        let bits = read_u64(value_data, i * 8, byte_order);
1653                        Value::F64(f64::from_bits(bits))
1654                    })
1655                    .collect();
1656                Some(Value::List(vals))
1657            }
1658        }
1659        _ => None,
1660    }
1661}
1662
1663// Byte-order-aware read helpers
1664fn read_u16(data: &[u8], offset: usize, bo: ByteOrderMark) -> u16 {
1665    match bo {
1666        ByteOrderMark::LittleEndian => LittleEndian::read_u16(&data[offset..]),
1667        ByteOrderMark::BigEndian => BigEndian::read_u16(&data[offset..]),
1668    }
1669}
1670
1671fn read_u32(data: &[u8], offset: usize, bo: ByteOrderMark) -> u32 {
1672    match bo {
1673        ByteOrderMark::LittleEndian => LittleEndian::read_u32(&data[offset..]),
1674        ByteOrderMark::BigEndian => BigEndian::read_u32(&data[offset..]),
1675    }
1676}
1677
1678fn read_u64(data: &[u8], offset: usize, bo: ByteOrderMark) -> u64 {
1679    match bo {
1680        ByteOrderMark::LittleEndian => LittleEndian::read_u64(&data[offset..]),
1681        ByteOrderMark::BigEndian => BigEndian::read_u64(&data[offset..]),
1682    }
1683}
1684
1685fn read_i16(data: &[u8], offset: usize, bo: ByteOrderMark) -> i16 {
1686    match bo {
1687        ByteOrderMark::LittleEndian => LittleEndian::read_i16(&data[offset..]),
1688        ByteOrderMark::BigEndian => BigEndian::read_i16(&data[offset..]),
1689    }
1690}
1691
1692fn read_i32(data: &[u8], offset: usize, bo: ByteOrderMark) -> i32 {
1693    match bo {
1694        ByteOrderMark::LittleEndian => LittleEndian::read_i32(&data[offset..]),
1695        ByteOrderMark::BigEndian => BigEndian::read_i32(&data[offset..]),
1696    }
1697}
1698
1699/// Process GeoTIFF key directory (tag GeoTiffDirectory / GeoKeyDirectory)
1700/// and replace raw directory/ascii/double params with named GeoTIFF tags.
1701fn process_geotiff_keys(tags: &mut Vec<Tag>) {
1702    // Extract GeoTiffDirectory values
1703    let dir_vals: Option<Vec<u16>> =
1704        tags.iter()
1705            .find(|t| t.name == "GeoTiffDirectory")
1706            .and_then(|t| match &t.raw_value {
1707                Value::List(items) => {
1708                    let vals: Vec<u16> = items
1709                        .iter()
1710                        .filter_map(|v| match v {
1711                            Value::U16(x) => Some(*x),
1712                            Value::U32(x) => Some(*x as u16),
1713                            _ => None,
1714                        })
1715                        .collect();
1716                    if vals.is_empty() {
1717                        None
1718                    } else {
1719                        Some(vals)
1720                    }
1721                }
1722                _ => None,
1723            });
1724
1725    let dir_vals = match dir_vals {
1726        Some(v) => v,
1727        None => return,
1728    };
1729
1730    if dir_vals.len() < 4 {
1731        return;
1732    }
1733
1734    let version = dir_vals[0];
1735    let revision = dir_vals[1];
1736    let minor_rev = dir_vals[2];
1737    let num_entries = dir_vals[3] as usize;
1738
1739    if dir_vals.len() < 4 + num_entries * 4 {
1740        return;
1741    }
1742
1743    // Extract ASCII params
1744    let ascii_params: Option<String> = tags
1745        .iter()
1746        .find(|t| t.name == "GeoTiffAsciiParams")
1747        .map(|t| t.print_value.clone());
1748
1749    // Extract double params
1750    let double_params: Option<Vec<f64>> = tags
1751        .iter()
1752        .find(|t| t.name == "GeoTiffDoubleParams")
1753        .and_then(|t| match &t.raw_value {
1754            Value::List(items) => {
1755                let vals: Vec<f64> = items
1756                    .iter()
1757                    .filter_map(|v| match v {
1758                        Value::F64(x) => Some(*x),
1759                        Value::F32(x) => Some(*x as f64),
1760                        _ => None,
1761                    })
1762                    .collect();
1763                if vals.is_empty() {
1764                    None
1765                } else {
1766                    Some(vals)
1767                }
1768            }
1769            _ => None,
1770        });
1771
1772    let mut new_tags = Vec::new();
1773
1774    // Version tag
1775    new_tags.push(Tag {
1776        id: TagId::Text("GeoTiffVersion".to_string()),
1777        name: "GeoTiffVersion".to_string(),
1778        description: "GeoTiff Version".to_string(),
1779        group: TagGroup {
1780            // GeoTiff.pm's own group, not the IFD0 tag that carries the keys.
1781            family0: "GeoTiff".into(),
1782            family1: "GeoTiff".into(),
1783            family2: "Location".into(),
1784            family3: "Main".into(),
1785        },
1786        raw_value: Value::String(format!("{}.{}.{}", version, revision, minor_rev)),
1787        print_value: format!("{}.{}.{}", version, revision, minor_rev),
1788        priority: 0,
1789    });
1790
1791    // Process each GeoKey
1792    for i in 0..num_entries {
1793        let base = 4 + i * 4;
1794        let key_id = dir_vals[base];
1795        let location = dir_vals[base + 1];
1796        let count = dir_vals[base + 2] as usize;
1797        let value_or_offset = dir_vals[base + 3];
1798
1799        let raw_val: Option<String> = match location {
1800            0 => {
1801                // Value stored inline in value_or_offset
1802                Some(format!("{}", value_or_offset))
1803            }
1804            34737 => {
1805                // ASCII params
1806                if let Some(ref ascii) = ascii_params {
1807                    let off = value_or_offset as usize;
1808                    let end = (off + count).min(ascii.len());
1809                    if off <= end {
1810                        let s = &ascii[off..end];
1811                        // Remove trailing '|' separators
1812                        let s = s.trim_end_matches('|').trim().to_string();
1813                        Some(s)
1814                    } else {
1815                        None
1816                    }
1817                } else {
1818                    None
1819                }
1820            }
1821            34736 => {
1822                // Double params
1823                if let Some(ref doubles) = double_params {
1824                    let off = value_or_offset as usize;
1825                    if count == 1 && off < doubles.len() {
1826                        Some(format!("{}", doubles[off]))
1827                    } else if count > 1 {
1828                        let vals: Vec<String> = doubles
1829                            .iter()
1830                            .skip(off)
1831                            .take(count)
1832                            .map(|v| format!("{}", v))
1833                            .collect();
1834                        Some(vals.join(" "))
1835                    } else {
1836                        None
1837                    }
1838                } else {
1839                    None
1840                }
1841            }
1842            _ => None,
1843        };
1844
1845        let val_str = match raw_val {
1846            Some(v) => v,
1847            None => continue,
1848        };
1849
1850        // Map GeoKey ID to tag name and print value
1851        let (tag_name, print_val) = geotiff_key_to_tag(key_id, &val_str);
1852        if tag_name.is_empty() {
1853            continue;
1854        }
1855
1856        new_tags.push(Tag {
1857            id: TagId::Text(tag_name.clone()),
1858            name: tag_name.clone(),
1859            description: tag_name.clone(),
1860            group: TagGroup {
1861                // GeoTIFF keys travel inside an IFD0 tag, but ExifTool reads
1862                // them with GeoTiff.pm, a group of its own -- same as the
1863                // GeoTiffVersion tag emitted just above.
1864                family0: "GeoTiff".into(),
1865                family1: "GeoTiff".into(),
1866                family2: "Location".into(),
1867                family3: "Main".into(),
1868            },
1869            raw_value: Value::String(val_str),
1870            print_value: print_val,
1871            priority: 0,
1872        });
1873    }
1874
1875    if !new_tags.is_empty() {
1876        // Remove raw GeoTIFF tags
1877        tags.retain(|t| {
1878            t.name != "GeoTiffDirectory"
1879                && t.name != "GeoTiffAsciiParams"
1880                && t.name != "GeoTiffDoubleParams"
1881        });
1882        tags.extend(new_tags);
1883    }
1884}
1885
1886/// Map a GeoKey ID to (tag_name, print_value).
1887fn geotiff_key_to_tag(key_id: u16, value: &str) -> (String, String) {
1888    let val_u16: Option<u16> = value.parse().ok();
1889
1890    match key_id {
1891        // Section 6.2.1: GeoTIFF Configuration Keys
1892        0x0001 => return ("GeoTiffVersion".to_string(), value.to_string()), // not used here
1893        0x0400 => {
1894            // GTModelType
1895            let print = match val_u16 {
1896                Some(1) => "Projected".to_string(),
1897                Some(2) => "Geographic".to_string(),
1898                Some(3) => "Geocentric".to_string(),
1899                Some(32767) => "User Defined".to_string(),
1900                _ => value.to_string(),
1901            };
1902            return ("GTModelType".to_string(), print);
1903        }
1904        0x0401 => {
1905            // GTRasterType
1906            let print = match val_u16 {
1907                Some(1) => "Pixel Is Area".to_string(),
1908                Some(2) => "Pixel Is Point".to_string(),
1909                Some(32767) => "User Defined".to_string(),
1910                _ => value.to_string(),
1911            };
1912            return ("GTRasterType".to_string(), print);
1913        }
1914        0x0402 => return ("GTCitation".to_string(), value.to_string()),
1915
1916        // Section 6.2.2: Geographic CS Parameter Keys
1917        0x0800 => {
1918            return (
1919                "GeographicType".to_string(),
1920                geotiff_pcs_name(val_u16.unwrap_or(0), value),
1921            )
1922        }
1923        0x0801 => return ("GeogCitation".to_string(), value.to_string()),
1924        0x0802 => {
1925            let print = match val_u16 {
1926                Some(32767) | Some(32766) => "User Defined".to_string(),
1927                _ => value.to_string(),
1928            };
1929            return ("GeogGeodeticDatum".to_string(), print);
1930        }
1931        0x0803 => return ("GeogPrimeMeridian".to_string(), value.to_string()),
1932        0x0804 => {
1933            return (
1934                "GeogLinearUnits".to_string(),
1935                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
1936            )
1937        }
1938        0x0805 => return ("GeogLinearUnitSize".to_string(), value.to_string()),
1939        0x0806 => return ("GeogAngularUnits".to_string(), value.to_string()),
1940        0x0807 => return ("GeogAngularUnitSize".to_string(), value.to_string()),
1941        0x0808 => return ("GeogEllipsoid".to_string(), value.to_string()),
1942        0x0809 => return ("GeogSemiMajorAxis".to_string(), value.to_string()),
1943        0x080a => return ("GeogSemiMinorAxis".to_string(), value.to_string()),
1944        0x080b => return ("GeogInvFlattening".to_string(), value.to_string()),
1945        0x080c => return ("GeogAzimuthUnits".to_string(), value.to_string()),
1946        0x080d => return ("GeogPrimeMeridianLong".to_string(), value.to_string()),
1947
1948        // Section 6.2.3: Projected CS Parameter Keys
1949        0x0C00 => {
1950            // ProjectedCSType
1951            return (
1952                "ProjectedCSType".to_string(),
1953                geotiff_pcs_name(val_u16.unwrap_or(0), value),
1954            );
1955        }
1956        0x0C01 => return ("PCSCitation".to_string(), value.to_string()),
1957        0x0C02 => {
1958            // UTM zones follow a regular range in GeoTiff.pm's Projection table:
1959            // 16001-16060 = UTM zone N (north), 16101-16160 = UTM zone N (south).
1960            let print = match val_u16 {
1961                Some(v @ 16001..=16060) => format!("UTM zone {}N", v - 16000),
1962                Some(v @ 16101..=16160) => format!("UTM zone {}S", v - 16100),
1963                Some(32767) | Some(32766) => "User Defined".to_string(),
1964                _ => value.to_string(),
1965            };
1966            return ("Projection".to_string(), print);
1967        }
1968        0x0C03 => return ("ProjCoordTrans".to_string(), value.to_string()),
1969        0x0C04 => {
1970            return (
1971                "ProjLinearUnits".to_string(),
1972                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
1973            )
1974        }
1975        0x0C05 => return ("ProjLinearUnitSize".to_string(), value.to_string()),
1976        0x0C06 => return ("ProjStdParallel1".to_string(), value.to_string()),
1977        0x0C07 => return ("ProjStdParallel2".to_string(), value.to_string()),
1978        0x0C08 => return ("ProjNatOriginLong".to_string(), value.to_string()),
1979        0x0C09 => return ("ProjNatOriginLat".to_string(), value.to_string()),
1980        0x0c0a => return ("ProjFalseEasting".to_string(), value.to_string()),
1981        0x0c0b => return ("ProjFalseNorthing".to_string(), value.to_string()),
1982        0x0c0c => return ("ProjFalseOriginLong".to_string(), value.to_string()),
1983        0x0c0d => return ("ProjFalseOriginLat".to_string(), value.to_string()),
1984        0x0c0e => return ("ProjFalseOriginEasting".to_string(), value.to_string()),
1985        0x0c0f => return ("ProjFalseOriginNorthing".to_string(), value.to_string()),
1986        0x0C10 => return ("ProjCenterLong".to_string(), value.to_string()),
1987        0x0C11 => return ("ProjCenterLat".to_string(), value.to_string()),
1988        0x0C12 => return ("ProjCenterEasting".to_string(), value.to_string()),
1989        0x0C13 => return ("ProjCenterNorthing".to_string(), value.to_string()),
1990        0x0C14 => return ("ProjScaleAtNatOrigin".to_string(), value.to_string()),
1991        0x0C15 => return ("ProjScaleAtCenter".to_string(), value.to_string()),
1992        0x0C16 => return ("ProjAzimuthAngle".to_string(), value.to_string()),
1993        0x0C17 => return ("ProjStraightVertPoleLong".to_string(), value.to_string()),
1994
1995        // Section 6.2.4: Vertical CS Keys
1996        0x1000 => return ("VerticalCSType".to_string(), value.to_string()),
1997        0x1001 => return ("VerticalCitation".to_string(), value.to_string()),
1998        0x1002 => return ("VerticalDatum".to_string(), value.to_string()),
1999        0x1003 => {
2000            return (
2001                "VerticalUnits".to_string(),
2002                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
2003            )
2004        }
2005
2006        _ => {}
2007    }
2008    (String::new(), String::new())
2009}
2010
2011fn geotiff_linear_unit_name(val: u16, fallback: &str) -> String {
2012    match val {
2013        9001 => "Linear Meter".to_string(),
2014        9002 => "Linear Foot".to_string(),
2015        9003 => "Linear Foot US Survey".to_string(),
2016        9004 => "Linear Foot Modified American".to_string(),
2017        9005 => "Linear Foot Clarke".to_string(),
2018        9006 => "Linear Foot Indian".to_string(),
2019        9007 => "Linear Link".to_string(),
2020        9008 => "Linear Link Benoit".to_string(),
2021        9009 => "Linear Link Sears".to_string(),
2022        9010 => "Linear Chain Benoit".to_string(),
2023        9011 => "Linear Chain Sears".to_string(),
2024        9012 => "Linear Yard Sears".to_string(),
2025        9013 => "Linear Yard Indian".to_string(),
2026        9014 => "Linear Fathom".to_string(),
2027        9015 => "Linear Mile International Nautical".to_string(),
2028        _ => fallback.to_string(),
2029    }
2030}
2031
2032fn geotiff_pcs_name(val: u16, fallback: &str) -> String {
2033    // Common PCS codes - just return the code with description for common ones
2034    match val {
2035        26918 => "NAD83 UTM zone 18N".to_string(),
2036        26919 => "NAD83 UTM zone 19N".to_string(),
2037        32618 => "WGS84 UTM zone 18N".to_string(),
2038        32619 => "WGS84 UTM zone 19N".to_string(),
2039        4326 => "WGS 84".to_string(),
2040        4269 => "NAD83".to_string(),
2041        4267 => "NAD27".to_string(),
2042        32767 => "User Defined".to_string(),
2043        _ => fallback.to_string(),
2044    }
2045}