Skip to main content

nom_exif/exif/
exif_exif.rs

1use std::fmt::Debug;
2
3use nom::{
4    branch::alt, bytes::streaming::tag, combinator, number::Endianness, IResult, Needed, Parser,
5};
6
7use crate::{
8    EntryValue, ExifEntry, ExifEntryRef, ExifIter, ExifTag, GPSInfo, IfdIndex, IfdKind, TagOrCode,
9};
10
11use super::ifd::ParsedImageFileDirectory;
12
13/// Represents parsed Exif information, can be converted from an [`ExifIter`]
14/// like this: `let exif: Exif = iter.into()`.
15#[derive(Clone, Debug, PartialEq)]
16pub struct Exif {
17    ifds: Vec<ParsedImageFileDirectory>,
18    gps_info: Option<GPSInfo>,
19    errors: Vec<(IfdIndex, TagOrCode, crate::EntryError)>,
20    has_embedded_track: bool,
21}
22
23impl Exif {
24    fn new(gps_info: Option<GPSInfo>, has_embedded_track: bool) -> Exif {
25        Exif {
26            ifds: Vec::new(),
27            gps_info,
28            errors: Vec::new(),
29            has_embedded_track,
30        }
31    }
32
33    /// Get entry value for the specified `tag` in ifd0 (the main image).
34    ///
35    /// *Note*:
36    ///
37    /// - The parsing error related to this tag won't be reported by this
38    ///   method. Either this entry is not parsed successfully, or the tag does
39    ///   not exist in the input data, this method will return None.
40    ///
41    /// - If you want to handle parsing error, please consider to use
42    ///   [`ExifIter`].
43    ///
44    /// - If you have any custom defined tag which does not exist in
45    ///   [`ExifTag`], you can always get the entry value by a raw tag code,
46    ///   see [`Self::get_by_code`].
47    ///
48    ///   ## Example
49    ///
50    ///   ```rust
51    ///   use nom_exif::*;
52    ///
53    ///   fn main() -> Result<()> {
54    ///       let mut parser = MediaParser::new();
55    ///       
56    ///       let ms = MediaSource::open("./testdata/exif.jpg")?;
57    ///       assert_eq!(ms.kind(), MediaKind::Image);
58    ///       let iter = parser.parse_exif(ms)?;
59    ///       let exif: Exif = iter.into();
60    ///
61    ///       assert_eq!(exif.get(ExifTag::Model).unwrap(), &"vivo X90 Pro+".into());
62    ///       Ok(())
63    ///   }
64    pub fn get(&self, tag: ExifTag) -> Option<&EntryValue> {
65        self.get_in(IfdIndex::MAIN, tag)
66    }
67
68    /// Get entry value for the specified `tag` in the specified `ifd`.
69    ///
70    /// *Note*:
71    ///
72    /// - The parsing error related to this tag won't be reported by this
73    ///   method. Either this entry is not parsed successfully, or the tag does
74    ///   not exist in the input data, this method will return None. Use
75    ///   [`Self::errors`] to inspect per-entry errors.
76    ///
77    /// - For raw tag codes (e.g. unrecognized tags), use [`Self::get_by_code`].
78    ///
79    ///   ## Example
80    ///
81    ///   ```rust
82    ///   use nom_exif::*;
83    ///
84    ///   fn main() -> Result<()> {
85    ///       let mut parser = MediaParser::new();
86    ///       let ms = MediaSource::open("./testdata/exif.jpg")?;
87    ///       let iter = parser.parse_exif(ms)?;
88    ///       let exif: Exif = iter.into();
89    ///
90    ///       assert_eq!(exif.get_in(IfdIndex::MAIN, ExifTag::Model).unwrap(),
91    ///                  &"vivo X90 Pro+".into());
92    ///       Ok(())
93    ///   }
94    ///   ```
95    pub fn get_in(&self, ifd: IfdIndex, tag: ExifTag) -> Option<&EntryValue> {
96        match tag.namespace() {
97            // Contested code: look only where this tag is actually defined,
98            // otherwise an absent tag would pick up its namesake's value.
99            Some(kind) => self.get_by_code_in(ifd, kind, tag.code()),
100            None => self.get_by_code(ifd, tag.code()),
101        }
102    }
103
104    /// Get entry value for the specified raw `code` in the specified `ifd`.
105    /// Used for tags not in the recognized [`ExifTag`] enum.
106    ///
107    /// A bare code cannot say *which* IFD namespace it belongs to. This scans
108    /// `Tiff`, `Exif`, `Gps`, `Interop` in that order and returns the first
109    /// hit; use [`Self::get_by_code_in`] when the namespace is known.
110    pub fn get_by_code(&self, ifd: IfdIndex, code: u16) -> Option<&EntryValue> {
111        self.ifds.get(ifd.as_usize()).and_then(|d| d.get_any(code))
112    }
113
114    /// Get entry value for a raw `code` within a specific IFD namespace.
115    ///
116    /// Unlike [`Self::get_by_code`] this is unambiguous — `(MAIN, Gps,
117    /// 0x000b)` is `GPSDOP` and cannot return IFD0's `ProcessingSoftware`.
118    pub fn get_by_code_in(&self, ifd: IfdIndex, kind: IfdKind, code: u16) -> Option<&EntryValue> {
119        self.ifds
120            .get(ifd.as_usize())
121            .and_then(|d| d.get(kind, code))
122    }
123
124    /// Iterate every parsed entry in every IFD, carrying the IFD *namespace*
125    /// each entry came from.
126    ///
127    /// Order is: IFD0 entries first (in `HashMap` order — not stable), then
128    /// IFD1, etc. Filter with
129    /// `.entries().filter(|e| e.ifd_kind() == IfdKind::Gps)`.
130    ///
131    /// Prefer this over [`Self::iter`], which cannot distinguish a GPS tag
132    /// from an IFD0 tag that shares its code.
133    pub fn entries(&self) -> impl Iterator<Item = ExifEntryRef<'_>> {
134        self.ifds.iter().enumerate().flat_map(|(idx, dir)| {
135            let ifd = IfdIndex::new(idx);
136            dir.iter().map(move |(kind, code, value)| {
137                ExifEntryRef::new(ifd, kind, TagOrCode::from_code_in(kind, code), value)
138            })
139        })
140    }
141
142    /// Iterate every parsed entry in every IFD.
143    ///
144    /// Order is: IFD0 entries first (in `HashMap` order — not stable), then
145    /// IFD1, etc. Filter by IFD with `.iter().filter(|e| e.ifd == IfdIndex::MAIN)`.
146    #[deprecated(
147        since = "3.8.0",
148        note = "use `entries()`: `ExifEntry` has no room for the IFD namespace, so tags that share a code across IFDs (e.g. 0x000b = ProcessingSoftware in IFD0, GPSDOP in the GPS IFD) are indistinguishable here"
149    )]
150    pub fn iter(&self) -> impl Iterator<Item = ExifEntry<'_>> {
151        self.ifds.iter().enumerate().flat_map(|(idx, dir)| {
152            let ifd = IfdIndex::new(idx);
153            dir.iter().map(move |(kind, code, value)| ExifEntry {
154                ifd,
155                tag: TagOrCode::from_code_in(kind, code),
156                value,
157            })
158        })
159    }
160
161    /// Get parsed GPS information.
162    ///
163    /// Returns `None` if the source had no `GPSInfo` IFD or if its parse
164    /// failed (failures land in [`Self::errors`]).
165    pub fn gps_info(&self) -> Option<&GPSInfo> {
166        self.gps_info.as_ref()
167    }
168
169    /// Per-entry errors collected during `From<ExifIter>` conversion. Each
170    /// tuple is `(ifd, tag, error)`. Empty slice if the parse was clean.
171    pub fn errors(&self) -> &[(IfdIndex, TagOrCode, crate::EntryError)] {
172        &self.errors
173    }
174
175    /// Whether the source file is known to embed a paired media track
176    /// that this parse path did *not* surface — a Pixel/Google or Samsung
177    /// Galaxy Motion Photo (JPEG with `GCamera:MotionPhoto` XMP and an
178    /// MP4 trailer). Use [`crate::MediaParser::parse_track`] on the same
179    /// source to extract the embedded track.
180    ///
181    /// **Content-detected, not MIME-guessed**: returns `true` only when
182    /// `parse_exif` observed a concrete content signal
183    /// (`GCamera:MotionPhoto="1"` plus a `Container:Directory` /
184    /// `MotionPhotoOffset` / `MicroVideoOffset`). A plain JPEG or HEIC
185    /// without such signals returns `false`.
186    ///
187    /// **Coverage**: Pixel/Google Motion Photos and Samsung Galaxy
188    /// Motion Photos that use the Adobe XMP Container directory format
189    /// (JPEG variants).
190    pub fn has_embedded_track(&self) -> bool {
191        self.has_embedded_track
192    }
193
194    /// Deprecated alias for [`Self::has_embedded_track`].
195    #[deprecated(
196        since = "3.1.0",
197        note = "renamed to `has_embedded_track` to reflect the actual semantics (paired track hint, not arbitrary embedded media)"
198    )]
199    pub fn has_embedded_media(&self) -> bool {
200        self.has_embedded_track()
201    }
202
203    fn put_value(&mut self, ifd: usize, kind: IfdKind, code: u16, v: EntryValue) {
204        while self.ifds.len() < ifd + 1 {
205            self.ifds.push(ParsedImageFileDirectory::new());
206        }
207        self.ifds[ifd].put(kind, code, v);
208    }
209}
210
211impl From<ExifIter> for Exif {
212    fn from(iter: ExifIter) -> Self {
213        let gps_info = iter.parse_gps().ok().flatten();
214        let has_embedded_track = iter.has_embedded_track();
215        let mut exif = Exif::new(gps_info, has_embedded_track);
216
217        for entry in iter {
218            let ifd = entry.ifd();
219            let kind = entry.ifd_kind();
220            let tag = entry.tag();
221            let code = tag.code();
222            match entry.into_result() {
223                Ok(v) => exif.put_value(ifd.as_usize(), kind, code, v),
224                Err(e) => exif.errors.push((ifd, tag, e)),
225            }
226        }
227
228        exif
229    }
230}
231
232pub(crate) const TIFF_HEADER_LEN: usize = 8;
233
234/// TIFF Header
235#[derive(Clone, PartialEq, Eq)]
236pub(crate) struct TiffHeader {
237    pub endian: Endianness,
238    pub ifd0_offset: u32,
239}
240
241impl Default for TiffHeader {
242    fn default() -> Self {
243        Self {
244            endian: Endianness::Big,
245            ifd0_offset: 0,
246        }
247    }
248}
249
250impl Debug for TiffHeader {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        let endian_str = match self.endian {
253            Endianness::Big => "Big",
254            Endianness::Little => "Little",
255            Endianness::Native => "Native",
256        };
257        f.debug_struct("TiffHeader")
258            .field("endian", &endian_str)
259            .field("ifd0_offset", &format!("{:#x}", self.ifd0_offset))
260            .finish()
261    }
262}
263
264pub(crate) const IFD_ENTRY_SIZE: usize = 12;
265
266impl TiffHeader {
267    pub fn parse(input: &[u8]) -> IResult<&[u8], TiffHeader> {
268        use nom::number::streaming::{u16, u32};
269        let (remain, endian) = TiffHeader::parse_endian(input)?;
270        let (_, (_, offset)) = (
271            combinator::verify(u16(endian), |magic| *magic == 0x2a),
272            u32(endian),
273        )
274            .parse(remain)?;
275
276        let header = Self {
277            endian,
278            ifd0_offset: offset,
279        };
280
281        Ok((remain, header))
282    }
283
284    pub fn parse_ifd_entry_num(input: &[u8], endian: Endianness) -> IResult<&[u8], u16> {
285        let (remain, num) = nom::number::streaming::u16(endian)(input)?; // Safe-slice
286        if num == 0 {
287            return Ok((remain, 0));
288        }
289
290        // 12 bytes per entry
291        let size = (num as usize)
292            .checked_mul(IFD_ENTRY_SIZE)
293            .expect("should fit");
294
295        if size > remain.len() {
296            return Err(nom::Err::Incomplete(Needed::new(size - remain.len())));
297        }
298
299        Ok((remain, num))
300    }
301
302    // pub fn first_ifd<'a>(&self, input: &'a [u8], tag_ids: HashSet<u16>) -> IResult<&'a [u8], IFD> {
303    //     // ifd0_offset starts from the beginning of Header, so we should
304    //     // subtract the header size, which is 8
305    //     let offset = self.ifd0_offset - 8;
306
307    //     // skip to offset
308    //     let (_, remain) = take(offset)(input)?;
309
310    //     IFD::parse(remain, self.endian, tag_ids)
311    // }
312
313    fn parse_endian(input: &[u8]) -> IResult<&[u8], Endianness> {
314        combinator::map(alt((tag("MM"), tag("II"))), |endian_marker| {
315            if endian_marker == b"MM" {
316                Endianness::Big
317            } else {
318                Endianness::Little
319            }
320        })
321        .parse(input)
322    }
323}
324
325pub(crate) fn check_exif_header(data: &[u8]) -> Result<bool, nom::Err<nom::error::Error<&[u8]>>> {
326    tag::<_, _, nom::error::Error<_>>(EXIF_IDENT)(data).map(|_| true)
327}
328
329pub(crate) fn check_exif_header2(i: &[u8]) -> IResult<&[u8], ()> {
330    let (remain, _) = (
331        nom::number::complete::be_u32,
332        nom::bytes::complete::tag(EXIF_IDENT),
333    )
334        .parse(i)?;
335    Ok((remain, ()))
336}
337
338pub(crate) const EXIF_IDENT: &str = "Exif\0\0";
339
340#[cfg(test)]
341mod tests {
342    use std::io::Read;
343    use std::thread;
344
345    use test_case::test_case;
346
347    use crate::exif::input_into_iter;
348    use crate::jpeg::extract_exif_data;
349    use crate::slice::SubsliceRange;
350    use crate::testkit::{open_sample, read_sample};
351    use crate::ExifIterEntry;
352
353    use super::*;
354
355    #[test]
356    fn header() {
357        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
358
359        let buf = [0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00];
360
361        let (_, header) = TiffHeader::parse(&buf).unwrap();
362        assert_eq!(
363            header,
364            TiffHeader {
365                endian: Endianness::Big,
366                ifd0_offset: 8,
367            }
368        );
369    }
370
371    #[test_case("exif.jpg")]
372    fn exif_iter_gps(path: &str) {
373        let buf = read_sample(path).unwrap();
374        let (_, data) = extract_exif_data(&buf).unwrap();
375        let range = data.and_then(|x| buf.subslice_in_range(x)).unwrap();
376        let data = bytes::Bytes::from(buf).slice(range);
377        let iter = input_into_iter(data, None).unwrap();
378        let gps = iter.parse_gps().unwrap().unwrap();
379        assert_eq!(gps.to_iso6709(), "+22.53113+114.02148/");
380    }
381
382    #[test_case("exif.jpg")]
383    fn clone_exif_iter_to_thread(path: &str) {
384        let buf = read_sample(path).unwrap();
385        let (_, data) = extract_exif_data(&buf).unwrap();
386        let range = data.and_then(|x| buf.subslice_in_range(x)).unwrap();
387        let data = bytes::Bytes::from(buf).slice(range);
388        let iter = input_into_iter(data, None).unwrap();
389        let iter2 = iter.clone();
390
391        let mut expect = String::new();
392        open_sample(&format!("{path}.txt"))
393            .unwrap()
394            .read_to_string(&mut expect)
395            .unwrap();
396
397        let jh = thread::spawn(move || iter_to_str(iter2));
398
399        let result = iter_to_str(iter);
400
401        // Uncomment to regenerate the golden file:
402        // use std::io::Write;
403        // open_sample_w(&format!("{path}.txt"))
404        //     .unwrap()
405        //     .write_all(result.as_bytes())
406        //     .unwrap();
407
408        assert_eq!(result.trim(), expect.trim());
409        assert_eq!(jh.join().unwrap().trim(), expect.trim());
410    }
411
412    fn iter_to_str(it: impl Iterator<Item = ExifIterEntry>) -> String {
413        let ss = it
414            .map(|x| {
415                format!(
416                    "{}/{:<7}.{:<32} » {}",
417                    x.ifd(),
418                    x.ifd_kind(),
419                    match x.tag() {
420                        crate::TagOrCode::Tag(t) => t.to_string(),
421                        crate::TagOrCode::Unknown(c) => format!("Unknown(0x{c:04x})"),
422                    },
423                    x.result()
424                        .map(|v| v.to_string())
425                        .map_err(|e| e.to_string())
426                        .unwrap_or_else(|s| s)
427                )
428            })
429            .collect::<Vec<String>>();
430        ss.join("\n")
431    }
432
433    #[test]
434    fn p5_baseline_exif_jpg_dump_snapshot() {
435        // Lock down the post-refactor invariant: parsing testdata/exif.jpg
436        // through the public API yields the same set of (ifd, tag, value)
437        // triples before and after every P5 task. Captured as a sorted
438        // formatted string so the assertion is a single Vec compare.
439        use crate::{MediaParser, MediaSource};
440        let mut parser = MediaParser::new();
441        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
442        let iter = parser.parse_exif(ms).unwrap();
443
444        let mut entries: Vec<String> = iter
445            .map(|e| {
446                let val = match e.result() {
447                    Ok(v) => format!("{v}"),
448                    Err(err) => format!("<err:{err}>"),
449                };
450                format!("{}.0x{:04x}={val}", e.ifd(), e.tag().code())
451            })
452            .collect();
453        entries.sort();
454        assert!(
455            entries.len() > 5,
456            "expected >5 entries, got {}",
457            entries.len()
458        );
459        assert!(
460            entries.iter().any(|s| s.contains("0x010f")),
461            "expected Make tag (0x010f) in snapshot, got {entries:?}"
462        );
463    }
464
465    #[test]
466    fn exif_get_in_main_routes_via_ifd_index() {
467        use crate::{ExifTag, IfdIndex, MediaParser, MediaSource};
468        let mut parser = MediaParser::new();
469        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
470        let iter = parser.parse_exif(ms).unwrap();
471        let exif: Exif = iter.into();
472
473        // Main image: same as exif.get(...)
474        let v_via_get = exif.get(ExifTag::Model);
475        let v_via_get_in = exif.get_in(IfdIndex::MAIN, ExifTag::Model);
476        assert_eq!(v_via_get, v_via_get_in);
477        assert!(
478            v_via_get.is_some(),
479            "Model tag expected in testdata/exif.jpg"
480        );
481    }
482
483    #[test]
484    fn exif_get_by_code_finds_unrecognized_or_recognized_tag() {
485        use crate::{ExifTag, IfdIndex, MediaParser, MediaSource};
486        let mut parser = MediaParser::new();
487        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
488        let iter = parser.parse_exif(ms).unwrap();
489        let exif: Exif = iter.into();
490        // Make = 0x010f
491        let v = exif.get_by_code(IfdIndex::MAIN, ExifTag::Make.code());
492        assert!(v.is_some());
493    }
494
495    #[test]
496    fn exif_gps_info_returns_borrow_no_result_wrap() {
497        use crate::{MediaParser, MediaSource};
498        let mut parser = MediaParser::new();
499        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
500        let iter = parser.parse_exif(ms).unwrap();
501        let exif: Exif = iter.into();
502        // gps_info returns Option<&GPSInfo> directly (no Result wrap).
503        let g: Option<&crate::GPSInfo> = exif.gps_info();
504        assert!(g.is_some(), "testdata/exif.jpg has GPS info");
505        assert_eq!(g.unwrap().to_iso6709(), "+22.53113+114.02148/");
506    }
507
508    #[test]
509    fn exif_iter_yields_main_ifd_entries() {
510        use crate::{ExifTag, IfdIndex, MediaParser, MediaSource};
511        let mut parser = MediaParser::new();
512        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
513        let iter = parser.parse_exif(ms).unwrap();
514        let exif: Exif = iter.into();
515
516        #[allow(deprecated)]
517        let main_count = exif.iter().filter(|e| e.ifd == IfdIndex::MAIN).count();
518        assert!(
519            main_count > 1,
520            "expected >1 entries in main IFD, got {main_count}"
521        );
522
523        // Ensure each entry is well-formed. `ExifEntry` carries no namespace,
524        // so a code-only lookup can only be asserted to *find* something —
525        // two namespaces under one IfdIndex may share a code (0x0001 is
526        // GPSLatitudeRef and InteropIndex in this very file).
527        #[allow(deprecated)]
528        for entry in exif.iter() {
529            let _: &crate::EntryValue = entry.value;
530            assert!(
531                exif.get_by_code(entry.ifd, entry.tag.code()).is_some(),
532                "every iterated code should resolve to some entry"
533            );
534        }
535
536        // Specifically: Model entry is present and matches get().
537        #[allow(deprecated)]
538        let model_via_iter = exif
539            .iter()
540            .find(|e| e.tag.tag() == Some(ExifTag::Model))
541            .map(|e| e.value);
542        assert_eq!(model_via_iter, exif.get(ExifTag::Model));
543    }
544
545    #[test]
546    fn entries_round_trip_through_get_by_code_in() {
547        use crate::{MediaParser, MediaSource};
548        let mut parser = MediaParser::new();
549        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
550        let iter = parser.parse_exif(ms).unwrap();
551        let exif: Exif = iter.into();
552
553        // Unlike the code-only lookup, (ifd, kind, code) is a total key: every
554        // entry must map back to itself.
555        for e in exif.entries() {
556            assert_eq!(
557                exif.get_by_code_in(e.ifd(), e.ifd_kind(), e.tag().code()),
558                Some(e.value()),
559                "entry {:?} in {}/{} should round-trip",
560                e.tag(),
561                e.ifd(),
562                e.ifd_kind()
563            );
564        }
565    }
566
567    #[test]
568    fn exif_errors_is_empty_for_clean_fixture() {
569        use crate::{MediaParser, MediaSource};
570        let mut parser = MediaParser::new();
571        let ms = MediaSource::open("testdata/exif.jpg").unwrap();
572        let iter = parser.parse_exif(ms).unwrap();
573        let exif: Exif = iter.into();
574        // Clean fixture: errors() returns empty slice but the method exists
575        // and the type matches the spec.
576        let errs: &[(crate::IfdIndex, crate::TagOrCode, crate::EntryError)] = exif.errors();
577        assert!(
578            errs.is_empty(),
579            "exif.jpg has no per-entry errors, got {errs:?}"
580        );
581    }
582
583    #[test]
584    fn exif_errors_captures_per_entry_errors_for_broken_fixture() {
585        use crate::{MediaParser, MediaSource};
586        let mut parser = MediaParser::new();
587        let ms = MediaSource::open("testdata/broken.jpg").unwrap();
588        let iter = parser.parse_exif(ms).unwrap();
589        let exif: Exif = iter.into();
590        // broken.jpg has malformed IFD entries — at least one should land in errors().
591        // (Note: if broken.jpg's particular breakage doesn't surface as a per-entry
592        // error, this assertion may be `>= 0`. Adjust as needed.)
593        let _ = exif.errors();
594    }
595
596    #[test]
597    fn has_embedded_track_true_for_pixel_motion_photo() {
598        use crate::{MediaParser, MediaSource};
599        let mut parser = MediaParser::new();
600        let ms = MediaSource::open("testdata/motion_photo_pixel_synth.jpg").unwrap();
601        let iter = parser.parse_exif(ms).unwrap();
602        assert!(
603            iter.has_embedded_track(),
604            "Pixel-style Motion Photo carries an embedded MP4 track"
605        );
606        let exif: Exif = iter.into();
607        assert!(exif.has_embedded_track(), "flag survives From<ExifIter>");
608    }
609
610    #[test]
611    fn has_embedded_track_false_for_plain_jpeg_and_heic() {
612        use crate::{MediaParser, MediaSource};
613        for path in ["testdata/exif.jpg", "testdata/exif.heic"] {
614            let mut parser = MediaParser::new();
615            let iter = parser.parse_exif(MediaSource::open(path).unwrap()).unwrap();
616            assert!(
617                !iter.has_embedded_track(),
618                "{path} has no Motion Photo / paired track signal"
619            );
620            let exif: Exif = iter.into();
621            assert!(!exif.has_embedded_track());
622        }
623    }
624
625    #[test]
626    #[allow(deprecated)]
627    fn deprecated_has_embedded_media_still_works() {
628        use crate::{MediaParser, MediaSource};
629        let mut parser = MediaParser::new();
630        let ms = MediaSource::open("testdata/motion_photo_pixel_synth.jpg").unwrap();
631        let iter = parser.parse_exif(ms).unwrap();
632        // Deprecated alias must still forward to the new method.
633        assert_eq!(iter.has_embedded_media(), iter.has_embedded_track());
634        let exif: Exif = iter.into();
635        assert_eq!(exif.has_embedded_media(), exif.has_embedded_track());
636    }
637
638    /// End-to-end: `has_embedded_track == true` ⇒ `parse_track` extracts a
639    /// real `TrackInfo` from the same source. This locks the v3.1 contract
640    /// for Pixel/Google Motion Photo JPEGs.
641    #[test]
642    fn parse_track_extracts_motion_photo_trailer() {
643        use crate::{MediaParser, MediaSource, TrackInfoTag};
644        let path = "testdata/motion_photo_pixel_synth.jpg";
645
646        let mut p1 = MediaParser::new();
647        let iter = p1.parse_exif(MediaSource::open(path).unwrap()).unwrap();
648        assert!(iter.has_embedded_track());
649
650        let mut p2 = MediaParser::new();
651        let track = p2
652            .parse_track(MediaSource::open(path).unwrap())
653            .expect("parse_track must extract the trailer MP4");
654        assert!(
655            track.get(TrackInfoTag::Width).is_some() || track.get(TrackInfoTag::Height).is_some(),
656            "trailer should yield at least one geometry tag"
657        );
658    }
659
660    /// Plain JPEGs (no Motion Photo XMP) must keep returning TrackNotFound.
661    #[test]
662    fn parse_track_on_plain_jpeg_returns_track_not_found() {
663        use crate::{Error, MediaParser, MediaSource};
664        let mut parser = MediaParser::new();
665        let err = parser
666            .parse_track(MediaSource::open("testdata/exif.jpg").unwrap())
667            .unwrap_err();
668        assert!(
669            matches!(err, Error::TrackNotFound),
670            "expected TrackNotFound, got {err:?}"
671        );
672    }
673}