Skip to main content

zenmeta/xmp/
gainmap.rs

1//! XMP serialization for Ultra HDR and related multi-image JPEG formats.
2//!
3//! This module handles the Adobe/Google XMP metadata used by Ultra HDR:
4//!
5//! - `hdrgm:*` namespace — gain-map numerical parameters (min, max,
6//!   gamma, offsets, HDR headroom). Generated by [`generate_xmp`] and
7//!   friends; parsed by [`parse_xmp`].
8//! - `Container:Directory` — GContainer item manifest used by Google
9//!   Camera and Android Dynamic Depth Format. Emitted by
10//!   [`generate_xmp_with_items`]; parsed alongside metadata by
11//!   [`parse_xmp_full`]. See [`super::container`] for `ContainerItem` and
12//!   `ItemSemantic`.
13//!
14//! Physical packet framing belongs to the containing codec.
15
16use alloc::format;
17use alloc::string::{String, ToString};
18use alloc::vec::Vec;
19
20use super::container::{ContainerItem, ItemSemantic, generate_container_directory};
21
22/// XMP namespace for HDR gain-map metadata.
23pub const HDRGM_NAMESPACE: &str = "http://ns.adobe.com/hdr-gain-map/1.0/";
24
25/// XMP namespace for GContainer directory.
26pub const CONTAINER_NAMESPACE: &str = "http://ns.google.com/photos/1.0/container/";
27
28/// XMP namespace for GContainer item.
29pub const ITEM_NAMESPACE: &str = "http://ns.google.com/photos/1.0/container/item/";
30
31/// Upper bound on XMP packet length accepted by [`parse_xmp`]. Guards
32/// against CPU exhaustion on crafted inputs.
33///
34/// Matches `ultrahdr_core::limits::MAX_XMP_LENGTH` (the pre-migration
35/// value) so callers moving between the old and new API see no change
36/// in accepted input size. Legitimate gain-map XMP is well under
37/// 32 KB, but Extended XMP reassembly + base64-encoded depth-map
38/// payloads (GDepth / Android Dynamic Depth) can push into MB range,
39/// so a generous cap is appropriate.
40pub const MAX_XMP_LENGTH: usize = 16 * 1024 * 1024;
41
42/// Errors from [`parse_xmp`].
43#[derive(Debug)]
44#[non_exhaustive]
45pub enum XmpError {
46    /// XMP exceeded [`MAX_XMP_LENGTH`].
47    LengthLimit {
48        /// Bytes actually passed in.
49        got: usize,
50        /// Enforced cap.
51        max: usize,
52    },
53    /// XMP does not carry the `hdrgm` namespace — not Ultra HDR.
54    NotUltraHdr,
55    /// Malformed, ambiguous or unsupported XML/RDF.
56    Invalid(String),
57}
58
59impl core::fmt::Display for XmpError {
60    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
61        match self {
62            Self::LengthLimit { got, max } => write!(f, "XMP length {got} exceeds maximum {max}"),
63            Self::NotUltraHdr => f.write_str(
64                "XMP is not an Ultra HDR packet (no hdrgm:Version and no hdrgm:GainMapMax)",
65            ),
66            Self::Invalid(message) => write!(f, "invalid gain-map XMP: {message}"),
67        }
68    }
69}
70impl core::error::Error for XmpError {}
71
72/// Generate XMP for a 2-item Primary + GainMap Ultra HDR image.
73///
74/// This single packet goes in the primary JPEG's APP1 marker. It carries
75/// both the numeric metadata (all `hdrgm:*` attrs) AND the
76/// `Container:Directory` listing.
77///
78/// For maximum compatibility with libultrahdr's canonical layout (where
79/// the primary XMP carries only the directory and the gain-map XMP
80/// carries the numbers), use [`generate_primary_xmp`] +
81/// [`generate_gainmap_xmp`] instead.
82///
83/// # Examples
84///
85/// Roundtrip a default set of gain-map parameters:
86///
87/// ```
88/// use zenmeta::xmp::gainmap::{generate_xmp, parse_xmp};
89///
90/// let mut params = zencodec::GainMapParams::default();
91/// for c in &mut params.channels {
92///     c.max = 2.0;
93///     c.gamma = 1.0;
94/// }
95/// let xmp = generate_xmp(&params, 5_000);
96/// let (parsed, length) = parse_xmp(&xmp).expect("parse");
97/// assert_eq!(length, Some(5_000));
98/// assert!((parsed.channels[0].max - 2.0).abs() < 1e-5);
99/// ```
100#[must_use]
101pub fn generate_xmp(metadata: &zencodec::GainMapParams, gainmap_length: usize) -> String {
102    let items = alloc::vec![
103        ContainerItem::primary("image/jpeg"),
104        ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", gainmap_length),
105    ];
106    generate_xmp_with_items(metadata, &items)
107}
108
109/// Generate XMP for the primary JPEG: `Container:Directory` only.
110///
111/// The primary image's XMP contains `hdrgm:Version` and the
112/// `Container:Directory` listing every secondary with its semantic and
113/// byte length. The numeric gain-map metadata goes in the secondary's
114/// XMP via [`generate_gainmap_xmp`]. This matches the layout produced
115/// by Google Camera / Android.
116#[must_use]
117pub fn generate_primary_xmp(gainmap_length: usize) -> String {
118    let items = alloc::vec![
119        ContainerItem::primary("image/jpeg"),
120        ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", gainmap_length),
121    ];
122    generate_primary_xmp_with_items(&items)
123}
124
125/// Generate XMP for the primary JPEG with a custom item list (e.g. for
126/// depth-map containers alongside the gain map).
127#[must_use]
128pub fn generate_primary_xmp_with_items(items: &[ContainerItem]) -> String {
129    let container_dir = generate_container_directory(items);
130    format!(
131        r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
132<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core">
133  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
134    <rdf:Description rdf:about=""
135        xmlns:hdrgm="{HDRGM_NAMESPACE}"
136        xmlns:Container="{CONTAINER_NAMESPACE}"
137        xmlns:Item="{ITEM_NAMESPACE}"
138        hdrgm:Version="1.0">
139{container_dir}
140    </rdf:Description>
141  </rdf:RDF>
142</x:xmpmeta>
143<?xpacket end="w"?>"#
144    )
145}
146
147/// Generate XMP for the gain-map (secondary) JPEG: numeric metadata
148/// only (no Container:Directory).
149///
150/// Goes in the gain-map JPEG's APP1 marker. Together with the primary's
151/// `Container:Directory` XMP, this matches libultrahdr's canonical
152/// output.
153#[must_use]
154pub fn generate_gainmap_xmp(metadata: &zencodec::GainMapParams) -> String {
155    let ch = &metadata.channels;
156    let single = metadata.is_single_channel();
157
158    let gain_map_min = format_triple(&[ch[0].min, ch[1].min, ch[2].min], single);
159    let gain_map_max = format_triple(&[ch[0].max, ch[1].max, ch[2].max], single);
160    let gamma = format_triple(&[ch[0].gamma, ch[1].gamma, ch[2].gamma], single);
161    let offset_sdr = format_triple(
162        &[ch[0].base_offset, ch[1].base_offset, ch[2].base_offset],
163        single,
164    );
165    let offset_hdr = format_triple(
166        &[
167            ch[0].alternate_offset,
168            ch[1].alternate_offset,
169            ch[2].alternate_offset,
170        ],
171        single,
172    );
173    let hdr_capacity_min = metadata.base_hdr_headroom;
174    let hdr_capacity_max = metadata.alternate_hdr_headroom;
175
176    format!(
177        r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
178<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core">
179  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
180    <rdf:Description rdf:about=""
181        xmlns:hdrgm="{HDRGM_NAMESPACE}"
182        hdrgm:Version="1.0"
183        hdrgm:GainMapMin="{gain_map_min}"
184        hdrgm:GainMapMax="{gain_map_max}"
185        hdrgm:Gamma="{gamma}"
186        hdrgm:OffsetSDR="{offset_sdr}"
187        hdrgm:OffsetHDR="{offset_hdr}"
188        hdrgm:HDRCapacityMin="{hdr_capacity_min:.6}"
189        hdrgm:HDRCapacityMax="{hdr_capacity_max:.6}"
190        hdrgm:BaseRenditionIsHDR="False"/>
191  </rdf:RDF>
192</x:xmpmeta>
193<?xpacket end="w"?>"#
194    )
195}
196
197/// Generate XMP with both numeric metadata and a custom container
198/// directory. Used when carrying depth / confidence maps alongside the
199/// gain map.
200#[must_use]
201pub fn generate_xmp_with_items(
202    metadata: &zencodec::GainMapParams,
203    items: &[ContainerItem],
204) -> String {
205    let ch = &metadata.channels;
206    let single = metadata.is_single_channel();
207    let gain_map_min = format_triple(&[ch[0].min, ch[1].min, ch[2].min], single);
208    let gain_map_max = format_triple(&[ch[0].max, ch[1].max, ch[2].max], single);
209    let gamma = format_triple(&[ch[0].gamma, ch[1].gamma, ch[2].gamma], single);
210    let offset_sdr = format_triple(
211        &[ch[0].base_offset, ch[1].base_offset, ch[2].base_offset],
212        single,
213    );
214    let offset_hdr = format_triple(
215        &[
216            ch[0].alternate_offset,
217            ch[1].alternate_offset,
218            ch[2].alternate_offset,
219        ],
220        single,
221    );
222    let hdr_capacity_min = metadata.base_hdr_headroom;
223    let hdr_capacity_max = metadata.alternate_hdr_headroom;
224    let container_dir = generate_container_directory(items);
225    format!(
226        r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
227<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core">
228  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
229    <rdf:Description rdf:about=""
230        xmlns:hdrgm="{HDRGM_NAMESPACE}"
231        xmlns:Container="{CONTAINER_NAMESPACE}"
232        xmlns:Item="{ITEM_NAMESPACE}"
233        hdrgm:Version="1.0"
234        hdrgm:GainMapMin="{gain_map_min}"
235        hdrgm:GainMapMax="{gain_map_max}"
236        hdrgm:Gamma="{gamma}"
237        hdrgm:OffsetSDR="{offset_sdr}"
238        hdrgm:OffsetHDR="{offset_hdr}"
239        hdrgm:HDRCapacityMin="{hdr_capacity_min:.6}"
240        hdrgm:HDRCapacityMax="{hdr_capacity_max:.6}"
241        hdrgm:BaseRenditionIsHDR="False">
242{container_dir}
243    </rdf:Description>
244  </rdf:RDF>
245</x:xmpmeta>
246<?xpacket end="w"?>"#
247    )
248}
249
250fn format_triple(values: &[f64; 3], single_channel: bool) -> String {
251    if single_channel {
252        format!("{:.6}", values[0])
253    } else {
254        format!("{:.6}, {:.6}, {:.6}", values[0], values[1], values[2])
255    }
256}
257
258/// Parse Ultra HDR XMP numeric metadata + optional `Item:Length` for
259/// the gain map.
260///
261/// Returns `(metadata, gainmap_length)`. For access to the full
262/// `Container:Directory` (depth maps, confidence maps, vendor items),
263/// use [`parse_xmp_full`] instead.
264pub fn parse_xmp(xmp: &str) -> Result<(zencodec::GainMapParams, Option<usize>), XmpError> {
265    if xmp.len() > MAX_XMP_LENGTH {
266        return Err(XmpError::LengthLimit {
267            got: xmp.len(),
268            max: MAX_XMP_LENGTH,
269        });
270    }
271    let packet = crate::xmp::Packet::parse(xmp).map_err(|e| XmpError::Invalid(e.to_string()))?;
272    let property = |name| {
273        packet
274            .property(HDRGM_NAMESPACE, name)
275            .map_err(|e| XmpError::Invalid(e.to_string()))
276    };
277    let version = property("Version")?;
278    if version.is_none() && property("GainMapMax")?.is_none() {
279        return Err(XmpError::NotUltraHdr);
280    }
281    if version.is_some_and(|v| v != ["1.0"]) {
282        return Err(XmpError::Invalid("unsupported hdrgm version".into()));
283    }
284    if property("BaseRenditionIsHDR")?.is_some_and(|v| v != ["False"]) {
285        return Err(XmpError::Invalid(
286            "HDR-base XMP requires a supported inverse reconstruction path".into(),
287        ));
288    }
289    let mut metadata = zencodec::GainMapParams::default();
290    for (name, field) in [
291        ("GainMapMin", 0),
292        ("GainMapMax", 1),
293        ("Gamma", 2),
294        ("OffsetSDR", 3),
295        ("OffsetHDR", 4),
296    ] {
297        if let Some(values) = property(name)? {
298            let values = numeric_values(values, name)?;
299            let triple = match values.as_slice() {
300                [v] => [*v; 3],
301                [r, g, b] => [*r, *g, *b],
302                _ => {
303                    return Err(XmpError::Invalid(format!(
304                        "{name}: expected one or three values"
305                    )));
306                }
307            };
308            for (ch, value) in metadata.channels.iter_mut().zip(triple) {
309                match field {
310                    0 => ch.min = value,
311                    1 => ch.max = value,
312                    2 => ch.gamma = value,
313                    3 => ch.base_offset = value,
314                    _ => ch.alternate_offset = value,
315                }
316            }
317        }
318    }
319    for (name, slot) in [
320        ("HDRCapacityMin", &mut metadata.base_hdr_headroom),
321        ("HDRCapacityMax", &mut metadata.alternate_hdr_headroom),
322    ] {
323        if let Some(values) = property(name)? {
324            let values = numeric_values(values, name)?;
325            if values.len() != 1 {
326                return Err(XmpError::Invalid(format!("{name}: expected scalar")));
327            }
328            *slot = values[0];
329        }
330    }
331    metadata
332        .validate()
333        .map_err(|e| XmpError::Invalid(e.to_string()))?;
334    let items = checked_container_items(&packet)?;
335    let mut gainmaps = items.iter().filter(|i| i.semantic == ItemSemantic::GainMap);
336    let length = gainmaps.next().and_then(|i| i.length);
337    if gainmaps.next().is_some() {
338        return Err(XmpError::Invalid("multiple gain-map items".into()));
339    }
340    Ok((metadata, length))
341}
342fn numeric_values(values: Vec<String>, name: &str) -> Result<Vec<f64>, XmpError> {
343    values
344        .iter()
345        .flat_map(|v| v.split(','))
346        .map(|v| {
347            v.trim()
348                .parse::<f64>()
349                .ok()
350                .filter(|v| v.is_finite())
351                .ok_or_else(|| XmpError::Invalid(format!("{name}: invalid number")))
352        })
353        .collect()
354}
355
356/// Parse numeric parameters and container items. Invalid packets yield no
357/// metadata; use [`parse_xmp`] when a diagnostic error is required.
358#[must_use]
359pub fn parse_xmp_full(xmp: &str) -> (Option<zencodec::GainMapParams>, Vec<ContainerItem>) {
360    let Ok(packet) = crate::xmp::Packet::parse(xmp) else {
361        return (None, Vec::new());
362    };
363    (
364        parse_xmp(xmp).ok().map(|(m, _)| m),
365        checked_container_items(&packet).unwrap_or_default(),
366    )
367}
368
369pub(super) fn checked_container_items(
370    packet: &crate::xmp::Packet<'_>,
371) -> Result<Vec<ContainerItem>, XmpError> {
372    checked_items_in_namespace(packet, CONTAINER_NAMESPACE, ITEM_NAMESPACE)
373}
374
375pub(super) fn checked_items_in_namespace(
376    packet: &crate::xmp::Packet<'_>,
377    container_ns: &str,
378    item_ns: &str,
379) -> Result<Vec<ContainerItem>, XmpError> {
380    packet
381        .resource_sequence(container_ns, "Directory", container_ns, "Item")
382        .map_err(|e| XmpError::Invalid(e.to_string()))?
383        .into_iter()
384        .map(|fields| {
385            let value = |name: &str| {
386                fields
387                    .iter()
388                    .find(|f| f.namespace() == item_ns && f.name() == name)
389                    .map(|f| f.value())
390            };
391            let semantic = value("Semantic")
392                .ok_or_else(|| XmpError::Invalid("missing item semantic".into()))?;
393            let mime =
394                value("Mime").ok_or_else(|| XmpError::Invalid("missing item MIME".into()))?;
395            let number = |name| {
396                value(name)
397                    .map(|s| {
398                        s.parse::<usize>()
399                            .map_err(|_| XmpError::Invalid(format!("invalid item {name}")))
400                    })
401                    .transpose()
402            };
403            Ok(ContainerItem {
404                semantic: ItemSemantic::from_xmp(semantic),
405                mime: mime.into(),
406                length: number("Length")?,
407                padding: number("Padding")?,
408            })
409        })
410        .collect()
411}
412
413// ===========================================================================
414// Tests
415// ===========================================================================
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn test_metadata() -> zencodec::GainMapParams {
422        let mut m = zencodec::GainMapParams::default();
423        for i in 0..3 {
424            m.channels[i].min = 0.0;
425            m.channels[i].max = 2.0;
426            m.channels[i].gamma = 1.0;
427            m.channels[i].base_offset = 1.0 / 64.0;
428            m.channels[i].alternate_offset = 1.0 / 64.0;
429        }
430        m.base_hdr_headroom = 0.0;
431        m.alternate_hdr_headroom = 2.0;
432        m
433    }
434
435    fn multi_channel_metadata() -> zencodec::GainMapParams {
436        let mut m = test_metadata();
437        m.channels[0].min = 1.0;
438        m.channels[1].min = 2.0;
439        m.channels[2].min = 3.0;
440        m.channels[0].max = 4.0;
441        m.channels[1].max = 5.0;
442        m.channels[2].max = 6.0;
443        m
444    }
445
446    #[test]
447    fn generate_xmp_contains_version_and_length() {
448        let xmp = generate_xmp(&test_metadata(), 10_000);
449        assert!(xmp.contains("hdrgm:Version=\"1.0\""));
450        assert!(xmp.contains("hdrgm:GainMapMax"));
451        assert!(xmp.contains("Item:Length=\"10000\""));
452        assert!(xmp.contains("Item:Semantic=\"GainMap\""));
453    }
454
455    /// XMP serializes f64 via `{:.6}` format, so roundtrip error is
456    /// bounded by that — one ULP at the 6th decimal ≈ 5e-7. Anything
457    /// looser hides real precision bugs.
458    const XMP_FLOAT_TOL: f64 = 1e-5;
459
460    #[test]
461    fn parse_xmp_roundtrips_scalar_values() {
462        let original = test_metadata();
463        let xmp = generate_xmp(&original, 5_000);
464        let (parsed, length) = parse_xmp(&xmp).unwrap();
465        assert_eq!(length, Some(5_000));
466        // Verify every numeric field roundtrips, not just .max.
467        for i in 0..3 {
468            assert!((parsed.channels[i].min - original.channels[i].min).abs() < XMP_FLOAT_TOL);
469            assert!((parsed.channels[i].max - original.channels[i].max).abs() < XMP_FLOAT_TOL);
470            assert!((parsed.channels[i].gamma - original.channels[i].gamma).abs() < XMP_FLOAT_TOL);
471            assert!(
472                (parsed.channels[i].base_offset - original.channels[i].base_offset).abs()
473                    < XMP_FLOAT_TOL
474            );
475            assert!(
476                (parsed.channels[i].alternate_offset - original.channels[i].alternate_offset).abs()
477                    < XMP_FLOAT_TOL
478            );
479        }
480        assert!((parsed.base_hdr_headroom - original.base_hdr_headroom).abs() < XMP_FLOAT_TOL);
481        assert!(
482            (parsed.alternate_hdr_headroom - original.alternate_hdr_headroom).abs() < XMP_FLOAT_TOL
483        );
484    }
485
486    #[test]
487    fn parse_xmp_roundtrips_multi_channel_values() {
488        let original = multi_channel_metadata();
489        let xmp = generate_xmp(&original, 5_000);
490        let (parsed, _) = parse_xmp(&xmp).unwrap();
491        // All three channels must differ — a regression that replicated
492        // channel-0 to 1/2 would have channels[0].min == channels[1].min.
493        for i in 0..3 {
494            assert!((parsed.channels[i].min - original.channels[i].min).abs() < XMP_FLOAT_TOL);
495            assert!((parsed.channels[i].max - original.channels[i].max).abs() < XMP_FLOAT_TOL);
496        }
497        assert!(
498            (parsed.channels[0].min - parsed.channels[1].min).abs() > 0.5,
499            "channels must remain distinct across the roundtrip"
500        );
501    }
502
503    #[test]
504    fn parse_xmp_rejects_non_ultrahdr_packet() {
505        let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
506<x:xmpmeta xmlns:x="adobe:ns:meta/">
507  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
508    <rdf:Description rdf:about=""
509        xmlns:dc="http://purl.org/dc/elements/1.1/"
510        dc:creator="SomeCamera"/>
511  </rdf:RDF>
512</x:xmpmeta>
513<?xpacket end="w"?>"#;
514        assert!(matches!(parse_xmp(xmp), Err(XmpError::NotUltraHdr)));
515    }
516
517    /// Pins the accepted-input ceiling. If someone shrinks
518    /// `MAX_XMP_LENGTH` (as was mistakenly done during the initial
519    /// container-module extraction — 16 MiB became 1 MiB), this test
520    /// fails by value-equality, not just behavior.
521    #[test]
522    fn max_xmp_length_is_16_mib() {
523        assert_eq!(MAX_XMP_LENGTH, 16 * 1024 * 1024);
524    }
525
526    #[test]
527    fn parse_xmp_rejects_oversized_input() {
528        let mut big = String::with_capacity(MAX_XMP_LENGTH + 100);
529        big.push_str("hdrgm:Version=\"1.0\" ");
530        while big.len() <= MAX_XMP_LENGTH {
531            big.push_str("                                   ");
532        }
533        assert!(matches!(parse_xmp(&big), Err(XmpError::LengthLimit { .. })));
534    }
535
536    /// 1 MiB of valid XMP must be accepted — confirms the 16 MiB cap
537    /// isn't silently re-tightened.
538    #[test]
539    fn parse_xmp_accepts_1_mib_input() {
540        let mut big = String::with_capacity(1024 * 1024 + 100);
541        big.push_str(&generate_gainmap_xmp(&test_metadata()));
542        while big.len() <= 1024 * 1024 {
543            big.push_str("                                   ");
544        }
545        assert!(parse_xmp(&big).is_ok(), "1 MiB XMP must not trip the cap");
546    }
547
548    /// Regression test for issue #144: the gain-map XMP packet from
549    /// `seine_sdr_gainmap_srgb.jpg` (Adobe Camera Raw output), verbatim.
550    /// Scalars are attributes; GainMapMin/Max/Gamma are rdf:Seq
551    /// elements. The old parser zeroed all three element-form fields.
552    #[test]
553    fn parse_xmp_rdf_seq_element_form_seine_fixture() {
554        let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
555<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0-c000 1.000000, 0000/00/00-00:00:00        ">
556 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
557  <rdf:Description rdf:about=""
558    xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/"
559   hdrgm:Version="1.0"
560   hdrgm:BaseRenditionIsHDR="False"
561   hdrgm:OffsetSDR="0.015625"
562   hdrgm:OffsetHDR="0.015625"
563   hdrgm:HDRCapacityMin="0"
564   hdrgm:HDRCapacityMax="1.3">
565   <hdrgm:GainMapMin>
566    <rdf:Seq>
567     <rdf:li>-0.256907</rdf:li>
568     <rdf:li>-0.261365</rdf:li>
569     <rdf:li>-0.280284</rdf:li>
570    </rdf:Seq>
571   </hdrgm:GainMapMin>
572   <hdrgm:GainMapMax>
573    <rdf:Seq>
574     <rdf:li>1.277177</rdf:li>
575     <rdf:li>1.277203</rdf:li>
576     <rdf:li>1.277969</rdf:li>
577    </rdf:Seq>
578   </hdrgm:GainMapMax>
579   <hdrgm:Gamma>
580    <rdf:Seq>
581     <rdf:li>0.953784</rdf:li>
582     <rdf:li>0.941095</rdf:li>
583     <rdf:li>0.919422</rdf:li>
584    </rdf:Seq>
585   </hdrgm:Gamma>
586  </rdf:Description>
587 </rdf:RDF>
588</x:xmpmeta>
589<?xpacket end="w"?>"#;
590        let (parsed, length) = parse_xmp(xmp).expect("seine gain-map XMP must parse");
591        assert_eq!(length, None);
592
593        let expect_min = [-0.256907, -0.261365, -0.280284];
594        let expect_max = [1.277177, 1.277203, 1.277969];
595        let expect_gamma = [0.953784, 0.941095, 0.919422];
596        for i in 0..3 {
597            assert!(
598                (parsed.channels[i].min - expect_min[i]).abs() < XMP_FLOAT_TOL,
599                "ch[{i}].min: {}",
600                parsed.channels[i].min
601            );
602            assert!(
603                (parsed.channels[i].max - expect_max[i]).abs() < XMP_FLOAT_TOL,
604                "ch[{i}].max: {}",
605                parsed.channels[i].max
606            );
607            assert!(
608                (parsed.channels[i].gamma - expect_gamma[i]).abs() < XMP_FLOAT_TOL,
609                "ch[{i}].gamma: {}",
610                parsed.channels[i].gamma
611            );
612            assert!((parsed.channels[i].base_offset - 0.015625).abs() < XMP_FLOAT_TOL);
613            assert!((parsed.channels[i].alternate_offset - 0.015625).abs() < XMP_FLOAT_TOL);
614        }
615        assert!((parsed.base_hdr_headroom - 0.0).abs() < XMP_FLOAT_TOL);
616        assert!((parsed.alternate_hdr_headroom - 1.3).abs() < XMP_FLOAT_TOL);
617        // The downstream gate that exposed #144: gamma=0 fails validate().
618        parsed.validate().expect("parsed params must validate");
619    }
620
621    #[test]
622    fn malformed_numbers_refuse_instead_of_changing_rendering() {
623        let xml = generate_gainmap_xmp(&test_metadata())
624            .replace("hdrgm:Gamma=\"1.000000\"", "hdrgm:Gamma=\"oops\"");
625        assert!(matches!(parse_xmp(&xml), Err(XmpError::Invalid(_))));
626    }
627
628    #[test]
629    fn namespace_alias_and_gainmap_item_identity() {
630        let items = alloc::vec![
631            ContainerItem::primary("image/jpeg"),
632            ContainerItem::secondary(ItemSemantic::DepthMap, "image/jpeg", 9),
633            ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", 123)
634        ];
635        let xml = generate_xmp_with_items(&test_metadata(), &items)
636            .replace("hdrgm:", "gain:")
637            .replace("xmlns:hdrgm=", "xmlns:gain=");
638        assert_eq!(parse_xmp(&xml).unwrap().1, Some(123));
639        let fake = xml.replace(HDRGM_NAMESPACE, "urn:spoof");
640        assert!(matches!(parse_xmp(&fake), Err(XmpError::NotUltraHdr)));
641        let duplicate = xml.replace(
642            "gain:Version=\"1.0\"",
643            "gain:Version=\"1.0\" gain:Version=\"1.0\"",
644        );
645        assert!(parse_xmp(&duplicate).is_err());
646    }
647
648    #[test]
649    fn parse_xmp_full_returns_items_even_without_numeric_metadata() {
650        let items = alloc::vec![
651            ContainerItem::primary("image/jpeg"),
652            ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", 123),
653        ];
654        let xmp = generate_primary_xmp_with_items(&items);
655        let (meta, parsed) = parse_xmp_full(&xmp);
656        assert!(meta.is_some()); // primary_xmp has hdrgm:Version
657        assert_eq!(parsed.len(), 2);
658        assert_eq!(parsed[1].length, Some(123));
659    }
660
661    #[test]
662    fn generate_gainmap_xmp_has_only_numeric_attrs() {
663        let xmp = generate_gainmap_xmp(&test_metadata());
664        assert!(xmp.contains("hdrgm:Version=\"1.0\""));
665        assert!(xmp.contains("hdrgm:GainMapMax"));
666        // Gain-map XMP should NOT carry Container:Directory.
667        assert!(!xmp.contains("Container:Directory"));
668    }
669
670    #[test]
671    fn parse_xmp_no_panic_on_garbage() {
672        let _ = parse_xmp("");
673        let _ = parse_xmp("hdrgm:GainMapMax=\"not_a_number\"");
674        let garbage: String = (0..4096).map(|i| (i % 128) as u8 as char).collect();
675        let _ = parse_xmp(&garbage);
676    }
677
678    // -----------------------------------------------------------------------
679    // Property tests: parse(generate(x)) ≈ x (within f64 serialization tol)
680    // -----------------------------------------------------------------------
681
682    use proptest::prelude::*;
683
684    // `GainMapChannel` and `GainMapParams` are both `#[non_exhaustive]`
685    // in zencodec, so we can't construct them with struct-literal syntax
686    // from this crate. Default + field assignment is the only option;
687    // silence the clippy lint that wants struct literals.
688    #[allow(clippy::field_reassign_with_default)]
689    fn arb_gainmap_params() -> impl Strategy<Value = zencodec::GainMapParams> {
690        // Bounded ranges keep `{:.6}` formatting well inside the f64 ULP
691        // envelope. XMP gain-map values in the wild are bounded to
692        // roughly [-1024, 1024] — we restrict well within that.
693        let ch_arb = (
694            -100.0f64..100.0, // min
695            -100.0f64..100.0, // max
696            0.01f64..10.0,    // gamma (must be > 0 per spec)
697            0.0f64..1.0,      // base_offset
698            0.0f64..1.0,      // alternate_offset
699        )
700            .prop_map(|(min, max, gamma, bo, ao)| {
701                let mut c = zencodec::GainMapChannel::default();
702                c.min = min.min(max);
703                c.max = min.max(max);
704                c.gamma = gamma;
705                c.base_offset = bo;
706                c.alternate_offset = ao;
707                c
708            });
709
710        (
711            [ch_arb.clone(), ch_arb.clone(), ch_arb],
712            -10.0f64..10.0, // base_hdr_headroom
713            0.0f64..10.0,   // alternate_hdr_headroom
714            any::<bool>(),  // use_base_color_space
715        )
716            .prop_map(|(channels, base_hr, alt_hr, use_base)| {
717                let mut m = zencodec::GainMapParams::default();
718                m.channels = channels;
719                m.base_hdr_headroom = base_hr;
720                m.alternate_hdr_headroom = alt_hr;
721                m.use_base_color_space = use_base;
722                m
723            })
724    }
725
726    proptest! {
727        /// Any GainMapParams in the supported numeric range roundtrips
728        /// through `generate_xmp` → `parse_xmp` with all per-channel
729        /// fields (min, max, gamma, base_offset, alternate_offset) and
730        /// both headroom fields preserved within f64 `{:.6}` tolerance.
731        #[test]
732        fn gainmap_xmp_roundtrip(params in arb_gainmap_params(), length in 1usize..10_000_000) {
733            let xmp = generate_xmp(&params, length);
734            let (parsed, parsed_length) = parse_xmp(&xmp)
735                .expect("parse should succeed on self-generated XMP");
736            prop_assert_eq!(parsed_length, Some(length));
737
738            for i in 0..3 {
739                prop_assert!(
740                    (parsed.channels[i].min - params.channels[i].min).abs() < XMP_FLOAT_TOL,
741                    "ch[{i}].min: orig {} parsed {}", params.channels[i].min, parsed.channels[i].min
742                );
743                prop_assert!(
744                    (parsed.channels[i].max - params.channels[i].max).abs() < XMP_FLOAT_TOL,
745                    "ch[{i}].max",
746                );
747                prop_assert!(
748                    (parsed.channels[i].gamma - params.channels[i].gamma).abs() < XMP_FLOAT_TOL,
749                    "ch[{i}].gamma",
750                );
751                prop_assert!(
752                    (parsed.channels[i].base_offset - params.channels[i].base_offset).abs()
753                        < XMP_FLOAT_TOL,
754                    "ch[{i}].base_offset",
755                );
756                prop_assert!(
757                    (parsed.channels[i].alternate_offset - params.channels[i].alternate_offset).abs()
758                        < XMP_FLOAT_TOL,
759                    "ch[{i}].alternate_offset",
760                );
761            }
762            prop_assert!(
763                (parsed.base_hdr_headroom - params.base_hdr_headroom).abs() < XMP_FLOAT_TOL,
764            );
765            prop_assert!(
766                (parsed.alternate_hdr_headroom - params.alternate_hdr_headroom).abs()
767                    < XMP_FLOAT_TOL,
768            );
769        }
770    }
771}