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