Skip to main content

nom_exif/exif/
exif_iter.rs

1use std::{collections::HashSet, fmt::Debug};
2
3use bytes::Bytes;
4use nom::{number::complete, Parser};
5
6use crate::{
7    error::EntryError,
8    slice::SliceChecked,
9    values::{DataFormat, EntryData, IRational, URational},
10    EntryValue, ExifTag,
11};
12
13use super::{exif_exif::IFD_ENTRY_SIZE, GPSInfo, LatLng, TiffHeader};
14use crate::TagOrCode;
15
16/// Index of an IFD (Image File Directory) within an EXIF blob.
17///
18/// `0` = main image (`IfdIndex::MAIN`), `1` = thumbnail (`IfdIndex::THUMBNAIL`),
19/// `>=2` = sub-IFDs in the order encountered. Use the constants for the common
20/// cases and [`IfdIndex::new`] for raw indexing.
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct IfdIndex(usize);
24
25impl IfdIndex {
26    /// Index of the main image IFD (always `0`).
27    pub const MAIN: Self = IfdIndex(0);
28
29    /// Index of the thumbnail IFD (`1` when present).
30    pub const THUMBNAIL: Self = IfdIndex(1);
31
32    /// Construct from a raw index. `0`/`1` correspond to [`Self::MAIN`] /
33    /// [`Self::THUMBNAIL`]; values `>= 2` are sub-IFDs.
34    pub const fn new(index: usize) -> Self {
35        IfdIndex(index)
36    }
37
38    /// Underlying raw index as a `usize`.
39    pub const fn as_usize(self) -> usize {
40        self.0
41    }
42}
43
44impl std::fmt::Display for IfdIndex {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "ifd{}", self.0)
47    }
48}
49
50/// Eager view into a single Exif entry. Yielded by [`crate::Exif::iter`] and
51/// designed to be cheap to copy: the `value` is a borrow into the parent
52/// [`crate::Exif`].
53///
54/// # Why pub fields instead of getters?
55///
56/// `ifd`, `tag`, and `value` are independent — there is no cross-field
57/// invariant to enforce. The Rust idiom for plain data carriers (cf.
58/// [`std::ops::Range`]) is `pub` fields. The lazy yield type
59/// [`crate::ExifIterEntry`] uses *private* fields because it carries a
60/// `value xor error` invariant.
61#[derive(Clone, Copy, Debug)]
62pub struct ExifEntry<'a> {
63    pub ifd: IfdIndex,
64    pub tag: TagOrCode,
65    pub value: &'a crate::EntryValue,
66}
67
68/// Represents an additional TIFF data block to be processed after the primary block.
69/// Used for CR3 files with multiple CMT boxes (CMT1, CMT2, CMT3).
70#[derive(Clone)]
71pub(crate) struct TiffDataBlock {
72    /// Block identifier (e.g., "CMT1", "CMT2", "CMT3")
73    #[allow(dead_code)]
74    pub block_id: String,
75    /// Pre-sliced bytes view for this block's data
76    pub data: Bytes,
77    /// TIFF header information (optional, if known)
78    pub header: Option<TiffHeader>,
79}
80
81/// Parses header from input data, and returns an [`ExifIter`].
82///
83/// All entries are lazy-parsed. That is, only when you iterate over
84/// [`ExifIter`] will the IFD entries be parsed one by one.
85///
86/// The one exception is the time zone entries. The method will try to find
87/// and parse the time zone data first, so we can correctly parse all time
88/// information in subsequent iterates.
89#[tracing::instrument]
90pub(crate) fn input_into_iter(
91    input: impl Into<bytes::Bytes> + Debug,
92    state: Option<TiffHeader>,
93) -> crate::Result<ExifIter> {
94    let input: bytes::Bytes = input.into();
95    let header = match state {
96        // header has been parsed, and header has been skipped, input data
97        // is the IFD data
98        Some(header) => header,
99        _ => {
100            // header has not been parsed, input data includes IFD header
101            let (_, header) = TiffHeader::parse(&input[..]).map_err(|e| {
102                crate::error::nom_err_to_malformed(e, crate::error::MalformedKind::TiffHeader)
103            })?;
104
105            tracing::debug!(
106                ?header,
107                data_len = format!("{:#x}", input.len()),
108                "TIFF header parsed"
109            );
110            header
111        }
112    };
113
114    let start = header.ifd0_offset as usize;
115    if start > input.len() {
116        return Err(crate::Error::UnexpectedEof {
117            context: "exif iter init",
118        });
119    }
120    tracing::debug!(?header, offset = start);
121
122    let mut ifd0 = IfdIter::try_new(0, input.clone(), header.to_owned(), start, None)?;
123
124    let tz = ifd0.find_tz_offset();
125    ifd0.tz = tz.clone();
126    let iter: ExifIter = ExifIter::new(input, header, tz, ifd0);
127
128    tracing::debug!(?iter, "got IFD0");
129
130    Ok(iter)
131}
132
133/// An iterator version of [`Exif`](crate::Exif). Use [`ExifIterEntry`] as
134/// iterator items.
135///
136/// Clone an `ExifIter` is very cheap; the underlying data is shared
137/// via `bytes::Bytes` reference counting.
138///
139/// The new cloned `ExifIter`'s iteration index will be reset to the first one.
140///
141/// If you want to convert an `ExifIter` `into` an [`Exif`](crate::Exif), you probably want
142/// to clone the `ExifIter` and use the new cloned one to do the converting.
143/// Since the original's iteration index may have been modified by
144/// `Iterator::next()` calls.
145pub struct ExifIter {
146    input: Bytes,
147    tiff_header: TiffHeader,
148    tz: Option<String>,
149    ifd0: IfdIter,
150
151    // Iterating status
152    ifds: Vec<IfdIter>,
153    visited_offsets: HashSet<usize>,
154
155    // Multi-block support for CR3 files with multiple CMT boxes
156    /// Additional TIFF data blocks to process after the primary block
157    additional_blocks: Vec<TiffDataBlock>,
158    /// Current block index: 0 = primary block, 1+ = additional blocks
159    current_block_index: usize,
160    /// Tags encountered so far for duplicate filtering (ifd_index, tag_code)
161    encountered_tags: HashSet<(usize, u16)>,
162    has_embedded_track: bool,
163}
164
165impl Debug for ExifIter {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("ExifIter")
168            .field("data len", &self.input.len())
169            .field("tiff_header", &self.tiff_header)
170            .field("ifd0", &self.ifd0)
171            .field("state", &self.ifds.first().map(|x| (x.index, x.pos)))
172            .field("ifds num", &self.ifds.len())
173            .field("additional_blocks", &self.additional_blocks.len())
174            .field("current_block_index", &self.current_block_index)
175            .finish_non_exhaustive()
176    }
177}
178
179impl Clone for ExifIter {
180    fn clone(&self) -> Self {
181        self.clone_rewound()
182    }
183}
184
185impl ExifIter {
186    pub(crate) fn new(
187        input: bytes::Bytes,
188        tiff_header: TiffHeader,
189        tz: Option<String>,
190        ifd0: IfdIter,
191    ) -> ExifIter {
192        let ifds = vec![ifd0.clone()];
193        ExifIter {
194            input,
195            tiff_header,
196            tz,
197            ifd0,
198            ifds,
199            visited_offsets: HashSet::new(),
200            additional_blocks: Vec::new(),
201            current_block_index: 0,
202            encountered_tags: HashSet::new(),
203            has_embedded_track: false,
204        }
205    }
206
207    /// Clone with iteration state reset to entry 0.
208    ///
209    /// Cheap: `ExifIter` shares its underlying `bytes::Bytes` via refcount.
210    pub fn clone_rewound(&self) -> Self {
211        let ifd0 = self.ifd0.clone_and_rewind();
212        let ifds = vec![ifd0.clone()];
213        Self {
214            input: self.input.clone(),
215            tiff_header: self.tiff_header.clone(),
216            tz: self.tz.clone(),
217            ifd0,
218            ifds,
219            visited_offsets: HashSet::new(),
220            additional_blocks: self.additional_blocks.clone(),
221            current_block_index: 0,
222            encountered_tags: HashSet::new(),
223            has_embedded_track: self.has_embedded_track,
224        }
225    }
226
227    /// Reset iteration to the first entry (in-place). After this call,
228    /// `next()` yields entries starting from IFD0 entry 0 again.
229    pub fn rewind(&mut self) {
230        let ifd0 = self.ifd0.clone_and_rewind();
231        self.ifds = vec![ifd0.clone()];
232        self.ifd0 = ifd0;
233        self.visited_offsets.clear();
234        self.current_block_index = 0;
235        self.encountered_tags.clear();
236    }
237
238    /// Try to find and parse GPS information.
239    ///
240    /// Calling this method won't affect the iterator's state.
241    ///
242    /// Returns:
243    ///
244    /// - An `Ok<Some<GPSInfo>>` if gps info is found and parsed successfully.
245    /// - An `Ok<None>` if gps info is not found.
246    /// - An `Err` if gps info is found but parsing failed.
247    #[tracing::instrument(skip_all)]
248    pub fn parse_gps(&self) -> crate::Result<Option<GPSInfo>> {
249        let mut iter = self.clone_rewound();
250        let Some(gps) = iter.find(|x| {
251            tracing::info!(?x, "find");
252            x.tag().tag().is_some_and(|t| t == ExifTag::GPSInfo)
253        }) else {
254            tracing::warn!(ifd0 = ?iter.ifds.first(), "GPSInfo not found");
255            return Ok(None);
256        };
257
258        let offset = match gps.result() {
259            Ok(v) => {
260                if let Some(offset) = v.as_u32() {
261                    offset
262                } else {
263                    return Err(EntryError::InvalidValue("invalid gps offset").into());
264                }
265            }
266            Err(e) => return Err(e.clone().into()),
267        };
268        if offset as usize >= iter.input.len() {
269            return Err(crate::Error::Malformed {
270                kind: crate::error::MalformedKind::IfdEntry,
271                message: "GPSInfo offset out of range".into(),
272            });
273        }
274
275        let mut gps_subifd = match IfdIter::try_new(
276            gps.ifd().as_usize(),
277            iter.input.clone(),
278            iter.tiff_header,
279            offset as usize,
280            iter.tz.clone(),
281        ) {
282            Ok(ifd0) => ifd0.tag_code(ExifTag::GPSInfo.code()),
283            Err(e) => return Err(e),
284        };
285        Ok(gps_subifd.parse_gps_info())
286    }
287
288    /// Add an additional TIFF data block to be iterated after the current block.
289    /// Used internally for CR3 files with multiple CMT boxes.
290    ///
291    /// # Arguments
292    /// * `block_id` - Identifier for this TIFF block (e.g., "CMT2", "CMT3")
293    /// * `data` - Pre-sliced `Bytes` view containing this block's TIFF data
294    /// * `header` - Optional TIFF header if already parsed
295    pub(crate) fn add_tiff_block(
296        &mut self,
297        block_id: String,
298        data: bytes::Bytes,
299        header: Option<TiffHeader>,
300    ) {
301        self.additional_blocks.push(TiffDataBlock {
302            block_id,
303            data,
304            header,
305        });
306    }
307
308    /// Internal-only setter used by [`crate::MediaParser::parse_exif`] to
309    /// stamp the iterator with content-detected embedded-track information.
310    pub(crate) fn set_has_embedded_track(&mut self, v: bool) {
311        self.has_embedded_track = v;
312    }
313
314    /// Whether the source file is known to embed a paired media track that
315    /// `parse_exif` did *not* surface — a Pixel/Google or Samsung Galaxy
316    /// Motion Photo (JPEG with `GCamera:MotionPhoto` XMP and an MP4
317    /// trailer). Use [`crate::MediaParser::parse_track`] on the same
318    /// source to extract the embedded track.
319    ///
320    /// **Content-detected, not MIME-guessed**: returns `true` only when
321    /// the parser observes concrete signals during `parse_exif`
322    /// (`GCamera:MotionPhoto="1"` plus a `Container:Directory` /
323    /// `MotionPhotoOffset` / `MicroVideoOffset`). A plain JPEG or HEIC
324    /// without such signals returns `false`.
325    ///
326    /// **Coverage**: Pixel/Google Motion Photos and Samsung Galaxy
327    /// Motion Photos that use the Adobe XMP Container directory format
328    /// (JPEG variants).
329    pub fn has_embedded_track(&self) -> bool {
330        self.has_embedded_track
331    }
332
333    /// Deprecated alias for [`Self::has_embedded_track`].
334    #[deprecated(
335        since = "3.1.0",
336        note = "renamed to `has_embedded_track`; the original `has_embedded_media` was too vague and lumped in still-image previews"
337    )]
338    pub fn has_embedded_media(&self) -> bool {
339        self.has_embedded_track()
340    }
341}
342
343/// Lazy yield from [`ExifIter`]. Carries a *value xor error* invariant —
344/// every entry holds exactly one of [`Self::value`] or [`Self::error`].
345///
346/// # Why private fields?
347///
348/// Public fields would let callers construct nonsense like `value=Some,
349/// error=Some`. Private fields + getters preserve the invariant while
350/// exposing the natural API: [`Self::result`] for borrowed access,
351/// [`Self::into_result`] for ownership transfer (consumes `self`, no panic
352/// path).
353#[derive(Clone)]
354pub struct ExifIterEntry {
355    ifd: IfdIndex,
356    tag: TagOrCode,
357    res: Result<EntryValue, crate::error::EntryError>,
358}
359
360impl ExifIterEntry {
361    /// IFD this entry was found in (`IfdIndex::MAIN` for the primary image).
362    pub fn ifd(&self) -> IfdIndex {
363        self.ifd
364    }
365
366    /// Recognized tag, or raw `u16` code if not in [`ExifTag`].
367    pub fn tag(&self) -> TagOrCode {
368        self.tag
369    }
370
371    /// Borrow the value. `None` iff this entry hit a parse error.
372    pub fn value(&self) -> Option<&EntryValue> {
373        self.res.as_ref().ok()
374    }
375
376    /// Borrow the error. `None` iff this entry parsed successfully.
377    pub fn error(&self) -> Option<&crate::error::EntryError> {
378        self.res.as_ref().err()
379    }
380
381    /// Borrow either value or error, mirroring the underlying invariant.
382    pub fn result(&self) -> Result<&EntryValue, &crate::error::EntryError> {
383        self.res.as_ref()
384    }
385
386    /// Consume self and return the value or error. No second-call panic
387    /// path (the entry is moved out).
388    pub fn into_result(self) -> Result<EntryValue, crate::error::EntryError> {
389        self.res
390    }
391
392    pub(crate) fn make_ok(ifd: usize, tag: TagOrCode, v: EntryValue) -> Self {
393        Self {
394            ifd: IfdIndex::new(ifd),
395            tag,
396            res: Ok(v),
397        }
398    }
399}
400
401impl std::fmt::Debug for ExifIterEntry {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        let value = match &self.res {
404            Ok(v) => format!("{v}"),
405            Err(e) => format!("{e:?}"),
406        };
407        f.debug_struct("ExifIterEntry")
408            .field("ifd", &self.ifd)
409            .field("tag", &self.tag)
410            .field("value", &value)
411            .finish()
412    }
413}
414
415const MAX_IFD_DEPTH: usize = 8;
416
417impl ExifIter {
418    /// Attempt to load and start iterating the next additional TIFF block.
419    /// Returns true if a new block was successfully loaded, false if no more blocks.
420    fn load_next_block(&mut self) -> bool {
421        // Move to the next additional block
422        let block_index = self.current_block_index;
423        if block_index >= self.additional_blocks.len() {
424            return false;
425        }
426
427        let block = &self.additional_blocks[block_index];
428        tracing::debug!(
429            block_id = block.block_id,
430            block_index,
431            "Loading additional TIFF block"
432        );
433
434        // Get the data for this block from the shared input
435        let block_data = block.data.clone();
436        let header = block.header.clone();
437
438        // Try to create an ExifIter for this block
439        match input_into_iter(block_data, header) {
440            Ok(iter) => {
441                // Update our state with the new block's data
442                self.ifd0 = iter.ifd0;
443                self.ifds = vec![self.ifd0.clone()];
444                self.visited_offsets.clear();
445                self.current_block_index += 1;
446
447                tracing::debug!(block_index, "Successfully loaded additional TIFF block");
448                true
449            }
450            Err(e) => {
451                tracing::warn!(
452                    block_index,
453                    error = %e,
454                    "Failed to load additional TIFF block, skipping"
455                );
456                // Move to next block and try again
457                self.current_block_index += 1;
458                self.load_next_block()
459            }
460        }
461    }
462
463    /// Check if a tag should be included based on duplicate filtering.
464    /// Returns true if the tag should be included, false if it's a duplicate.
465    fn should_include_tag(&mut self, ifd_index: usize, tag_code: u16) -> bool {
466        let tag_key = (ifd_index, tag_code);
467        if self.encountered_tags.contains(&tag_key) {
468            tracing::debug!(ifd_index, tag_code, "Skipping duplicate tag");
469            false
470        } else {
471            self.encountered_tags.insert(tag_key);
472            true
473        }
474    }
475}
476
477impl Iterator for ExifIter {
478    type Item = ExifIterEntry;
479
480    #[tracing::instrument(skip_all)]
481    fn next(&mut self) -> Option<Self::Item> {
482        loop {
483            if self.ifds.is_empty() {
484                // Current block exhausted, try to load next additional block
485                if !self.load_next_block() {
486                    tracing::debug!(?self, "all IFDs and blocks have been parsed");
487                    return None;
488                }
489                // Continue with the newly loaded block
490                continue;
491            }
492
493            if self.ifds.len() > MAX_IFD_DEPTH {
494                let depth = self.ifds.len();
495                self.ifds.clear();
496                tracing::error!(
497                    ifds_depth = depth,
498                    "ifd depth is too deep, just go back to ifd0"
499                );
500                self.ifds.push(self.ifd0.clone_with_state());
501            }
502
503            let mut ifd = self.ifds.pop()?;
504            let cur_ifd_idx = ifd.ifd_idx;
505            match ifd.next() {
506                Some((tag_code, entry)) => {
507                    tracing::debug!(ifd = ifd.ifd_idx, ?tag_code, "next tag entry");
508
509                    match entry {
510                        IfdEntry::IfdNew(new_ifd) => {
511                            if new_ifd.offset > 0 {
512                                if self.visited_offsets.contains(&new_ifd.offset) {
513                                    // Ignore repeated ifd parsing to avoid dead looping
514                                    continue;
515                                }
516                                self.visited_offsets.insert(new_ifd.offset);
517                            }
518
519                            let is_subifd = if new_ifd.ifd_idx == ifd.ifd_idx {
520                                // Push the current ifd before enter sub-ifd.
521                                self.ifds.push(ifd);
522                                tracing::debug!(?tag_code, ?new_ifd, "got new SUB-IFD");
523                                true
524                            } else {
525                                // Otherwise this is a next ifd. It means that the
526                                // current ifd has been parsed, so we don't need to
527                                // push it.
528                                tracing::debug!("IFD{} parsing completed", cur_ifd_idx);
529                                tracing::debug!(?new_ifd, "got new IFD");
530                                false
531                            };
532
533                            let (ifd_idx, offset) = (new_ifd.ifd_idx, new_ifd.offset);
534                            self.ifds.push(new_ifd);
535
536                            if is_subifd {
537                                // Check for duplicates before returning sub-ifd entry
538                                let tc = tag_code.unwrap();
539                                if !self.should_include_tag(ifd_idx, tc.code()) {
540                                    continue;
541                                }
542                                // Return sub-ifd as an entry
543                                return Some(ExifIterEntry::make_ok(
544                                    ifd_idx,
545                                    tc,
546                                    EntryValue::U32(offset as u32),
547                                ));
548                            }
549                        }
550                        IfdEntry::Entry(v) => {
551                            let tc = tag_code.unwrap();
552                            // Check for duplicates before returning entry
553                            if !self.should_include_tag(ifd.ifd_idx, tc.code()) {
554                                self.ifds.push(ifd);
555                                continue;
556                            }
557                            let res = Some(ExifIterEntry::make_ok(ifd.ifd_idx, tc, v));
558                            self.ifds.push(ifd);
559                            return res;
560                        }
561                        IfdEntry::Err(e) => {
562                            tracing::warn!(?tag_code, ?e, "parse ifd entry error");
563                            self.ifds.push(ifd);
564                            continue;
565                        }
566                    }
567                }
568                None => continue,
569            }
570        }
571    }
572}
573
574#[derive(Clone)]
575pub(crate) struct IfdIter {
576    ifd_idx: usize,
577    tag_code: Option<TagOrCode>,
578
579    // starts from TIFF header
580    input: Bytes,
581
582    // ifd data offset
583    offset: usize,
584
585    header: TiffHeader,
586    entry_num: u16,
587
588    pub tz: Option<String>,
589
590    // Iterating status
591    index: u16,
592    pos: usize,
593}
594
595impl Debug for IfdIter {
596    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597        f.debug_struct("IfdIter")
598            .field("ifd_idx", &self.ifd_idx)
599            .field("tag", &self.tag_code)
600            .field("data len", &self.input.len())
601            .field("tz", &self.tz)
602            .field("header", &self.header)
603            .field("entry_num", &self.entry_num)
604            .field("index", &self.index)
605            .field("pos", &self.pos)
606            .finish()
607    }
608}
609
610impl IfdIter {
611    pub fn rewind(&mut self) {
612        self.index = 0;
613        // Skip the first two bytes, which is the entry num
614        self.pos = self.offset + 2;
615    }
616
617    pub fn clone_and_rewind(&self) -> Self {
618        let mut it = self.clone();
619        it.rewind();
620        it
621    }
622
623    pub fn tag_code_maybe(mut self, code: Option<u16>) -> Self {
624        self.tag_code = code.map(|x| x.into());
625        self
626    }
627
628    pub fn tag_code(mut self, code: u16) -> Self {
629        self.tag_code = Some(code.into());
630        self
631    }
632
633    fn is_gps_subifd(&self) -> bool {
634        matches!(
635            self.tag_code.as_ref().and_then(|t| t.tag()),
636            Some(ExifTag::GPSInfo)
637        )
638    }
639
640    #[allow(unused)]
641    pub fn tag(mut self, tag: TagOrCode) -> Self {
642        self.tag_code = Some(tag);
643        self
644    }
645
646    #[tracing::instrument(skip(input))]
647    pub fn try_new(
648        ifd_idx: usize,
649        input: Bytes,
650        header: TiffHeader,
651        offset: usize,
652        tz: Option<String>,
653    ) -> crate::Result<Self> {
654        if input.len() < 2 {
655            return Err(crate::Error::Malformed {
656                kind: crate::error::MalformedKind::TiffHeader,
657                message: "ifd data too small to decode entry num".into(),
658            });
659        }
660        // should use the complete header data to parse ifd entry num
661        assert!(offset <= input.len());
662        let ifd_data = input.slice(offset..);
663        let (_, entry_num) =
664            TiffHeader::parse_ifd_entry_num(&ifd_data, header.endian).map_err(|e| {
665                crate::error::nom_err_to_malformed(e, crate::error::MalformedKind::TiffHeader)
666            })?;
667
668        Ok(Self {
669            ifd_idx,
670            tag_code: None,
671            input,
672            offset,
673            header,
674            entry_num,
675            tz,
676            // Skip the first two bytes, which is the entry num
677            pos: offset + 2,
678            index: 0,
679        })
680    }
681
682    fn parse_tag_entry(&self, entry_data: &[u8]) -> Option<(u16, IfdEntry)> {
683        let endian = self.header.endian;
684        let (_, (tag, data_format, components_num, value_or_offset)) = (
685            complete::u16::<_, nom::error::Error<_>>(endian),
686            complete::u16(endian),
687            complete::u32(endian),
688            complete::u32(endian),
689        )
690            .parse(entry_data)
691            .ok()?;
692
693        // Tag 0 outside the GPS sub-IFD is treated as a sentinel for
694        // zero-padded malformed IFDs (overstated `entry_num`) and aborts
695        // iteration. Inside the GPS sub-IFD it is the legitimate
696        // GPSVersionID — let it parse normally.
697        if tag == 0 && !self.is_gps_subifd() {
698            return None;
699        }
700
701        let df: DataFormat = match DataFormat::try_from(data_format) {
702            Ok(df) => df,
703            Err(bad) => {
704                let t: TagOrCode = tag.into();
705                tracing::warn!(tag = ?t, format = bad, "invalid entry data format");
706                return Some((
707                    tag,
708                    IfdEntry::Err(EntryError::InvalidShape {
709                        format: bad,
710                        count: components_num,
711                    }),
712                ));
713            }
714        };
715        let (tag, res) = self.parse_entry(tag, df, components_num, entry_data, value_or_offset);
716        Some((tag, res))
717    }
718
719    fn get_data_pos(&self, value_or_offset: u32) -> usize {
720        // value_or_offset.saturating_sub(self.offset)
721        value_or_offset as usize
722    }
723
724    fn parse_entry(
725        &self,
726        tag: u16,
727        data_format: DataFormat,
728        components_num: u32,
729        entry_data: &[u8],
730        value_or_offset: u32,
731    ) -> (u16, IfdEntry) {
732        // get component_size according to data format
733        let component_size = data_format.component_size();
734
735        // get entry data
736        let size = components_num as usize * component_size;
737        let data = if size <= 4 {
738            &entry_data[8..8 + size] // Safe-slice
739        } else {
740            let start = self.get_data_pos(value_or_offset);
741            let end = start + size;
742            let Some(data) = self.input.slice_checked(start..end) else {
743                tracing::warn!(
744                    "entry data overflow, tag: {:04x} start: {:08x} end: {:08x} ifd data len {:08x}",
745                    tag,
746                    start,
747                    end,
748                    self.input.len(),
749                );
750                return (
751                    tag,
752                    IfdEntry::Err(EntryError::Truncated {
753                        needed: size,
754                        available: self.input.len().saturating_sub(start),
755                    }),
756                );
757            };
758
759            data
760        };
761
762        if SUBIFD_TAGS.contains(&tag) {
763            if let Some(value) = self.new_ifd_iter(self.ifd_idx, value_or_offset, Some(tag)) {
764                return (tag, value);
765            }
766        }
767
768        let entry = EntryData {
769            endian: self.header.endian,
770            tag,
771            data,
772            data_format,
773            components_num,
774        };
775        match EntryValue::parse(&entry, &self.tz) {
776            Ok(v) => (tag, IfdEntry::Entry(v)),
777            Err(e) => (tag, IfdEntry::Err(e)),
778        }
779    }
780
781    fn new_ifd_iter(
782        &self,
783        ifd_idx: usize,
784        value_or_offset: u32,
785        tag: Option<u16>,
786    ) -> Option<IfdEntry> {
787        let offset = self.get_data_pos(value_or_offset);
788        if offset < self.input.len() {
789            match IfdIter::try_new(
790                ifd_idx,
791                self.input.clone(),
792                self.header.to_owned(),
793                offset,
794                self.tz.clone(),
795            ) {
796                Ok(iter) => return Some(IfdEntry::IfdNew(iter.tag_code_maybe(tag))),
797                Err(e) => {
798                    tracing::warn!(?tag, ?e, "Create next/sub IFD failed");
799                }
800            }
801            // return (
802            //     tag,
803            //     // IfdEntry::Ifd {
804            //     //     idx: self.ifd_idx,
805            //     //     offset: value_or_offset,
806            //     // },
807            //     IfdEntry::IfdNew(),
808            // );
809        }
810        None
811    }
812
813    pub fn find_exif_iter(&self) -> Option<IfdIter> {
814        let endian = self.header.endian;
815        // find ExifOffset
816        for i in 0..self.entry_num {
817            let pos = self.pos + i as usize * IFD_ENTRY_SIZE;
818            let (_, tag) =
819                complete::u16::<_, nom::error::Error<_>>(endian)(&self.input[pos..]).ok()?;
820            if tag == ExifTag::ExifOffset.code() {
821                let entry_data = self.input.slice_checked(pos..pos + IFD_ENTRY_SIZE)?;
822                let (_, entry) = self.parse_tag_entry(entry_data)?;
823                match entry {
824                    IfdEntry::IfdNew(iter) => return Some(iter),
825                    IfdEntry::Entry(_) | IfdEntry::Err(_) => return None,
826                }
827            }
828        }
829        None
830    }
831
832    pub fn find_tz_offset(&self) -> Option<String> {
833        let iter = self.find_exif_iter()?;
834        let mut offset = None;
835        for entry in iter {
836            let Some(tag) = entry.0 else {
837                continue;
838            };
839            if tag.code() == ExifTag::OffsetTimeOriginal.code()
840                || tag.code() == ExifTag::OffsetTimeDigitized.code()
841            {
842                return entry.1.as_str().map(|x| x.to_owned());
843            } else if tag.code() == ExifTag::OffsetTime.code() {
844                offset = entry.1.as_str().map(|x| x.to_owned());
845            }
846        }
847
848        offset
849    }
850
851    // Assume the current ifd is GPSInfo subifd.
852    pub fn parse_gps_info(&mut self) -> Option<GPSInfo> {
853        use crate::exif::gps::{Altitude, LatRef, LonRef, Speed, SpeedUnit};
854
855        let mut latitude_ref = None;
856        let mut latitude = None;
857        let mut longitude_ref = None;
858        let mut longitude = None;
859        let mut altitude_ref = None;
860        let mut altitude_value = None;
861        let mut speed_unit = None;
862        let mut speed_value = None;
863        let mut has_data = false;
864
865        for (tag, entry) in self {
866            let Some(tag) = tag.and_then(|x| x.tag()) else {
867                continue;
868            };
869            has_data = true;
870            match tag {
871                ExifTag::GPSLatitudeRef => {
872                    latitude_ref = entry.as_char().and_then(LatRef::from_char);
873                }
874                ExifTag::GPSLongitudeRef => {
875                    longitude_ref = entry.as_char().and_then(LonRef::from_char);
876                }
877                ExifTag::GPSAltitudeRef => {
878                    altitude_ref = entry.as_u8();
879                }
880                ExifTag::GPSLatitude => {
881                    if let Some(v) = entry.as_urational_slice() {
882                        latitude = LatLng::try_from(v).ok();
883                    } else if let Some(v) = entry.as_irational_slice() {
884                        latitude = LatLng::try_from(v).ok();
885                    }
886                }
887                ExifTag::GPSLongitude => {
888                    if let Some(v) = entry.as_urational_slice() {
889                        longitude = LatLng::try_from(v).ok();
890                    } else if let Some(v) = entry.as_irational_slice() {
891                        longitude = LatLng::try_from(v).ok();
892                    }
893                }
894                ExifTag::GPSAltitude => {
895                    if let Some(v) = entry.as_urational() {
896                        altitude_value = Some(*v);
897                    } else if let Some(v) = entry.as_irational() {
898                        if let Ok(u) = URational::try_from(*v) {
899                            altitude_value = Some(u);
900                        }
901                    }
902                }
903                ExifTag::GPSSpeedRef => {
904                    speed_unit = entry.as_char().and_then(SpeedUnit::from_char);
905                }
906                ExifTag::GPSSpeed => {
907                    if let Some(v) = entry.as_urational() {
908                        speed_value = Some(*v);
909                    } else if let Some(v) = entry.as_irational() {
910                        if let Ok(u) = URational::try_from(*v) {
911                            speed_value = Some(u);
912                        }
913                    }
914                }
915                _ => (),
916            }
917        }
918
919        if !has_data {
920            tracing::warn!("GPSInfo data not found");
921            return None;
922        }
923
924        let altitude = match (altitude_ref, altitude_value) {
925            (Some(0), Some(v)) => Altitude::AboveSeaLevel(v),
926            (Some(1), Some(v)) => Altitude::BelowSeaLevel(v),
927            _ => Altitude::Unknown,
928        };
929
930        let speed = match (speed_unit, speed_value) {
931            (Some(unit), Some(value)) => Some(Speed { unit, value }),
932            _ => None,
933        };
934
935        Some(GPSInfo {
936            latitude_ref: latitude_ref.unwrap_or(LatRef::North),
937            latitude: latitude.unwrap_or_default(),
938            longitude_ref: longitude_ref.unwrap_or(LonRef::East),
939            longitude: longitude.unwrap_or_default(),
940            altitude,
941            speed,
942        })
943    }
944
945    fn clone_with_state(&self) -> IfdIter {
946        let mut it = self.clone();
947        it.index = self.index;
948        it.pos = self.pos;
949        it
950    }
951}
952
953#[derive(Debug)]
954pub(crate) enum IfdEntry {
955    IfdNew(IfdIter), // ifd index
956    Entry(EntryValue),
957    Err(EntryError),
958}
959
960impl IfdEntry {
961    pub fn as_u8(&self) -> Option<u8> {
962        if let IfdEntry::Entry(EntryValue::U8(v)) = self {
963            Some(*v)
964        } else {
965            None
966        }
967    }
968
969    pub fn as_char(&self) -> Option<char> {
970        if let IfdEntry::Entry(EntryValue::Text(s)) = self {
971            s.chars().next()
972        } else {
973            None
974        }
975    }
976
977    fn as_irational(&self) -> Option<&IRational> {
978        if let IfdEntry::Entry(EntryValue::IRational(v)) = self {
979            Some(v)
980        } else {
981            None
982        }
983    }
984
985    fn as_irational_slice(&self) -> Option<&Vec<IRational>> {
986        if let IfdEntry::Entry(EntryValue::IRationalArray(v)) = self {
987            Some(v)
988        } else {
989            None
990        }
991    }
992
993    fn as_urational(&self) -> Option<&URational> {
994        if let IfdEntry::Entry(EntryValue::URational(v)) = self {
995            Some(v)
996        } else {
997            None
998        }
999    }
1000
1001    fn as_urational_slice(&self) -> Option<&Vec<URational>> {
1002        if let IfdEntry::Entry(EntryValue::URationalArray(v)) = self {
1003            Some(v)
1004        } else {
1005            None
1006        }
1007    }
1008
1009    fn as_str(&self) -> Option<&str> {
1010        if let IfdEntry::Entry(e) = self {
1011            e.as_str()
1012        } else {
1013            None
1014        }
1015    }
1016}
1017
1018pub(crate) const SUBIFD_TAGS: &[u16] = &[ExifTag::ExifOffset.code(), ExifTag::GPSInfo.code()];
1019
1020impl Iterator for IfdIter {
1021    type Item = (Option<TagOrCode>, IfdEntry);
1022
1023    #[tracing::instrument(skip(self))]
1024    fn next(&mut self) -> Option<Self::Item> {
1025        tracing::debug!(
1026            ifd = self.ifd_idx,
1027            index = self.index,
1028            entry_num = self.entry_num,
1029            offset = format!("{:08x}", self.offset),
1030            pos = format!("{:08x}", self.pos),
1031            "next IFD entry"
1032        );
1033        if self.input.len() < self.pos + IFD_ENTRY_SIZE {
1034            return None;
1035        }
1036
1037        let endian = self.header.endian;
1038        if self.index > self.entry_num {
1039            return None;
1040        }
1041        if self.index == self.entry_num {
1042            tracing::debug!(
1043                self.ifd_idx,
1044                self.index,
1045                pos = self.pos,
1046                "try to get next ifd"
1047            );
1048            self.index += 1;
1049
1050            // next IFD offset
1051            let (_, offset) =
1052                complete::u32::<_, nom::error::Error<_>>(endian)(&self.input[self.pos..]).ok()?;
1053
1054            if offset == 0 {
1055                // IFD parsing completed
1056                tracing::debug!(?self, "IFD parsing completed");
1057                return None;
1058            }
1059
1060            return self
1061                .new_ifd_iter(self.ifd_idx + 1, offset, None)
1062                .map(|x| (None, x));
1063        }
1064
1065        let entry_data = self
1066            .input
1067            .slice_checked(self.pos..self.pos + IFD_ENTRY_SIZE)?;
1068        self.index += 1;
1069        self.pos += IFD_ENTRY_SIZE;
1070
1071        let (tag, res) = self.parse_tag_entry(entry_data)?;
1072
1073        Some((Some(tag.into()), res)) // Safe-slice
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079
1080    use crate::exif::extract_exif_with_mime;
1081    use crate::exif::input_into_iter;
1082    use crate::file::MediaMimeImage;
1083    use crate::slice::SubsliceRange;
1084    use crate::testkit::read_sample;
1085    use crate::Exif;
1086    use test_case::test_case;
1087
1088    #[test_case(
1089        "exif.jpg",
1090        "+08:00",
1091        "2023-07-09T20:36:33+08:00",
1092        MediaMimeImage::Jpeg
1093    )]
1094    #[test_case("exif-no-tz.jpg", "", "2023-07-09 20:36:33", MediaMimeImage::Jpeg)]
1095    #[test_case("broken.jpg", "-", "2014-09-21 15:51:22", MediaMimeImage::Jpeg)]
1096    #[test_case(
1097        "exif.heic",
1098        "+08:00",
1099        "2022-07-22T21:26:32+08:00",
1100        MediaMimeImage::Heic
1101    )]
1102    #[test_case(
1103        "exif.avif",
1104        "+08:00",
1105        "2022-07-22T21:26:32+08:00",
1106        MediaMimeImage::Avif
1107    )]
1108    #[test_case("tif.tif", "-", "-", MediaMimeImage::Tiff)]
1109    #[test_case(
1110        "fujifilm_x_t1_01.raf.meta",
1111        "-",
1112        "2014-01-30 12:49:13",
1113        MediaMimeImage::Raf
1114    )]
1115    fn exif_iter_tz(path: &str, tz: &str, time: &str, img_type: MediaMimeImage) {
1116        let buf = read_sample(path).unwrap();
1117        let (data, _) = extract_exif_with_mime(img_type, &buf, None).unwrap();
1118        let range = data.and_then(|x| buf.subslice_in_range(x)).unwrap();
1119        let iter = input_into_iter(bytes::Bytes::from(buf).slice(range), None).unwrap();
1120        let expect = if tz == "-" {
1121            None
1122        } else {
1123            Some(tz.to_string())
1124        };
1125        assert_eq!(iter.tz, expect);
1126        let exif: Exif = iter.into();
1127        let value = exif.get(crate::ExifTag::DateTimeOriginal);
1128        if time == "-" {
1129            assert!(value.is_none());
1130        } else {
1131            let value = value.unwrap();
1132            assert_eq!(value.to_string(), time);
1133        }
1134    }
1135
1136    #[test]
1137    fn ifd_index_constants() {
1138        use crate::IfdIndex;
1139        assert_eq!(IfdIndex::MAIN.as_usize(), 0);
1140        assert_eq!(IfdIndex::THUMBNAIL.as_usize(), 1);
1141    }
1142
1143    #[test]
1144    fn ifd_index_roundtrip_via_new_and_as_usize() {
1145        use crate::IfdIndex;
1146        for raw in [0, 1, 2, 3, 7, 99] {
1147            assert_eq!(IfdIndex::new(raw).as_usize(), raw);
1148        }
1149    }
1150
1151    #[test]
1152    fn ifd_index_equality_and_hash() {
1153        use crate::IfdIndex;
1154        use std::collections::HashSet;
1155        let mut set: HashSet<IfdIndex> = HashSet::new();
1156        set.insert(IfdIndex::MAIN);
1157        set.insert(IfdIndex::new(0)); // duplicate
1158        set.insert(IfdIndex::THUMBNAIL);
1159        assert_eq!(set.len(), 2);
1160    }
1161
1162    #[test]
1163    fn ifd_index_display_format() {
1164        use crate::IfdIndex;
1165        assert_eq!(format!("{}", IfdIndex::MAIN), "ifd0");
1166        assert_eq!(format!("{}", IfdIndex::new(7)), "ifd7");
1167    }
1168
1169    #[test]
1170    fn tag_or_code_for_known_tag_resolves_to_tag_variant() {
1171        use crate::{ExifTag, TagOrCode};
1172        let t: TagOrCode = ExifTag::Make.code().into();
1173        assert_eq!(t, TagOrCode::Tag(ExifTag::Make));
1174        assert_eq!(t.code(), ExifTag::Make.code());
1175    }
1176
1177    #[test]
1178    fn tag_or_code_for_unknown_tag_resolves_to_unknown_variant() {
1179        use crate::TagOrCode;
1180        let t: TagOrCode = 0xffff_u16.into();
1181        assert_eq!(t, TagOrCode::Unknown(0xffff));
1182        assert_eq!(t.code(), 0xffff);
1183    }
1184
1185    #[test]
1186    fn exif_entry_pub_fields_construct_and_destructure() {
1187        use crate::{EntryValue, ExifEntry, ExifTag, IfdIndex, TagOrCode};
1188        let val = EntryValue::Text("vivo X90 Pro+".into());
1189        let e = ExifEntry {
1190            ifd: IfdIndex::MAIN,
1191            tag: TagOrCode::Tag(ExifTag::Model),
1192            value: &val,
1193        };
1194        // Pub fields: just match.
1195        let ExifEntry { ifd, tag, value } = e;
1196        assert_eq!(ifd, IfdIndex::MAIN);
1197        assert_eq!(tag.code(), ExifTag::Model.code());
1198        assert!(matches!(value, EntryValue::Text(_)));
1199        // Copy works because EntryValue is borrowed.
1200        let _e2 = e;
1201        let _e3 = e;
1202    }
1203
1204    #[test]
1205    fn exif_iter_entry_value_xor_error_invariant() {
1206        use crate::{MediaParser, MediaSource};
1207        let mut parser = MediaParser::new();
1208        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1209        for entry in parser.parse_exif(ms).unwrap() {
1210            // Exactly one of value / error is Some.
1211            let has_v = entry.value().is_some();
1212            let has_e = entry.error().is_some();
1213            assert!(has_v ^ has_e, "entry must be value xor error");
1214            // result() agrees with value()/error().
1215            match entry.result() {
1216                Ok(v) => assert_eq!(Some(v), entry.value()),
1217                Err(e) => assert_eq!(Some(e), entry.error()),
1218            }
1219        }
1220    }
1221
1222    #[test]
1223    fn exif_iter_entry_into_result_consumes_self() {
1224        use crate::{MediaParser, MediaSource};
1225        let mut parser = MediaParser::new();
1226        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1227        let mut count_ok = 0usize;
1228        for entry in parser.parse_exif(ms).unwrap() {
1229            // into_result consumes; once consumed, we can't call any other
1230            // method (the entry is gone). This is the spec's panic-free
1231            // replacement for v2's take_result.
1232            if entry.into_result().is_ok() {
1233                count_ok += 1;
1234            }
1235        }
1236        assert!(count_ok > 0);
1237    }
1238
1239    #[test]
1240    fn exif_iter_entry_tag_returns_tag_or_code() {
1241        use crate::{ExifTag, MediaParser, MediaSource, TagOrCode};
1242        let mut parser = MediaParser::new();
1243        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1244        let make_present = parser
1245            .parse_exif(ms)
1246            .unwrap()
1247            .any(|e| matches!(e.tag(), TagOrCode::Tag(ExifTag::Make)));
1248        assert!(make_present);
1249    }
1250
1251    #[test]
1252    fn exif_iter_rewind_resets_iteration_state() {
1253        use crate::{MediaParser, MediaSource};
1254        let mut parser = MediaParser::new();
1255        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1256        let mut iter = parser.parse_exif(ms).unwrap();
1257        let first_count = iter.by_ref().count();
1258        assert!(first_count > 0);
1259        // Already exhausted.
1260        assert_eq!(iter.by_ref().count(), 0);
1261        iter.rewind();
1262        let after_rewind = iter.count();
1263        assert_eq!(first_count, after_rewind);
1264    }
1265
1266    #[test]
1267    fn exif_iter_clone_rewound_yields_independent_full_iter() {
1268        use crate::{MediaParser, MediaSource};
1269        let mut parser = MediaParser::new();
1270        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1271        let mut iter = parser.parse_exif(ms).unwrap();
1272        let _consumed = iter.by_ref().take(2).count();
1273        let cloned = iter.clone_rewound();
1274        // cloned starts from entry 0 even though `iter` consumed 2 entries.
1275        let cloned_total = cloned.count();
1276        let remaining = iter.count();
1277        assert!(cloned_total > remaining);
1278    }
1279
1280    #[test]
1281    fn exif_iter_parse_gps_returns_option_no_iteration_advance() {
1282        use crate::{MediaParser, MediaSource};
1283        let mut parser = MediaParser::new();
1284        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
1285        let iter = parser.parse_exif(ms).unwrap();
1286        let gps = iter.parse_gps().unwrap();
1287        assert!(gps.is_some());
1288        // parse_gps doesn't drive the outer iterator.
1289        let count = iter.count();
1290        assert!(count > 0);
1291    }
1292
1293    // Regression test for https://github.com/mindeng/nom-exif/issues/50:
1294    // GPS sub-IFDs whose first entry is GPSVersionID (tag 0x0000), as emitted
1295    // by Sony A7C2 HIF files. A previous defensive `tag == 0` short-circuit
1296    // in `parse_tag_entry` aborted iteration on that entry and discarded the
1297    // whole sub-IFD. This builds the minimal little-endian TIFF that triggers
1298    // it: IFD0 → GPSInfo → GPS sub-IFD with GPSVersionID up front.
1299    #[test]
1300    fn gps_subifd_first_entry_is_gpsversion_id_issue_50() {
1301        use crate::exif::exif_iter::input_into_iter;
1302        #[rustfmt::skip]
1303        let tiff: &[u8] = &[
1304            // TIFF header: little-endian, IFD0 at 0x08
1305            b'I', b'I', 0x2a, 0x00,
1306            0x08, 0x00, 0x00, 0x00,
1307
1308            // IFD0 @ 0x08: 1 entry → GPSInfo pointer to GPS sub-IFD @ 0x1a
1309            0x01, 0x00,
1310            0x25, 0x88, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00,
1311            0x1a, 0x00, 0x00, 0x00,
1312            0x00, 0x00, 0x00, 0x00,                         // no IFD1
1313
1314            // GPS sub-IFD @ 0x1a: 5 entries
1315            0x05, 0x00,
1316            // [0] GPSVersionID tag=0, BYTE×4, inline [2,3,0,0]
1317            0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00,
1318            0x02, 0x03, 0x00, 0x00,
1319            // [1] GPSLatitudeRef tag=1, ASCII×2 "N\0"
1320            0x01, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00,
1321            b'N', 0x00, 0x00, 0x00,
1322            // [2] GPSLatitude tag=2, RATIONAL×3 @ 0x5c
1323            0x02, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, 0x00,
1324            0x5c, 0x00, 0x00, 0x00,
1325            // [3] GPSLongitudeRef tag=3, ASCII×2 "E\0"
1326            0x03, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00,
1327            b'E', 0x00, 0x00, 0x00,
1328            // [4] GPSLongitude tag=4, RATIONAL×3 @ 0x74
1329            0x04, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, 0x00,
1330            0x74, 0x00, 0x00, 0x00,
1331            0x00, 0x00, 0x00, 0x00,                         // no next sub-IFD
1332
1333            // GPSLatitude rational data @ 0x5c: 36/1, 0/1, 0/1
1334            0x24, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1335            0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1336            0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1337
1338            // GPSLongitude rational data @ 0x74: 120/1, 0/1, 0/1
1339            0x78, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1340            0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1341            0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
1342        ];
1343
1344        let iter = input_into_iter(tiff.to_vec(), None).unwrap();
1345
1346        // parse_gps recovers the full sub-IFD despite GPSVersionID being first.
1347        let gps = iter
1348            .parse_gps()
1349            .expect("parse_gps must succeed")
1350            .expect("GPS sub-IFD with GPSVersionID first must yield GPSInfo");
1351        assert_eq!(gps.latitude_decimal(), Some(36.0));
1352        assert_eq!(gps.longitude_decimal(), Some(120.0));
1353
1354        // GPSVersionID itself is also surfaced through normal iteration —
1355        // tag 0 is no longer dropped inside the GPS sub-IFD.
1356        let tags: Vec<u16> = iter.map(|e| e.tag().code()).collect();
1357        assert!(
1358            tags.contains(&crate::ExifTag::GPSVersionID.code()),
1359            "GPSVersionID (tag 0) should be visible to iterators; got {tags:?}"
1360        );
1361    }
1362}