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:
File diff suppressed because it is too large
Load Diff
@@ -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 `&` 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 & 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
//! 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 `</rdf:RDF>`,
|
||||
//! 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, "<?xpacket begin=\"\u{feff}\" id=\"{PACKET_ID}\"?>");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"{TOOLKIT}\">"
|
||||
);
|
||||
let _ = writeln!(out, " <rdf:RDF xmlns:rdf=\"{NS_RDF}\">");
|
||||
if let Some(block) = description_block(xmp, " ") {
|
||||
let _ = writeln!(out, "{block}");
|
||||
}
|
||||
let _ = writeln!(out, " </rdf:RDF>");
|
||||
let _ = writeln!(out, "</x:xmpmeta>");
|
||||
// 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, "<?xpacket end=\"w\"?>");
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite(xmp: &Xmp, existing: &str) -> Result<String, XmpError> {
|
||||
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<usize> = None;
|
||||
// An `rdf:Description` whose fate is not yet decided. See [`Pending`].
|
||||
let mut pending: Option<Pending> = 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<u8> = 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 {
|
||||
// `<rdf:Description rdf:about=""/>` 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 {
|
||||
// `<rdf:RDF/>`. 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 `</rdf:RDF>` 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<Vec<u8>>,
|
||||
/// 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<Vec<u8>>) {
|
||||
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<Vec<u8>>, held: &mut Vec<u8>) {
|
||||
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<Vec<u8>>, 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<u8>` 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<BytesStart<'static>> {
|
||||
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<String> {
|
||||
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}<rdf:Description rdf:about=\"\"");
|
||||
for (prefix, namespace) in namespaces {
|
||||
let _ = write!(out, "\n{indent} xmlns:{prefix}=\"{namespace}\"");
|
||||
}
|
||||
let _ = write!(out, ">\n{body}{indent}</rdf:Description>");
|
||||
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} <rdf:{container}>");
|
||||
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} <rdf:li{lang}>{}</rdf:li>",
|
||||
escape(value.as_str())
|
||||
);
|
||||
}
|
||||
let _ = writeln!(out, "{indent} </rdf:{container}>");
|
||||
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<Vec<u8>> {
|
||||
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6">
|
||||
<!-- written by somebody else -->
|
||||
<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/"
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
crs:Version="15.0"
|
||||
crs:Exposure2012="+0.75"
|
||||
xmp:Rating="2">
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
#[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("<!-- written by somebody else -->"), "{out}");
|
||||
assert!(out.contains("<?xpacket end=\"w\"?>"), "{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("<rdf:Description").count(),
|
||||
once.matches("<rdf:Description").count(),
|
||||
"a Description was added by a rewrite that changed nothing:\n{twice}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_that_held_only_ours_goes_with_it() {
|
||||
// Somebody else's file whose one `Description` carries nothing but a
|
||||
// rating. With the rating cleared it asserts nothing, and an element
|
||||
// asserting nothing is not something to preserve.
|
||||
let theirs = 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="2"/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:foo="http://ns.adobe.com/xap/1.0/">
|
||||
<foo:Rating>1</foo:Rating>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>"#;
|
||||
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:dc="http://example.invalid/other/">
|
||||
<dc:subject>not ours to delete</dc:subject>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>"#;
|
||||
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/>
|
||||
</x:xmpmeta>"#;
|
||||
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("<preset><name>Warm</name></preset>"),
|
||||
Err(XmpError::NotXmp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_needing_escaping_survives_the_round_trip() {
|
||||
let awkward = Xmp {
|
||||
keywords: vec!["Bells & whistles".into(), "<angle>".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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user