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.
225 lines
7.9 KiB
Rust
225 lines
7.9 KiB
Rust
//! TRACES: FR-CAT-13
|
||
//! The interoperability contract, exercised from outside the crate.
|
||
//!
|
||
//! The unit tests beside each module check the pieces. This checks the promise
|
||
//! FR-CAT-13 actually makes to a photographer, through the public API only and
|
||
//! with no knowledge of how any of it works: what DarkRoom writes, another
|
||
//! application can read; what another application wrote, DarkRoom can read; and
|
||
//! what DarkRoom does not understand, it does not destroy.
|
||
|
||
use dr_types::ColourLabel;
|
||
use dr_xmp::{reconcile, Field, Precedence, Rating, Xmp, XmpError, PROPERTIES};
|
||
|
||
/// Everything the requirement names, in one record.
|
||
fn everything() -> Xmp {
|
||
let mut xmp = Xmp {
|
||
rating: Some(Rating::Stars(4)),
|
||
keywords: vec!["puffin".into(), "Iceland".into(), "Bells & whistles".into()],
|
||
hierarchical_subjects: vec!["Places|Iceland|Látrabjarg".into()],
|
||
title: Some("Puffin on a cliff".into()),
|
||
description: Some("Late evening, wind off the sea.".into()),
|
||
creators: vec!["A. Photographer".into(), "An Assistant".into()],
|
||
copyright: Some("© 2026 A. Photographer".into()),
|
||
credit: Some("Puffin Pictures".into()),
|
||
usage_terms: Some("Editorial use only. No <derivatives>.".into()),
|
||
label: None,
|
||
};
|
||
xmp.set_colour(Some(ColourLabel::Green));
|
||
xmp
|
||
}
|
||
|
||
/// A sidecar as another application left it: its own develop settings, its own
|
||
/// structures, and its own opinion about the rating.
|
||
const SOMEBODY_ELSES: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c140">
|
||
<!-- do not lose me -->
|
||
<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/"
|
||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
|
||
crs:Version="15.0"
|
||
crs:Exposure2012="+0.75"
|
||
crs:ProcessVersion="11.0"
|
||
tiff:Orientation="6"
|
||
xmp:Rating="2"
|
||
xmp:Label="Red">
|
||
<crs:ToneCurvePV2012>
|
||
<rdf:Seq>
|
||
<rdf:li>0, 0</rdf:li>
|
||
<rdf:li>255, 255</rdf:li>
|
||
</rdf:Seq>
|
||
</crs:ToneCurvePV2012>
|
||
<dc:subject>
|
||
<rdf:Bag>
|
||
<rdf:li>gannet</rdf:li>
|
||
</rdf:Bag>
|
||
</dc:subject>
|
||
</rdf:Description>
|
||
</rdf:RDF>
|
||
</x:xmpmeta>
|
||
<?xpacket end="w"?>"#;
|
||
|
||
#[test]
|
||
fn what_was_written_is_what_reads_back() {
|
||
// The round trip the requirement asks for, over every field it names.
|
||
let before = everything();
|
||
let after = Xmp::parse(&before.to_text()).expect("our own packet must read");
|
||
assert_eq!(after, before);
|
||
}
|
||
|
||
#[test]
|
||
fn the_round_trip_survives_a_second_pass() {
|
||
// Parse, write, parse, write: a format that drifts on the second pass is a
|
||
// format that drifts once per sync.
|
||
let once = everything().to_text();
|
||
let twice = Xmp::parse(&once).unwrap().to_text();
|
||
assert_eq!(once, twice);
|
||
}
|
||
|
||
#[test]
|
||
fn what_was_written_into_another_applications_file_reads_back() {
|
||
// The same round trip, through the path that actually runs in the field:
|
||
// there was already a sidecar there.
|
||
let before = everything();
|
||
let text = before.rewrite(SOMEBODY_ELSES).expect("a packet to rewrite");
|
||
assert_eq!(Xmp::parse(&text).unwrap(), before);
|
||
}
|
||
|
||
#[test]
|
||
fn nothing_of_theirs_is_lost_when_we_write_ours() {
|
||
// The ownership rule, from the outside. Every assertion here is somebody
|
||
// else's work, and none of it is a property DarkRoom claims.
|
||
let text = everything().rewrite(SOMEBODY_ELSES).unwrap();
|
||
|
||
for theirs in [
|
||
"crs:Version=\"15.0\"",
|
||
"crs:Exposure2012=\"+0.75\"",
|
||
"crs:ProcessVersion=\"11.0\"",
|
||
"tiff:Orientation=\"6\"",
|
||
"crs:ToneCurvePV2012",
|
||
"0, 0",
|
||
"255, 255",
|
||
"<!-- do not lose me -->",
|
||
"x:xmptk=\"Adobe XMP Core 5.6-c140\"",
|
||
"<?xpacket end=\"w\"?>",
|
||
] {
|
||
assert!(text.contains(theirs), "lost {theirs:?} from:\n{text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn theirs_is_replaced_rather_than_left_beside_ours() {
|
||
// The other half of ownership. Their rating and label and keyword are gone,
|
||
// because those are ours to state; leaving both copies in would make the
|
||
// next reader's answer depend on which it met first.
|
||
let text = everything().rewrite(SOMEBODY_ELSES).unwrap();
|
||
assert!(!text.contains("xmp:Rating=\"2\""), "{text}");
|
||
assert!(!text.contains("xmp:Label=\"Red\""), "{text}");
|
||
assert!(!text.contains("gannet"), "{text}");
|
||
|
||
let back = Xmp::parse(&text).unwrap();
|
||
assert_eq!(back.rating, Some(Rating::Stars(4)));
|
||
assert_eq!(back.colour(), Some(ColourLabel::Green));
|
||
}
|
||
|
||
#[test]
|
||
fn writing_the_same_thing_twice_changes_nothing() {
|
||
// A sidecar that grew a block on every save would double in size across a
|
||
// week of culling, and would sync every time.
|
||
let once = everything().rewrite(SOMEBODY_ELSES).unwrap();
|
||
let twice = everything().rewrite(&once).unwrap();
|
||
assert_eq!(once, twice);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photograph_with_no_sidecar_gets_a_whole_one() {
|
||
// The caller that looked for a file and found none holds an empty string.
|
||
let text = everything().rewrite("").unwrap();
|
||
assert_eq!(Xmp::parse(&text).unwrap(), everything());
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_that_is_not_a_packet_is_refused_rather_than_replaced() {
|
||
// Overwriting a file whose contents were not understood is the one failure
|
||
// a photographer cannot recover from.
|
||
assert_eq!(
|
||
everything().rewrite("<lrtemplate>not xmp at all</lrtemplate>"),
|
||
Err(XmpError::NotXmp)
|
||
);
|
||
assert!(matches!(
|
||
Xmp::parse("<x:xmpmeta></x:different>"),
|
||
Err(XmpError::NotXml(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn reading_their_file_finds_the_work_they_did_in_it() {
|
||
// The migration case: a library culled and keyworded somewhere else.
|
||
let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap();
|
||
assert_eq!(theirs.rating, Some(Rating::Stars(2)));
|
||
assert_eq!(theirs.colour(), Some(ColourLabel::Red));
|
||
assert_eq!(theirs.keywords, ["gannet"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_disagreement_is_carried_out_conservatively_and_reported() {
|
||
// The recorded precedence decision, end to end: their keyword joins ours,
|
||
// our rating stands because nothing here can order the two edits, and the
|
||
// caller is told which field disagreed so it can offer the reload.
|
||
let mine = Xmp {
|
||
rating: Some(Rating::Stars(5)),
|
||
keywords: vec!["puffin".into()],
|
||
..Xmp::default()
|
||
};
|
||
let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap();
|
||
|
||
let out = reconcile(&mine, &theirs, Precedence::Catalog);
|
||
assert_eq!(out.merged.keywords, ["puffin", "gannet"]);
|
||
assert_eq!(out.merged.rating, Some(Rating::Stars(5)));
|
||
assert_eq!(out.conflicts, [Field::Rating]);
|
||
assert_eq!(
|
||
out.merged.colour(),
|
||
Some(ColourLabel::Red),
|
||
"a label we never set is not a disagreement, it is an arrival"
|
||
);
|
||
|
||
// And the other way, which only a person's explicit request reaches.
|
||
let reload = reconcile(&mine, &theirs, Precedence::Sidecar);
|
||
assert_eq!(reload.merged.rating, Some(Rating::Stars(2)));
|
||
assert_eq!(
|
||
reload.merged.keywords,
|
||
["puffin", "gannet"],
|
||
"a reload is still not a reason to throw away a keyword"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_property_the_requirement_names_is_owned() {
|
||
// The list FR-CAT-13 gives, checked against the table that decides what a
|
||
// rewrite may touch. A property missing here is one this application can
|
||
// neither read nor hand back.
|
||
let names: Vec<String> = PROPERTIES
|
||
.iter()
|
||
.map(|p| format!("{}:{}", p.prefix, p.local))
|
||
.collect();
|
||
for required in [
|
||
"dc:subject",
|
||
"lr:hierarchicalSubject",
|
||
"xmp:Rating",
|
||
"xmp:Label",
|
||
"dc:title",
|
||
"dc:description",
|
||
"dc:creator",
|
||
"dc:rights",
|
||
"photoshop:Credit",
|
||
"xmpRights:UsageTerms",
|
||
] {
|
||
assert!(
|
||
names.iter().any(|n| n == required),
|
||
"{required} is not in PROPERTIES"
|
||
);
|
||
}
|
||
}
|