diff --git a/crates/metadata/README.md b/crates/metadata/README.md index 72ea0063d..080f36eb8 100644 --- a/crates/metadata/README.md +++ b/crates/metadata/README.md @@ -19,6 +19,7 @@ are one crate. | `parse_xmp(&str) -> Result` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. | | `write_xmp(&Metadata) -> String` | A complete `` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. | | `XmpData::has_location_metadata`, `XmpData::properties_truncated` | Whether a packet names GPS or a place anywhere (array items and repeated structs included), and whether the flattened map is complete, for a caller that must prove a file carries no location. `parse_gps_position` reads an XMP coordinate pair. | +| `embedded_xmp(&Embedded)`, `parse_xmp_pair` | A JPEG's standard XMP with the Extended XMP it links (`xmpNote:HasExtendedXMP`) read as one packet; an `EmbeddedXmpError` (keeping the standard packet) when the link or the extension cannot be read whole. `extract` and `from_embedded` use it best-effort. Extended XMP: 16 MiB, 4096 chunks, only the linked GUID's chunks counted. | | `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. | | `parse_gpx(&str) -> Result` | GPX track points with their track and segment; `Tracklog::locate` gives the position at a capture time, `Tracklog::only_track` one track. | | `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | diff --git a/crates/metadata/src/container.rs b/crates/metadata/src/container.rs index bb787c545..842020683 100644 --- a/crates/metadata/src/container.rs +++ b/crates/metadata/src/container.rs @@ -33,6 +33,10 @@ pub struct Embedded { pub iptc: Option>, /// True when the PNG/WebP chunk stream could not be fully inspected. pub chunk_walk_incomplete: bool, + /// Why the Extended XMP the standard packet links to (`xmpNote:HasExtendedXMP`) could not be + /// read: its chunks are missing, truncated, too large, or do not cover their declared length + /// exactly once. [`Embedded::xmp_extended`] is `None` then. + pub xmp_extended_error: Option, } /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. @@ -70,6 +74,7 @@ pub fn embedded(bytes: &[u8]) -> Embedded { other => craft_tiff::writer::encode(t.order, other), }), chunk_walk_incomplete: false, + xmp_extended_error: None, } } else { Embedded::default() @@ -209,12 +214,97 @@ fn jpeg_segments_inner(b: &[u8], collect_all_xmp: bool) -> (Embedded, Vec)>, ext_headers: &[(&[u8], u32)]) { + // a standard packet that links an extension: only that GUID's chunks count, checked strictly + let reference = (!ext.is_empty()) + .then(|| out.xmp.as_deref().and_then(|x| crate::parse_xmp(x).ok()).and_then(|d| crate::xmp::extended_xmp_reference(&d).ok().flatten())) + .flatten(); + if let Some(guid) = reference { + match extended_xmp(b, &guid) { + Ok(packet) => { + out.xmp_extended = Some(packet); + out.xmp_extended_guid = Some(guid); + } + Err(error) => out.xmp_extended_error = Some(error), + } + } else if !ext.is_empty() { ext.sort_by_key(|p| p.0); - out.xmp_extended_guid = verified_extension_guid(&ext_headers, &ext); + out.xmp_extended_guid = verified_extension_guid(ext_headers, &ext); out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::>()).into_owned()); } - (out, xmp_packets) +} + +/// Most bytes an Extended XMP packet may have, and most chunks it may be split into. +const MAX_EXTENDED_XMP_BYTES: usize = 16 * 1024 * 1024; +const MAX_EXTENDED_XMP_CHUNKS: usize = 4096; + +/// The Extended XMP packet of a JPEG for `guid` (upper-case hex, from the standard packet's +/// `xmpNote:HasExtendedXMP`; Adobe XMP Specification Part 3, 1.1.3.1): the APP1 chunks carrying +/// that GUID, which must all declare one full length of at most [`MAX_EXTENDED_XMP_BYTES`] and +/// together cover it exactly once, in at most [`MAX_EXTENDED_XMP_CHUNKS`] complete segments. +/// Chunks of any other GUID are not looked at, so they cannot exhaust the limits. +fn extended_xmp(b: &[u8], guid: &str) -> Result { + let mut chunks: Vec<(u32, &[u8])> = Vec::new(); + let (mut total, mut received) = (None, 0usize); + let mut i = 2usize; + while let (Some(&0xff), Some(&marker)) = (b.get(i), b.get(i + 1)) { + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))).filter(|&l| l >= 2) else { break }; + let (start, end) = (i + 4, i + 2 + len); + let payload = b.get(start..end.min(b.len())).unwrap_or_default(); + if let Some(p) = payload.strip_prefix(XMP_EXT_SIG).filter(|_| marker == 0xe1) + && p.get(..32).is_some_and(|g| g.eq_ignore_ascii_case(guid.as_bytes())) + { + if end > b.len() { + return Err("Extended XMP APP1 segment is truncated".into()); + } + let (Some(full), Some(offset), Some(data)) = (p.get(32..36), p.get(36..40), p.get(40..)) else { + return Err("Extended XMP APP1 header is truncated".into()); + }; + let full = u32::from_be_bytes([full[0], full[1], full[2], full[3]]); + if *total.get_or_insert(full) != full { + return Err("Extended XMP chunks declare different full lengths".into()); + } + received = received.saturating_add(data.len()); + if usize::try_from(full).map_or(true, |f| f > MAX_EXTENDED_XMP_BYTES) || received > MAX_EXTENDED_XMP_BYTES { + return Err("Extended XMP is larger than 16 MiB".into()); + } + if chunks.len() >= MAX_EXTENDED_XMP_CHUNKS { + return Err("Extended XMP has too many chunks".into()); + } + chunks.push((u32::from_be_bytes([offset[0], offset[1], offset[2], offset[3]]), data)); + } + i = end; + } + let total = total.ok_or("the referenced Extended XMP is missing")?; + chunks.sort_by_key(|c| c.0); + let mut packet = Vec::with_capacity(received); + for (offset, data) in chunks { + if usize::try_from(offset).ok() != Some(packet.len()) { + return Err("Extended XMP chunks have a gap, an overlap or a repeated offset".into()); + } + packet.extend_from_slice(data); + } + if u32::try_from(packet.len()).ok() != Some(total) || total == 0 { + return Err("Extended XMP chunks do not cover their declared full length".into()); + } + String::from_utf8(packet).map_err(|_| "Extended XMP is not UTF-8".into()) } /// The GUID shared by all Extended XMP segments when their offset-sorted chunks are contiguous from diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index baf9fe18c..1bc9660b8 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -332,7 +332,7 @@ pub fn extract(bytes: &[u8]) -> Metadata { /// "unreadable" uses [`try_read_exif`] and [`parse_xmp`] on the blocks first. pub fn from_embedded(e: &Embedded) -> Metadata { let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); - let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()); + let xmp = embedded_xmp(e).unwrap_or_else(|error| error.standard.map(|x| *x)); // the size the Exif states: a size only the XMP states may be the upright one, so it can't tell how the photo is stored let stored = m.width.zip(m.height); // and the orientation the Exif states: the photo is displayed by that alone, so an orientation only the XMP @@ -355,6 +355,54 @@ pub fn from_embedded(e: &Embedded) -> Metadata { m } +/// The XMP of `e` read as one packet: the standard packet, with the Extended XMP it links to +/// (`xmpNote:HasExtendedXMP`) merged in (see [`xmp::parse_xmp_pair`]). An error when the standard +/// packet is not XMP, when its link is malformed, when the linked extension is missing or +/// unreadable ([`Embedded::xmp_extended_error`]), or when there is an extension nothing links to: +/// a caller that must see all of a file's metadata (to prove it carries no location, say) refuses +/// the file then. [`from_embedded`] falls back to [`EmbeddedXmpError::standard`]. +pub fn embedded_xmp(e: &Embedded) -> Result, EmbeddedXmpError> { + let fail = |standard: Option, message: String| EmbeddedXmpError { standard: standard.map(Box::new), message }; + let Some(packet) = e.xmp.as_deref() else { + return match (&e.xmp_extended, &e.xmp_extended_error) { + (None, None) => Ok(None), + _ => Err(fail(None, "Extended XMP is present without a standard packet".into())), + }; + }; + let parsed = parse_xmp(packet).map_err(|error| fail(None, format!("embedded XMP is invalid: {error}")))?; + let mut standard = parsed.clone(); + standard.properties.remove("xmpNote:HasExtendedXMP"); + standard.values.remove("xmpNote:HasExtendedXMP"); + let reference = xmp::extended_xmp_reference(&parsed).map_err(|error| fail(Some(standard.clone()), error))?; + match (reference, e.xmp_extended.as_deref()) { + (Some(guid), Some(extended)) if e.xmp_extended_guid.as_deref() == Some(guid.as_str()) => xmp::parse_xmp_pair(packet, extended, &[]) + .map(Some) + .map_err(|error| fail(Some(standard), format!("the linked Extended XMP is invalid: {error}"))), + (Some(_), _) => { + let why = e.xmp_extended_error.clone().unwrap_or_else(|| "the linked Extended XMP is missing".into()); + Err(fail(Some(standard), why)) + } + (None, Some(_)) => Err(fail(Some(standard), "Extended XMP is present but the standard packet does not link it".into())), + (None, None) => Ok(Some(standard)), + } +} + +/// Why [`embedded_xmp`] could not read a file's XMP whole. +#[derive(Clone, Debug, PartialEq)] +pub struct EmbeddedXmpError { + /// The standard packet, when it could be read: what a best-effort reader uses. + pub standard: Option>, + pub message: String, +} + +impl std::fmt::Display for EmbeddedXmpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for EmbeddedXmpError {} + /// Turn regions drawn on a photo as it is stored onto the upright photo. MWG gives each box as a fraction of /// `AppliedToDimensions` (`drawn_on`); for a photo whose Exif `orientation` turns it a quarter turn, some apps give the /// stored, sideways size there and boxes on the stored pixels to match (Lightroom says so per box with @@ -752,4 +800,262 @@ mod tests { assert_eq!(m.orientation, Some(Orientation::Rotate90)); assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect); } + + const JPEG_XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; + + const JPEG_XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + + fn jpeg_segment(marker: u8, payload: &[u8]) -> Vec { + let len = u16::try_from(payload.len() + 2).expect("test APP1 payload fits u16"); + let mut segment = vec![0xff, marker]; + segment.extend_from_slice(&len.to_be_bytes()); + segment.extend_from_slice(payload); + segment + } + + fn xmp_jpeg(standard: &str, guid: &str, extension: &str, chunks: &[(u32, &[u8])]) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &packet)); + for (offset, data) in chunks { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&(extension.len() as u32).to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(data); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + jpeg + } + + fn append_extended_chunk(jpeg: &mut Vec, guid: &str, total_length: u32, offset: u32, data: &[u8]) { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&total_length.to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(data); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + + fn xmp_document(body: &str) -> String { + format!( + r#""# + ) + } + + fn referenced_standard(guid: &str, extra: &str) -> String { + xmp_document(&format!(r#"xmlns:reference="http://ns.adobe.com/xmp/note/" reference:HasExtendedXMP="{guid}" {extra}"#)) + } + + fn split_chunks(packet: &str) -> [(u32, &[u8]); 2] { + let split = packet.len() / 2; + let (first, second) = packet.as_bytes().split_at(split); + [(split as u32, second), (0, first)] + } + + fn raf_with_jpeg(jpeg: &[u8]) -> Vec { + let offset = 120u32; + let length = u32::try_from(jpeg.len()).expect("test JPEG fits RAF length"); + let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + raf.resize(offset as usize, 0); + raf[84..88].copy_from_slice(&offset.to_be_bytes()); + raf[88..92].copy_from_slice(&length.to_be_bytes()); + raf.extend_from_slice(jpeg); + raf + } + + #[test] + fn extended_xmp_gps_reaches_extractor() { + let guid = "A1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="45,0N" exif:GPSLongitude="7,0E""#); + let standard = referenced_standard(guid, ""); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + let gps = extract(&jpeg).gps.expect("the extension GPS reaches extraction"); + assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0)); + } + + #[test] + fn extended_xmp_caption_reaches_extractor() { + let guid = "B1234567890123456789012345678901"; + let caption = "multi-chunk caption ".repeat(160); + let extension = xmp_document(&format!(r#"xmlns:dc="http://purl.org/dc/elements/1.1/">{caption}"#)); + let standard = referenced_standard(guid, ""); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + assert_eq!(extract(&jpeg).caption.as_deref(), Some(caption.trim())); + } + + #[test] + fn extended_xmp_split_gps_properties_are_combined() { + let guid = "C1234567890123456789012345678901"; + let standard = xmp_document(&format!( + r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}" exif:GPSLatitude="45,0N""# + )); + let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLongitude="7,0E""#); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + let gps = extract(&jpeg).gps.expect("GPS properties across packets compose"); + assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0)); + } + + #[test] + fn extended_xmp_structured_values_are_composed() { + let guid = "F1234567890123456789012345678901"; + let standard = xmp_document(&format!( + r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}">True"# + )); + let extension = + xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/">2"#); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + assert_eq!(extract(&jpeg).flash.map(|flash| flash.raw), Some(17)); + } + + #[test] + fn extended_xmp_ignores_malformed_chunks_for_unreferenced_guids() { + let guid = "A1234567890123456789012345678901"; + let other_guid = "B1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + let mut malformed = JPEG_XMP_EXT_SIG.to_vec(); + malformed.extend_from_slice(other_guid.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &malformed)); + + assert_eq!(extract(&jpeg).title.as_deref(), Some("Referenced title")); + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_ignores_chunk_limit_for_unreferenced_guids() { + let guid = "C1234567890123456789012345678901"; + let other_guid = "D1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + for _ in 0..4097 { + append_extended_chunk(&mut jpeg, other_guid, 1, 0, &[]); + } + + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_ignores_received_byte_limit_for_unreferenced_guids() { + let guid = "E1234567890123456789012345678901"; + let other_guid = "F1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + let data = vec![0; 65_200]; + for _ in 0..258 { + append_extended_chunk(&mut jpeg, other_guid, 65_200, 0, &data); + } + + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_rejects_mismatched_guid_and_bad_coverage() { + let guid = "D1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extension title""#); + let standard = referenced_standard(guid, r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#); + let bytes = extension.as_bytes(); + let middle = bytes.len() / 2; + let inconsistent_lengths = { + let mut jpeg = vec![0xff, 0xd8]; + let mut standard_payload = JPEG_XMP_SIG.to_vec(); + standard_payload.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &standard_payload)); + for (offset, declared, part) in + [(0u32, bytes.len() as u32, &bytes[..middle]), (middle as u32, (bytes.len() + 1) as u32, &bytes[middle..])] + { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&declared.to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(part); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + jpeg + }; + let oversized_length = { + let mut jpeg = vec![0xff, 0xd8]; + let mut standard_payload = JPEG_XMP_SIG.to_vec(); + standard_payload.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &standard_payload)); + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&(16u32 * 1024 * 1024 + 1).to_be_bytes()); + payload.extend_from_slice(&0u32.to_be_bytes()); + payload.extend_from_slice(bytes); + jpeg.extend(jpeg_segment(0xe1, &payload)); + jpeg + }; + let foreign_namespace_reference = { + let foreign_standard = xmp_document(&format!( + r#"xmlns:note="urn:example:foreign-note" note:HasExtendedXMP="{guid}" xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""# + )); + xmp_jpeg(&foreign_standard, guid, &extension, &split_chunks(&extension)) + }; + let mut truncated_segment = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + truncated_segment.pop(); + + let cases = [ + xmp_jpeg(&standard, "E1234567890123456789012345678901", &extension, &split_chunks(&extension)), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle + 1) as u32, &bytes[middle..])]), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle - 1) as u32, &bytes[middle..])]), + xmp_jpeg(&standard, guid, &extension, &[(0, bytes), (0, bytes)]), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle])]), + inconsistent_lengths, + oversized_length, + truncated_segment, + foreign_namespace_reference, + ]; + for (index, jpeg) in cases.iter().enumerate() { + // the last case links nothing (a foreign HasExtendedXMP): its chunks stay visible as an unlinked + // extension, as before, and embedded_xmp still refuses it below + if index + 1 < cases.len() { + assert!(embedded(jpeg).xmp_extended.is_none(), "malformed extension case {index} was exposed"); + } + assert_eq!(extract(jpeg).title.as_deref(), Some("Standard title"), "standard XMP remains readable"); + assert!(embedded_xmp(&embedded(jpeg)).is_err(), "embedded_xmp must reject malformed extension case {index}"); + let error = embedded_xmp(&embedded(jpeg)).expect_err("invalid referenced extension is reported"); + assert_eq!(error.standard.as_deref().and_then(|xmp| xmp.metadata.title.as_deref()), Some("Standard title")); + } + } + + #[test] + fn standard_only_jpeg_xmp_still_extracts() { + let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard-only title""#); + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &packet)); + assert_eq!(extract(&jpeg).title.as_deref(), Some("Standard-only title")); + } + + #[test] + fn embedded_xmp_rejects_malformed_xmp_but_extract_remains_best_effort() { + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(b" Result>, values: &BTreeMap) -> Metadata { let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let num = |k: &str| first(k).and_then(|s| parse_number(&s)); let mut m = Metadata { @@ -1107,13 +1120,67 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result Result { + let ext = parse_xmp_with(extended, extra)?; + let std = parse_xmp_with(standard, extra)?; + let mut properties = ext.properties; + properties.extend(std.properties); + let mut values = ext.values; + for (key, preferred) in std.values { + match values.get_mut(&key) { + Some(fallback) => *fallback = merge_xmp_value(&preferred, fallback), + None => { + values.insert(key, preferred); + } + } + } + properties.remove("xmpNote:HasExtendedXMP"); + values.remove("xmpNote:HasExtendedXMP"); + let metadata = project(&properties, &values); + Ok(XmpData { + metadata, + properties, + properties_truncated: std.properties_truncated || ext.properties_truncated, + values, + toolkit: std.toolkit, + names_location: std.names_location || ext.names_location, + }) +} + +/// `preferred` with what `fallback` adds: array items appended, struct fields merged. +fn merge_xmp_value(preferred: &XmpValue, fallback: &XmpValue) -> XmpValue { + match (preferred, fallback) { + (XmpValue::Array(preferred), XmpValue::Array(fallback)) => XmpValue::Array(preferred.iter().chain(fallback).cloned().collect()), + (XmpValue::Struct(preferred), XmpValue::Struct(fallback)) => { + let mut merged = fallback.clone(); + for (key, value) in preferred { + let combined = merged.get(key).map_or_else(|| value.clone(), |fallback| merge_xmp_value(value, fallback)); + merged.insert(key.clone(), combined); + } + XmpValue::Struct(merged) + } + _ => preferred.clone(), + } +} + +/// The GUID a standard packet's `xmpNote:HasExtendedXMP` names, upper-cased; `Ok(None)` without +/// one. Only the XMP Note namespace counts (a foreign `HasExtendedXMP` is not a link), and the +/// property must occur once, as 32 hexadecimal digits. +pub fn extended_xmp_reference(data: &XmpData) -> Result, String> { + let Some(values) = data.properties.get("xmpNote:HasExtendedXMP") else { return Ok(None) }; + let [value] = values.as_slice() else { + return Err("XMP HasExtendedXMP reference must occur exactly once".into()); + }; + if value.len() != 32 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("XMP HasExtendedXMP reference must be 32 hexadecimal characters".into()); + } + Ok(Some(value.to_ascii_uppercase())) } fn frac(v: f64) -> String { @@ -1189,9 +1256,13 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { x.push_str(&format!("\n", escape(xml_safe(with.toolkit)))); x.push_str(" \n"); x.push_str(" own, + "xmpNote" => None, + _ => Some(ns), + }) { x.push_str(&format!("\n xmlns:{p}=\"{}\"", escape(xml_safe(u)))); } x.push_str(">\n");