Skip to main content

zenmeta/
apple.rs

1//! Apple iOS MakerNote HDR gain-map metadata extraction.
2//!
3//! Apple HEIC/JPEG captures encode their HDR gain-map *headroom* inside the
4//! EXIF MakerNote (TIFF tag `0x927C`), **not** in XMP — the embedded gain-map
5//! image's XMP carries only `HDRGainMapVersion`. The headroom is derived from
6//! two Apple MakerNote tags, per exiftool's `Image::ExifTool::Apple`:
7//!
8//! | Tag      | Name          | Type          | Role             |
9//! |----------|---------------|---------------|------------------|
10//! | `0x0021` | `HDRHeadroom` | `rational64s` | "maker33"        |
11//! | `0x0030` | `HDRGain`     | `rational64s` | "maker48"        |
12//! | `0x000A` | `HDRImageType`| `int32s`      | 3 = HDR, 4 = SDR |
13//!
14//! The headroom (in stops, i.e. log2 luminance ratio) is computed from
15//! `maker33` and `maker48` with the community/Apple-derived piecewise formula
16//! (see [`AppleHdrInfo::headroom_stops`]). The exact constants are not in a
17//! published Apple specification; final fidelity is validated by the gain-map
18//! round-trip test, not asserted to be bit-exact here.
19//!
20//! References:
21//! - exiftool `lib/Image/ExifTool/Apple.pm`
22//! - <https://juniperphoton.substack.com/p/decoding-some-hidden-magic-of-makerapple>
23//! - <https://photoinvestigator.co/blog/the-mystery-of-maker-apple-metadata/>
24//!
25//! This module is pure byte parsing: `no_std` + `alloc`, no transcendental
26//! math, zero new dependencies. The IFD-walk mechanics mirror the proven
27//! reader in `zenraw::apple`, with the corrected exiftool tag IDs.
28
29#[cfg(test)]
30use alloc::vec::Vec;
31
32use zencodec::GainMapParams as GainMapMetadata;
33
34/// Apple MakerNote tag IDs (per exiftool `Apple.pm`).
35pub mod tags {
36    /// `HDRImageType`: 3 = HDR Image, 4 = Original (SDR) Image.
37    pub const HDR_IMAGE_TYPE: u16 = 0x000A;
38    /// `HDRHeadroom` (`rational64s`) — "maker33" in community notation.
39    pub const HDR_HEADROOM: u16 = 0x0021;
40    /// `HDRGain` (`rational64s`) — "maker48" in community notation.
41    pub const HDR_GAIN: u16 = 0x0030;
42}
43
44/// Standard EXIF/TIFF tag for the Exif sub-IFD pointer.
45const TIFF_TAG_EXIF_IFD: u16 = 0x8769;
46/// Standard EXIF/TIFF tag for the MakerNote (UNDEFINED blob).
47const TIFF_TAG_MAKERNOTE: u16 = 0x927C;
48
49/// HDR information recovered from an Apple MakerNote.
50#[derive(Clone, Copy, Debug, PartialEq, Default)]
51pub struct AppleHdrInfo {
52    /// `0x21 HDRHeadroom` ("maker33"). `None` when the tag is absent — which
53    /// means there is no Apple HDR gain-map signal at all.
54    pub hdr_headroom: Option<f64>,
55    /// `0x30 HDRGain` ("maker48"). Defaults to `0.0` when the tag is absent
56    /// (Apple omits it on many captures; the formula treats absent as zero).
57    pub hdr_gain: f64,
58    /// `0x0a HDRImageType`: `Some(3)` = HDR Image, `Some(4)` = Original/SDR.
59    pub hdr_image_type: Option<i32>,
60}
61
62impl AppleHdrInfo {
63    /// HDR headroom in **stops** (log2 luminance ratio), or `None` when no
64    /// `HDRHeadroom` (`0x21`) tag was present.
65    ///
66    /// Piecewise mapping of `maker33` (`HDRHeadroom`) and `maker48`
67    /// (`HDRGain`), clamped to be non-negative:
68    ///
69    /// ```text
70    /// if maker33 < 1:  stops = maker48 <= 0.01 ? -20·maker48 + 1.8  : -0.101·maker48 + 1.601
71    /// else:            stops = maker48 <= 0.01 ? -70·maker48 + 3.0  : -0.44 ·maker48 + 2.86
72    /// stops = max(stops, 0)
73    /// ```
74    pub fn headroom_stops(&self) -> Option<f64> {
75        let maker33 = self.hdr_headroom?;
76        let maker48 = self.hdr_gain;
77        if !maker33.is_finite() || !maker48.is_finite() {
78            return None;
79        }
80        self.headroom_stops_compatibility()
81    }
82
83    fn headroom_stops_compatibility(&self) -> Option<f64> {
84        let maker33 = self.hdr_headroom?;
85        let maker48 = self.hdr_gain;
86        let stops = if maker33 < 1.0 {
87            if maker48 <= 0.01 {
88                -20.0 * maker48 + 1.8
89            } else {
90                -0.101 * maker48 + 1.601
91            }
92        } else if maker48 <= 0.01 {
93            -70.0 * maker48 + 3.0
94        } else {
95            -0.44 * maker48 + 2.86
96        };
97        // Clamp to non-negative without pulling in `f64::max` (std-only in no_std).
98        Some(if stops > 0.0 { stops } else { 0.0 })
99    }
100
101    /// Whether `HDRImageType` marks this as the HDR rendition (`3`).
102    pub fn is_hdr(&self) -> bool {
103        self.hdr_image_type == Some(3)
104    }
105
106    /// Whether any Apple HDR gain-map signal is present (the `0x21` tag).
107    pub fn has_gain_map(&self) -> bool {
108        self.hdr_headroom.is_some()
109    }
110}
111
112/// Map recovered Apple headroom to the canonical [`GainMapMetadata`]
113/// (= `zencodec::GainMapParams`).
114///
115/// The base image is SDR (`base_hdr_headroom = 0`); the alternate (HDR)
116/// headroom is the computed stops value (already log2-domain, matching
117/// `GainMapParams`). Per-channel gain spans `[0, stops]` in log2 with unit
118/// gamma — the Apple convention where the `[0,1]` gain-map image scales
119/// luminance from SDR up to the headroom. Returns `None` when no `0x21`
120/// headroom tag is present.
121///
122/// **Note:** the per-channel curve is the documented Apple convention; the
123/// gain-map round-trip test is the authority on fidelity.
124pub fn from_apple_headroom(info: &AppleHdrInfo) -> Option<GainMapMetadata> {
125    let stops = info.headroom_stops()?;
126    // `GainMapParams` is `#[non_exhaustive]`: build via Default + field set.
127    let mut params = GainMapMetadata::default();
128    params.base_hdr_headroom = 0.0;
129    params.alternate_hdr_headroom = stops;
130    for ch in &mut params.channels {
131        ch.min = 0.0;
132        ch.max = stops;
133        ch.gamma = 1.0;
134    }
135    Some(params)
136}
137
138/// Extract Apple HDR info directly from EXIF TIFF bytes (e.g. the payload of a
139/// HEIF `Exif` item or a JPEG `APP1` segment, with any leading TIFF-offset
140/// prefix tolerated).
141///
142/// Walks IFD0 → Exif sub-IFD (`0x8769`) → MakerNote (`0x927C`) → Apple iOS
143/// IFD. Returns `None` if the bytes are not a parseable TIFF, lack an Apple
144/// MakerNote, or carry no HDR tags.
145pub fn parse_exif_for_apple_hdr(exif: &[u8]) -> Option<AppleHdrInfo> {
146    parse_exif_with_mode(exif, Interpretation::Strict)
147}
148
149fn parse_exif_with_mode(exif: &[u8], mode: Interpretation) -> Option<AppleHdrInfo> {
150    let (tiff, endian) = tiff_start(exif)?;
151    // IFD0 offset is the u32 at byte 4 of the TIFF header.
152    let ifd0_off = endian.u32(tiff, 4)? as usize;
153    // Find the Exif sub-IFD pointer in IFD0.
154    let (kind, count, exif_ptr) = ifd_find(tiff, endian, ifd0_off, TIFF_TAG_EXIF_IFD, mode)?;
155    if mode == Interpretation::Strict && (kind != 4 || count != 1) {
156        return None;
157    }
158    let exif_ifd_off = endian.u32(exif_ptr, 0)? as usize;
159    // Find the MakerNote blob in the Exif sub-IFD.
160    let (kind, _, maker) = ifd_find(tiff, endian, exif_ifd_off, TIFF_TAG_MAKERNOTE, mode)?;
161    if mode == Interpretation::Strict && kind != 7 {
162        return None;
163    }
164    parse_makernote_with_mode(maker, mode)
165}
166
167/// One borrowed vendor entry, including unknown/private tags.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub struct MakerNoteEntry<'a> {
170    tag: u16,
171    kind: u16,
172    count: u32,
173    value: &'a [u8],
174}
175impl<'a> MakerNoteEntry<'a> {
176    /// Numeric vendor tag, including unknown tags.
177    pub fn tag(&self) -> u16 {
178        self.tag
179    }
180    /// TIFF field type.
181    pub fn kind(&self) -> u16 {
182        self.kind
183    }
184    /// Number of typed values.
185    pub fn count(&self) -> u32 {
186        self.count
187    }
188    /// Raw value bytes in the MakerNote byte order.
189    pub fn value(&self) -> &'a [u8] {
190        self.value
191    }
192}
193/// An entry that could not be read. Inspection does not silently hide it.
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
195#[non_exhaustive]
196pub struct MakerNoteEntryError {
197    /// Zero-based entry index in the original note.
198    pub index: usize,
199    /// Numeric tag when the entry header is readable.
200    pub tag: Option<u16>,
201}
202/// Apple MakerNote inspection view. Other vendors remain opaque.
203/// No allocation or value copies; unknown entries are visible for audit/diff.
204#[derive(Debug)]
205pub struct AppleMakerNote<'a> {
206    data: &'a [u8],
207    endian: Endian,
208    entries: usize,
209    count: usize,
210}
211impl<'a> AppleMakerNote<'a> {
212    /// Recognize an Apple note, bounded to 16 MiB and 4096 entries.
213    pub fn parse(data: &'a [u8]) -> Option<Self> {
214        Self::parse_with_mode(data, Interpretation::Strict)
215    }
216
217    fn parse_with_mode(data: &'a [u8], mode: Interpretation) -> Option<Self> {
218        if (mode == Interpretation::Strict && data.len() > 16 * 1024 * 1024)
219            || (mode == Interpretation::Compatibility && data.len() < 20)
220            || data.get(..10)? != b"Apple iOS\0"
221        {
222            return None;
223        }
224        let endian = match data.get(12..14)? {
225            b"MM" => Endian::Big,
226            b"II" => Endian::Little,
227            _ => return None,
228        };
229        let ifd = if endian.u16(data, 14)? == 42 {
230            12usize.checked_add(endian.u32(data, 16)? as usize)?
231        } else {
232            14
233        };
234        let count = endian.u16(data, ifd)? as usize;
235        if mode == Interpretation::Strict && count > 4096 {
236            return None;
237        }
238        Some(Self {
239            data,
240            endian,
241            entries: ifd.checked_add(2)?,
242            count,
243        })
244    }
245    /// Byte order for interpreting numeric value bytes.
246    pub fn byte_order(&self) -> zencodec::exif::ByteOrder {
247        match self.endian {
248            Endian::Big => zencodec::exif::ByteOrder::Big,
249            Endian::Little => zencodec::exif::ByteOrder::Little,
250        }
251    }
252    /// Borrow all entries in order, reporting malformed entries individually.
253    pub fn entries(
254        &self,
255    ) -> impl Iterator<Item = core::result::Result<MakerNoteEntry<'a>, MakerNoteEntryError>> + '_
256    {
257        self.entries_with_mode(Interpretation::Strict)
258    }
259
260    fn entries_with_mode(
261        &self,
262        mode: Interpretation,
263    ) -> impl Iterator<Item = core::result::Result<MakerNoteEntry<'a>, MakerNoteEntryError>> + '_
264    {
265        (0..self.count).map(move |index| {
266            let offset = self.entries + index * 12;
267            let tag = self.endian.u16(self.data, offset);
268            let error = MakerNoteEntryError { index, tag };
269            if mode == Interpretation::Compatibility {
270                self.data.get(offset..offset + 12).ok_or(error)?;
271            }
272            let tag = tag.ok_or(error)?;
273            let kind = self.endian.u16(self.data, offset + 2).ok_or(error)?;
274            let count = self.endian.u32(self.data, offset + 4).ok_or(error)?;
275            if mode == Interpretation::Strict
276                && !(1..=13).contains(&kind)
277                && !(16..=18).contains(&kind)
278            {
279                return Err(error);
280            }
281            let size = mode
282                .type_size(kind)
283                .checked_mul(count as usize)
284                .ok_or(error)?;
285            let start = if size <= 4 {
286                offset + 8
287            } else {
288                self.endian.u32(self.data, offset + 8).ok_or(error)? as usize
289            };
290            let value = self
291                .data
292                .get(start..start.checked_add(size).ok_or(error)?)
293                .ok_or(error)?;
294            Ok(MakerNoteEntry {
295                tag,
296                kind,
297                count,
298                value,
299            })
300        })
301    }
302}
303
304/// Parse an Apple iOS MakerNote blob (`"Apple iOS\0"` + version + byte-order
305/// marker + IFD) and pull out the HDR tags.
306///
307/// Addressing has two bases (verified against iPhone 8/13/16/17 captures):
308/// the byte-order marker and IFD live at offset 12, but the entries' *value
309/// offsets* for out-of-line data are relative to the MakerNote start
310/// (`maker[0]`). Unrecognized notes, malformed entries or duplicate rendering
311/// tags refuse extraction. The inspection view reports malformed entries individually.
312pub fn parse_apple_makernote(maker: &[u8]) -> Option<AppleHdrInfo> {
313    parse_makernote_with_mode(maker, Interpretation::Strict)
314}
315
316fn parse_makernote_with_mode(maker: &[u8], mode: Interpretation) -> Option<AppleHdrInfo> {
317    let view = AppleMakerNote::parse_with_mode(maker, mode)?;
318    let mut info = AppleHdrInfo::default();
319    let mut seen = 0u8;
320    for entry in view.entries_with_mode(mode) {
321        let entry = match entry {
322            Ok(entry) => entry,
323            Err(_) if mode == Interpretation::Compatibility => continue,
324            Err(_) => return None,
325        };
326        let bit = match entry.tag {
327            tags::HDR_HEADROOM => 1,
328            tags::HDR_GAIN => 2,
329            tags::HDR_IMAGE_TYPE => 4,
330            _ => continue,
331        };
332        if mode == Interpretation::Compatibility {
333            match entry.tag {
334                tags::HDR_HEADROOM => {
335                    info.hdr_headroom = read_rational(entry.value, entry.kind, view.endian)
336                }
337                tags::HDR_GAIN => {
338                    if let Some(gain) = read_rational(entry.value, entry.kind, view.endian) {
339                        info.hdr_gain = gain;
340                    }
341                }
342                tags::HDR_IMAGE_TYPE => {
343                    info.hdr_image_type = read_int(
344                        entry.value,
345                        match entry.kind {
346                            6 => 1,
347                            8 => 3,
348                            kind => kind,
349                        },
350                        view.endian,
351                    )
352                }
353                _ => unreachable!(),
354            }
355            continue;
356        }
357        if seen & bit != 0 || entry.count != 1 {
358            return None;
359        }
360        seen |= bit;
361        match entry.tag {
362            tags::HDR_HEADROOM => {
363                info.hdr_headroom = Some(read_rational(entry.value, entry.kind, view.endian)?)
364            }
365            tags::HDR_GAIN => info.hdr_gain = read_rational(entry.value, entry.kind, view.endian)?,
366            tags::HDR_IMAGE_TYPE => {
367                info.hdr_image_type = Some(read_int(entry.value, entry.kind, view.endian)?)
368            }
369            _ => unreachable!(),
370        }
371    }
372    Some(info)
373}
374
375#[derive(Clone, Copy, PartialEq, Eq)]
376enum Interpretation {
377    Strict,
378    Compatibility,
379}
380
381impl Interpretation {
382    fn type_size(self, kind: u16) -> usize {
383        if self == Self::Compatibility && matches!(kind, 13 | 16..=18) {
384            1 // The released helper treats these as unknown one-byte types.
385        } else {
386            type_size(kind)
387        }
388    }
389}
390
391/// Compatibility with ultrahdr-core 0.6.0's published Apple helpers.
392///
393/// These operations preserve first-match EXIF selection, last-readable-value
394/// MakerNote selection, malformed-entry tolerance, and the historical numeric
395/// interpretation. Use the parent module's strict operations for validation.
396/// No value copies are needed; directory work is bounded by TIFF's u16 count.
397pub mod compatibility {
398    use super::{AppleHdrInfo, Interpretation};
399
400    /// Extract using the released helper's tolerant EXIF selection rules.
401    pub fn parse_exif_for_apple_hdr(exif: &[u8]) -> Option<AppleHdrInfo> {
402        super::parse_exif_with_mode(exif, Interpretation::Compatibility)
403    }
404
405    /// Extract using the released helper's tolerant MakerNote rules.
406    pub fn parse_apple_makernote(maker: &[u8]) -> Option<AppleHdrInfo> {
407        super::parse_makernote_with_mode(maker, Interpretation::Compatibility)
408    }
409
410    /// Preserve the released formula, including its non-finite-input behavior.
411    pub fn headroom_stops(info: &AppleHdrInfo) -> Option<f64> {
412        info.headroom_stops_compatibility()
413    }
414}
415
416// ── TIFF/IFD primitives ──────────────────────────────────────────────────
417
418/// Endianness of a TIFF/IFD structure.
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
420enum Endian {
421    Big,
422    Little,
423}
424
425impl Endian {
426    fn u16(self, b: &[u8], o: usize) -> Option<u16> {
427        let s = b.get(o..o.checked_add(2)?)?;
428        let a = [s[0], s[1]];
429        Some(match self {
430            Endian::Big => u16::from_be_bytes(a),
431            Endian::Little => u16::from_le_bytes(a),
432        })
433    }
434
435    fn u32(self, b: &[u8], o: usize) -> Option<u32> {
436        let s = b.get(o..o.checked_add(4)?)?;
437        let a = [s[0], s[1], s[2], s[3]];
438        Some(match self {
439            Endian::Big => u32::from_be_bytes(a),
440            Endian::Little => u32::from_le_bytes(a),
441        })
442    }
443
444    fn i32(self, b: &[u8], o: usize) -> Option<i32> {
445        self.u32(b, o).map(|v| v as i32)
446    }
447}
448
449/// TIFF data type → element size in bytes (TIFF 6.0 + BigTIFF extensions used
450/// by EXIF). Unknown types fall back to 1 to stay within bounds.
451fn type_size(dtype: u16) -> usize {
452    match dtype {
453        1 | 2 | 6 | 7 => 1,         // BYTE, ASCII, SBYTE, UNDEFINED
454        3 | 8 => 2,                 // SHORT, SSHORT
455        4 | 9 | 11 | 13 => 4,       // LONG, SLONG, FLOAT
456        5 | 10 | 12 | 16..=18 => 8, // RATIONAL, SRATIONAL, DOUBLE
457        _ => 1,
458    }
459}
460
461/// Locate the TIFF header within an EXIF blob, tolerating a leading offset
462/// prefix (HEIF `Exif` items prepend a 4-byte `tiff_header_offset`). Returns
463/// the TIFF slice (starting at the byte-order marker) and its endianness.
464fn tiff_start(exif: &[u8]) -> Option<(&[u8], Endian)> {
465    let window = exif.len().min(16);
466    for base in 0..window {
467        match exif.get(base..base + 4) {
468            Some(b"II\x2a\x00") => return Some((&exif[base..], Endian::Little)),
469            Some(b"MM\x00\x2a") => return Some((&exif[base..], Endian::Big)),
470            _ => {}
471        }
472    }
473    None
474}
475
476/// Find an IFD entry by tag and return `(dtype, count, value_bytes)`. Value
477/// bytes are read inline (≤ 4 bytes) or from the pointed-to offset (relative
478/// to the TIFF start). `None` if the tag is absent or the data is truncated.
479fn ifd_find(
480    tiff: &[u8],
481    endian: Endian,
482    ifd_off: usize,
483    want: u16,
484    mode: Interpretation,
485) -> Option<(u16, u32, &[u8])> {
486    let count = endian.u16(tiff, ifd_off)? as usize;
487    let entries = ifd_off.checked_add(2)?;
488    let mut result = None;
489    for i in 0..count {
490        let e = entries + i * 12;
491        if e + 12 > tiff.len() {
492            return None;
493        }
494        if endian.u16(tiff, e)? != want {
495            continue;
496        }
497        let dtype = endian.u16(tiff, e + 2)?;
498        let n = endian.u32(tiff, e + 4)?;
499        let total = mode.type_size(dtype).checked_mul(n as usize)?;
500        let value = if total <= 4 {
501            tiff.get(e + 8..e + 8 + total.min(4))?
502        } else {
503            let off = endian.u32(tiff, e + 8)? as usize;
504            tiff.get(off..off.checked_add(total)?)?
505        };
506        if mode == Interpretation::Compatibility {
507            return Some((dtype, n, value));
508        }
509        if result.replace((dtype, n, value)).is_some() {
510            return None;
511        }
512    }
513    result
514}
515
516/// Read a rational (`5` = unsigned, `10` = signed) as `f64`. Apple writes
517/// `HDRHeadroom`/`HDRGain` as `rational64s` (type 10). Returns `None` on a
518/// zero denominator or short data.
519fn read_rational(value: &[u8], dtype: u16, endian: Endian) -> Option<f64> {
520    if value.len() < 8 {
521        return None;
522    }
523    match dtype {
524        10 => {
525            let num = endian.i32(value, 0)?;
526            let den = endian.i32(value, 4)?;
527            if den == 0 {
528                None
529            } else {
530                Some(num as f64 / den as f64)
531            }
532        }
533        5 => {
534            let num = endian.u32(value, 0)?;
535            let den = endian.u32(value, 4)?;
536            if den == 0 {
537                None
538            } else {
539                Some(num as f64 / den as f64)
540            }
541        }
542        _ => None,
543    }
544}
545
546/// Read a small integer tag (`HDRImageType` is `int32s`/SHORT) as `i32`.
547fn read_int(value: &[u8], dtype: u16, endian: Endian) -> Option<i32> {
548    match dtype {
549        3 => endian.u16(value, 0).map(|v| v as i32),
550        8 => endian.u16(value, 0).map(|v| v as i16 as i32),
551        4 | 9 => endian.i32(value, 0),
552        1 => value.first().map(|&b| b as i32),
553        6 => value.first().map(|&b| b as i8 as i32),
554        _ => None,
555    }
556}
557
558// Published implementation is a test-only compatibility oracle; see PROVENANCE.md.
559#[cfg(test)]
560#[path = "../tests/support/apple_0_6_0.rs"]
561mod released;
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use alloc::vec;
567
568    fn assert_released_parity(data: &[u8], exif: bool) {
569        let old = if exif {
570            released::parse_exif_for_apple_hdr(data)
571        } else {
572            released::parse_apple_makernote(data)
573        }
574        .map(|info| (info.hdr_headroom, info.hdr_gain, info.hdr_image_type));
575        let new = if exif {
576            compatibility::parse_exif_for_apple_hdr(data)
577        } else {
578            compatibility::parse_apple_makernote(data)
579        }
580        .map(|info| (info.hdr_headroom, info.hdr_gain, info.hdr_image_type));
581        assert_eq!(new, old, "exif={exif}, bytes={data:?}");
582    }
583
584    #[test]
585    fn compatibility_matches_released_parser_on_mutations_and_truncations() {
586        for (data, exif) in [
587            (build_apple_makernote_be(1686, 1000, 1, 20, 3), false),
588            (build_exif_with_apple_makernote(), true),
589        ] {
590            for end in 0..=data.len() {
591                assert_released_parity(&data[..end], exif);
592            }
593            for offset in 0..data.len() {
594                for value in [
595                    0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13, 16, 17, 18, 42, 127, 128, 255,
596                ] {
597                    let mut changed = data.clone();
598                    changed[offset] = value;
599                    assert_released_parity(&changed, exif);
600                }
601            }
602        }
603    }
604
605    #[test]
606    fn oversized_exif_value_count_is_rejected_on_every_pointer_width() {
607        let mut exif = build_exif_with_apple_makernote();
608        // IFD0's Exif pointer claims u32::MAX LONG values: 4 * count
609        // overflows usize on i686 in the released reader.
610        exif[14..18].copy_from_slice(&u32::MAX.to_be_bytes());
611        assert_eq!(compatibility::parse_exif_for_apple_hdr(&exif), None);
612        assert_eq!(parse_exif_for_apple_hdr(&exif), None);
613    }
614
615    #[test]
616    fn compatibility_preserves_nonfinite_formula_behavior() {
617        for maker33 in [
618            f64::NEG_INFINITY,
619            -1.0,
620            0.0,
621            0.5,
622            1.0,
623            2.0,
624            f64::INFINITY,
625            f64::NAN,
626        ] {
627            for maker48 in [
628                f64::NEG_INFINITY,
629                -1.0,
630                0.0,
631                0.01,
632                0.5,
633                f64::INFINITY,
634                f64::NAN,
635            ] {
636                let old = released::AppleHdrInfo {
637                    hdr_headroom: Some(maker33),
638                    hdr_gain: maker48,
639                    hdr_image_type: None,
640                };
641                let new = AppleHdrInfo {
642                    hdr_headroom: Some(maker33),
643                    hdr_gain: maker48,
644                    hdr_image_type: None,
645                };
646                assert_eq!(compatibility::headroom_stops(&new), old.headroom_stops());
647            }
648        }
649    }
650
651    #[test]
652    fn strict_and_compatibility_rules_remain_distinct() {
653        let mut note = build_apple_makernote_be(1686, 1000, 1, 20, 3);
654        // Duplicate HDRHeadroom in the third directory entry (originally HDRGain).
655        note[40..42].copy_from_slice(&tags::HDR_HEADROOM.to_be_bytes());
656        assert!(parse_apple_makernote(&note).is_none());
657        assert_released_parity(&note, false);
658        assert!(compatibility::parse_apple_makernote(&note).is_some());
659    }
660
661    #[test]
662    fn headroom_formula_all_branches() {
663        // maker33 >= 1, maker48 <= 0.01  ->  -70*0 + 3.0
664        let hi = AppleHdrInfo {
665            hdr_headroom: Some(1.686),
666            hdr_gain: 0.0,
667            hdr_image_type: Some(3),
668        };
669        assert!((hi.headroom_stops().unwrap() - 3.0).abs() < 1e-9);
670
671        // maker33 >= 1, maker48 > 0.01   ->  -0.44*0.5 + 2.86 = 2.64
672        let hi = AppleHdrInfo {
673            hdr_headroom: Some(2.0),
674            hdr_gain: 0.5,
675            hdr_image_type: None,
676        };
677        assert!((hi.headroom_stops().unwrap() - 2.64).abs() < 1e-9);
678
679        // maker33 < 1, maker48 <= 0.01   ->  -20*0 + 1.8
680        let hi = AppleHdrInfo {
681            hdr_headroom: Some(0.5),
682            hdr_gain: 0.0,
683            hdr_image_type: None,
684        };
685        assert!((hi.headroom_stops().unwrap() - 1.8).abs() < 1e-9);
686
687        // maker33 < 1, maker48 > 0.01    ->  -0.101*0.05 + 1.601
688        let hi = AppleHdrInfo {
689            hdr_headroom: Some(0.5),
690            hdr_gain: 0.05,
691            hdr_image_type: None,
692        };
693        assert!((hi.headroom_stops().unwrap() - (-0.101 * 0.05 + 1.601)).abs() < 1e-9);
694    }
695
696    #[test]
697    fn no_headroom_tag_means_no_signal() {
698        let hi = AppleHdrInfo::default();
699        assert_eq!(hi.headroom_stops(), None);
700        assert!(!hi.has_gain_map());
701        assert_eq!(from_apple_headroom(&hi), None);
702    }
703
704    #[test]
705    fn maps_to_gain_map_params() {
706        let hi = AppleHdrInfo {
707            hdr_headroom: Some(1.686),
708            hdr_gain: 0.0,
709            hdr_image_type: Some(3),
710        };
711        let p = from_apple_headroom(&hi).unwrap();
712        assert!((p.alternate_hdr_headroom - 3.0).abs() < 1e-9);
713        assert_eq!(p.base_hdr_headroom, 0.0);
714        for ch in &p.channels {
715            assert_eq!(ch.min, 0.0);
716            assert!((ch.max - 3.0).abs() < 1e-9);
717            assert_eq!(ch.gamma, 1.0);
718        }
719        assert!(p.validate().is_ok());
720    }
721
722    /// Build a big-endian Apple MakerNote blob carrying the three HDR tags and
723    /// confirm the IFD walk recovers them.
724    #[test]
725    fn parse_apple_makernote_extracts_hdr_tags() {
726        let maker = build_apple_makernote_be(1686, 1000, 0, 1, 3);
727        let hi = parse_apple_makernote(&maker).unwrap();
728        assert_eq!(hi.hdr_headroom, Some(1.686));
729        assert_eq!(hi.hdr_gain, 0.0);
730        assert_eq!(hi.hdr_image_type, Some(3));
731        assert!(hi.is_hdr());
732        assert!((hi.headroom_stops().unwrap() - 3.0).abs() < 1e-9);
733    }
734
735    /// Wrap the MakerNote in a full EXIF TIFF (IFD0 → Exif IFD → MakerNote)
736    /// and confirm the top-level walk recovers the HDR info.
737    #[test]
738    fn parse_full_exif_walk() {
739        let exif = build_exif_with_apple_makernote();
740        let hi = parse_exif_for_apple_hdr(&exif).unwrap();
741        assert_eq!(hi.hdr_headroom, Some(1.686));
742        assert_eq!(hi.hdr_image_type, Some(3));
743    }
744
745    #[test]
746    fn rejects_non_apple_makernote() {
747        assert_eq!(parse_apple_makernote(b"Nikon\0\0\0not apple here!!"), None);
748        assert_eq!(parse_apple_makernote(b"short"), None);
749    }
750
751    #[test]
752    fn inspect_unknown_entries_and_report_truncation() {
753        let mut bytes = build_apple_makernote_be(1686, 1000, 0, 1, 3);
754        let view = AppleMakerNote::parse(&bytes).unwrap();
755        assert_eq!(view.entries().count(), 3);
756        assert!(view.entries().all(|e| e.is_ok()));
757        // First entry becomes an unknown, still inspectable vendor field.
758        bytes[16..18].copy_from_slice(&0xf123u16.to_be_bytes());
759        let view = AppleMakerNote::parse(&bytes).unwrap();
760        assert_eq!(view.entries().next().unwrap().unwrap().tag(), 0xf123);
761        let truncated = &bytes[..24];
762        let view = AppleMakerNote::parse(truncated).unwrap();
763        assert!(view.entries().any(|e| e.is_err()));
764    }
765    #[test]
766    fn invalid_critical_rational_refuses_instead_of_guessing() {
767        let bytes = build_apple_makernote_be(1686, 0, 0, 1, 3);
768        assert!(parse_apple_makernote(&bytes).is_none());
769        assert!(
770            AppleHdrInfo {
771                hdr_headroom: Some(f64::NAN),
772                ..Default::default()
773            }
774            .headroom_stops()
775            .is_none()
776        );
777    }
778
779    // ── test fixtures: hand-built big-endian TIFF/MakerNote ───────────────
780
781    fn be16(v: u16) -> [u8; 2] {
782        v.to_be_bytes()
783    }
784    fn be32(v: u32) -> [u8; 4] {
785        v.to_be_bytes()
786    }
787
788    /// One IFD entry: tag, type, count=1, and either an inline value (≤4 B,
789    /// left-justified) or a 4-byte offset into the out-of-line area.
790    fn ifd_entry(tag: u16, dtype: u16, count: u32, inline_or_off: [u8; 4]) -> Vec<u8> {
791        let mut e = Vec::new();
792        e.extend_from_slice(&be16(tag));
793        e.extend_from_slice(&be16(dtype));
794        e.extend_from_slice(&be32(count));
795        e.extend_from_slice(&inline_or_off);
796        e
797    }
798
799    /// Build an Apple iOS MakerNote (big-endian, custom layout) with
800    /// HDRHeadroom (0x21, srational), HDRGain (0x30, srational), and
801    /// HDRImageType (0x0a, short).
802    fn build_apple_makernote_be(
803        hr_num: i32,
804        hr_den: i32,
805        g_num: i32,
806        g_den: i32,
807        img_type: u16,
808    ) -> Vec<u8> {
809        // Header: "Apple iOS\0" + version(2). The byte-order marker "MM" is the
810        // first 2 bytes of `tiff` (appended below) and thus lands at offset 12.
811        let mut blob = Vec::new();
812        blob.extend_from_slice(b"Apple iOS\0");
813        blob.extend_from_slice(&be16(14)); // version
814
815        // From here offsets are relative to offset 12 (the "MM").
816        // Custom Apple layout: entry count starts at byte 2 of `tiff`.
817        // tiff[0..2] = "MM"; tiff[2..] = count + entries.
818        // Two srationals go out-of-line; lay them after the entries.
819        let n_entries: u16 = 3;
820        // tiff offsets: 0:MM, 2:count, 4:entries(3*12=36) -> 40, then ool data.
821        let ool_tiff = 2 + 2 + (n_entries as usize) * 12; // = 40 (within tiff)
822        // Out-of-line offsets are relative to the MakerNote start (maker[0]);
823        // tiff begins at maker[12], so add 12.
824        let hr_off = (12 + ool_tiff) as u32; // headroom srational (8 B)
825        let g_off = (12 + ool_tiff + 8) as u32; // gain srational (8 B)
826
827        let mut tiff = Vec::new();
828        tiff.extend_from_slice(b"MM");
829        tiff.extend_from_slice(&be16(n_entries));
830        // HDRImageType (short) inline, left-justified in the 4-byte value cell.
831        let mut img_inline = [0u8; 4];
832        img_inline[..2].copy_from_slice(&be16(img_type));
833        tiff.extend_from_slice(&ifd_entry(tags::HDR_IMAGE_TYPE, 3, 1, img_inline));
834        // HDRHeadroom srational, out-of-line.
835        tiff.extend_from_slice(&ifd_entry(tags::HDR_HEADROOM, 10, 1, be32(hr_off)));
836        // HDRGain srational, out-of-line.
837        tiff.extend_from_slice(&ifd_entry(tags::HDR_GAIN, 10, 1, be32(g_off)));
838        // Out-of-line rationals.
839        tiff.extend_from_slice(&be32(hr_num as u32));
840        tiff.extend_from_slice(&be32(hr_den as u32));
841        tiff.extend_from_slice(&be32(g_num as u32));
842        tiff.extend_from_slice(&be32(g_den as u32));
843
844        blob.extend_from_slice(&tiff);
845        blob
846    }
847
848    /// Build a minimal EXIF TIFF whose IFD0 points to an Exif sub-IFD that
849    /// holds the Apple MakerNote.
850    fn build_exif_with_apple_makernote() -> Vec<u8> {
851        let maker = build_apple_makernote_be(1686, 1000, 0, 1, 3);
852
853        // Layout (all offsets relative to the TIFF header start "MM\0*"):
854        //   0  : "MM\0*"  (4)
855        //   4  : ifd0_offset = 8  (4)
856        //   8  : IFD0: count=1 (2) + 1 entry (12) + next=0 (4)  -> ends at 26
857        //   26 : Exif IFD: count=1 (2) + 1 entry (12) + next=0 (4) -> ends at 44
858        //   44 : MakerNote bytes
859        let exif_ifd_off: u32 = 26;
860        let maker_off: u32 = 44;
861
862        let mut t = Vec::new();
863        t.extend_from_slice(b"MM\x00\x2a"); // big-endian TIFF header
864        t.extend_from_slice(&be32(8)); // IFD0 at offset 8
865
866        // IFD0: one entry — Exif sub-IFD pointer (type LONG).
867        t.extend_from_slice(&be16(1));
868        t.extend_from_slice(&ifd_entry(TIFF_TAG_EXIF_IFD, 4, 1, be32(exif_ifd_off)));
869        t.extend_from_slice(&be32(0)); // next IFD = none  (offset 22..26)
870
871        // Exif IFD: one entry — MakerNote (UNDEFINED, count = maker.len()).
872        t.extend_from_slice(&be16(1));
873        t.extend_from_slice(&ifd_entry(
874            TIFF_TAG_MAKERNOTE,
875            7,
876            maker.len() as u32,
877            be32(maker_off),
878        ));
879        t.extend_from_slice(&be32(0)); // next IFD = none
880
881        debug_assert_eq!(t.len() as u32, maker_off);
882        t.extend_from_slice(&maker);
883        t
884    }
885
886    #[test]
887    fn tolerates_leading_tiff_offset_prefix() {
888        // HEIF Exif items prepend a 4-byte offset before the TIFF header.
889        let mut exif = vec![0u8, 0, 0, 0];
890        exif.extend_from_slice(&build_exif_with_apple_makernote());
891        let hi = parse_exif_for_apple_hdr(&exif).unwrap();
892        assert_eq!(hi.hdr_headroom, Some(1.686));
893    }
894}