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}