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.
769 lines
31 KiB
Rust
769 lines
31 KiB
Rust
//! 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);
|
||
}
|
||
}
|