//! 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}>{}{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}{prefix}:{local}>");
}
}
}
/// 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);
}
}