//! TRACES: FR-CAT-13 //! Writing a standard XMP sidecar without wrecking somebody else's. //! //! # Why this is a rewrite and not a serialisation //! //! The obvious implementation is to render an [`Xmp`] as a document and save //! it. That is correct exactly once — for a photograph that has no sidecar — //! and is data loss every other time, because the file it replaces belongs //! partly to whoever wrote it first. The ownership rule in the crate //! documentation is the whole design, and [`rewrite`] is where it is enforced: //! owned properties out, ours in, everything else through untouched. //! //! "Untouched" is meant literally. Events the reader hands back are written //! straight to the output, so an element nobody here understands is reproduced //! byte for byte, comments and processing instructions included. The only tags //! rebuilt are the ones an owned *attribute* was removed from, and those are //! being modified anyway. //! //! # Where the new properties go //! //! Into an `rdf:Description` of our own, appended just before ``, //! carrying its own namespace declarations. //! //! That looks like the long way round — the file usually has an //! `rdf:Description` already, and the properties could be injected into it. //! But doing so needs the prefixes we write to be in scope there, and deciding //! that means reasoning about what every ancestor declared and whether a //! prefix we want is already bound to something else. RDF allows any number of //! `rdf:Description` elements about one resource and XMP itself groups them by //! schema, so a block of our own is both the idiomatic shape and the one that //! needs to know nothing at all about the document it is joining. use std::fmt::Write as _; use std::io::Write as _; use quick_xml::escape::escape; use quick_xml::events::{BytesStart, Event}; use quick_xml::name::ResolveResult; use quick_xml::{NsReader, Writer}; use crate::{owner, Property, Shape, Xmp, XmpError, NS_RDF, PROPERTIES}; /// The packet identifier every XMP file carries, fixed by the specification. const PACKET_ID: &str = "W5M0MpCehiHzreSzNTczkc9d"; /// What `x:xmptk` says on a document this crate created. /// /// Written on a *new* packet only. An existing file's toolkit attribute is /// left exactly as it was, because rewriting it would claim authorship of a /// document we contributed ten lines to — and because it is not in /// [`PROPERTIES`], which is the same statement made once, formally. const TOOLKIT: &str = "DarkRoom"; pub(crate) fn to_text(xmp: &Xmp) -> String { // Writes into a `String` cannot fail, so their results are discarded — the // same choice `Sidecar::to_text` makes, and for the same reason. let mut out = String::new(); // The leading U+FEFF inside `begin` is what the specification asks for and // what every reader looks for; it is part of the packet's syntax rather // than a byte-order mark on the file. let _ = writeln!(out, ""); let _ = writeln!( out, "" ); let _ = writeln!(out, " "); if let Some(block) = description_block(xmp, " ") { let _ = writeln!(out, "{block}"); } let _ = writeln!(out, " "); let _ = writeln!(out, ""); // No padding whitespace before the trailing packet marker. Adobe writes // kilobytes of it so a packet embedded *inside* an image file can grow // without moving everything after it; a standalone sidecar is rewritten // whole, so the padding would be bytes that sync on every edit for nothing. let _ = writeln!(out, ""); out } pub(crate) fn rewrite(xmp: &Xmp, existing: &str) -> Result { if existing.trim().is_empty() { return Ok(to_text(xmp)); } let mut reader = NsReader::from_str(existing); let mut writer = Writer::new(Vec::new()); let block = description_block(xmp, " "); // How deep we are inside an owned property that is being dropped. `None` // is the ordinary state; `Some(n)` means an owned property's start tag has // been swallowed and `n` elements are open inside it, so its own end tag // brings the count to zero. let mut dropping: Option = None; // An `rdf:Description` whose fate is not yet decided. See [`Pending`]. let mut pending: Option = None; // Whitespace between elements, written only once we know what follows it. // See [`flush`] for why it cannot simply be passed through. let mut held: Vec = Vec::new(); let mut injected = false; let mut saw_rdf = false; loop { 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(_)); if let Some(depth) = dropping.as_mut() { match &event { Event::Start(_) => *depth += 1, Event::End(_) => { *depth -= 1; if *depth == 0 { dropping = None; } } // A property whose end tag never arrives. The reader would // normally have refused the document before this, but a // truncated file must not silently produce output with half of // it missing. Event::Eof => { return Err(XmpError::NotXml( "the document ends inside a property".into(), )) } _ => {} } continue; } match event { Event::Eof => break, Event::Start(e) | Event::Empty(e) => { let local = e.local_name().as_ref().to_vec(); // An owned property, wherever it sits and whatever prefix it // wears. Dropped here and re-emitted below from what we hold, // which is what makes the owned set a *replacement* rather than // an addition — without this, a rating edited here and a rating // already in the file would both be in the result and the next // reader would pick one at random. if owner(namespace.as_deref(), &local).is_some() { if !is_empty { dropping = Some(1); } continue; } let in_rdf = namespace.as_deref() == Some(NS_RDF.as_bytes()); let is_rdf_root = in_rdf && local == b"RDF"; if is_rdf_root { saw_rdf = true; } let tag = match strip_owned_attributes(&reader, &e) { Some(stripped) => stripped, None => e, }; // A `Description` at the top level may turn out to hold nothing // once our properties are out of it, so it is held back until // its end tag says. if in_rdf && local == b"Description" && pending.is_none() { let data = carries_data(&reader, &tag); if !is_empty { pending = Some(Pending::new(tag.into_owned(), data)); continue; } if !data { // `` asserts nothing. held.clear(); continue; } } if let Some(open) = pending.as_mut() { open.meaningful = true; if !is_empty { open.depth += 1; } let _ = open.body.write_event(if is_empty { Event::Empty(tag) } else { Event::Start(tag) }); continue; } flush(&mut writer, &mut held); if is_rdf_root && is_empty { // ``. There is no end tag to put anything before, // so the empty element becomes an open tag, our block, and // a close — the one place this rewrites the document's // shape rather than its content. let _ = writer.write_event(Event::Start(tag.borrow())); write_block(&mut writer, block.as_deref()); let _ = writer.write_event(Event::End(tag.to_end())); injected = true; } else if is_empty { let _ = writer.write_event(Event::Empty(tag)); } else { let _ = writer.write_event(Event::Start(tag)); } } Event::End(e) => { if pending.is_some() { if pending.as_ref().is_some_and(|p| p.depth == 0) { if let Some(open) = pending.take() { if open.keep() { flush(&mut writer, &mut held); open.write_into(&mut writer); } else { // Withdrawn, and the whitespace leading up to it // goes with it. Leaving that behind is what // would make a file grow a blank line on every // save. held.clear(); } } } else if let Some(open) = pending.as_mut() { open.depth -= 1; let _ = open.body.write_event(Event::End(e)); } continue; } let closes_rdf = namespace.as_deref() == Some(NS_RDF.as_bytes()) && e.local_name().as_ref() == b"RDF"; if closes_rdf && !injected { // The whitespace before `` is replaced rather than // kept, so the bytes around the injection point are the same // however many times this has run over the same file. held.clear(); write_block(&mut writer, block.as_deref()); injected = true; } flush(&mut writer, &mut held); let _ = writer.write_event(Event::End(e)); } Event::Text(t) => { // Whitespace between elements is not content, and holding it is // what lets a withdrawn `Description` take its own indentation // with it. let blank = t .xml10_content() .map(|s| s.trim().is_empty()) .unwrap_or(false); match pending.as_mut() { Some(open) => { if !blank { open.meaningful = true; } let _ = open.body.write_event(Event::Text(t)); } None if blank => held.extend_from_slice(&t.into_inner()), None => { flush(&mut writer, &mut held); let _ = writer.write_event(Event::Text(t)); } } } other => match pending.as_mut() { // A comment, a processing instruction or a chunk of CDATA // inside a `Description` is somebody's content, and keeps the // `Description` alive whatever else was taken out of it. Some(open) => { open.meaningful = true; let _ = open.body.write_event(other); } None => { flush(&mut writer, &mut held); let _ = writer.write_event(other); } }, } } // Whatever trailing whitespace the document ended with. flush(&mut writer, &mut held); if !saw_rdf { return Err(XmpError::NotXmp); } String::from_utf8(writer.into_inner()).map_err(|e| XmpError::NotXml(e.to_string())) } /// An `rdf:Description` held back until its end tag says whether it survives. /// /// # Why an empty one is withdrawn rather than left /// /// This is not tidiness, it is what makes a rewrite idempotent. The block this /// crate injects *is* an `rdf:Description` containing only owned properties, so /// the next write strips those out and what is left is an empty husk. A build /// that kept those would add one to the file on every save, for ever, and each /// one would sync. /// /// It is also the right answer for a `Description` somebody else wrote that /// happened to hold only DarkRoom's properties: with those gone it asserts /// nothing, and an element asserting nothing is not something to preserve. /// /// **What it may not withdraw** is a `Description` still carrying content or an /// attribute of substance, which is what [`Self::keep`] decides. struct Pending { /// The start tag, already stripped of any owned attribute. tag: BytesStart<'static>, /// Everything written inside it so far, verbatim. body: Writer>, /// Elements open inside it. Zero means the next end tag is its own. depth: usize, /// Whether anything worth keeping has gone into the body. meaningful: bool, /// Whether the start tag itself carries data. See [`carries_data`]. data: bool, } impl Pending { fn new(tag: BytesStart<'static>, data: bool) -> Self { Pending { tag, body: Writer::new(Vec::new()), depth: 0, meaningful: false, data, } } /// Whether this `Description` still says anything. fn keep(&self) -> bool { self.meaningful || self.data } fn write_into(self, writer: &mut Writer>) { let Pending { tag, body, .. } = self; let _ = writer.write_event(Event::Start(tag.borrow())); let _ = writer.get_mut().write_all(&body.into_inner()); let _ = writer.write_event(Event::End(tag.to_end())); } } /// Whether a start tag carries anything but bookkeeping. /// /// Namespace declarations and RDF's own attributes — `rdf:about` and its /// relatives — describe the document rather than the photograph, so a /// `Description` holding only those and no children states nothing at all. That /// is what makes it safe to withdraw; anything else on the tag is somebody's /// data written in attribute form, and the element stays. /// /// `xmlns` is matched literally because it is the one prefix the Namespaces /// specification reserves and binds itself — the same reasoning `read`'s /// `xml:lang` follows — while `rdf` is resolved, because that prefix is an /// ordinary one a document may spell however it likes. fn carries_data(reader: &NsReader<&[u8]>, tag: &BytesStart<'_>) -> bool { tag.attributes().flatten().any(|attribute| { let key = attribute.key.as_ref(); if key == b"xmlns" || key.starts_with(b"xmlns:") { return false; } let (resolved, _) = reader.resolver().resolve_attribute(attribute.key); owned_namespace(&resolved).as_deref() != Some(NS_RDF.as_bytes()) }) } /// Write the whitespace that has been held back, if any. /// /// Every path that writes a real event calls this first, so held whitespace /// reaches the output in the order it arrived — the only paths that do not are /// the two that deliberately discard it, and both are the sites this rewrite /// changes the document at. fn flush(writer: &mut Writer>, held: &mut Vec) { if held.is_empty() { return; } let _ = writer.get_mut().write_all(&held[..]); held.clear(); } /// Put our block into the output, on a line of its own. /// /// The newline is written even when there is no block, so the bytes around the /// injection point depend only on what DarkRoom holds and not on how many times /// this has been run over the file. fn write_block(writer: &mut Writer>, block: Option<&str>) { // Straight into the buffer rather than as events, because this is a // fragment we composed and already escaped; round-tripping it through the // reader to produce events would be a parser in the middle of a serialiser. // Writes to a `Vec` cannot fail. let _ = writer.get_mut().write_all(b"\n"); if let Some(block) = block { let _ = writer.get_mut().write_all(block.as_bytes()); let _ = writer.get_mut().write_all(b"\n"); } } /// The same start tag with every owned attribute taken off it. /// /// `None` where it carried none, which is the overwhelmingly common case and /// the one where the original tag is passed through untouched — quoting style, /// attribute order and the whitespace between them included. A tag this does /// rebuild is one being modified anyway, so reformatting it is not a loss. fn strip_owned_attributes( reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ) -> Option> { let owns_any = e.attributes().flatten().any(|a| { let (resolved, local) = reader.resolver().resolve_attribute(a.key); owner(owned_namespace(&resolved).as_deref(), local.as_ref()).is_some() }); if !owns_any { return None; } let mut kept = BytesStart::new(String::from_utf8_lossy(e.name().as_ref()).into_owned()); for attribute in e.attributes().flatten() { let (resolved, local) = reader.resolver().resolve_attribute(attribute.key); if owner(owned_namespace(&resolved).as_deref(), local.as_ref()).is_some() { continue; } // The value goes back exactly as it arrived: the reader hands over the // raw, still-escaped bytes and this writes them verbatim, so an // attribute we are not interested in survives its own escaping. kept.push_attribute(attribute); } Some(kept) } /// The `rdf:Description` holding everything DarkRoom has to say. /// /// `None` where it has nothing — a photograph whose every owned property is /// empty produces no block at all, rather than an empty element that would /// grow the file and mean nothing. /// /// Namespaces are declared on the block itself, in first-use order, and only /// the ones actually used. Declaring them here rather than relying on the /// document is what makes the block portable into any packet: see the module /// documentation for why that beats injecting into an existing `Description`. fn description_block(xmp: &Xmp, indent: &str) -> Option { let inner = format!("{indent} "); // `rdf` first and always: the block's own element needs it, whether or not // any property inside uses a container. let mut namespaces: Vec<(&'static str, &'static str)> = vec![("rdf", NS_RDF)]; let mut body = String::new(); for property in PROPERTIES { let values = xmp.values(property.field); if values.is_empty() { continue; } if !namespaces.iter().any(|(_, ns)| *ns == property.namespace) { namespaces.push((property.prefix, property.namespace)); } write_property(&mut body, &inner, property, &values); } if body.is_empty() { return None; } let mut out = format!("{indent}\n{body}{indent}"); Some(out) } /// One property, in the container its declared shape names. /// /// Reading tolerates any shape; writing does not improvise. A file other /// applications have to read is not the place to be creative about which RDF /// container a keyword list lives in. fn write_property(out: &mut String, indent: &str, property: &Property, values: &[String]) { let (prefix, local) = (property.prefix, property.local); match property.shape.container() { // A simple property is one value by definition. Where a caller somehow // holds several, the first is written and the rest are dropped rather // than concatenated into a value nobody wrote. None => { if let Some(first) = values.first() { let _ = writeln!( out, "{indent}<{prefix}:{local}>{}", escape(first.as_str()) ); } } Some(container) => { let _ = writeln!(out, "{indent}<{prefix}:{local}>"); let _ = writeln!(out, "{indent} "); for value in values { // `x-default` on an `rdf:Alt` entry, and nothing on a `Bag` or // a `Seq`. An alternative with no default is one a reader // asking for "the caption" finds nothing in, which is how a // caption goes missing in an application that never showed a // language picker. let lang = if matches!(property.shape, Shape::Alt) { " xml:lang=\"x-default\"" } else { "" }; let _ = writeln!( out, "{indent} {}", escape(value.as_str()) ); } let _ = writeln!(out, "{indent} "); let _ = writeln!(out, "{indent}"); } } } /// The namespace a resolver bound this name to, as bytes we own. /// /// The twin of `read`'s helper. Duplicated rather than shared because the two /// modules are the two halves of the format and neither should have to reach /// into the other for a four-line match. fn owned_namespace(resolved: &ResolveResult<'_>) -> Option> { match resolved { ResolveResult::Bound(ns) => Some(ns.0.to_vec()), _ => None, } } #[cfg(test)] mod tests { use super::*; use crate::Rating; fn sample() -> Xmp { Xmp { rating: Some(Rating::Stars(4)), label: Some("Yellow".into()), keywords: vec!["puffin".into(), "Iceland".into()], hierarchical_subjects: vec!["Places|Iceland".into()], title: Some("Puffin on a cliff".into()), description: Some("Látrabjarg, late evening.".into()), creators: vec!["A. Photographer".into()], copyright: Some("© 2026 A. Photographer".into()), credit: Some("Puffin Pictures".into()), usage_terms: Some("Editorial use only.".into()), } } /// A sidecar written by another application, carrying develop settings, a /// comment and a processing instruction that mean nothing here. const FOREIGN: &str = r#" 0, 0255, 255 "#; #[test] fn a_new_packet_reads_back_as_what_went_into_it() { let before = sample(); let after = Xmp::parse(&before.to_text()).unwrap(); assert_eq!(after, before); } #[test] fn an_empty_record_still_produces_a_valid_packet() { // Everything cleared is a state a user can reach, and the file that // results has to be readable rather than empty. let text = Xmp::default().to_text(); assert!(Xmp::parse(&text).unwrap().is_empty()); } #[test] fn the_same_record_always_produces_the_same_bytes() { // What lets a caller compare content instead of trusting a dirty flag, // which is how a sidecar avoids syncing on every keystroke. assert_eq!(sample().to_text(), sample().to_text()); assert_eq!( sample().rewrite(FOREIGN).unwrap(), sample().rewrite(FOREIGN).unwrap() ); } #[test] fn another_application_s_work_survives_a_rewrite() { // The ownership rule, tested. Every one of these is somebody else's and // none of them is in PROPERTIES. let out = sample().rewrite(FOREIGN).unwrap(); assert!(out.contains("crs:Exposure2012=\"+0.75\""), "{out}"); assert!(out.contains("crs:Version=\"15.0\""), "{out}"); assert!(out.contains("crs:ToneCurvePV2012"), "{out}"); assert!(out.contains(""), "{out}"); assert!(out.contains(""), "{out}"); assert!(out.contains("x:xmptk=\"Adobe XMP Core 5.6\""), "{out}"); } #[test] fn an_owned_property_is_replaced_rather_than_joined() { // The file said two stars and we say four. Leaving both in would make // the next reader's answer depend on which it happened to meet first. let out = sample().rewrite(FOREIGN).unwrap(); assert!(!out.contains("xmp:Rating=\"2\""), "{out}"); assert_eq!( Xmp::parse(&out).unwrap().rating, Some(Rating::Stars(4)), "{out}" ); } #[test] fn a_rewrite_round_trips_everything_it_owns() { let out = sample().rewrite(FOREIGN).unwrap(); assert_eq!(Xmp::parse(&out).unwrap(), sample()); } #[test] fn clearing_a_field_removes_it_from_the_file() { // The second consequence of the ownership rule: the owned set is // replaced wholesale, so "no rating" means the property goes. let with = sample().rewrite(FOREIGN).unwrap(); let cleared = Xmp { rating: None, ..sample() }; let out = cleared.rewrite(&with).unwrap(); assert_eq!(Xmp::parse(&out).unwrap().rating, None, "{out}"); assert!(out.contains("crs:Exposure2012"), "{out}"); } #[test] fn no_empty_description_is_left_behind() { // The husk this crate would otherwise add to the file on every save: // our own injected block is an `rdf:Description` holding only owned // properties, so the next write strips it back to an empty element. // Exactly one `Description` should survive here — theirs. let once = sample().rewrite(FOREIGN).unwrap(); let twice = sample().rewrite(&once).unwrap(); assert_eq!( twice.matches(" "#; let out = Xmp::default().rewrite(theirs).unwrap(); assert!(!out.contains("rdf:Description"), "{out}"); assert!(Xmp::parse(&out).unwrap().is_empty()); } #[test] fn a_rewrite_is_idempotent() { // Writing the same record twice must not accumulate blocks, which is // the failure that turns a sidecar into a file that grows on every // save. let once = sample().rewrite(FOREIGN).unwrap(); let twice = sample().rewrite(&once).unwrap(); assert_eq!(once, twice); } #[test] fn an_owned_property_is_removed_whatever_prefix_it_wore() { // Ownership is by namespace. A file calling the basic schema `foo` // still has its rating replaced rather than duplicated. let odd = r#" 1 "#; let out = sample().rewrite(odd).unwrap(); assert!(!out.contains("foo:Rating"), "{out}"); assert_eq!(Xmp::parse(&out).unwrap().rating, Some(Rating::Stars(4))); } #[test] fn a_property_of_somebody_else_s_with_one_of_our_local_names_is_left_alone() { // The other direction of the same rule, and the one that would be data // loss: `subject` in a namespace that is not Dublin Core is not ours. let theirs = r#" not ours to delete "#; let out = sample().rewrite(theirs).unwrap(); assert!(out.contains("not ours to delete"), "{out}"); } #[test] fn an_empty_rdf_root_gains_somewhere_to_put_things() { let empty = r#" "#; let out = sample().rewrite(empty).unwrap(); assert_eq!(Xmp::parse(&out).unwrap(), sample(), "{out}"); } #[test] fn a_missing_sidecar_is_a_new_one() { // The caller that read a file that was not there holds an empty string, // and should not have to special-case it. assert_eq!(sample().rewrite("").unwrap(), sample().to_text()); assert_eq!(sample().rewrite(" \n").unwrap(), sample().to_text()); } #[test] fn xml_that_is_not_a_packet_is_refused_rather_than_overwritten() { // Refusing is the point: the alternative is replacing a file whose // contents were not understood. assert_eq!( sample().rewrite("Warm"), Err(XmpError::NotXmp) ); } #[test] fn a_value_needing_escaping_survives_the_round_trip() { let awkward = Xmp { keywords: vec!["Bells & whistles".into(), "".into()], title: Some("\"quoted\"".into()), ..Xmp::default() }; let out = Xmp::parse(&awkward.to_text()).unwrap(); assert_eq!(out, awkward); } #[test] fn a_rejection_survives_the_round_trip() { let rejected = Xmp { rating: Some(Rating::Rejected), ..Xmp::default() }; let out = Xmp::parse(&rejected.to_text()).unwrap(); assert_eq!(out.rating, Some(Rating::Rejected)); } #[test] fn a_label_this_build_does_not_recognise_survives_the_round_trip() { let renamed = Xmp { label: Some("Second Pass".into()), ..Xmp::default() }; assert_eq!(Xmp::parse(&renamed.to_text()).unwrap(), renamed); } }