Speak the sidecar format every other editor already reads

FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a
byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a
keyword's text is for, `dr-export`'s metadata module said "neither is read by
`dr-decode` today" about its own half, and `dr-preset-xmp` reads a different
file for a different requirement. So a library imported from Lightroom could
come in and never go back out: a one-way door, which is not a thing a
photographer walks their archive through.

`core/dr-xmp` reads and writes the properties the requirement names —
`dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC
core fields — from whichever shape the file happens to use. A property may
arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no
container at all, because the specification is not what wrote the file; so one
collector takes whatever is in a property and the declared shape decides only
how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection
rather than folded into zero stars, since DarkRoom keeps those on two axes and
the mapping belongs where both are visible.

Writing is a rewrite rather than a serialisation, and that is the whole design.
An XMP sidecar is a shared document: the file beside a raw carries somebody
else's `crs:` settings and comments and namespaces, and rendering our record
over it would be data loss on every photograph but the first. The rule is
stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns
exactly those properties, identified by namespace URI and never by prefix, and
nothing else in the document. Everything unowned is copied through byte for
byte. A `Description` left empty once our properties come out of it is
withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to
the file on every save.

Precedence is settled conservatively, because a standard XMP carries no
revision and no device and there is nothing in it to order two edits by.
Keywords union, following the rule `dr_catalog::merge` already makes for
assignments; every other field is taken only where DarkRoom holds none,
following `Version::merge`'s judgement rule, and a genuine disagreement is
reported rather than resolved so a caller can offer the reload the requirement
asks for. What is deliberately left open — when a reload may happen without
asking — is written down in the module rather than picked silently.

No new dependency: quick-xml was already in the tree for WebDAV and for
Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md`
now says so along with the two smaller gaps, GPS and the filename convention.
This commit is contained in:
2026-09-06 19:01:48 +02:00
parent 68ebf5d78b
commit 5fa4c0772b
9 changed files with 2703 additions and 20 deletions
+571
View File
@@ -0,0 +1,571 @@
//! 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<Item>,
/// The `rdf:li` currently open, if one is.
li: Option<Item>,
/// 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<Item> {
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<Xmp, XmpError> {
let mut reader = NsReader::from_str(text);
let mut found: Found = Found::new();
let mut capture: Option<Capture> = 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 `<?xpacket?>` 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 {
// `<dc:title/>`. 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<String> {
if shape.is_list() {
let mut out: Vec<String> = 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<Vec<u8>> {
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<Vec<u8>>, 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<String> {
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:lr="http://ns.adobe.com/lightroom/1.0/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:xmpRights="http://ns.adobe.com/xap/1.0/rights/"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="4"
xmp:Label="Yellow"
photoshop:Credit="Puffin Pictures"
crs:Exposure2012="+0.75">
<dc:subject>
<rdf:Bag>
<rdf:li>puffin</rdf:li>
<rdf:li>Iceland</rdf:li>
</rdf:Bag>
</dc:subject>
<lr:hierarchicalSubject>
<rdf:Bag>
<rdf:li>Places|Iceland</rdf:li>
</rdf:Bag>
</lr:hierarchicalSubject>
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">Puffin on a cliff</rdf:li>
</rdf:Alt>
</dc:title>
<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">Látrabjarg, late evening.</rdf:li>
</rdf:Alt>
</dc:description>
<dc:creator>
<rdf:Seq>
<rdf:li>A. Photographer</rdf:li>
</rdf:Seq>
</dc:creator>
<dc:rights>
<rdf:Alt>
<rdf:li xml:lang="x-default">© 2026 A. Photographer</rdf:li>
</rdf:Alt>
</dc:rights>
<xmpRights:UsageTerms>
<rdf:Alt>
<rdf:li xml:lang="x-default">Editorial use only.</rdf:li>
</rdf:Alt>
</xmpRights:UsageTerms>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:Rating>3</xmp:Rating>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
let attribute = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/>
</rdf:RDF>
</x:xmpmeta>"#;
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://example.invalid/not-dublin-core/"
xmlns:whatever="http://purl.org/dc/elements/1.1/">
<dc:subject>
<rdf:Bag><rdf:li>impostor</rdf:li></rdf:Bag>
</dc:subject>
<whatever:subject>
<rdf:Bag><rdf:li>puffin</rdf:li></rdf:Bag>
</whatever:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
#[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 `&amp;` as its own event, so taking the first run
// of text would store "Bells " and lose the rest for ever.
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject><rdf:Bag><rdf:li>Bells &amp; whistles</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject>puffin</dc:subject>
<dc:title>Straight to the point</dc:title>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="fr-FR">Macareux</rdf:li>
<rdf:li xml:lang="x-default">Puffin</rdf:li>
</rdf:Alt>
</dc:title>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="fr-FR">Macareux</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
</rdf:RDF>"#;
assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Macareux"));
}
#[test]
fn a_word_listed_twice_is_one_keyword() {
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject><rdf:Bag>
<rdf:li>puffin</rdf:li><rdf:li>puffin</rdf:li>
</rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
assert_eq!(Xmp::parse(text).unwrap().keywords, ["puffin"]);
}
#[test]
fn a_rejection_survives_the_read() {
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="-1"/>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmp:Rating="later">
<dc:subject><rdf:Bag><rdf:li>puffin</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
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("<preset><name>Warm</name></preset>").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("<rdf:RDF></rdf:Description>"),
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:Exposure2012="+0.75"/>
</rdf:RDF>"#;
assert!(Xmp::parse(text).unwrap().is_empty());
}
}