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}
18
19/// Set the show_unknown level for the current thread (used by MakerNotes).
20pub fn set_show_unknown(level: u8) {
21    SHOW_UNKNOWN.with(|s| s.set(level));
22}
23
24/// Get the show_unknown level for the current thread (used by MakerNotes).
25pub fn get_show_unknown() -> u8 {
26    SHOW_UNKNOWN.with(|s| s.get())
27}
28
29/// Byte order of the TIFF data.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ByteOrderMark {
32    LittleEndian,
33    BigEndian,
34}
35
36/// Parsed TIFF header.
37#[derive(Debug)]
38pub struct TiffHeader {
39    pub byte_order: ByteOrderMark,
40    pub ifd0_offset: u32,
41}
42
43/// EXIF IFD entry as read from the file.
44#[derive(Debug)]
45struct IfdEntry {
46    tag: u16,
47    data_type: u16,
48    count: u32,
49    value_offset: u32,
50    /// For values that fit in 4 bytes, the raw 4 bytes
51    inline_data: [u8; 4],
52}
53
54/// Size in bytes for each TIFF data type.
55fn type_size(data_type: u16) -> Option<usize> {
56    match data_type {
57        1 => Some(1),  // BYTE
58        2 => Some(1),  // ASCII
59        3 => Some(2),  // SHORT
60        4 => Some(4),  // LONG
61        5 => Some(8),  // RATIONAL
62        6 => Some(1),  // SBYTE
63        7 => Some(1),  // UNDEFINED
64        8 => Some(2),  // SSHORT
65        9 => Some(4),  // SLONG
66        10 => Some(8), // SRATIONAL
67        11 => Some(4), // FLOAT
68        12 => Some(8), // DOUBLE
69        13 => Some(4), // IFD
70        _ => None,
71    }
72}
73
74/// Parse a TIFF header from raw bytes.
75pub fn parse_tiff_header(data: &[u8]) -> Result<TiffHeader> {
76    if data.len() < 8 {
77        return Err(Error::InvalidTiffHeader);
78    }
79
80    let byte_order = match (data[0], data[1]) {
81        (b'I', b'I') => ByteOrderMark::LittleEndian,
82        (b'M', b'M') => ByteOrderMark::BigEndian,
83        _ => return Err(Error::InvalidTiffHeader),
84    };
85
86    let magic = match byte_order {
87        ByteOrderMark::LittleEndian => LittleEndian::read_u16(&data[2..4]),
88        ByteOrderMark::BigEndian => BigEndian::read_u16(&data[2..4]),
89    };
90
91    if magic != 42 {
92        return Err(Error::InvalidTiffHeader);
93    }
94
95    let ifd0_offset = match byte_order {
96        ByteOrderMark::LittleEndian => LittleEndian::read_u32(&data[4..8]),
97        ByteOrderMark::BigEndian => BigEndian::read_u32(&data[4..8]),
98    };
99
100    Ok(TiffHeader {
101        byte_order,
102        ifd0_offset,
103    })
104}
105
106/// EXIF metadata reader.
107pub struct ExifReader;
108
109impl ExifReader {
110    /// Parse EXIF data from a byte slice (starting at the TIFF header).
111    pub fn read(data: &[u8]) -> Result<Vec<Tag>> {
112        let header = parse_tiff_header(data)?;
113        let mut tags = Vec::new();
114
115        // Emit ExifByteOrder tag
116        let bo_str = match header.byte_order {
117            ByteOrderMark::LittleEndian => "Little-endian (Intel, II)",
118            ByteOrderMark::BigEndian => "Big-endian (Motorola, MM)",
119        };
120        tags.push(Tag {
121            id: TagId::Text("ExifByteOrder".to_string()),
122            name: "ExifByteOrder".to_string(),
123            description: "Exif Byte Order".to_string(),
124            group: TagGroup {
125                family0: "EXIF".to_string(),
126                family1: "IFD0".to_string(),
127                family2: "ExifTool".to_string(),
128            },
129            raw_value: Value::String(bo_str.to_string()),
130            print_value: bo_str.to_string(),
131            priority: 0,
132        });
133
134        // Detect CR2: "CR" at offset 8 in TIFF data
135        let is_cr2 = data.len() > 10 && &data[8..10] == b"CR";
136
137        // Read IFD0 (main image)
138        Self::read_ifd(data, &header, header.ifd0_offset, "IFD0", &mut tags)?;
139
140        // For CR2 files, rename IFD0 StripOffsets→PreviewImageStart and
141        // StripByteCounts→PreviewImageLength, then construct PreviewImage.
142        if is_cr2 {
143            // Rename tags in-place
144            for tag in tags.iter_mut() {
145                if tag.group.family1 == "IFD0" {
146                    if tag.name == "StripOffsets" {
147                        tag.name = "PreviewImageStart".to_string();
148                        tag.description = "Preview Image Start".to_string();
149                        tag.id = TagId::Text("PreviewImageStart".to_string());
150                    } else if tag.name == "StripByteCounts" {
151                        tag.name = "PreviewImageLength".to_string();
152                        tag.description = "Preview Image Length".to_string();
153                        tag.id = TagId::Text("PreviewImageLength".to_string());
154                    }
155                }
156            }
157            // Construct PreviewImage from PreviewImageStart + PreviewImageLength
158            let preview_start = tags
159                .iter()
160                .find(|t| t.name == "PreviewImageStart" && t.group.family1 == "IFD0")
161                .and_then(|t| t.raw_value.as_u64())
162                .map(|v| v as usize);
163            let preview_len = tags
164                .iter()
165                .find(|t| t.name == "PreviewImageLength" && t.group.family1 == "IFD0")
166                .and_then(|t| t.raw_value.as_u64())
167                .map(|v| v as usize);
168            if let (Some(start), Some(len)) = (preview_start, preview_len) {
169                if len > 0 && start + len <= data.len() {
170                    let img_data = data[start..start + len].to_vec();
171                    let pv = format!("(Binary data {} bytes, use -b option to extract)", len);
172                    tags.push(Tag {
173                        id: TagId::Text("PreviewImage".to_string()),
174                        name: "PreviewImage".to_string(),
175                        description: "Preview Image".to_string(),
176                        group: TagGroup {
177                            family0: "EXIF".to_string(),
178                            family1: "IFD0".to_string(),
179                            family2: "Preview".to_string(),
180                        },
181                        raw_value: Value::Binary(img_data),
182                        print_value: pv,
183                        priority: 0,
184                    });
185                }
186            }
187        }
188
189        // Extract Make + Model for MakerNotes detection and sub-table dispatch
190        let make = tags
191            .iter()
192            .find(|t| t.name == "Make")
193            .map(|t| t.print_value.clone())
194            .unwrap_or_default();
195
196        let model = tags
197            .iter()
198            .find(|t| t.name == "Model")
199            .map(|t| t.print_value.clone())
200            .unwrap_or_default();
201
202        // Store model for sub-table dispatch
203        let make_and_model = if model.is_empty() {
204            make.clone()
205        } else {
206            model
207        };
208
209        // Find and parse MakerNotes
210        // Look for the MakerNote tag (0x927C) that was stored as Undefined
211        let mn_info: Option<(usize, usize)> = {
212            // Re-scan ExifIFD for MakerNote offset/size
213            let mut result = None;
214            Self::find_makernote(data, &header, &mut result);
215            result
216        };
217
218        if let Some((mn_offset, mn_size)) = mn_info {
219            let mn_tags = crate::metadata::makernotes::parse_makernotes(
220                data,
221                mn_offset,
222                mn_size,
223                &make,
224                &make_and_model,
225                header.byte_order,
226            );
227            // Remove the raw MakerNote tag and replace with parsed tags
228            tags.retain(|t| t.name != "MakerNote");
229            // In Perl ExifTool, MakerNotes tags with equal/higher priority overwrite EXIF tags.
230            // Tags in the EXIF-primary list: EXIF wins (skip MakerNotes duplicate).
231            // Other tags: MakerNotes wins (remove EXIF version, add MakerNotes version).
232            {
233                // Tags where EXIF takes priority over MakerNotes (structural/authoritative EXIF)
234                let exif_primary: &[&str] = &[
235                    "ThumbnailOffset",
236                    "ThumbnailLength",
237                    "ThumbnailImage",
238                    "StripOffsets",
239                    "StripByteCounts",
240                    "PreviewImageStart",
241                    "PreviewImageLength",
242                    "PreviewImage",
243                    "ImageWidth",
244                    "ImageHeight",
245                    "BitsPerSample",
246                    "Compression",
247                    "PhotometricInterpretation",
248                    "SamplesPerPixel",
249                    "RowsPerStrip",
250                    "PlanarConfiguration",
251                    "XResolution",
252                    "YResolution",
253                    "ResolutionUnit",
254                    "Orientation",
255                    "Make",
256                    "Model",
257                    "Software",
258                    "ExifByteOrder",
259                    "CR2CFAPattern",
260                    "RawImageSegmentation",
261                    "ColorSpace",
262                    "ExifVersion",
263                    "FlashpixVersion",
264                    "ExifImageWidth",
265                    "ExifImageHeight",
266                    "InteropIndex",
267                    "InteropVersion",
268                    "DateTimeOriginal",
269                    "CreateDate",
270                    "ModifyDate",
271                    "DateTime",
272                    "FocalPlaneXResolution",
273                    "FocalPlaneYResolution",
274                    "FocalPlaneResolutionUnit",
275                    "CustomRendered",
276                    "ExposureMode",
277                    "SceneCaptureType",
278                    "Flash",
279                    "FocalLength",
280                    "ISO",
281                    "ExposureTime",
282                    "ExposureProgram",
283                    "FNumber",
284                    "ShutterSpeedValue",
285                    "ApertureValue",
286                    "ComponentsConfiguration",
287                    "UserComment",
288                ];
289                let mn_name_set: std::collections::HashSet<String> =
290                    mn_tags.iter().map(|t| t.name.clone()).collect();
291                let exif_has: std::collections::HashSet<String> =
292                    tags.iter().map(|t| t.name.clone()).collect();
293                // Remove EXIF non-primary tags when MakerNotes provides them (MakerNotes wins)
294                tags.retain(|t| {
295                    !mn_name_set.contains(&t.name) || exif_primary.contains(&t.name.as_str())
296                });
297                // Add MakerNotes tags, but skip EXIF-primary tags that EXIF already provides
298                for mn_tag in mn_tags {
299                    if exif_primary.contains(&mn_tag.name.as_str())
300                        && exif_has.contains(&mn_tag.name)
301                    {
302                        // EXIF wins - don't add MakerNotes version
303                        continue;
304                    }
305                    tags.push(mn_tag);
306                }
307            }
308        }
309
310        // DNG PrivateData (0xC634): parse Adobe MakN for MakerNotes if no MakerNote found
311        if mn_info.is_none() {
312            // Scan for DNGPrivateData in tags — look for "Adobe\0" header
313            // Find the offset from the tag value (stored as binary/undefined)
314            Self::parse_dng_private_data(data, &header, &make, &make_and_model, &mut tags);
315        }
316
317        // Parse IPTC data embedded in TIFF (tag 0x83BB "IPTC-NAA")
318        // The raw tag stores IPTC data as undefined bytes or a list of u32 values
319        {
320            let iptc_data: Option<Vec<u8>> =
321                tags.iter().find(|t| t.name == "IPTC-NAA").and_then(|t| {
322                    match &t.raw_value {
323                        Value::Undefined(bytes) => Some(bytes.clone()),
324                        Value::Binary(bytes) => Some(bytes.clone()),
325                        Value::List(items) => {
326                            // IPTC-NAA stored as uint32 list - convert back to bytes (big-endian)
327                            let mut bytes = Vec::with_capacity(items.len() * 4);
328                            for item in items {
329                                if let Value::U32(v) = item {
330                                    bytes.extend_from_slice(&v.to_be_bytes())
331                                }
332                            }
333                            if bytes.is_empty() {
334                                None
335                            } else {
336                                Some(bytes)
337                            }
338                        }
339                        _ => None,
340                    }
341                });
342
343            if let Some(iptc_bytes) = iptc_data {
344                // Compute MD5 of the raw IPTC data for CurrentIPTCDigest
345                let md5_hex = crate::md5::md5_hex(&iptc_bytes);
346
347                if let Ok(iptc_tags) = crate::metadata::IptcReader::read(&iptc_bytes) {
348                    // Replace raw IPTC-NAA tag with parsed IPTC tags
349                    tags.retain(|t| t.name != "IPTC-NAA");
350                    tags.extend(iptc_tags);
351                }
352
353                // Add CurrentIPTCDigest tag
354                tags.push(crate::tag::Tag {
355                    id: crate::tag::TagId::Text("CurrentIPTCDigest".into()),
356                    name: "CurrentIPTCDigest".into(),
357                    description: "Current IPTC Digest".into(),
358                    group: crate::tag::TagGroup {
359                        family0: "IPTC".into(),
360                        family1: "IPTC".into(),
361                        family2: "Other".into(),
362                    },
363                    raw_value: Value::String(md5_hex.clone()),
364                    print_value: md5_hex,
365                    priority: 0,
366                });
367            }
368        }
369
370        // Parse ICC_Profile data embedded in TIFF (tag 0x8773)
371        {
372            let icc_data: Option<Vec<u8>> =
373                tags.iter()
374                    .find(|t| t.name == "ICC_Profile")
375                    .and_then(|t| match &t.raw_value {
376                        Value::Undefined(bytes) => Some(bytes.clone()),
377                        Value::Binary(bytes) => Some(bytes.clone()),
378                        _ => None,
379                    });
380
381            if let Some(icc_bytes) = icc_data {
382                if let Ok(icc_tags) = crate::formats::icc::read_icc(&icc_bytes) {
383                    // Replace raw ICC_Profile tag with parsed ICC tags
384                    tags.retain(|t| t.name != "ICC_Profile");
385                    tags.extend(icc_tags);
386                }
387            }
388        }
389
390        // Process GeoTIFF key directory if present
391        process_geotiff_keys(&mut tags);
392
393        // Final deduplication: within MakerNotes, if the same tag name appears multiple times
394        // (e.g., from different sub-tables), keep the last occurrence.
395        // Only deduplicate MakerNotes tags (family0 == "MakerNotes") to avoid affecting
396        // structural EXIF/IFD tags.
397        {
398            // Find MakerNotes tags that have duplicates
399            let mn_tags_start = tags
400                .iter()
401                .position(|t| t.group.family0 == "MakerNotes")
402                .unwrap_or(tags.len());
403            if mn_tags_start < tags.len() {
404                // For each MakerNotes tag name, find the last occurrence index
405                let mut last_idx: std::collections::HashMap<&str, usize> =
406                    std::collections::HashMap::new();
407                for (i, t) in tags[mn_tags_start..].iter().enumerate() {
408                    last_idx.insert(t.name.as_str(), i + mn_tags_start);
409                }
410                // Retain only the last occurrence of each MakerNotes duplicate
411                let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
412                // Process in reverse, keeping only the last (= rightmost = first in reverse)
413                let mut keep = vec![false; tags.len()];
414                for (i, t) in tags.iter().enumerate().rev() {
415                    if t.group.family0 != "MakerNotes" {
416                        keep[i] = true;
417                        continue;
418                    }
419                    if seen.insert(t.name.as_str()) {
420                        keep[i] = true; // first seen in reverse = last occurrence
421                    }
422                }
423                let mut iter = keep.iter();
424                tags.retain(|_| *iter.next().unwrap_or(&true));
425            }
426        }
427
428        Ok(tags)
429    }
430
431    /// Find MakerNote (tag 0x927C) offset and size in ExifIFD.
432    /// Parse DNG PrivateData (0xC634) to extract embedded MakerNotes
433    fn parse_dng_private_data(
434        data: &[u8],
435        header: &TiffHeader,
436        make: &str,
437        model: &str,
438        tags: &mut Vec<Tag>,
439    ) {
440        // Scan IFD0 for tag 0xC634
441        let ifd0_offset = header.ifd0_offset as usize;
442        if ifd0_offset + 2 > data.len() {
443            return;
444        }
445        let entry_count = read_u16(data, ifd0_offset, header.byte_order) as usize;
446        let entries_start = ifd0_offset + 2;
447        for i in 0..entry_count {
448            let eoff = entries_start + i * 12;
449            if eoff + 12 > data.len() {
450                break;
451            }
452            let tag = read_u16(data, eoff, header.byte_order);
453            if tag == 0xC634 {
454                let dtype = read_u16(data, eoff + 2, header.byte_order);
455                let count = read_u32(data, eoff + 4, header.byte_order) as usize;
456                let elem_size = match dtype {
457                    1 | 7 => 1,
458                    _ => 0,
459                };
460                let total = elem_size * count;
461                if total < 14 {
462                    continue;
463                }
464                let off = read_u32(data, eoff + 8, header.byte_order) as usize;
465                if off + total > data.len() {
466                    continue;
467                }
468                let pdata = &data[off..off + total];
469                // Parse Adobe DNGPrivateData: "Adobe\0" + blocks
470                if !pdata.starts_with(b"Adobe\0") {
471                    continue;
472                }
473                let mut bpos = 6;
474                while bpos + 8 <= pdata.len() {
475                    let btag = &pdata[bpos..bpos + 4];
476                    let bsize = u32::from_be_bytes([
477                        pdata[bpos + 4],
478                        pdata[bpos + 5],
479                        pdata[bpos + 6],
480                        pdata[bpos + 7],
481                    ]) as usize;
482                    bpos += 8;
483                    if bpos + bsize > pdata.len() {
484                        break;
485                    }
486                    if btag == b"MakN" && bsize > 6 {
487                        let mn_block = &pdata[bpos..bpos + bsize];
488                        let mn_bo = if &mn_block[0..2] == b"II" {
489                            ByteOrderMark::LittleEndian
490                        } else {
491                            ByteOrderMark::BigEndian
492                        };
493                        let mut mn_start = 6; // skip byte order + original offset
494                                              // Hack for extra 12 bytes in MakN header (Adobe Camera Raw bug)
495                        if bsize >= 18 && &mn_block[6..10] == b"\0\0\0\x01" {
496                            mn_start += 12;
497                        }
498                        if mn_start < bsize {
499                            // Emit MakerNoteByteOrder
500                            let mn_bo_str = if mn_bo == ByteOrderMark::LittleEndian {
501                                "Little-endian (Intel, II)"
502                            } else {
503                                "Big-endian (Motorola, MM)"
504                            };
505                            tags.push(Tag {
506                                id: TagId::Text("MakerNoteByteOrder".into()),
507                                name: "MakerNoteByteOrder".into(),
508                                description: "Maker Note Byte Order".into(),
509                                group: TagGroup {
510                                    family0: "File".into(),
511                                    family1: "File".into(),
512                                    family2: "Image".into(),
513                                },
514                                raw_value: Value::String(mn_bo_str.into()),
515                                print_value: mn_bo_str.into(),
516                                priority: 0,
517                            });
518                            // Canon MakerNotes have a TIFF footer with the original offset.
519                            // Sub-table value_offsets are relative to the original file.
520                            // We need to pass the full DNG data so offsets resolve correctly.
521                            let mn_data_in_block = &mn_block[mn_start..];
522                            let mn_abs_offset = off + (bpos - 8 + 8) + mn_start; // absolute offset in DNG file
523                                                                                 // Check for Canon TIFF footer (last 8 bytes)
524                            let fix_base = if mn_data_in_block.len() > 8 {
525                                let footer = &mn_data_in_block[mn_data_in_block.len() - 8..];
526                                if (footer[0..2] == *b"II" || footer[0..2] == *b"MM")
527                                    && (footer[2..4] == *b"\x2a\x00"
528                                        || footer[2..4] == *b"\x00\x2a")
529                                {
530                                    let old_off = if footer[0] == b'I' {
531                                        u32::from_le_bytes([
532                                            footer[4], footer[5], footer[6], footer[7],
533                                        ])
534                                    } else {
535                                        u32::from_be_bytes([
536                                            footer[4], footer[5], footer[6], footer[7],
537                                        ])
538                                    } as usize;
539                                    if old_off > 0 && mn_abs_offset > old_off {
540                                        mn_abs_offset as isize - old_off as isize
541                                    } else {
542                                        0
543                                    }
544                                } else {
545                                    0
546                                }
547                            } else {
548                                0
549                            };
550
551                            let mn_tags = if fix_base != 0 {
552                                // Pass full DNG data with corrected offset and base fix
553                                crate::metadata::makernotes::parse_makernotes_with_base(
554                                    data,
555                                    mn_abs_offset,
556                                    mn_data_in_block.len(),
557                                    make,
558                                    model,
559                                    mn_bo,
560                                    fix_base,
561                                )
562                            } else {
563                                crate::metadata::makernotes::parse_makernotes(
564                                    mn_data_in_block,
565                                    0,
566                                    mn_data_in_block.len(),
567                                    make,
568                                    model,
569                                    mn_bo,
570                                )
571                            };
572                            // DNG MakerNote tags that Perl doesn't emit (conditions/Unknown/offset issues)
573                            let dng_suppress = [
574                                "AESetting",
575                                "CameraISO",
576                                "ImageStabilization",
577                                "SpotMeteringMode",
578                                "RawJpgSize",
579                                "Warning",
580                            ];
581                            for mn_tag in mn_tags {
582                                // Skip EXIF-primary tags and DNG-suppressed tags
583                                if tags.iter().any(|t| t.name == mn_tag.name) {
584                                    continue;
585                                }
586                                if dng_suppress.contains(&mn_tag.name.as_str()) {
587                                    continue;
588                                }
589                                tags.push(mn_tag);
590                            }
591                        }
592                    }
593                    bpos += bsize;
594                }
595                break;
596            }
597        }
598    }
599
600    fn find_makernote(data: &[u8], header: &TiffHeader, result: &mut Option<(usize, usize)>) {
601        // First find ExifIFD offset from IFD0
602        let ifd0_offset = header.ifd0_offset as usize;
603        if ifd0_offset + 2 > data.len() {
604            return;
605        }
606        let entry_count = read_u16(data, ifd0_offset, header.byte_order) as usize;
607        let entries_start = ifd0_offset + 2;
608
609        for i in 0..entry_count {
610            let eoff = entries_start + i * 12;
611            if eoff + 12 > data.len() {
612                break;
613            }
614            let tag = read_u16(data, eoff, header.byte_order);
615            if tag == 0x8769 {
616                // ExifIFD pointer
617                let exif_offset = read_u32(data, eoff + 8, header.byte_order) as usize;
618                Self::find_makernote_in_ifd(data, header, exif_offset, result);
619                break;
620            }
621        }
622    }
623
624    fn find_makernote_in_ifd(
625        data: &[u8],
626        header: &TiffHeader,
627        ifd_offset: usize,
628        result: &mut Option<(usize, usize)>,
629    ) {
630        if ifd_offset + 2 > data.len() {
631            return;
632        }
633        let entry_count = read_u16(data, ifd_offset, header.byte_order) as usize;
634        let entries_start = ifd_offset + 2;
635
636        for i in 0..entry_count {
637            let eoff = entries_start + i * 12;
638            if eoff + 12 > data.len() {
639                break;
640            }
641            let tag = read_u16(data, eoff, header.byte_order);
642            if tag == 0x927C {
643                let data_type = read_u16(data, eoff + 2, header.byte_order);
644                let count = read_u32(data, eoff + 4, header.byte_order) as usize;
645                let type_size = match data_type {
646                    1 | 2 | 6 | 7 => 1,
647                    3 | 8 => 2,
648                    4 | 9 | 11 | 13 => 4,
649                    5 | 10 | 12 => 8,
650                    _ => 1,
651                };
652                let total_size = type_size * count;
653
654                if total_size <= 4 {
655                    // Inline - too small for real MakerNotes
656                    break;
657                }
658                let offset = read_u32(data, eoff + 8, header.byte_order) as usize;
659                if offset + total_size <= data.len() {
660                    *result = Some((offset, total_size));
661                }
662                break;
663            }
664        }
665    }
666
667    /// Parse EXIF data from a byte slice with an explicit byte order and offset.
668    fn read_ifd(
669        data: &[u8],
670        header: &TiffHeader,
671        offset: u32,
672        ifd_name: &str,
673        tags: &mut Vec<Tag>,
674    ) -> Result<Option<u32>> {
675        let offset = offset as usize;
676        if offset + 2 > data.len() {
677            return Err(Error::InvalidExif(format!(
678                "{} offset {} beyond data length {}",
679                ifd_name,
680                offset,
681                data.len()
682            )));
683        }
684
685        let entry_count = read_u16(data, offset, header.byte_order) as usize;
686        let entries_start = offset + 2;
687        let _entries_end = entries_start + entry_count * 12;
688
689        // Validate: at minimum, first entry must fit
690        if entries_start + 12 > data.len() && entry_count > 0 {
691            return Err(Error::InvalidExif(format!(
692                "{} entries extend beyond data (need {}, have {})",
693                ifd_name,
694                entries_start + 12,
695                data.len()
696            )));
697        }
698        // Clamp entry count if IFD extends beyond data
699        let entry_count = entry_count.min((data.len().saturating_sub(entries_start)) / 12);
700        let entries_end = entries_start + entry_count * 12;
701
702        for i in 0..entry_count {
703            let entry_offset = entries_start + i * 12;
704            let entry = parse_ifd_entry(data, entry_offset, header.byte_order);
705
706            // Check for sub-IFDs (ExifIFD, GPS, Interop)
707            match entry.tag {
708                0x8769 => {
709                    // ExifIFD
710                    let sub_offset = entry.value_offset;
711                    if (sub_offset as usize) < data.len() {
712                        let _ = Self::read_ifd(data, header, sub_offset, "ExifIFD", tags);
713                    }
714                    continue;
715                }
716                0x8825 => {
717                    // GPS IFD
718                    let sub_offset = entry.value_offset;
719                    if (sub_offset as usize) < data.len() {
720                        let _ = Self::read_ifd(data, header, sub_offset, "GPS", tags);
721                    }
722                    continue;
723                }
724                0xA005 => {
725                    // Interop IFD
726                    let sub_offset = entry.value_offset;
727                    if (sub_offset as usize) < data.len() {
728                        let _ = Self::read_ifd(data, header, sub_offset, "InteropIFD", tags);
729                    }
730                    continue;
731                }
732                // PrintIM tag: extract version from "PrintIM" + 4-byte version
733                0xC4A5 => {
734                    let total_size = match entry.data_type {
735                        1 | 2 | 6 | 7 => entry.count as usize,
736                        _ => 0,
737                    };
738                    if total_size > 11 {
739                        let off = entry.value_offset as usize;
740                        if off + 11 <= data.len() && &data[off..off + 7] == b"PrintIM" {
741                            let ver =
742                                crate::encoding::decode_utf8_or_latin1(&data[off + 7..off + 11])
743                                    .to_string();
744                            tags.push(Tag {
745                                id: TagId::Text("PrintIMVersion".into()),
746                                name: "PrintIMVersion".into(),
747                                description: "PrintIM Version".into(),
748                                group: TagGroup {
749                                    family0: "PrintIM".into(),
750                                    family1: "PrintIM".into(),
751                                    family2: "Printing".into(),
752                                },
753                                raw_value: Value::String(ver.clone()),
754                                print_value: ver,
755                                priority: 0,
756                            });
757                        }
758                    }
759                    continue; // Suppress raw PrintIM tag
760                }
761                // Suppress GPS tag 0x0006 (GPSAltitude) when value is 0/0
762                0x0006 if ifd_name == "GPS" => {
763                    if let Some(Value::URational(0, 0)) =
764                        read_ifd_value(data, &entry, header.byte_order)
765                    {
766                        continue;
767                    }
768                }
769                // In SubIFD, tag 0x0201 = JpgFromRawStart (JPEG preview offset)
770                0x0201 if ifd_name.starts_with("SubIFD") => {
771                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
772                        let pv = val.to_display_string();
773                        tags.push(Tag {
774                            id: TagId::Numeric(entry.tag),
775                            name: "JpgFromRawStart".into(),
776                            description: "Jpg From Raw Start".into(),
777                            group: TagGroup {
778                                family0: "EXIF".into(),
779                                family1: ifd_name.to_string(),
780                                family2: "Image".into(),
781                            },
782                            raw_value: val,
783                            print_value: pv,
784                            priority: 0,
785                        });
786                    }
787                    continue;
788                }
789                // In SubIFD, tag 0x0202 = JpgFromRawLength (JPEG preview byte count)
790                0x0202 if ifd_name.starts_with("SubIFD") => {
791                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
792                        let pv = val.to_display_string();
793                        tags.push(Tag {
794                            id: TagId::Numeric(entry.tag),
795                            name: "JpgFromRawLength".into(),
796                            description: "Jpg From Raw Length".into(),
797                            group: TagGroup {
798                                family0: "EXIF".into(),
799                                family1: ifd_name.to_string(),
800                                family2: "Image".into(),
801                            },
802                            raw_value: val,
803                            print_value: pv,
804                            priority: 0,
805                        });
806                    }
807                    continue;
808                }
809                // SubIFD pointer (0x014A): follow to read SubIFD entries
810                0x014A if ifd_name == "IFD0" => {
811                    // Read SubIFD offset(s) — may be a single uint32 or array
812                    if let Some(val) = read_ifd_value(data, &entry, header.byte_order) {
813                        let offsets: Vec<u32> = match &val {
814                            Value::U32(v) => vec![*v],
815                            Value::List(items) => items
816                                .iter()
817                                .filter_map(|v| {
818                                    if let Value::U32(o) = v {
819                                        Some(*o)
820                                    } else {
821                                        None
822                                    }
823                                })
824                                .collect(),
825                            _ => vec![],
826                        };
827                        for (idx, &off) in offsets.iter().enumerate() {
828                            if (off as usize) < data.len() {
829                                let sub_name = format!("SubIFD{}", idx);
830                                let before_idx = tags.len();
831                                let _ = Self::read_ifd(data, header, off, &sub_name, tags);
832
833                                // Check if this SubIFD has JPEG compression
834                                let is_jpeg = tags[before_idx..].iter().any(|t| {
835                                    t.name == "Compression"
836                                        && (t.print_value.contains("JPEG")
837                                            || t.raw_value.as_u64() == Some(6))
838                                });
839
840                                if is_jpeg {
841                                    // Rename StripOffsets/StripByteCounts based on SubIFD index
842                                    // Perl: SubIFD2 → JpgFromRaw*, others → PreviewImage*
843                                    let (start_name, len_name, img_name) = if idx == 2 {
844                                        ("JpgFromRawStart", "JpgFromRawLength", "JpgFromRaw")
845                                    } else {
846                                        ("PreviewImageStart", "PreviewImageLength", "PreviewImage")
847                                    };
848                                    // Find StripOffsets and StripByteCounts in this SubIFD
849                                    let strip_off = tags[before_idx..]
850                                        .iter()
851                                        .find(|t| t.name == "StripOffsets")
852                                        .and_then(|t| t.raw_value.as_u64());
853                                    let strip_len = tags[before_idx..]
854                                        .iter()
855                                        .find(|t| t.name == "StripByteCounts")
856                                        .and_then(|t| t.raw_value.as_u64());
857                                    if let (Some(s), Some(l)) = (strip_off, strip_len) {
858                                        tags.push(Tag {
859                                            id: TagId::Text(start_name.into()),
860                                            name: start_name.into(),
861                                            description: start_name.into(),
862                                            group: TagGroup {
863                                                family0: "EXIF".into(),
864                                                family1: sub_name.clone(),
865                                                family2: "Preview".into(),
866                                            },
867                                            raw_value: Value::U32(s as u32),
868                                            print_value: s.to_string(),
869                                            priority: 0,
870                                        });
871                                        tags.push(Tag {
872                                            id: TagId::Text(len_name.into()),
873                                            name: len_name.into(),
874                                            description: len_name.into(),
875                                            group: TagGroup {
876                                                family0: "EXIF".into(),
877                                                family1: sub_name.clone(),
878                                                family2: "Preview".into(),
879                                            },
880                                            raw_value: Value::U32(l as u32),
881                                            print_value: l.to_string(),
882                                            priority: 0,
883                                        });
884                                        // Extract binary image data
885                                        let s = s as usize;
886                                        let l = l as usize;
887                                        if l > 0 && s + l <= data.len() {
888                                            let pv = format!(
889                                                "(Binary data {} bytes, use -b option to extract)",
890                                                l
891                                            );
892                                            tags.push(Tag {
893                                                id: TagId::Text(img_name.into()),
894                                                name: img_name.into(),
895                                                description: img_name.into(),
896                                                group: TagGroup {
897                                                    family0: "EXIF".into(),
898                                                    family1: sub_name.clone(),
899                                                    family2: "Preview".into(),
900                                                },
901                                                raw_value: Value::Binary(data[s..s + l].to_vec()),
902                                                print_value: pv,
903                                                priority: 0,
904                                            });
905                                        }
906                                    }
907                                }
908
909                                // Legacy: Also check for already-named JpgFromRawStart tags
910                                let jpg_start = tags[before_idx..]
911                                    .iter()
912                                    .find(|t| t.name == "JpgFromRawStart")
913                                    .and_then(|t| t.raw_value.as_u64());
914                                let jpg_len = tags[before_idx..]
915                                    .iter()
916                                    .find(|t| t.name == "JpgFromRawLength")
917                                    .and_then(|t| t.raw_value.as_u64());
918                                if let (Some(start), Some(len)) = (jpg_start, jpg_len) {
919                                    let start = start as usize;
920                                    let len = len as usize;
921                                    if len > 0 && start + len <= data.len() {
922                                        let pv = format!(
923                                            "(Binary data {} bytes, use -b option to extract)",
924                                            len
925                                        );
926                                        tags.push(Tag {
927                                            id: TagId::Text("JpgFromRaw".into()),
928                                            name: "JpgFromRaw".into(),
929                                            description: "Jpg From Raw".into(),
930                                            group: TagGroup {
931                                                family0: "EXIF".into(),
932                                                family1: sub_name,
933                                                family2: "Preview".into(),
934                                            },
935                                            raw_value: Value::Binary(
936                                                data[start..start + len].to_vec(),
937                                            ),
938                                            print_value: pv,
939                                            priority: 0,
940                                        });
941                                    }
942                                }
943                            }
944                        }
945                    }
946                    continue;
947                }
948                // In CR2 IFD2 (preview JPEG), suppress StripOffsets/StripByteCounts
949                // because IFD3 has the correct values for the raw data.
950                // Also suppress tags that duplicate IFD0 content (ImageWidth, ImageHeight,
951                // BitsPerSample, Compression) since the first (IFD0) value is preferred.
952                0x0100 | 0x0101 | 0x0102 | 0x0103 | 0x0111 | 0x0117 if ifd_name == "IFD2" => {
953                    continue;
954                }
955                // In CR2 IFD3 (raw data), suppress Compression (IFD0 value is preferred).
956                0x0103 if ifd_name == "IFD3" => {
957                    continue;
958                }
959                _ => {}
960            }
961
962            if let Some(mut value) = read_ifd_value(data, &entry, header.byte_order) {
963                // GPS TimeStamp (0x0007): convert 0/0 rationals to 0/1 so it displays as "0, 0, 0"
964                // (Perl treats 0/0 as 0 for GPS time, enabling GPSDateTime composite)
965                if ifd_name == "GPS" && entry.tag == 0x0007 {
966                    if let Value::List(ref mut items) = value {
967                        for item in items.iter_mut() {
968                            if matches!(item, Value::URational(0, 0)) {
969                                *item = Value::URational(0, 1);
970                            }
971                        }
972                    }
973                }
974                let tag_info = exif_tags::lookup(ifd_name, entry.tag);
975                let (name, description, family2) = match tag_info {
976                    Some(info) => (
977                        info.name.to_string(),
978                        info.description.to_string(),
979                        info.family2.to_string(),
980                    ),
981                    None => {
982                        // Skip known SubDirectory/internal tags that Perl doesn't emit
983                        if matches!(
984                            entry.tag,
985                            // 0x014A handled above (SubIFD traversal)
986                            // 0x02BC (ApplicationNotes) now parsed as XMP above
987                            0xC634 // DNG PrivateData — processed after IFD scan
988                        ) {
989                            continue;
990                        }
991                        // Fallback to generated tags
992                        match exif_tags::lookup_generated(entry.tag) {
993                            Some((n, d)) => (n.to_string(), d.to_string(), "Other".to_string()),
994                            None => {
995                                // Perl doesn't emit unknown EXIF tags by default
996                                continue;
997                            }
998                        }
999                    }
1000                };
1001
1002                // Parse ApplicationNotes (0x02BC) as XMP
1003                if name == "ApplicationNotes" {
1004                    if let Value::Binary(ref xmp_bytes) = value {
1005                        if let Ok(xmp_tags) = crate::metadata::XmpReader::read(xmp_bytes) {
1006                            tags.extend(xmp_tags);
1007                        }
1008                    }
1009                    continue;
1010                }
1011                // Suppress known SubDirectory/internal tags
1012                if matches!(
1013                    name.as_str(),
1014                    "MinSampleValue" | "MaxSampleValue" | // Not emitted by Perl for raw formats
1015                    "ProcessingSoftware" | // Protected tag, not always emitted
1016                    "PanasonicTitle" | "PanasonicTitle2" // DNG tags, wrong match for RW2
1017                ) {
1018                    continue;
1019                }
1020
1021                let print_value = if name.starts_with("Tag0x") && get_show_unknown() >= 2 {
1022                    // -U mode: show binary data for unknown tags
1023                    match &value {
1024                        Value::Binary(bytes) | Value::Undefined(bytes) => bytes
1025                            .iter()
1026                            .map(|b| format!("{:02x}", b))
1027                            .collect::<Vec<_>>()
1028                            .join(" "),
1029                        _ => value.to_display_string(),
1030                    }
1031                } else if name.starts_with("Tag0x") {
1032                    // -u mode: show unknown tags but use standard display for values
1033                    value.to_display_string()
1034                } else {
1035                    exif_tags::print_conv(ifd_name, entry.tag, &value)
1036                        .or_else(|| {
1037                            // Fallback to generated print conversions
1038                            value
1039                                .as_u64()
1040                                .and_then(|v| {
1041                                    crate::tags::print_conv_generated::print_conv_by_name(
1042                                        &name, v as i64,
1043                                    )
1044                                })
1045                                .map(|s| s.to_string())
1046                        })
1047                        .unwrap_or_else(|| value.to_display_string())
1048                };
1049
1050                tags.push(Tag {
1051                    id: TagId::Numeric(entry.tag),
1052                    name,
1053                    description,
1054                    group: TagGroup {
1055                        family0: "EXIF".to_string(),
1056                        family1: ifd_name.to_string(),
1057                        family2,
1058                    },
1059                    raw_value: value,
1060                    print_value,
1061                    priority: 0,
1062                });
1063            }
1064        }
1065
1066        // Read next IFD offset
1067        let next_ifd_offset = if entries_end + 4 <= data.len() {
1068            read_u32(data, entries_end, header.byte_order)
1069        } else {
1070            0
1071        };
1072        if next_ifd_offset != 0 && ifd_name == "IFD0" {
1073            // IFD1 = thumbnail
1074            let ifd1_start_idx = tags.len();
1075            let ifd1_next = Self::read_ifd(data, header, next_ifd_offset, "IFD1", tags)
1076                .ok()
1077                .flatten();
1078            // Suppress IFD1 tags that duplicate IFD0 tags (only keep thumbnail-specific ones)
1079            // In Perl, IFD1 (thumbnail) tags are secondary and don't appear in output if IFD0 has them.
1080            {
1081                let ifd0_names: std::collections::HashSet<String> = tags[..ifd1_start_idx]
1082                    .iter()
1083                    .map(|t| t.name.clone())
1084                    .collect();
1085                let thumbnail_tags = [
1086                    "ThumbnailOffset",
1087                    "ThumbnailLength",
1088                    "ThumbnailImage",
1089                    "Compression",
1090                    "PhotometricInterpretation",
1091                    "JPEGInterchangeFormat",
1092                    "JPEGInterchangeFormatLength",
1093                    "SubfileType",
1094                    "StripOffsets",
1095                    "StripByteCounts",
1096                ];
1097                tags.retain(|t| {
1098                    if t.group.family1 != "IFD1" {
1099                        return true;
1100                    }
1101                    // Keep thumbnail-specific tags
1102                    if thumbnail_tags.contains(&t.name.as_str()) {
1103                        return true;
1104                    }
1105                    // Suppress IFD1 tags that IFD0 already has
1106                    !ifd0_names.contains(&t.name)
1107                });
1108            }
1109
1110            // Create ThumbnailImage tag if offset+length are present
1111            let thumb_offset = tags
1112                .iter()
1113                .find(|t| t.name == "ThumbnailOffset" && t.group.family1 == "IFD1")
1114                .and_then(|t| t.raw_value.as_u64());
1115            let thumb_length = tags
1116                .iter()
1117                .find(|t| t.name == "ThumbnailLength" && t.group.family1 == "IFD1")
1118                .and_then(|t| t.raw_value.as_u64());
1119
1120            if let (Some(off), Some(len)) = (thumb_offset, thumb_length) {
1121                let off = off as usize;
1122                let len = len as usize;
1123                if off + len <= data.len() && len > 0 {
1124                    tags.push(Tag {
1125                        id: TagId::Text("ThumbnailImage".into()),
1126                        name: "ThumbnailImage".into(),
1127                        description: "Thumbnail Image".into(),
1128                        group: TagGroup {
1129                            family0: "EXIF".into(),
1130                            family1: "IFD1".into(),
1131                            family2: "Image".into(),
1132                        },
1133                        raw_value: Value::Binary(data[off..off + len].to_vec()),
1134                        print_value: format!("(Binary data {} bytes)", len),
1135                        priority: 0,
1136                    });
1137                }
1138            }
1139
1140            // CR2 files have additional IFDs (IFD2, IFD3) following IFD1 in the chain.
1141            // CR2 is identified by "CR" bytes at offset 8 in the TIFF data.
1142            let is_cr2 = data.len() > 10 && &data[8..10] == b"CR";
1143            if is_cr2 {
1144                if let Some(ifd2_offset) = ifd1_next {
1145                    // IFD2 = preview JPEG image data (emit selected tags)
1146                    let ifd2_next = Self::read_ifd(data, header, ifd2_offset, "IFD2", tags)
1147                        .ok()
1148                        .flatten();
1149                    // IFD3 = raw image data (emit CR2CFAPattern, RawImageSegmentation, StripOffsets, StripByteCounts)
1150                    if let Some(ifd3_offset) = ifd2_next {
1151                        let _ = Self::read_ifd(data, header, ifd3_offset, "IFD3", tags);
1152                    }
1153                }
1154            }
1155        }
1156
1157        Ok(if next_ifd_offset != 0 {
1158            Some(next_ifd_offset)
1159        } else {
1160            None
1161        })
1162    }
1163
1164    /// Parse a TIFF where IFD0 is treated as a named IFD (e.g. "GPS", "ExifIFD").
1165    /// Used for CR3 CMT4 (GPS-only TIFF) and CMT2 (ExifIFD-only TIFF).
1166    /// Does NOT emit ExifByteOrder or do MakerNote/IFD1 processing.
1167    pub fn read_as_named_ifd(data: &[u8], ifd_name: &str) -> Vec<Tag> {
1168        let header = match parse_tiff_header(data) {
1169            Ok(h) => h,
1170            Err(_) => return Vec::new(),
1171        };
1172        let mut tags = Vec::new();
1173        let _ = Self::read_ifd(data, &header, header.ifd0_offset, ifd_name, &mut tags);
1174        tags
1175    }
1176}
1177
1178fn parse_ifd_entry(data: &[u8], offset: usize, byte_order: ByteOrderMark) -> IfdEntry {
1179    let tag = read_u16(data, offset, byte_order);
1180    let data_type = read_u16(data, offset + 2, byte_order);
1181    let count = read_u32(data, offset + 4, byte_order);
1182    let value_offset = read_u32(data, offset + 8, byte_order);
1183    let mut inline_data = [0u8; 4];
1184    inline_data.copy_from_slice(&data[offset + 8..offset + 12]);
1185
1186    IfdEntry {
1187        tag,
1188        data_type,
1189        count,
1190        value_offset,
1191        inline_data,
1192    }
1193}
1194
1195fn read_ifd_value(data: &[u8], entry: &IfdEntry, byte_order: ByteOrderMark) -> Option<Value> {
1196    let elem_size = type_size(entry.data_type)?;
1197    let total_size = elem_size * entry.count as usize;
1198
1199    let value_data = if total_size <= 4 {
1200        &entry.inline_data[..total_size]
1201    } else {
1202        let offset = entry.value_offset as usize;
1203        if offset + total_size > data.len() {
1204            return None;
1205        }
1206        &data[offset..offset + total_size]
1207    };
1208
1209    // IPTC-NAA (0x83BB): always read as raw binary regardless of declared type
1210    if entry.tag == 0x83BB {
1211        return Some(Value::Binary(value_data.to_vec()));
1212    }
1213
1214    // ApplicationNotes (0x02BC): always read as raw binary (XMP data)
1215    if entry.tag == 0x02BC {
1216        return Some(Value::Binary(value_data.to_vec()));
1217    }
1218
1219    match entry.data_type {
1220        // BYTE
1221        1 => {
1222            if entry.count == 1 {
1223                Some(Value::U8(value_data[0]))
1224            } else {
1225                Some(Value::List(
1226                    value_data.iter().map(|&b| Value::U8(b)).collect(),
1227                ))
1228            }
1229        }
1230        // ASCII
1231        2 => {
1232            let s = crate::encoding::decode_utf8_or_latin1(value_data);
1233            Some(Value::String(s.trim_end_matches('\0').to_string()))
1234        }
1235        // SHORT
1236        3 => {
1237            if entry.count == 1 {
1238                Some(Value::U16(read_u16(value_data, 0, byte_order)))
1239            } else {
1240                let vals: Vec<Value> = (0..entry.count as usize)
1241                    .map(|i| Value::U16(read_u16(value_data, i * 2, byte_order)))
1242                    .collect();
1243                Some(Value::List(vals))
1244            }
1245        }
1246        // LONG
1247        4 | 13 => {
1248            if entry.count == 1 {
1249                Some(Value::U32(read_u32(value_data, 0, byte_order)))
1250            } else {
1251                let vals: Vec<Value> = (0..entry.count as usize)
1252                    .map(|i| Value::U32(read_u32(value_data, i * 4, byte_order)))
1253                    .collect();
1254                Some(Value::List(vals))
1255            }
1256        }
1257        // RATIONAL (unsigned)
1258        5 => {
1259            if entry.count == 1 {
1260                let n = read_u32(value_data, 0, byte_order);
1261                let d = read_u32(value_data, 4, byte_order);
1262                Some(Value::URational(n, d))
1263            } else {
1264                let vals: Vec<Value> = (0..entry.count as usize)
1265                    .map(|i| {
1266                        let n = read_u32(value_data, i * 8, byte_order);
1267                        let d = read_u32(value_data, i * 8 + 4, byte_order);
1268                        Value::URational(n, d)
1269                    })
1270                    .collect();
1271                Some(Value::List(vals))
1272            }
1273        }
1274        // SBYTE
1275        6 => {
1276            if entry.count == 1 {
1277                Some(Value::I16(value_data[0] as i8 as i16))
1278            } else {
1279                let vals: Vec<Value> = value_data
1280                    .iter()
1281                    .map(|&b| Value::I16(b as i8 as i16))
1282                    .collect();
1283                Some(Value::List(vals))
1284            }
1285        }
1286        // UNDEFINED
1287        7 => Some(Value::Undefined(value_data.to_vec())),
1288        // SSHORT
1289        8 => {
1290            if entry.count == 1 {
1291                Some(Value::I16(read_i16(value_data, 0, byte_order)))
1292            } else {
1293                let vals: Vec<Value> = (0..entry.count as usize)
1294                    .map(|i| Value::I16(read_i16(value_data, i * 2, byte_order)))
1295                    .collect();
1296                Some(Value::List(vals))
1297            }
1298        }
1299        // SLONG
1300        9 => {
1301            if entry.count == 1 {
1302                Some(Value::I32(read_i32(value_data, 0, byte_order)))
1303            } else {
1304                let vals: Vec<Value> = (0..entry.count as usize)
1305                    .map(|i| Value::I32(read_i32(value_data, i * 4, byte_order)))
1306                    .collect();
1307                Some(Value::List(vals))
1308            }
1309        }
1310        // SRATIONAL
1311        10 => {
1312            if entry.count == 1 {
1313                let n = read_i32(value_data, 0, byte_order);
1314                let d = read_i32(value_data, 4, byte_order);
1315                Some(Value::IRational(n, d))
1316            } else {
1317                let vals: Vec<Value> = (0..entry.count as usize)
1318                    .map(|i| {
1319                        let n = read_i32(value_data, i * 8, byte_order);
1320                        let d = read_i32(value_data, i * 8 + 4, byte_order);
1321                        Value::IRational(n, d)
1322                    })
1323                    .collect();
1324                Some(Value::List(vals))
1325            }
1326        }
1327        // FLOAT
1328        11 => {
1329            if entry.count == 1 {
1330                let bits = read_u32(value_data, 0, byte_order);
1331                Some(Value::F32(f32::from_bits(bits)))
1332            } else {
1333                let vals: Vec<Value> = (0..entry.count as usize)
1334                    .map(|i| {
1335                        let bits = read_u32(value_data, i * 4, byte_order);
1336                        Value::F32(f32::from_bits(bits))
1337                    })
1338                    .collect();
1339                Some(Value::List(vals))
1340            }
1341        }
1342        // DOUBLE
1343        12 => {
1344            if entry.count == 1 {
1345                let bits = read_u64(value_data, 0, byte_order);
1346                Some(Value::F64(f64::from_bits(bits)))
1347            } else {
1348                let vals: Vec<Value> = (0..entry.count as usize)
1349                    .map(|i| {
1350                        let bits = read_u64(value_data, i * 8, byte_order);
1351                        Value::F64(f64::from_bits(bits))
1352                    })
1353                    .collect();
1354                Some(Value::List(vals))
1355            }
1356        }
1357        _ => None,
1358    }
1359}
1360
1361// Byte-order-aware read helpers
1362fn read_u16(data: &[u8], offset: usize, bo: ByteOrderMark) -> u16 {
1363    match bo {
1364        ByteOrderMark::LittleEndian => LittleEndian::read_u16(&data[offset..]),
1365        ByteOrderMark::BigEndian => BigEndian::read_u16(&data[offset..]),
1366    }
1367}
1368
1369fn read_u32(data: &[u8], offset: usize, bo: ByteOrderMark) -> u32 {
1370    match bo {
1371        ByteOrderMark::LittleEndian => LittleEndian::read_u32(&data[offset..]),
1372        ByteOrderMark::BigEndian => BigEndian::read_u32(&data[offset..]),
1373    }
1374}
1375
1376fn read_u64(data: &[u8], offset: usize, bo: ByteOrderMark) -> u64 {
1377    match bo {
1378        ByteOrderMark::LittleEndian => LittleEndian::read_u64(&data[offset..]),
1379        ByteOrderMark::BigEndian => BigEndian::read_u64(&data[offset..]),
1380    }
1381}
1382
1383fn read_i16(data: &[u8], offset: usize, bo: ByteOrderMark) -> i16 {
1384    match bo {
1385        ByteOrderMark::LittleEndian => LittleEndian::read_i16(&data[offset..]),
1386        ByteOrderMark::BigEndian => BigEndian::read_i16(&data[offset..]),
1387    }
1388}
1389
1390fn read_i32(data: &[u8], offset: usize, bo: ByteOrderMark) -> i32 {
1391    match bo {
1392        ByteOrderMark::LittleEndian => LittleEndian::read_i32(&data[offset..]),
1393        ByteOrderMark::BigEndian => BigEndian::read_i32(&data[offset..]),
1394    }
1395}
1396
1397/// Process GeoTIFF key directory (tag GeoTiffDirectory / GeoKeyDirectory)
1398/// and replace raw directory/ascii/double params with named GeoTIFF tags.
1399fn process_geotiff_keys(tags: &mut Vec<Tag>) {
1400    // Extract GeoTiffDirectory values
1401    let dir_vals: Option<Vec<u16>> =
1402        tags.iter()
1403            .find(|t| t.name == "GeoTiffDirectory")
1404            .and_then(|t| match &t.raw_value {
1405                Value::List(items) => {
1406                    let vals: Vec<u16> = items
1407                        .iter()
1408                        .filter_map(|v| match v {
1409                            Value::U16(x) => Some(*x),
1410                            Value::U32(x) => Some(*x as u16),
1411                            _ => None,
1412                        })
1413                        .collect();
1414                    if vals.is_empty() {
1415                        None
1416                    } else {
1417                        Some(vals)
1418                    }
1419                }
1420                _ => None,
1421            });
1422
1423    let dir_vals = match dir_vals {
1424        Some(v) => v,
1425        None => return,
1426    };
1427
1428    if dir_vals.len() < 4 {
1429        return;
1430    }
1431
1432    let version = dir_vals[0];
1433    let revision = dir_vals[1];
1434    let minor_rev = dir_vals[2];
1435    let num_entries = dir_vals[3] as usize;
1436
1437    if dir_vals.len() < 4 + num_entries * 4 {
1438        return;
1439    }
1440
1441    // Extract ASCII params
1442    let ascii_params: Option<String> = tags
1443        .iter()
1444        .find(|t| t.name == "GeoTiffAsciiParams")
1445        .map(|t| t.print_value.clone());
1446
1447    // Extract double params
1448    let double_params: Option<Vec<f64>> = tags
1449        .iter()
1450        .find(|t| t.name == "GeoTiffDoubleParams")
1451        .and_then(|t| match &t.raw_value {
1452            Value::List(items) => {
1453                let vals: Vec<f64> = items
1454                    .iter()
1455                    .filter_map(|v| match v {
1456                        Value::F64(x) => Some(*x),
1457                        Value::F32(x) => Some(*x as f64),
1458                        _ => None,
1459                    })
1460                    .collect();
1461                if vals.is_empty() {
1462                    None
1463                } else {
1464                    Some(vals)
1465                }
1466            }
1467            _ => None,
1468        });
1469
1470    let mut new_tags = Vec::new();
1471
1472    // Version tag
1473    new_tags.push(Tag {
1474        id: TagId::Text("GeoTiffVersion".to_string()),
1475        name: "GeoTiffVersion".to_string(),
1476        description: "GeoTiff Version".to_string(),
1477        group: TagGroup {
1478            family0: "EXIF".into(),
1479            family1: "IFD0".into(),
1480            family2: "Location".into(),
1481        },
1482        raw_value: Value::String(format!("{}.{}.{}", version, revision, minor_rev)),
1483        print_value: format!("{}.{}.{}", version, revision, minor_rev),
1484        priority: 0,
1485    });
1486
1487    // Process each GeoKey
1488    for i in 0..num_entries {
1489        let base = 4 + i * 4;
1490        let key_id = dir_vals[base];
1491        let location = dir_vals[base + 1];
1492        let count = dir_vals[base + 2] as usize;
1493        let value_or_offset = dir_vals[base + 3];
1494
1495        let raw_val: Option<String> = match location {
1496            0 => {
1497                // Value stored inline in value_or_offset
1498                Some(format!("{}", value_or_offset))
1499            }
1500            34737 => {
1501                // ASCII params
1502                if let Some(ref ascii) = ascii_params {
1503                    let off = value_or_offset as usize;
1504                    let end = (off + count).min(ascii.len());
1505                    if off <= end {
1506                        let s = &ascii[off..end];
1507                        // Remove trailing '|' separators
1508                        let s = s.trim_end_matches('|').trim().to_string();
1509                        Some(s)
1510                    } else {
1511                        None
1512                    }
1513                } else {
1514                    None
1515                }
1516            }
1517            34736 => {
1518                // Double params
1519                if let Some(ref doubles) = double_params {
1520                    let off = value_or_offset as usize;
1521                    if count == 1 && off < doubles.len() {
1522                        Some(format!("{}", doubles[off]))
1523                    } else if count > 1 {
1524                        let vals: Vec<String> = doubles
1525                            .iter()
1526                            .skip(off)
1527                            .take(count)
1528                            .map(|v| format!("{}", v))
1529                            .collect();
1530                        Some(vals.join(" "))
1531                    } else {
1532                        None
1533                    }
1534                } else {
1535                    None
1536                }
1537            }
1538            _ => None,
1539        };
1540
1541        let val_str = match raw_val {
1542            Some(v) => v,
1543            None => continue,
1544        };
1545
1546        // Map GeoKey ID to tag name and print value
1547        let (tag_name, print_val) = geotiff_key_to_tag(key_id, &val_str);
1548        if tag_name.is_empty() {
1549            continue;
1550        }
1551
1552        new_tags.push(Tag {
1553            id: TagId::Text(tag_name.clone()),
1554            name: tag_name.clone(),
1555            description: tag_name.clone(),
1556            group: TagGroup {
1557                family0: "EXIF".into(),
1558                family1: "IFD0".into(),
1559                family2: "Location".into(),
1560            },
1561            raw_value: Value::String(val_str),
1562            print_value: print_val,
1563            priority: 0,
1564        });
1565    }
1566
1567    if !new_tags.is_empty() {
1568        // Remove raw GeoTIFF tags
1569        tags.retain(|t| {
1570            t.name != "GeoTiffDirectory"
1571                && t.name != "GeoTiffAsciiParams"
1572                && t.name != "GeoTiffDoubleParams"
1573        });
1574        tags.extend(new_tags);
1575    }
1576}
1577
1578/// Map a GeoKey ID to (tag_name, print_value).
1579fn geotiff_key_to_tag(key_id: u16, value: &str) -> (String, String) {
1580    let val_u16: Option<u16> = value.parse().ok();
1581
1582    match key_id {
1583        // Section 6.2.1: GeoTIFF Configuration Keys
1584        0x0001 => return ("GeoTiffVersion".to_string(), value.to_string()), // not used here
1585        0x0400 => {
1586            // GTModelType
1587            let print = match val_u16 {
1588                Some(1) => "Projected".to_string(),
1589                Some(2) => "Geographic".to_string(),
1590                Some(3) => "Geocentric".to_string(),
1591                Some(32767) => "User Defined".to_string(),
1592                _ => value.to_string(),
1593            };
1594            return ("GTModelType".to_string(), print);
1595        }
1596        0x0401 => {
1597            // GTRasterType
1598            let print = match val_u16 {
1599                Some(1) => "Pixel Is Area".to_string(),
1600                Some(2) => "Pixel Is Point".to_string(),
1601                Some(32767) => "User Defined".to_string(),
1602                _ => value.to_string(),
1603            };
1604            return ("GTRasterType".to_string(), print);
1605        }
1606        0x0402 => return ("GTCitation".to_string(), value.to_string()),
1607
1608        // Section 6.2.2: Geographic CS Parameter Keys
1609        0x0800 => {
1610            return (
1611                "GeographicType".to_string(),
1612                geotiff_pcs_name(val_u16.unwrap_or(0), value),
1613            )
1614        }
1615        0x0801 => return ("GeogCitation".to_string(), value.to_string()),
1616        0x0802 => return ("GeogGeodeticDatum".to_string(), value.to_string()),
1617        0x0803 => return ("GeogPrimeMeridian".to_string(), value.to_string()),
1618        0x0804 => {
1619            return (
1620                "GeogLinearUnits".to_string(),
1621                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
1622            )
1623        }
1624        0x0805 => return ("GeogLinearUnitSize".to_string(), value.to_string()),
1625        0x0806 => return ("GeogAngularUnits".to_string(), value.to_string()),
1626        0x0807 => return ("GeogAngularUnitSize".to_string(), value.to_string()),
1627        0x0808 => return ("GeogEllipsoid".to_string(), value.to_string()),
1628        0x0809 => return ("GeogSemiMajorAxis".to_string(), value.to_string()),
1629        0x080a => return ("GeogSemiMinorAxis".to_string(), value.to_string()),
1630        0x080b => return ("GeogInvFlattening".to_string(), value.to_string()),
1631        0x080c => return ("GeogAzimuthUnits".to_string(), value.to_string()),
1632        0x080d => return ("GeogPrimeMeridianLong".to_string(), value.to_string()),
1633
1634        // Section 6.2.3: Projected CS Parameter Keys
1635        0x0C00 => {
1636            // ProjectedCSType
1637            return (
1638                "ProjectedCSType".to_string(),
1639                geotiff_pcs_name(val_u16.unwrap_or(0), value),
1640            );
1641        }
1642        0x0C01 => return ("PCSCitation".to_string(), value.to_string()),
1643        0x0C02 => return ("Projection".to_string(), value.to_string()),
1644        0x0C03 => return ("ProjCoordTrans".to_string(), value.to_string()),
1645        0x0C04 => {
1646            return (
1647                "ProjLinearUnits".to_string(),
1648                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
1649            )
1650        }
1651        0x0C05 => return ("ProjLinearUnitSize".to_string(), value.to_string()),
1652        0x0C06 => return ("ProjStdParallel1".to_string(), value.to_string()),
1653        0x0C07 => return ("ProjStdParallel2".to_string(), value.to_string()),
1654        0x0C08 => return ("ProjNatOriginLong".to_string(), value.to_string()),
1655        0x0C09 => return ("ProjNatOriginLat".to_string(), value.to_string()),
1656        0x0c0a => return ("ProjFalseEasting".to_string(), value.to_string()),
1657        0x0c0b => return ("ProjFalseNorthing".to_string(), value.to_string()),
1658        0x0c0c => return ("ProjFalseOriginLong".to_string(), value.to_string()),
1659        0x0c0d => return ("ProjFalseOriginLat".to_string(), value.to_string()),
1660        0x0c0e => return ("ProjFalseOriginEasting".to_string(), value.to_string()),
1661        0x0c0f => return ("ProjFalseOriginNorthing".to_string(), value.to_string()),
1662        0x0C10 => return ("ProjCenterLong".to_string(), value.to_string()),
1663        0x0C11 => return ("ProjCenterLat".to_string(), value.to_string()),
1664        0x0C12 => return ("ProjCenterEasting".to_string(), value.to_string()),
1665        0x0C13 => return ("ProjCenterNorthing".to_string(), value.to_string()),
1666        0x0C14 => return ("ProjScaleAtNatOrigin".to_string(), value.to_string()),
1667        0x0C15 => return ("ProjScaleAtCenter".to_string(), value.to_string()),
1668        0x0C16 => return ("ProjAzimuthAngle".to_string(), value.to_string()),
1669        0x0C17 => return ("ProjStraightVertPoleLong".to_string(), value.to_string()),
1670
1671        // Section 6.2.4: Vertical CS Keys
1672        0x1000 => return ("VerticalCSType".to_string(), value.to_string()),
1673        0x1001 => return ("VerticalCitation".to_string(), value.to_string()),
1674        0x1002 => return ("VerticalDatum".to_string(), value.to_string()),
1675        0x1003 => {
1676            return (
1677                "VerticalUnits".to_string(),
1678                geotiff_linear_unit_name(val_u16.unwrap_or(0), value),
1679            )
1680        }
1681
1682        _ => {}
1683    }
1684    (String::new(), String::new())
1685}
1686
1687fn geotiff_linear_unit_name(val: u16, fallback: &str) -> String {
1688    match val {
1689        9001 => "Linear Meter".to_string(),
1690        9002 => "Linear Foot".to_string(),
1691        9003 => "Linear Foot US Survey".to_string(),
1692        9004 => "Linear Foot Modified American".to_string(),
1693        9005 => "Linear Foot Clarke".to_string(),
1694        9006 => "Linear Foot Indian".to_string(),
1695        9007 => "Linear Link".to_string(),
1696        9008 => "Linear Link Benoit".to_string(),
1697        9009 => "Linear Link Sears".to_string(),
1698        9010 => "Linear Chain Benoit".to_string(),
1699        9011 => "Linear Chain Sears".to_string(),
1700        9012 => "Linear Yard Sears".to_string(),
1701        9013 => "Linear Yard Indian".to_string(),
1702        9014 => "Linear Fathom".to_string(),
1703        9015 => "Linear Mile International Nautical".to_string(),
1704        _ => fallback.to_string(),
1705    }
1706}
1707
1708fn geotiff_pcs_name(val: u16, fallback: &str) -> String {
1709    // Common PCS codes - just return the code with description for common ones
1710    match val {
1711        26918 => "NAD83 UTM zone 18N".to_string(),
1712        26919 => "NAD83 UTM zone 19N".to_string(),
1713        32618 => "WGS84 UTM zone 18N".to_string(),
1714        32619 => "WGS84 UTM zone 19N".to_string(),
1715        4326 => "WGS 84".to_string(),
1716        4269 => "NAD83".to_string(),
1717        4267 => "NAD27".to_string(),
1718        32767 => "User Defined".to_string(),
1719        _ => fallback.to_string(),
1720    }
1721}