Skip to main content

zenmeta/xmp/
container.rs

1//! XMP container-directory values and serialization, independent of file framing.
2
3mod directory;
4pub use directory::Directory;
5
6use alloc::string::String;
7use alloc::vec::Vec;
8
9// ===========================================================================
10// GContainer (XMP Container:Directory)
11// ===========================================================================
12
13/// Semantic role of an item in a GContainer directory.
14///
15/// These correspond to `Item:Semantic` values in the XMP
16/// `Container:Directory`. Used by Ultra HDR, Android Dynamic Depth
17/// Format, and Google Camera output.
18#[derive(Clone, Debug, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum ItemSemantic {
21    /// Primary image (`"Primary"`).
22    Primary,
23    /// HDR gain map (`"GainMap"`).
24    GainMap,
25    /// Depth map (`"DepthMap"`).
26    DepthMap,
27    /// Confidence / quality map for depth (`"ConfidenceMap"`).
28    ConfidenceMap,
29    /// Unrecognized or vendor-specific semantic.
30    Other(String),
31}
32
33impl ItemSemantic {
34    /// Parse from XMP `Item:Semantic` attribute value.
35    #[must_use]
36    pub fn from_xmp(s: &str) -> Self {
37        match s {
38            "Primary" => Self::Primary,
39            "GainMap" => Self::GainMap,
40            "DepthMap" => Self::DepthMap,
41            "ConfidenceMap" => Self::ConfidenceMap,
42            other => Self::Other(String::from(other)),
43        }
44    }
45
46    /// XMP-facing string representation.
47    #[must_use]
48    pub fn as_xmp_str(&self) -> &str {
49        match self {
50            Self::Primary => "Primary",
51            Self::GainMap => "GainMap",
52            Self::DepthMap => "DepthMap",
53            Self::ConfidenceMap => "ConfidenceMap",
54            Self::Other(s) => s.as_str(),
55        }
56    }
57}
58
59/// An item in a GContainer directory.
60///
61/// Parsed from `Container:Directory` → `rdf:Seq` → `rdf:li` entries in
62/// XMP. Each item describes a secondary image appended after the primary
63/// JPEG's EOI.
64///
65/// The [`padding`](Self::padding) field is from Google's Dynamic Depth
66/// Format extension and not present in canonical Ultra HDR.
67#[derive(Clone, Debug, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct ContainerItem {
70    /// Semantic role.
71    pub semantic: ItemSemantic,
72    /// MIME type (e.g. `"image/jpeg"`, `"image/png"`).
73    pub mime: String,
74    /// Byte length of this item's payload. `None` for the primary image
75    /// (its size is determined by its own EOI).
76    pub length: Option<usize>,
77    /// Padding bytes before this item. Used by some Dynamic Depth
78    /// Format implementations.
79    pub padding: Option<usize>,
80}
81
82impl ContainerItem {
83    /// Create a primary image item.
84    #[must_use]
85    pub fn primary(mime: impl Into<String>) -> Self {
86        Self {
87            semantic: ItemSemantic::Primary,
88            mime: mime.into(),
89            length: None,
90            padding: None,
91        }
92    }
93
94    /// Create a secondary item with a known length.
95    #[must_use]
96    pub fn secondary(semantic: ItemSemantic, mime: impl Into<String>, length: usize) -> Self {
97        Self {
98            semantic,
99            mime: mime.into(),
100            length: Some(length),
101            padding: None,
102        }
103    }
104}
105
106/// GContainer `Container:Directory` items extracted from XMP.
107///
108/// Returns items in order of appearance; the first item is conventionally
109/// [`ItemSemantic::Primary`].
110///
111/// Parsed from `rdf:Seq` inside `Container:Directory`, extracting
112/// `Item:Semantic`, `Item:Mime`, `Item:Length`, and `Item:Padding`.
113#[must_use]
114pub fn parse_container_items(xmp: &str) -> Vec<ContainerItem> {
115    // The public fragment generator omits namespace declarations. Supply its
116    // documented context only for that fragment shape, never for a full packet.
117    let wrapped;
118    let xmp = if xmp.trim_start().starts_with("<Container:Directory") {
119        wrapped = alloc::format!(
120            r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:Container="http://ns.google.com/photos/1.0/container/" xmlns:Item="http://ns.google.com/photos/1.0/container/item/">{xmp}</rdf:Description></rdf:RDF>"#
121        );
122        wrapped.as_str()
123    } else {
124        xmp
125    };
126    crate::xmp::Packet::parse(xmp)
127        .ok()
128        .and_then(|p| {
129            let gain = super::gainmap::checked_container_items(&p).ok()?;
130            let depth = super::gainmap::checked_items_in_namespace(
131                &p,
132                "http://ns.google.com/photos/dd/1.0/container/",
133                "http://ns.google.com/photos/dd/1.0/item/",
134            )
135            .ok()?;
136            if gain.is_empty() {
137                Some(depth)
138            } else if depth.is_empty() {
139                Some(gain)
140            } else {
141                None
142            }
143        })
144        .unwrap_or_default()
145}
146
147/// Generate a `Container:Directory` XML fragment for a list of items.
148///
149/// The result is an `rdf:Seq` block suitable for embedding inside an
150/// `rdf:Description` that declares the Container and Item namespaces.
151#[must_use]
152pub fn generate_container_directory(items: &[ContainerItem]) -> String {
153    let mut xml = String::from("      <Container:Directory>\n        <rdf:Seq>\n");
154    for item in items {
155        xml.push_str("          <rdf:li rdf:parseType=\"Resource\">\n");
156        xml.push_str("            <Container:Item\n");
157        xml.push_str(&alloc::format!(
158            "                Item:Semantic=\"{}\"\n",
159            escape_xml_attribute(item.semantic.as_xmp_str())
160        ));
161        xml.push_str(&alloc::format!(
162            "                Item:Mime=\"{}\"",
163            escape_xml_attribute(&item.mime)
164        ));
165        if let Some(length) = item.length {
166            xml.push_str(&alloc::format!(
167                "\n                Item:Length=\"{length}\""
168            ));
169        }
170        if let Some(padding) = item.padding {
171            xml.push_str(&alloc::format!(
172                "\n                Item:Padding=\"{padding}\""
173            ));
174        }
175        xml.push_str("/>\n");
176        xml.push_str("          </rdf:li>\n");
177    }
178    xml.push_str("        </rdf:Seq>\n      </Container:Directory>");
179    xml
180}
181
182fn escape_xml_attribute(value: &str) -> String {
183    value
184        .replace('&', "&amp;")
185        .replace('<', "&lt;")
186        .replace('"', "&quot;")
187        .replace('\r', "&#13;")
188        .replace('\n', "&#10;")
189        .replace('\t', "&#9;")
190}
191
192// ===========================================================================
193// Tests
194// ===========================================================================
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use alloc::vec;
200
201    #[test]
202    fn item_semantic_roundtrip_known() {
203        for s in ["Primary", "GainMap", "DepthMap", "ConfidenceMap"] {
204            let parsed = ItemSemantic::from_xmp(s);
205            assert_eq!(parsed.as_xmp_str(), s);
206        }
207    }
208
209    #[test]
210    fn item_semantic_roundtrip_custom() {
211        let other = ItemSemantic::from_xmp("CustomVendorSemantic");
212        assert_eq!(other.as_xmp_str(), "CustomVendorSemantic");
213    }
214
215    #[test]
216    fn item_semantic_empty_string_is_other() {
217        let e = ItemSemantic::from_xmp("");
218        assert_eq!(e.as_xmp_str(), "");
219    }
220
221    #[test]
222    fn container_item_primary_constructor_has_no_length() {
223        let p = ContainerItem::primary("image/jpeg");
224        assert_eq!(p.semantic, ItemSemantic::Primary);
225        assert_eq!(p.mime, "image/jpeg");
226        assert!(p.length.is_none());
227        assert!(p.padding.is_none());
228    }
229
230    #[test]
231    fn container_item_secondary_constructor_sets_length() {
232        let g = ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", 5000);
233        assert_eq!(g.length, Some(5000));
234        assert_eq!(g.semantic, ItemSemantic::GainMap);
235    }
236
237    #[test]
238    fn parse_container_items_ultrahdr_shape() {
239        let xmp = r#"
240            <Container:Directory>
241                <rdf:Seq>
242                    <rdf:li rdf:parseType="Resource">
243                        <Container:Item
244                            Item:Semantic="Primary"
245                            Item:Mime="image/jpeg"/>
246                    </rdf:li>
247                    <rdf:li rdf:parseType="Resource">
248                        <Container:Item
249                            Item:Semantic="GainMap"
250                            Item:Mime="image/jpeg"
251                            Item:Length="5000"/>
252                    </rdf:li>
253                </rdf:Seq>
254            </Container:Directory>"#;
255        let items = parse_container_items(xmp);
256        assert_eq!(items.len(), 2);
257        assert_eq!(items[0].semantic, ItemSemantic::Primary);
258        assert_eq!(items[0].mime, "image/jpeg");
259        assert!(items[0].length.is_none());
260        assert_eq!(items[1].semantic, ItemSemantic::GainMap);
261        assert_eq!(items[1].length, Some(5000));
262    }
263
264    #[test]
265    fn parse_container_items_android_ddf_shape() {
266        let xmp = r#"
267            <Container:Directory>
268                <rdf:Seq>
269                    <rdf:li rdf:parseType="Resource">
270                        <Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/>
271                    </rdf:li>
272                    <rdf:li rdf:parseType="Resource">
273                        <Container:Item Item:Semantic="DepthMap" Item:Mime="image/jpeg" Item:Length="8000"/>
274                    </rdf:li>
275                    <rdf:li rdf:parseType="Resource">
276                        <Container:Item Item:Semantic="ConfidenceMap" Item:Mime="image/jpeg" Item:Length="3000"/>
277                    </rdf:li>
278                </rdf:Seq>
279            </Container:Directory>"#;
280        let items = parse_container_items(xmp);
281        assert_eq!(items.len(), 3);
282        assert_eq!(items[1].semantic, ItemSemantic::DepthMap);
283        assert_eq!(items[2].semantic, ItemSemantic::ConfidenceMap);
284    }
285
286    #[test]
287    fn parse_container_items_with_padding() {
288        let xmp = r#"
289            <Container:Directory>
290                <rdf:Seq>
291                    <rdf:li rdf:parseType="Resource">
292                        <Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/>
293                    </rdf:li>
294                    <rdf:li rdf:parseType="Resource">
295                        <Container:Item Item:Semantic="GainMap" Item:Mime="image/jpeg" Item:Length="5000" Item:Padding="16"/>
296                    </rdf:li>
297                </rdf:Seq>
298            </Container:Directory>"#;
299        let items = parse_container_items(xmp);
300        assert_eq!(items[1].padding, Some(16));
301    }
302
303    #[test]
304    fn parse_container_items_empty_string() {
305        assert!(parse_container_items("").is_empty());
306    }
307
308    #[test]
309    fn parse_container_items_missing_attrs_skips_item() {
310        // rdf:li with no Item:Semantic should be skipped rather than
311        // erroring.
312        let xmp = r#"
313            <Container:Directory>
314                <rdf:Seq>
315                    <rdf:li rdf:parseType="Resource">
316                        <Container:Item Item:Mime="image/jpeg"/>
317                    </rdf:li>
318                </rdf:Seq>
319            </Container:Directory>"#;
320        assert!(parse_container_items(xmp).is_empty());
321    }
322
323    #[test]
324    fn generate_container_directory_roundtrip_two_items() {
325        let items = vec![
326            ContainerItem::primary("image/jpeg"),
327            ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", 5000),
328        ];
329        let xml = generate_container_directory(&items);
330        let parsed = parse_container_items(&xml);
331        assert_eq!(parsed.len(), 2);
332        assert_eq!(parsed[0].semantic, ItemSemantic::Primary);
333        assert_eq!(parsed[1].semantic, ItemSemantic::GainMap);
334        assert_eq!(parsed[1].length, Some(5000));
335    }
336
337    #[test]
338    fn generate_container_directory_roundtrip_preserves_padding() {
339        let items = vec![
340            ContainerItem::primary("image/jpeg"),
341            ContainerItem {
342                semantic: ItemSemantic::GainMap,
343                mime: String::from("image/jpeg"),
344                length: Some(1000),
345                padding: Some(32),
346            },
347        ];
348        let xml = generate_container_directory(&items);
349        let parsed = parse_container_items(&xml);
350        assert_eq!(parsed[1].padding, Some(32));
351    }
352
353    #[test]
354    fn generate_container_directory_includes_all_fields() {
355        let items = vec![
356            ContainerItem::primary("image/jpeg"),
357            ContainerItem::secondary(ItemSemantic::GainMap, "image/jpeg", 5000),
358            ContainerItem::secondary(ItemSemantic::DepthMap, "image/png", 12000),
359        ];
360        let xml = generate_container_directory(&items);
361        assert!(xml.contains("GainMap"));
362        assert!(xml.contains("DepthMap"));
363        assert!(xml.contains("image/png"));
364        assert!(xml.contains("12000"));
365    }
366
367    // -----------------------------------------------------------------------
368    // Property tests: parse(generate(x)) == x (modulo canonicalization)
369    // -----------------------------------------------------------------------
370
371    use proptest::prelude::*;
372
373    fn arb_item_semantic() -> impl Strategy<Value = ItemSemantic> {
374        prop_oneof![
375            Just(ItemSemantic::Primary),
376            Just(ItemSemantic::GainMap),
377            Just(ItemSemantic::DepthMap),
378            Just(ItemSemantic::ConfidenceMap),
379            // `Other` must avoid XML-special chars to survive a naive XML
380            // roundtrip. The parser doesn't decode entities, so restrict
381            // to a safe ASCII subset.
382            "[A-Za-z][A-Za-z0-9_-]{0,15}".prop_map(ItemSemantic::Other),
383        ]
384    }
385
386    fn arb_container_item() -> impl Strategy<Value = ContainerItem> {
387        (
388            arb_item_semantic(),
389            prop_oneof![
390                Just(String::from("image/jpeg")),
391                Just(String::from("image/png")),
392                Just(String::from("image/heic")),
393                Just(String::from("application/octet-stream")),
394            ],
395            proptest::option::of(1usize..10_000_000),
396            proptest::option::of(0usize..1024),
397        )
398            .prop_map(|(semantic, mime, length, padding)| ContainerItem {
399                semantic,
400                mime,
401                length,
402                padding,
403            })
404    }
405
406    proptest! {
407        /// Full field-preserving roundtrip:
408        /// `generate_container_directory` → `parse_container_items`
409        /// preserves `semantic`, `mime`, `length`, and `padding` exactly.
410        /// The emitter is a pure pass-through (no Primary-special-casing,
411        /// no forced defaults); `None` length/padding is absent in the XML
412        /// and parses back as `None`.
413        #[test]
414        fn container_directory_roundtrip(
415            items in proptest::collection::vec(arb_container_item(), 1..5),
416        ) {
417            let xml = generate_container_directory(&items);
418            let parsed = parse_container_items(&xml);
419            prop_assert_eq!(parsed.len(), items.len());
420            for (i, (orig, got)) in items.iter().zip(parsed.iter()).enumerate() {
421                prop_assert_eq!(
422                    &orig.semantic, &got.semantic,
423                    "item {}: semantic", i,
424                );
425                prop_assert_eq!(&orig.mime, &got.mime, "item {}: mime", i);
426                prop_assert_eq!(orig.length, got.length, "item {}: length", i);
427                prop_assert_eq!(orig.padding, got.padding, "item {}: padding", i);
428            }
429        }
430    }
431}