//! TRACES: FR-CAT-13 //! Reading a standard XMP sidecar. //! //! # One collector for four shapes //! //! A property may appear in this file in more ways than the specification //! needs, because the specification is not what wrote it. `xmp:Rating` is an //! attribute on `rdf:Description` in one application's output and a child //! element in another's; `dc:subject` is an `rdf:Bag` of `rdf:li` in both, but //! a phone that half-implemented XMP will write it as a bare string. A reader //! that insisted on the declared [`Shape`](crate::Shape) per property would //! reject files that every other editor reads. //! //! So reading is shape-blind: whatever is inside an owned property's element, //! it is collected as a list of `(language, text)` items, and the property's //! declared shape decides only *how many* of them survive — all of them for a //! list, one for a scalar. That is one collector rather than four parsers, and //! it is why a `dc:title` written as a plain string and one written as an //! `rdf:Alt` both arrive as the same title. //! //! # Text arrives in pieces //! //! quick-xml reports an entity reference as its own event rather than folding //! it into the surrounding text, so `Bells & whistles` is three events. The //! collector therefore accumulates rather than taking the first run — a keyword //! with an ampersand in it is an ordinary keyword, and reading it as "Bells" //! would be a silent, permanent corruption of the user's vocabulary. use quick_xml::escape::unescape; use quick_xml::events::Event; use quick_xml::name::ResolveResult; use quick_xml::{NsReader, XmlVersion}; use crate::{owner, Field, Found, Item, Shape, Xmp, XmpError, NS_RDF}; /// The property currently open, and what has been collected of it. struct Capture { field: Field, /// How many elements are open *inside* the property. Zero means the next /// `End` closes the property itself. depth: usize, items: Vec, /// The `rdf:li` currently open, if one is. li: Option, /// Text found directly inside the property element, which is what a /// container-less value looks like. direct: String, } impl Capture { fn new(field: Field) -> Self { Capture { field, depth: 0, items: Vec::new(), li: None, direct: String::new(), } } /// Append a run of text to wherever we currently are. fn push_text(&mut self, text: &str) { match self.li.as_mut() { Some(li) => li.text.push_str(text), None => self.direct.push_str(text), } } /// What this property turned out to hold. /// /// The `rdf:li` entries where there were any, and the direct text /// otherwise. Blank entries are dropped: an empty `rdf:li` is a container /// somebody left behind, not a keyword. fn finish(mut self) -> Vec { for item in &mut self.items { item.text = item.text.trim().to_string(); } self.items.retain(|i| !i.text.is_empty()); if self.items.is_empty() { let direct = self.direct.trim(); if !direct.is_empty() { self.items.push(Item::plain(direct)); } } self.items } } pub(crate) fn parse(text: &str) -> Result { let mut reader = NsReader::from_str(text); let mut found: Found = Found::new(); let mut capture: Option = None; let mut saw_rdf = false; loop { // The resolved namespace borrows the reader, and the next read needs it // mutably, so it is turned into bytes we own inside this block and the // borrow ends with the block. A sidecar is a few kilobytes and this is // one small allocation per element, which is not the cost worth // contorting the loop to avoid. let (namespace, event) = { let (resolved, event) = reader .read_resolved_event() .map_err(|e| XmpError::NotXml(e.to_string()))?; (owned_namespace(&resolved), event) }; let is_empty = matches!(event, Event::Empty(_)); match event { Event::Eof => break, Event::Start(e) | Event::Empty(e) => { let local = e.local_name().as_ref().to_vec(); if let Some(open) = capture.as_mut() { // Inside a property. The only element that means anything // here is `rdf:li`; a container — `rdf:Bag`, `rdf:Seq`, // `rdf:Alt` — is stepped over, because which one it is // changes nothing about the values inside it. if is_rdf(&namespace, &local, b"li") { let item = Item { lang: language_of(&e), text: String::new(), }; if is_empty { open.items.push(item); } else { open.li = Some(item); } } if !is_empty { open.depth += 1; } continue; } if is_rdf(&namespace, &local, b"RDF") { saw_rdf = true; } // The attribute form. Every element is checked rather than only // `rdf:Description`, because the property is identified by its // name and the element it hangs off is not part of that // identity — and a file that put one somewhere unexpected is // still a file whose rating we would rather read than lose. for attribute in e.attributes().flatten() { let (resolved, attr_local) = reader.resolver().resolve_attribute(attribute.key); let ns = owned_namespace(&resolved); let Some(property) = owner(ns.as_deref(), attr_local.as_ref()) else { continue; }; // `normalized_value` rather than the deprecated // `unescape_value`, and `Implicit1_0` because an XMP packet // opens with `` rather than an XML declaration, // so the version is unstated and the specification says to // assume 1.0 — the same call `dr-preset-xmp` makes. let Ok(value) = attribute.normalized_value(XmlVersion::Implicit1_0) else { continue; }; let value = value.trim(); if !value.is_empty() { found .entry(property.field) .or_default() .push(Item::plain(value)); } } // The element form. if let Some(property) = owner(namespace.as_deref(), &local) { if is_empty { // ``. The property is present and says // nothing, which is different from being absent only to // a writer; here it contributes no items. found.entry(property.field).or_default(); } else { capture = Some(Capture::new(property.field)); } } } Event::End(e) => { let closes_li = is_rdf(&namespace, e.local_name().as_ref(), b"li"); let closed = match capture.as_mut() { None => false, Some(open) if open.depth == 0 => true, Some(open) => { open.depth -= 1; if closes_li { if let Some(li) = open.li.take() { open.items.push(li); } } false } }; if closed { let open = capture.take().expect("checked in the match above"); found.entry(open.field).or_default().extend(open.finish()); } } Event::Text(t) => { if let Some(open) = capture.as_mut() { if let Ok(text) = t.xml10_content() { open.push_text(&text); } } } Event::CData(t) => { if let Some(open) = capture.as_mut() { if let Ok(text) = t.decode() { open.push_text(&text); } } } Event::GeneralRef(r) => { if let Some(open) = capture.as_mut() { // The event carries the entity's *name*, so it is put back // between its delimiters and unescaped — which resolves the // five predefined entities and the numeric character // references in one call rather than in a table of our own. let Ok(name) = r.decode() else { continue }; match unescape(&format!("&{name};")) { Ok(resolved) => open.push_text(&resolved), // A document-defined entity, which an XMP packet has no // business carrying. Dropped with a note rather than // failing the file: it costs one character of one // value, and refusing would cost every keyword in it. Err(_) => log::debug!("xmp: unknown entity &{name};, dropped"), } } } _ => {} } } if !saw_rdf { return Err(XmpError::NotXmp); } let mut xmp = Xmp::default(); for property in crate::PROPERTIES { let Some(items) = found.get(&property.field) else { continue; }; xmp.set_values(property.field, select(property.shape, items)); } Ok(xmp) } /// The values of one property, as its declared shape wants them. /// /// A list keeps everything, de-duplicated: an `rdf:Bag` is a set, and a word /// listed twice would become two identical rows in the keyword panel that no /// user could tell apart — the same argument `dr_catalog::keywords::create` /// makes for resolving rather than duplicating. /// /// A scalar keeps one, and prefers `x-default` — the entry an `rdf:Alt` means /// when nobody asked for a language. Where there is no `x-default` the first /// entry stands, because a caption in one language beats no caption. fn select(shape: Shape, items: &[Item]) -> Vec { if shape.is_list() { let mut out: Vec = Vec::with_capacity(items.len()); for item in items { if !item.text.is_empty() && !out.contains(&item.text) { out.push(item.text.clone()); } } return out; } items .iter() .find(|i| i.lang.as_deref() == Some("x-default") && !i.text.is_empty()) .or_else(|| items.iter().find(|i| !i.text.is_empty())) .map(|i| vec![i.text.clone()]) .unwrap_or_default() } /// The namespace a resolver bound this name to, as bytes we own. fn owned_namespace(resolved: &ResolveResult<'_>) -> Option> { match resolved { ResolveResult::Bound(ns) => Some(ns.0.to_vec()), // An unprefixed name, or a prefix nothing declared. Neither can be one // of ours: every property this crate owns lives in a namespace, and a // prefix with no declaration is a broken document rather than a hint. _ => None, } } /// Whether this is the named RDF element. fn is_rdf(namespace: &Option>, local: &[u8], name: &[u8]) -> bool { namespace.as_deref() == Some(NS_RDF.as_bytes()) && local == name } /// The `xml:lang` an `rdf:Alt` entry declares. /// /// Matched on the literal `xml:lang` rather than through the resolver, and /// that is correct rather than lazy: `xml` is the one prefix the XML /// specification reserves and binds itself, so it means the same thing in every /// document and no declaration is required for it — which is exactly why a /// resolver may not have a binding to report. fn language_of(e: &quick_xml::events::BytesStart<'_>) -> Option { for attribute in e.attributes().flatten() { if attribute.key.as_ref() == b"xml:lang" { let value = attribute.normalized_value(XmlVersion::Implicit1_0).ok()?; return Some(value.trim().to_string()); } } None } #[cfg(test)] mod tests { use super::*; use crate::Rating; use dr_types::ColourLabel; /// The shape Lightroom writes: everything as attributes on one /// `rdf:Description`, keywords in a `Bag`, captions in an `Alt`. const LIGHTROOM: &str = r#" puffin Iceland Places|Iceland Puffin on a cliff Látrabjarg, late evening. A. Photographer © 2026 A. Photographer Editorial use only. "#; #[test] fn a_lightroom_sidecar_reads_in_full() { // The requirement in one test: keywords, rating, colour label and the // IPTC core fields, out of a file this application did not write. let xmp = Xmp::parse(LIGHTROOM).unwrap(); assert_eq!(xmp.rating, Some(Rating::Stars(4))); assert_eq!(xmp.colour(), Some(ColourLabel::Yellow)); assert_eq!(xmp.keywords, ["puffin", "Iceland"]); assert_eq!(xmp.hierarchical_subjects, ["Places|Iceland"]); assert_eq!(xmp.title.as_deref(), Some("Puffin on a cliff")); assert_eq!( xmp.description.as_deref(), Some("Látrabjarg, late evening.") ); assert_eq!(xmp.creators, ["A. Photographer"]); assert_eq!(xmp.copyright.as_deref(), Some("© 2026 A. Photographer")); assert_eq!(xmp.credit.as_deref(), Some("Puffin Pictures")); assert_eq!(xmp.usage_terms.as_deref(), Some("Editorial use only.")); } #[test] fn a_property_written_as_an_element_reads_the_same_as_one_written_as_an_attribute() { // Two applications, two shapes, one meaning. Insisting on either would // reject files every other editor reads. let element = r#" 3 "#; let attribute = r#" "#; assert_eq!( Xmp::parse(element).unwrap().rating, Xmp::parse(attribute).unwrap().rating ); assert_eq!(Xmp::parse(element).unwrap().rating, Some(Rating::Stars(3))); } /// A document binding the conventional prefixes to nothing of the sort, and /// naming the real namespaces under prefixes of its own. const HOSTILE_PREFIXES: &str = r#" impostor puffin "#; #[test] fn a_property_is_identified_by_its_namespace_and_not_by_its_prefix() { // The ownership rule's first consequence, tested rather than asserted // in prose: matching the string "dc:subject" would read the impostor // and miss the keyword. let xmp = Xmp::parse(HOSTILE_PREFIXES).unwrap(); assert_eq!(xmp.keywords, ["puffin"]); } #[test] fn a_value_containing_an_entity_arrives_whole() { // The parser reports `&` as its own event, so taking the first run // of text would store "Bells " and lose the rest for ever. let text = r#" Bells & whistles "#; assert_eq!(Xmp::parse(text).unwrap().keywords, ["Bells & whistles"]); } #[test] fn a_container_less_value_still_reads() { // A phone that half-implemented XMP. The declared shape is a Bag and // this is a bare string; refusing it would lose a real keyword. let text = r#" puffin Straight to the point "#; let xmp = Xmp::parse(text).unwrap(); assert_eq!(xmp.keywords, ["puffin"]); assert_eq!(xmp.title.as_deref(), Some("Straight to the point")); } #[test] fn the_default_language_wins_where_a_caption_has_several() { let text = r#" Macareux Puffin "#; assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Puffin")); } #[test] fn a_caption_with_no_default_language_still_reads() { // A caption in one language beats no caption. let text = r#" Macareux "#; assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Macareux")); } #[test] fn a_word_listed_twice_is_one_keyword() { let text = r#" puffinpuffin "#; assert_eq!(Xmp::parse(text).unwrap().keywords, ["puffin"]); } #[test] fn a_rejection_survives_the_read() { let text = r#" "#; assert_eq!(Xmp::parse(text).unwrap().rating, Some(Rating::Rejected)); } #[test] fn an_unreadable_rating_costs_the_rating_and_not_the_file() { // The tolerance the module promises: a sidecar arriving without its // rating is still worth every keyword in it. let text = r#" puffin "#; let xmp = Xmp::parse(text).unwrap(); assert_eq!(xmp.rating, None); assert_eq!(xmp.keywords, ["puffin"]); } #[test] fn xml_that_is_not_a_packet_says_so_rather_than_reading_as_empty() { // "This photograph has no metadata" and "this is the wrong file" are // different answers, and a caller acts differently on each. let err = Xmp::parse("Warm").unwrap_err(); assert_eq!(err, XmpError::NotXmp); } #[test] fn malformed_xml_is_a_typed_error() { // A closing tag that does not match its opening one. Distinguished from // `NotXmp` because the two mean different things to a caller: this file // is damaged, where the test above's is merely the wrong file. assert!(matches!( Xmp::parse(""), Err(XmpError::NotXml(_)) )); } #[test] fn a_document_carrying_nothing_of_ours_reads_as_empty_rather_than_failing() { // Somebody else's sidecar, with only their own namespaces in it. That // is a perfectly good file about a photograph we have nothing to say // about yet. let text = r#" "#; assert!(Xmp::parse(text).unwrap().is_empty()); } }