diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index 2ce43078..a17b8c42 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -82,10 +82,15 @@ The v1 contract, stated precisely: (`PhotoMetadata` over `gamut-xmp` types); parsing/serializing the XMP *packet bytes* is [`gamut-xmp`](../gamut-xmp)'s job, and the JPEG `APP13`/TIFF tag plumbing is the container's (issue #34). -- **Typed accessors cover every scalar/list IPTC Core property.** The structured - `Iptc4xmpCore:CreatorContactInfo` and all IPTC **Extension** structures (image regions, artwork, - licensors, …) have no typed model — they still round-trip losslessly as raw properties in - `PhotoMetadata::xmp`, reachable via `get_field`/`set_field` where mapped. +- **Typed accessors cover every scalar/list IPTC Core property**, plus the structured + `Iptc4xmpCore:CreatorContactInfo` and the most-used IPTC **Extension** structures — image + regions, artwork/object and licensors (`extension`). Reading one and writing it back changes + nothing: a field enters the typed value only when what the writer would emit reproduces the field + that was read — value, container kind and qualifiers — and every other field is kept in the + type's `other` list and re-emitted verbatim. The property itself enters the typed view on the same + terms, so a shape the setter could not write back reads as absent and is left where it lies. The remaining Extension structures (locations, persons, + controlled-vocabulary terms, …) have no typed model — they still round-trip losslessly as raw + properties in `PhotoMetadata::xmp`, reachable via `get_field`/`set_field` where mapped. - **Strict write, honest read.** Writing never silently truncates or drops: unencodable text, overlong values (octet limits are enforced on write only; overlong wire values are preserved on read), and an IIM-inexpressible `photoshop:DateCreated` are hard errors. Reading never guesses: a @@ -97,8 +102,10 @@ The v1 contract, stated precisely: exiv2/ExifTool de-facto behaviour) is this crate's explicit knob. Scalar-shaped IIM datasets that repeat on the wire (2:04 Object Attribute Reference, 2:85 By-line Title) reconcile their first value; all repeats still round-trip on the IIM side. -- **IIM records 1–2 are tabled; records 3–9 are preserved.** The tag table names the structural and - PMD-mapped datasets; any other dataset in any record round-trips byte-exact without a name. +- **Datasets with a determinate length are tabled; everything else is preserved.** The tag table + names every dataset IIM 4.2 states a maximum octet count for that a `u16` can hold — all of + records 1 and 2 bar `2:202` (256000 octets), plus `7:10` Size Mode (one octet). Any other dataset + in any record round-trips byte-exact without a name. ## Status diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index afefd89f..5b8cabb4 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -30,14 +30,78 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe | P5 | IPTC mapping | **Keystone** — IIM ↔ XMP reconciliation (precedence policy + date split/join) | ✅ | | P6 | — | IIM/IRB writer round-trip + exiv2 differential gate (`tooling/gamut-iptc-oracle`) | ✅ | | v1 | issue #182 | API finalization (two entry points, published field map, complete Core accessors), strict-write/honest-read error contract, tech-reference drift guard, divan benches, docs | ✅ | +| P7 | issue #422 | Breadth: the complete IIM 4.2 record-1/record-2 tag table, and typed models for the four most-used structured properties (`extension`) | ✅ | -## Deferred / out of scope (v1) +## Breadth (issue #422) + +- **Structured properties.** `extension` models `Iptc4xmpCore:CreatorContactInfo`, + `Iptc4xmpExt:ImageRegion` (with `RegionBoundary`/`RegionBoundaryPoint`/`Entity`), + `Iptc4xmpExt:ArtworkOrObject` and `plus:Licensor` as typed projections over the XMP graph, in the + `from_xmp`/`to_xmp` shape `gamut_exif::GpsInfo` uses for its sub-IFD. Every one is XMP-only — none + carries an `IIMid` — so none extends the reconciliation surface; `tests/techreference.rs` pins + that, and each structure's field set, to the reference. Reading a structure and writing it back + changes nothing: a field is taken into the typed value only when the property the writer will + emit for it *reproduces* the field that was read — value, RDF container kind and qualifiers — + and every other field is kept in the type's `other` list and re-emitted verbatim. That one rule + covers a field the model does not name, one it names but cannot read, and one it can read but + could not write back as it stands (an `rdf:resource` where the model writes element text, a + qualifier, a language beside the default, an unexpected container kind, a coordinate with no XMP + `Real` value). Only two differences remain, both idempotent: a structure's fields come back in + the model's order, and a number or an `x-default` tag may be re-spelled. +- **IIM tag table.** `iim::IimTagInfo` now names every dataset IPTC-IIM 4.2 states an octet maximum + for that `max_octets` can hold: 14 Envelope + 56 Application datasets (chapters 5 and 6 bar + `2:202`), plus `7:10` Size Mode, the one dataset outside those chapters whose length the spec + fixes ("one octet"). The table is descriptive — no `FIELD_MAP` row references a dataset outside + the PMD-mapped subset — so reading, merging and writing are byte-for-byte unchanged by it. +- **Authority.** The PMD tech reference maps only the ~20 IIM-mapped rows and the `ipmd_struct` + field sets, both of which `tests/techreference.rs` re-derives at test time. The rest of the + record-1/2 table comes from `iim-4.2.pdf`, and both of its guards read a source outside this + crate: + - **names** — the standard sets every DataSet's name in a column of its own, which + `pdftotext -bbox-layout` recovers by position. `tests/data/extract-iim-names.py` does that and + writes `tests/data/iim-4.2-dataset-names.tsv`; `iim`'s own + `tag_table_names_match_the_standards_own_dataset_names` compares every row against it, and + against the six datasets the standard names that gamut deliberately does not. The extraction is + not run by the gate — `pdftotext` is a system package the toolchain does not provision — so its + output is committed as a derived artefact with the command that regenerates it recorded beside + it. A name mistyped in `KNOWN_TAGS` alone fails there. A name mistyped *identically* in + `KNOWN_TAGS` and in the committed `.tsv` does not: the guard compares the table against the + artefact, and nothing re-derives the artefact from the PDF. What carries that residual is the + artefact's own never-hand-edit banner, not a gate — having CI re-derive it where `pdftotext` + is present is issue #623. + - **octet maximum, repeatability and value kind** — stated in the standard's prose, so + `tag_table_matches_the_exiv2_dataset_table` compares them against exiv2's independent + transcription of the same chapters, parsed out of the vendored `third_party/exiv2` sources. A + slipped digit fails there, which no round trip can see. + + The structural laws (ordering, uniqueness, the fixed date/time form lengths) and the exiv2 wire + differential in `tests/oracle.rs` sit alongside them. + +## Deferred / out of scope Intentional, documented skips — none lose data on round-trip: -- **IPTC Extension structures** (image regions, artwork/object, licensors, locations shown, …) and - the structured `Iptc4xmpCore:CreatorContactInfo`: no typed model. They pass through +- **A structured field the projection cannot express reads as absent** — a URL held as + `rdf:resource`, a value carrying a qualifier, a language alternative with entries beside the + default, an unexpected container kind. Nothing is lost: the field is in the type's `other` list + and the graph keeps it verbatim. Whether the model should widen to report the value as well is + issue #609. +- **A whole structured property the projection cannot express reads as absent, on the same terms.** + `creator_contact_info`, `image_regions`, `artwork_or_objects` and `licensors` report a value only + when writing it back would give the property back — so a bare structure written where the standard + puts an array, an array member that is not a structure, a qualifier on the property or on an + `rdf:li`, an array or structure holding nothing, and two top-level properties of one name all read + as nothing. Nothing is lost: the graph keeps the property untouched, and the setter beside the + accessor does not remove what the accessor did not report. Reading a property and setting it back + is the identity, pinned by a generated cross of every pair, value shape and qualifier list. +- **The remaining eleven IPTC Extension structures** (`Location`, `PersonWDetails`, `CvTerm`, + `EntityWRole`, `ProductWGtin`, `RegistryEntry`, `EmbdEncRightsExpr`, `LinkedEncRightsExpr`, + `CopyrightOwner`, `ImageCreator`, `ImageSupplier`): no typed model — issue #538. They pass through `PhotoMetadata::xmp` as raw `gamut-xmp` values untouched. +- **IIM datasets with no octet maximum `max_octets` can state** (`2:202`, and records 7–9 apart from + `7:10`): not in the tag table, because `IimTagInfo::max_octets` is a `u16` and can only state a + determinate maximum — issue #539, whose remainder is six datasets, `7:10` having since been named. + They still round-trip byte-exact, as every unmodeled dataset in any record does. - **Exotic ISO 2022 character sets**: dataset 1:90 designations other than the spec default (decoded as Latin-1, the exiv2/ExifTool de-facto reading of ISO 646 IRV) and UTF-8 (`ESC % G`) are reported as `Error::Unsupported`, never mis-decoded. @@ -50,8 +114,10 @@ Intentional, documented skips — none lose data on round-trip: - **Length limits are write-side only** (strict-write/honest-read contract): `IptcWriter` rejects overlong or unencodable values and IIM-inexpressible `DateCreated`s; the parser accepts and preserves overlong wire values rather than reject real-world files. -- **IIM records 3–9**: no named tag-table entries (the table covers the structural record-1 and - PMD-mapped record-2 datasets); all unmodeled datasets in any record round-trip byte-exact. +- **IIM records 3–6**: no named tag-table entries, because IIM 4.2 defines no datasets for them — + record 3 (Digital Newsphoto Parameter) is a separate publication, records 4 and 5 are not + allocated, and record 6 (Abstract Relationship) has only the method identifiers of Appendix F. + Datasets in those records round-trip byte-exact, unnamed. ## Reference discrepancies diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs new file mode 100644 index 00000000..442927a4 --- /dev/null +++ b/crates/gamut-iptc/src/extension.rs @@ -0,0 +1,2843 @@ +//! Typed models for the structured IPTC properties: the IPTC Extension structures and the +//! Core's `Iptc4xmpCore:CreatorContactInfo`. +//! +//! IPTC's structured properties are ordinary XMP structure values ([`XmpValue::Structured`]), +//! usually inside a `Bag`. This module is a typed *projection* over that graph — the same +//! relationship [`gamut_exif::GpsInfo`](https://docs.rs/gamut-exif) has to its sub-IFD — not a +//! parallel representation: the raw properties stay in [`PhotoMetadata::xmp`] and are what +//! round-trips. Each type converts both ways with `from_xmp`/`to_xmp`, and +//! [`PhotoMetadata`] exposes one accessor pair per property. +//! +//! # Which structures are modelled +//! +//! The four the IPTC Photo Metadata Standard 2025.1 puts to most use in practice: +//! +//! | Type | XMP property | Serialization | +//! | ---- | ------------ | ------------- | +//! | [`CreatorContactInfo`] | `Iptc4xmpCore:CreatorContactInfo` | a single structure | +//! | [`ImageRegion`] | `Iptc4xmpExt:ImageRegion` | `Bag` of structures | +//! | [`ArtworkOrObject`] | `Iptc4xmpExt:ArtworkOrObject` | `Bag` of structures | +//! | [`Licensor`] | `plus:Licensor` | `Bag` of structures | +//! +//! [`RegionBoundary`], [`RegionBoundaryPoint`] and [`Entity`] are the nested structures +//! [`ImageRegion`] is built from, and retain what they do not model on the same terms. The +//! remaining Extension structures (`Location`, `PersonWDetails`, `CvTerm`, `EmbdEncRightsExpr`, +//! `ProductWGtin`, `RegistryEntry`, `CopyrightOwner`, `ImageCreator`, `ImageSupplier`, +//! `LinkedEncRightsExpr`, `EntityWRole`) have no typed model yet and pass through +//! [`PhotoMetadata::xmp`] untouched, exactly as all of them did before. +//! +//! # These properties never conflict +//! +//! Every property here is **XMP-only**: none carries an `IIMid` in the IPTC technical reference, +//! so none is in [`crate::schema::FIELD_MAP`]. There is therefore nothing for the legacy carrier to +//! disagree with — a structured field can never appear in +//! [`IptcReader::conflicts`](crate::IptcReader::conflicts), and no +//! [`ConflictPolicy`](crate::ConflictPolicy) applies to it. Reconciliation is a property of the +//! IIM↔XMP mapping, not of the field, so extending the modelled surface here does not extend the +//! reconciliation surface. +//! +//! # Fidelity +//! +//! A typed view is a projection, so the model is narrower than the graph — but reading a structure +//! and writing it back loses nothing at all. What decides that is **round-trippability, not +//! readability**: a field becomes part of the typed value only when the property the writer will +//! emit for that value *reproduces* the field that was read — same value, same RDF container kind, +//! same qualifiers. Every other field is kept in the type's `other` list and re-emitted verbatim, +//! after the fields the model does write. +//! +//! One rule therefore covers every way a field falls outside the model: +//! +//! - a field the model does not name — a vendor extension, or the "any other metadata property" +//! the standard explicitly allows an [`ImageRegion`] to carry; +//! - a field it names but cannot read — a coordinate whose text is not a number, an identifier +//! holding a structure where text belongs, a language alternative with no `x-default` entry; +//! - a field it can read but could not write back as it stands — a URL held as an `rdf:resource` +//! where the model writes element text, a value carrying a qualifier, a language alternative +//! holding a language beside the default or plain text written where one belongs, an array of +//! any container kind other than the one the model writes, an array holding an item of a kind +//! the model does not take, a bare structure where the model writes an array of them, a +//! coordinate that is not a value of the XMP `Real` type (`NaN`, an infinity, or a decimal that +//! overflows to one), or two fields of a single name. +//! +//! `retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back` derives that list +//! from the module's shape corpus rather than restating it, so it cannot fall out of date. +//! +//! Such a field reads as **absent** — the typed view does not report a value it would go on to +//! destroy — and survives a read-modify-write untouched. +//! +//! What a read-modify-write does change: +//! +//! - **field order within a structure**: a structure is re-emitted in the model's field order, +//! with the retained fields last. A structure's fields are an unordered set (XMP Part 1 §6.3.3), +//! so this is a re-ordering and not a loss; values, and the relative order of an array's items, +//! are preserved. +//! - **the lexical form of a number**: a coordinate written `0.50` is re-emitted as `0.5`. +//! - **the case of an `x-default` language tag**: an entry tagged `X-Default` is re-emitted as +//! `x-default`, which Part 1 §8.2.2.4 matches as the same tag. +//! +//! The last two re-spell a value without changing it, and doing them twice changes nothing more, +//! so they are the only two differences that still count as reproducing a field. +//! +//! Deciding this on the write side costs one throwaway value per field read — the very value the +//! writer would emit — and, for a structure field, a throwaway conversion of the structure below +//! it. IPTC structures nest three deep at most and hold a handful of fields each, so the cost is +//! bounded by the size of the structure being read; nothing here is on an encoder's pixel path. +//! +//! A retained field whose name the model also carries is emitted only when the modelled field is +//! not: a structure with two fields of one name is ill-formed and does not read back, so the +//! modelled value stays the authority when there is one, and the retained field is written when it +//! is the only copy. +//! +//! # The same rule at both levels +//! +//! [`PhotoMetadata`]'s four accessor pairs for these properties apply that gate to the property +//! itself, not only to the fields inside it. Inside a structure the retention list is the type's +//! `other` field; at the top level it is the graph — a property the read does not consume is left +//! exactly where it lies, and the setter beside it does not remove it. So a read that reports +//! nothing is never a read that destroys something: a bare structure written where the standard +//! puts an array of structures, an array member that is not a structure, an `rdf:li` or a property +//! carrying a qualifier, an array or structure holding nothing at all, and two top-level properties +//! of one name all read as absent and survive a read-modify-write untouched. Two of one name are +//! refused for the reason [`Reader::read`] refuses two fields of one name: only one of them could +//! be written back. +//! +//! An array setter writes the RDF container kind the property it replaces already carries — an +//! `rdf:Bag`, an `rdf:Seq` whose order the caller may be relying on, or an `rdf:Alt` — and the +//! `rdf:Bag` the standard specifies when it replaces nothing. It writes every value the caller +//! passes, including one carrying no field at all. Handed nothing to write, it removes the +//! property — but only when the read reported one, because a caller cannot have meant to clear a +//! property it was never shown. +//! +//! Retention is not merging. A setter handed values *replaces* the property, and a replaced +//! property does not keep its qualifiers: the container kind is kept because it is part of how the +//! values themselves are held, and nothing else is. So the shapes above survive a read-modify-write +//! because the read declines to report them, not because the setter works around them — a caller +//! that reads nothing and then deliberately writes a value has replaced the property, qualifiers +//! and all, which is what a setter is for. + +use std::borrow::Borrow; + +use gamut_xmp::{XML_NAMESPACE, XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; + +use crate::photo_metadata::PhotoMetadata; +use crate::schema::ns; + +// --- Reading a structure's field list, keeping only what the writer can reproduce ------------- + +/// The language tag of a language alternative's default entry (XMP Part 1 §8.2.2.4). +const X_DEFAULT: &str = "x-default"; + +/// A structure's field list under a typed read, remembering which fields the read consumed. +/// +/// A field is consumed only when the property the writer will emit for the value read *reproduces* +/// that field (see [`reproduces`]); every other field is left for the type's `other` list and +/// re-emitted verbatim. Deciding it on the write side rather than on the read side is what makes +/// the three ways a field can fall outside the model — unnamed, unreadable, unwritable — one rule +/// (see the [module docs](self)). +struct Reader<'a> { + /// The fields being read. + fields: &'a [XmpProperty], + /// Whether the read consumed the field at the same index. + used: Vec, +} + +impl<'a> Reader<'a> { + /// A reader over `fields`, with nothing consumed yet. + fn new(fields: &'a [XmpProperty]) -> Self { + Self { + fields, + used: vec![false; fields.len()], + } + } + + /// Reads the field named `ns:name` through `parse`, and keeps the value only when the property + /// `write` will emit for it reproduces the field that was read. + /// + /// Two fields of one name are never read: only one of them could be written back, so both are + /// left to be kept verbatim instead. + fn read( + &mut self, + ns: &str, + name: &str, + parse: impl FnOnce(&'a XmpValue) -> Option, + write: impl FnOnce(&T) -> Option, + ) -> Option { + let fields = self.fields; + let mut matches = fields + .iter() + .enumerate() + .filter(|(_, p)| p.namespace == ns && p.name == name); + let (index, field) = matches.next()?; + if matches.next().is_some() { + return None; + } + let value = parse(&field.value)?; + let emitted = XmpProperty::new(ns, name, write(&value)?); + if !reproduces(field, &emitted) { + return None; + } + self.used[index] = true; + Some(value) + } + + /// The simple text of the field named `ns:name`. + fn text(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, parse_text, |value| Some(text_value(value))) + } + + /// The `x-default` entry of the language-alternative field named `ns:name`. + fn lang_alt(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, parse_lang_alt, |value| { + Some(lang_alt_value(value)) + }) + } + + /// Every simple item of the array field named `ns:name` (empty when the field is absent or the + /// read did not consume it). + fn list(&mut self, ns: &str, name: &str, ordered: bool) -> Vec { + self.read(ns, name, parse_list, |values| list_value(ordered, values)) + .unwrap_or_default() + } + + /// The field named `ns:name` parsed as an XMP `Real`. + fn number(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, parse_number, |value| number_value(*value)) + } + + /// The single structured field named `ns:name`, read through `parse` and written through + /// `write`. + fn nested( + &mut self, + ns: &str, + name: &str, + parse: impl FnOnce(&[XmpProperty]) -> T, + write: impl FnOnce(&T) -> XmpValue, + ) -> Option { + self.read( + ns, + name, + |value| structure(value).map(parse), + |value| Some(write(value)), + ) + } + + /// Every structure of the array field named `ns:name`, read through `parse` and written through + /// `write` (empty when the field is absent or the read did not consume it). + fn nested_array( + &mut self, + ns: &str, + name: &str, + ordered: bool, + parse: impl Fn(&[XmpProperty]) -> T, + write: impl Fn(&T) -> XmpValue, + ) -> Vec { + self.read( + ns, + name, + |value| { + let parsed: Vec = structures(value).into_iter().map(&parse).collect(); + (!parsed.is_empty()).then_some(parsed) + }, + |values| nested_array_value(ordered, values.iter().map(&write).collect()), + ) + .unwrap_or_default() + } + + /// Every field the read did not consume, cloned for verbatim retention. + fn other(self) -> Vec { + let Self { fields, used } = self; + fields + .iter() + .zip(used) + .filter(|&(_, used)| !used) + .map(|(property, _)| property.clone()) + .collect() + } +} + +/// The structure field lists an array value holds, skipping items that are not structures. +/// +/// A bare structure written where the standard puts an array reads as that array's single element +/// (see the [module docs](self)); everything that is neither reads as nothing at all. +fn structures(value: &XmpValue) -> Vec<&[XmpProperty]> { + match value { + XmpValue::Array(array) => array + .items() + .iter() + .filter_map(|item| structure(&item.value)) + .collect(), + single => structure(single).into_iter().collect(), + } +} + +/// The structure fields of `value`, or `None` if it is not a structure. +fn structure(value: &XmpValue) -> Option<&[XmpProperty]> { + match value { + XmpValue::Structured(fields) => Some(fields), + _ => None, + } +} + +// --- Reproduction: whether writing back what was read gives the field back -------------------- + +/// Whether `emitted` — the property the writer will produce for the value read from `read` — +/// reproduces `read`. +/// +/// Reproduction is equality of value and qualifiers, with the two lexical re-spellings the module +/// documents allowed: a number may be written in another form for the same value, and a language +/// tag may be re-cased, because XMP Part 1 §8.2.2.4 matches tags case-insensitively. A structure's +/// fields are an unordered set (Part 1 §6.3.3), so the model's field order is not a difference; an +/// array's items are ordered, and its RDF container kind is part of its value (Part 1 §6.3.4). +fn reproduces(read: &XmpProperty, emitted: &XmpProperty) -> bool { + read.namespace == emitted.namespace + && read.name == emitted.name + && same_value(&read.value, &emitted.value) + && same_qualifiers(&read.qualifiers, &emitted.qualifiers) +} + +/// Whether two values carry the same information (see [`reproduces`]). +fn same_value(read: &XmpValue, emitted: &XmpValue) -> bool { + match (read, emitted) { + (XmpValue::Simple(read), XmpValue::Simple(emitted)) => { + read == emitted || same_number(read, emitted) + } + (XmpValue::Uri(read), XmpValue::Uri(emitted)) => read == emitted, + (XmpValue::Structured(read), XmpValue::Structured(emitted)) => { + read.len() == emitted.len() + && read + .iter() + .all(|field| emitted.iter().any(|other| reproduces(field, other))) + } + (XmpValue::Array(read), XmpValue::Array(emitted)) => same_array(read, emitted), + _ => false, + } +} + +/// Whether two arrays are the same RDF container kind holding the same items in the same order. +fn same_array(read: &XmpArray, emitted: &XmpArray) -> bool { + let items = match (read, emitted) { + (XmpArray::Bag(read), XmpArray::Bag(emitted)) + | (XmpArray::Seq(read), XmpArray::Seq(emitted)) + | (XmpArray::Alt(read), XmpArray::Alt(emitted)) => (read, emitted), + _ => return false, + }; + items.0.len() == items.1.len() + && items.0.iter().zip(items.1).all(|(read, emitted)| { + same_value(&read.value, &emitted.value) + && same_qualifiers(&read.qualifiers, &emitted.qualifiers) + }) +} + +/// Whether two qualifier lists hold the same qualifiers, matching an `xml:lang` tag +/// case-insensitively (XMP Part 1 §8.2.2.4). +fn same_qualifiers(read: &[XmpProperty], emitted: &[XmpProperty]) -> bool { + read.len() == emitted.len() + && read.iter().all(|qualifier| { + emitted + .iter() + .any(|other| match (lang(qualifier), lang(other)) { + (Some(read), Some(emitted)) => read.eq_ignore_ascii_case(emitted), + _ => reproduces(qualifier, other), + }) + }) +} + +/// The tag `qualifier` carries if it is an `xml:lang` qualifier. +fn lang(qualifier: &XmpProperty) -> Option<&str> { + (qualifier.namespace == XML_NAMESPACE && qualifier.name == "lang") + .then(|| qualifier.text()) + .flatten() +} + +/// Whether two texts spell the same finite XMP `Real` (Part 1 §8.2.1) — the difference between +/// ` 0.50 ` and `0.5`, which the writer's own formatting introduces. +fn same_number(read: &str, emitted: &str) -> bool { + let number = |text: &str| { + text.trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + }; + matches!((number(read), number(emitted)), (Some(read), Some(emitted)) if read == emitted) +} + +// --- The value each modelled field is read from and written back as --------------------------- +// +// Every field is read by a `parse_*` and written by the `*_value` beside it, so what the reader +// compares against is the very value the writer will emit and the two cannot drift apart. + +/// The simple text of a value. +fn parse_text(value: &XmpValue) -> Option { + value.text().map(str::to_owned) +} + +/// A simple text value. +fn text_value(value: &str) -> XmpValue { + XmpValue::Simple(value.to_owned()) +} + +/// The `x-default` entry of a language alternative, tolerating a plain simple value. +/// +/// The entry is found by its `xml:lang` qualifier, compared case-insensitively as XMP Part 1 +/// §8.2.2.4 requires — the same match [`XmpMeta::get_lang_alt`] makes on a top-level property. An +/// alternative list with no default entry reads as absent, rather than having another language +/// relabelled as the default; one holding a language *beside* the default reads its default and is +/// then refused by [`reproduces`], because [`lang_alt_value`] would write the other language away. +fn parse_lang_alt(value: &XmpValue) -> Option { + match value { + XmpValue::Array(XmpArray::Alt(items)) => items + .iter() + .find(|item| { + item.lang() + .is_some_and(|lang| lang.eq_ignore_ascii_case(X_DEFAULT)) + }) + .and_then(XmpItem::text), + simple => simple.text(), + } + .map(str::to_owned) +} + +/// A language alternative holding one `x-default` entry. +fn lang_alt_value(value: &str) -> XmpValue { + XmpValue::Array(XmpArray::Alt(vec![XmpItem::lang_text( + X_DEFAULT, + value.to_owned(), + )])) +} + +/// Every simple item of an array value, or `None` when the value is not an array or holds no +/// simple item. +fn parse_list(value: &XmpValue) -> Option> { + match value { + XmpValue::Array(array) => { + let texts: Vec = array.texts().map(str::to_owned).collect(); + (!texts.is_empty()).then_some(texts) + } + _ => None, + } +} + +/// An array of simple text, or `None` when there is nothing to write. +fn list_value(ordered: bool, values: &[String]) -> Option { + array_value(ordered, values.iter().map(XmpItem::simple).collect()) +} + +/// A value parsed as an XMP `Real`; text that does not parse reads as absent. +fn parse_number(value: &XmpValue) -> Option { + value.text()?.trim().parse().ok() +} + +/// An XMP `Real`, or `None` for a value the type has no form for. +/// +/// `NaN` and the infinities are not values of the XMP `Real` type (Part 1 §8.2.1), so they are +/// never written — and a coordinate stating one in the graph is therefore never consumed, and is +/// kept verbatim (see the [module docs](self)). +fn number_value(value: f64) -> Option { + value + .is_finite() + .then(|| XmpValue::Simple(value.to_string())) +} + +/// An array of structure values, or `None` when there is nothing to write. +fn nested_array_value(ordered: bool, values: Vec) -> Option { + array_value(ordered, values.into_iter().map(XmpItem::new).collect()) +} + +/// An `rdf:Seq` when `ordered` and an `rdf:Bag` otherwise, or `None` when there are no items: an +/// empty array says nothing a missing property does not. +fn array_value(ordered: bool, items: Vec) -> Option { + if items.is_empty() { + return None; + } + Some(XmpValue::Array(if ordered { + XmpArray::Seq(items) + } else { + XmpArray::Bag(items) + })) +} + +// --- Writing helpers ------------------------------------------------------------------------- + +/// Appends `ns:name`, unless there is no value to write. +fn put(out: &mut Vec, ns: &str, name: &str, value: Option) { + if let Some(value) = value { + out.push(XmpProperty::new(ns, name, value)); + } +} + +/// Appends `ns:name` as simple text, unless the value is absent. +fn put_text(out: &mut Vec, ns: &str, name: &str, value: Option<&String>) { + put(out, ns, name, value.map(String::as_str).map(text_value)); +} + +/// Appends `ns:name` as a language alternative holding one `x-default` item, unless absent. +fn put_lang_alt(out: &mut Vec, ns: &str, name: &str, value: Option<&String>) { + put(out, ns, name, value.map(String::as_str).map(lang_alt_value)); +} + +/// Appends `ns:name` as an array of simple text, unless the list is empty. +fn put_list(out: &mut Vec, ns: &str, name: &str, ordered: bool, values: &[String]) { + put(out, ns, name, list_value(ordered, values)); +} + +/// Appends `ns:name` as an XMP `Real`, unless the value is absent or has no `Real` form. +fn put_number(out: &mut Vec, ns: &str, name: &str, value: Option) { + put(out, ns, name, value.and_then(number_value)); +} + +/// Appends `ns:name` as a single structure value, unless absent. +fn put_nested(out: &mut Vec, ns: &str, name: &str, value: Option) { + put(out, ns, name, value); +} + +/// Appends `ns:name` as an array of structure values, unless the list is empty. +fn put_nested_array( + out: &mut Vec, + ns: &str, + name: &str, + ordered: bool, + values: Vec, +) { + put(out, ns, name, nested_array_value(ordered, values)); +} + +// --- Verbatim retention of the fields a typed read did not consume --------------------------- + +/// Appends the retained fields, skipping one whose `(namespace, name)` a *modelled* field already +/// emitted carries. +/// +/// The retention list is public, so a caller can put a name the model also carries in it. Emitting +/// both would produce a structure with two fields of one name, which is ill-formed and does not +/// read back — so a namesake of a modelled field is dropped, but only when that field was actually +/// emitted. When it was not, the retained field is the only copy of that name and is written; and +/// two retained fields of one name are both written, because dropping either would lose a field +/// the graph carries. +fn put_other(out: &mut Vec, other: &[XmpProperty]) { + let modelled = out.len(); + for property in other { + if !out[..modelled] + .iter() + .any(|p| p.namespace == property.namespace && p.name == property.name) + { + out.push(property.clone()); + } + } +} + +// --- Creator's contact info ------------------------------------------------------------------- + +/// The creator's contact details (`Iptc4xmpCore:CreatorContactInfo`, IPTC Core 1.5 §8.1). +/// +/// Every field is a simple text property in the `Iptc4xmpCore:` namespace. The email, phone and +/// web-URL fields are single properties that the standard allows to hold several comma-separated +/// values, so they are modelled as the text they carry rather than split. +/// +/// This property is XMP-only and never participates in IIM↔XMP reconciliation (see the +/// [module docs](self)). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct CreatorContactInfo { + /// Street address (`Iptc4xmpCore:CiAdrExtadr`). + pub address: Option, + /// City (`Iptc4xmpCore:CiAdrCity`). + pub city: Option, + /// Country (`Iptc4xmpCore:CiAdrCtry`). + pub country: Option, + /// Postal code (`Iptc4xmpCore:CiAdrPcode`). + pub postal_code: Option, + /// State or province (`Iptc4xmpCore:CiAdrRegion`). + pub region: Option, + /// Work email address(es) (`Iptc4xmpCore:CiEmailWork`). + pub email: Option, + /// Work phone number(s) (`Iptc4xmpCore:CiTelWork`). + pub phone: Option, + /// Work web URL(s) (`Iptc4xmpCore:CiUrlWork`). + pub web_url: Option, + /// Every other field the structure carries, verbatim, so a read-modify-write does not drop a + /// vendor extension (see the [module docs](self)). + pub other: Vec, +} + +impl CreatorContactInfo { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + address: r.text(ns::IPTC_CORE, "CiAdrExtadr"), + city: r.text(ns::IPTC_CORE, "CiAdrCity"), + country: r.text(ns::IPTC_CORE, "CiAdrCtry"), + postal_code: r.text(ns::IPTC_CORE, "CiAdrPcode"), + region: r.text(ns::IPTC_CORE, "CiAdrRegion"), + email: r.text(ns::IPTC_CORE, "CiEmailWork"), + phone: r.text(ns::IPTC_CORE, "CiTelWork"), + web_url: r.text(ns::IPTC_CORE, "CiUrlWork"), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_text(&mut f, ns::IPTC_CORE, "CiAdrExtadr", self.address.as_ref()); + put_text(&mut f, ns::IPTC_CORE, "CiAdrCity", self.city.as_ref()); + put_text(&mut f, ns::IPTC_CORE, "CiAdrCtry", self.country.as_ref()); + put_text( + &mut f, + ns::IPTC_CORE, + "CiAdrPcode", + self.postal_code.as_ref(), + ); + put_text(&mut f, ns::IPTC_CORE, "CiAdrRegion", self.region.as_ref()); + put_text(&mut f, ns::IPTC_CORE, "CiEmailWork", self.email.as_ref()); + put_text(&mut f, ns::IPTC_CORE, "CiTelWork", self.phone.as_ref()); + put_text(&mut f, ns::IPTC_CORE, "CiUrlWork", self.web_url.as_ref()); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +// --- Artwork or object ------------------------------------------------------------------------ + +/// An artwork or object shown in the image (`Iptc4xmpExt:ArtworkOrObject`, IPTC Extension 1.8 +/// §12.1). +/// +/// This property is XMP-only and never participates in IIM↔XMP reconciliation (see the +/// [module docs](self)). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ArtworkOrObject { + /// Title, `x-default` alternative (`Iptc4xmpExt:AOTitle`). + pub title: Option, + /// Creator names, in order (`Iptc4xmpExt:AOCreator`). + pub creator_names: Vec, + /// Creator identifiers, in the same order as the names (`Iptc4xmpExt:AOCreatorId`). + pub creator_identifiers: Vec, + /// Date the artwork was created (`Iptc4xmpExt:AODateCreated`, an XMP date-time). + pub date_created: Option, + /// Approximate creation date or range (`Iptc4xmpExt:AOCircaDateCreated`). + pub circa_date_created: Option, + /// Copyright notice (`Iptc4xmpExt:AOCopyrightNotice`). + pub copyright_notice: Option, + /// Current copyright owner's name (`Iptc4xmpExt:AOCurrentCopyrightOwnerName`). + pub current_copyright_owner_name: Option, + /// Current copyright owner's identifier (`Iptc4xmpExt:AOCurrentCopyrightOwnerId`). + pub current_copyright_owner_identifier: Option, + /// Current licensor's name (`Iptc4xmpExt:AOCurrentLicensorName`). + pub current_licensor_name: Option, + /// Current licensor's identifier (`Iptc4xmpExt:AOCurrentLicensorId`). + pub current_licensor_identifier: Option, + /// Content description, `x-default` alternative (`Iptc4xmpExt:AOContentDescription`). + pub content_description: Option, + /// Contribution description, `x-default` alternative + /// (`Iptc4xmpExt:AOContributionDescription`). + pub contribution_description: Option, + /// Physical description, `x-default` alternative (`Iptc4xmpExt:AOPhysicalDescription`). + pub physical_description: Option, + /// The source holding the artwork (`Iptc4xmpExt:AOSource`). + pub source: Option, + /// The source's inventory number (`Iptc4xmpExt:AOSourceInvNo`). + pub source_inventory_number: Option, + /// URL of the source's inventory record (`Iptc4xmpExt:AOSourceInvURL`). + pub source_inventory_url: Option, + /// Style periods (`Iptc4xmpExt:AOStylePeriod`). + pub style_periods: Vec, + /// Every other field the structure carries, verbatim, so a read-modify-write does not drop a + /// vendor extension (see the [module docs](self)). + pub other: Vec, +} + +impl ArtworkOrObject { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + title: r.lang_alt(ns::IPTC_EXT, "AOTitle"), + creator_names: r.list(ns::IPTC_EXT, "AOCreator", true), + creator_identifiers: r.list(ns::IPTC_EXT, "AOCreatorId", true), + date_created: r.text(ns::IPTC_EXT, "AODateCreated"), + circa_date_created: r.text(ns::IPTC_EXT, "AOCircaDateCreated"), + copyright_notice: r.text(ns::IPTC_EXT, "AOCopyrightNotice"), + current_copyright_owner_name: r.text(ns::IPTC_EXT, "AOCurrentCopyrightOwnerName"), + current_copyright_owner_identifier: r.text(ns::IPTC_EXT, "AOCurrentCopyrightOwnerId"), + current_licensor_name: r.text(ns::IPTC_EXT, "AOCurrentLicensorName"), + current_licensor_identifier: r.text(ns::IPTC_EXT, "AOCurrentLicensorId"), + content_description: r.lang_alt(ns::IPTC_EXT, "AOContentDescription"), + contribution_description: r.lang_alt(ns::IPTC_EXT, "AOContributionDescription"), + physical_description: r.lang_alt(ns::IPTC_EXT, "AOPhysicalDescription"), + source: r.text(ns::IPTC_EXT, "AOSource"), + source_inventory_number: r.text(ns::IPTC_EXT, "AOSourceInvNo"), + source_inventory_url: r.text(ns::IPTC_EXT, "AOSourceInvURL"), + style_periods: r.list(ns::IPTC_EXT, "AOStylePeriod", false), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + /// + /// The container kinds follow the standard: `AOCreator` and `AOCreatorId` are ordered `Seq`s + /// (identifiers are given in the same sequence as the names), `AOStylePeriod` an unordered + /// `Bag`. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_lang_alt(&mut f, ns::IPTC_EXT, "AOTitle", self.title.as_ref()); + put_list(&mut f, ns::IPTC_EXT, "AOCreator", true, &self.creator_names); + put_list( + &mut f, + ns::IPTC_EXT, + "AOCreatorId", + true, + &self.creator_identifiers, + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AODateCreated", + self.date_created.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCircaDateCreated", + self.circa_date_created.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCopyrightNotice", + self.copyright_notice.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCurrentCopyrightOwnerName", + self.current_copyright_owner_name.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCurrentCopyrightOwnerId", + self.current_copyright_owner_identifier.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCurrentLicensorName", + self.current_licensor_name.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOCurrentLicensorId", + self.current_licensor_identifier.as_ref(), + ); + put_lang_alt( + &mut f, + ns::IPTC_EXT, + "AOContentDescription", + self.content_description.as_ref(), + ); + put_lang_alt( + &mut f, + ns::IPTC_EXT, + "AOContributionDescription", + self.contribution_description.as_ref(), + ); + put_lang_alt( + &mut f, + ns::IPTC_EXT, + "AOPhysicalDescription", + self.physical_description.as_ref(), + ); + put_text(&mut f, ns::IPTC_EXT, "AOSource", self.source.as_ref()); + put_text( + &mut f, + ns::IPTC_EXT, + "AOSourceInvNo", + self.source_inventory_number.as_ref(), + ); + put_text( + &mut f, + ns::IPTC_EXT, + "AOSourceInvURL", + self.source_inventory_url.as_ref(), + ); + put_list( + &mut f, + ns::IPTC_EXT, + "AOStylePeriod", + false, + &self.style_periods, + ); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +// --- Licensor --------------------------------------------------------------------------------- + +/// A licensor of the image (`plus:Licensor`, a PLUS 1.2 structure embedded in the IPTC Extension +/// schema, IPTC Extension 1.8 §11.24). +/// +/// The standard caps the property at three licensors; gamut does not enforce that on read — an +/// over-long array reads as the licensors it holds. +/// +/// This property is XMP-only and never participates in IIM↔XMP reconciliation (see the +/// [module docs](self)). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct Licensor { + /// Licensor identifier (`plus:LicensorID`). + pub identifier: Option, + /// Licensor name (`plus:LicensorName`). + pub name: Option, + /// Street address (`plus:LicensorStreetAddress`). + pub address: Option, + /// Extended address detail (`plus:LicensorExtendedAddress`). + pub address_detail: Option, + /// City (`plus:LicensorCity`). + pub city: Option, + /// State or province (`plus:LicensorRegion`). + pub region: Option, + /// Postal code (`plus:LicensorPostalCode`). + pub postal_code: Option, + /// Country (`plus:LicensorCountry`). + pub country: Option, + /// Kind of the first telephone number (`plus:LicensorTelephoneType1`). + pub telephone_type1: Option, + /// First telephone number (`plus:LicensorTelephone1`). + pub telephone1: Option, + /// Kind of the second telephone number (`plus:LicensorTelephoneType2`). + pub telephone_type2: Option, + /// Second telephone number (`plus:LicensorTelephone2`). + pub telephone2: Option, + /// Email address (`plus:LicensorEmail`). + pub email: Option, + /// Web URL (`plus:LicensorURL`). + pub web_url: Option, + /// Every other field the structure carries, verbatim, so a read-modify-write does not drop a + /// vendor extension (see the [module docs](self)). + pub other: Vec, +} + +impl Licensor { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + identifier: r.text(ns::PLUS, "LicensorID"), + name: r.text(ns::PLUS, "LicensorName"), + address: r.text(ns::PLUS, "LicensorStreetAddress"), + address_detail: r.text(ns::PLUS, "LicensorExtendedAddress"), + city: r.text(ns::PLUS, "LicensorCity"), + region: r.text(ns::PLUS, "LicensorRegion"), + postal_code: r.text(ns::PLUS, "LicensorPostalCode"), + country: r.text(ns::PLUS, "LicensorCountry"), + telephone_type1: r.text(ns::PLUS, "LicensorTelephoneType1"), + telephone1: r.text(ns::PLUS, "LicensorTelephone1"), + telephone_type2: r.text(ns::PLUS, "LicensorTelephoneType2"), + telephone2: r.text(ns::PLUS, "LicensorTelephone2"), + email: r.text(ns::PLUS, "LicensorEmail"), + web_url: r.text(ns::PLUS, "LicensorURL"), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_text(&mut f, ns::PLUS, "LicensorID", self.identifier.as_ref()); + put_text(&mut f, ns::PLUS, "LicensorName", self.name.as_ref()); + put_text( + &mut f, + ns::PLUS, + "LicensorStreetAddress", + self.address.as_ref(), + ); + put_text( + &mut f, + ns::PLUS, + "LicensorExtendedAddress", + self.address_detail.as_ref(), + ); + put_text(&mut f, ns::PLUS, "LicensorCity", self.city.as_ref()); + put_text(&mut f, ns::PLUS, "LicensorRegion", self.region.as_ref()); + put_text( + &mut f, + ns::PLUS, + "LicensorPostalCode", + self.postal_code.as_ref(), + ); + put_text(&mut f, ns::PLUS, "LicensorCountry", self.country.as_ref()); + put_text( + &mut f, + ns::PLUS, + "LicensorTelephoneType1", + self.telephone_type1.as_ref(), + ); + put_text( + &mut f, + ns::PLUS, + "LicensorTelephone1", + self.telephone1.as_ref(), + ); + put_text( + &mut f, + ns::PLUS, + "LicensorTelephoneType2", + self.telephone_type2.as_ref(), + ); + put_text( + &mut f, + ns::PLUS, + "LicensorTelephone2", + self.telephone2.as_ref(), + ); + put_text(&mut f, ns::PLUS, "LicensorEmail", self.email.as_ref()); + put_text(&mut f, ns::PLUS, "LicensorURL", self.web_url.as_ref()); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +// --- Image region ----------------------------------------------------------------------------- + +/// An entity or concept referenced by a controlled-vocabulary term (the IPTC Extension +/// `EntityConcept` structure, IPTC Extension 1.8 §12.4). +/// +/// Used for an [`ImageRegion`]'s content types and roles. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct Entity { + /// Globally unique identifiers of the entity or concept (`xmp:Identifier`). + pub identifiers: Vec, + /// Full name, `x-default` alternative (`Iptc4xmpExt:Name`). + pub name: Option, + /// Every field the typed read took nothing from, verbatim, so a read-modify-write does not drop + /// a vendor extension (see the [module docs](self)). + pub other: Vec, +} + +impl Entity { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + identifiers: r.list(ns::XMP, "Identifier", false), + name: r.lang_alt(ns::IPTC_EXT, "Name"), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_list(&mut f, ns::XMP, "Identifier", false, &self.identifiers); + put_lang_alt(&mut f, ns::IPTC_EXT, "Name", self.name.as_ref()); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +/// One vertex of a polygon region boundary (`Iptc4xmpExt:RegionBoundaryPoint`, IPTC Extension 1.8 +/// §12.8). +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct RegionBoundaryPoint { + /// X-axis coordinate (`Iptc4xmpExt:rbX`). + pub x: Option, + /// Y-axis coordinate (`Iptc4xmpExt:rbY`). + pub y: Option, + /// Every field the typed read took nothing from, verbatim — including a coordinate whose text + /// is not a number (see the [module docs](self)). + pub other: Vec, +} + +impl RegionBoundaryPoint { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + x: r.number(ns::IPTC_EXT, "rbX"), + y: r.number(ns::IPTC_EXT, "rbY"), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_number(&mut f, ns::IPTC_EXT, "rbX", self.x); + put_number(&mut f, ns::IPTC_EXT, "rbY", self.y); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +/// The outline of an [`ImageRegion`] (`Iptc4xmpExt:RegionBoundary`, IPTC Extension 1.8 §12.7). +/// +/// Which coordinate fields apply is decided by [`shape`](Self::shape): `rectangle` uses +/// `x`/`y`/`width`/`height`, `circle` uses `x`/`y`/`radius`, and `polygon` uses `vertices` +/// (a single vertex expresses a point, two a line). gamut stores what the graph carries and does +/// not reject a boundary whose fields do not match its shape. +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct RegionBoundary { + /// Boundary shape — `rectangle`, `circle` or `polygon` (`Iptc4xmpExt:rbShape`). + pub shape: Option, + /// Measuring unit — `pixel` or `relative` (`Iptc4xmpExt:rbUnit`). + pub unit: Option, + /// X-axis coordinate of a rectangle's corner or a circle's centre (`Iptc4xmpExt:rbX`). + pub x: Option, + /// Y-axis coordinate of a rectangle's corner or a circle's centre (`Iptc4xmpExt:rbY`). + pub y: Option, + /// Rectangle width (`Iptc4xmpExt:rbW`). + pub width: Option, + /// Rectangle height (`Iptc4xmpExt:rbH`). + pub height: Option, + /// Circle radius (`Iptc4xmpExt:rbRx`). + pub radius: Option, + /// Polygon vertices, in order (`Iptc4xmpExt:rbVertices`). + pub vertices: Vec, + /// Every field the typed read took nothing from, verbatim, so a read-modify-write does not drop + /// a vendor extension (see the [module docs](self)). + pub other: Vec, +} + +impl RegionBoundary { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + shape: r.text(ns::IPTC_EXT, "rbShape"), + unit: r.text(ns::IPTC_EXT, "rbUnit"), + x: r.number(ns::IPTC_EXT, "rbX"), + y: r.number(ns::IPTC_EXT, "rbY"), + width: r.number(ns::IPTC_EXT, "rbW"), + height: r.number(ns::IPTC_EXT, "rbH"), + radius: r.number(ns::IPTC_EXT, "rbRx"), + vertices: r.nested_array( + ns::IPTC_EXT, + "rbVertices", + true, + RegionBoundaryPoint::from_fields, + RegionBoundaryPoint::to_xmp, + ), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + /// + /// The vertices are written as an ordered `Seq`, because a polygon's edges follow the vertex + /// sequence. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_text(&mut f, ns::IPTC_EXT, "rbShape", self.shape.as_ref()); + put_text(&mut f, ns::IPTC_EXT, "rbUnit", self.unit.as_ref()); + put_number(&mut f, ns::IPTC_EXT, "rbX", self.x); + put_number(&mut f, ns::IPTC_EXT, "rbY", self.y); + put_number(&mut f, ns::IPTC_EXT, "rbW", self.width); + put_number(&mut f, ns::IPTC_EXT, "rbH", self.height); + put_number(&mut f, ns::IPTC_EXT, "rbRx", self.radius); + put_nested_array( + &mut f, + ns::IPTC_EXT, + "rbVertices", + true, + self.vertices + .iter() + .map(RegionBoundaryPoint::to_xmp) + .collect(), + ); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +/// A region of the image (`Iptc4xmpExt:ImageRegion`, IPTC Extension 1.8 §11.20) — the basis of +/// face and subject tagging. +/// +/// The standard allows a region to carry *any* other metadata property alongside the five it +/// defines; those are kept verbatim in [`other`](Self::other), so a region survives +/// [`from_xmp`](Self::from_xmp) → [`to_xmp`](Self::to_xmp) with nothing dropped. +/// +/// This property is XMP-only and never participates in IIM↔XMP reconciliation (see the +/// [module docs](self)). +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct ImageRegion { + /// The region's outline (`Iptc4xmpExt:RegionBoundary`). + pub boundary: Option, + /// Region identifier, unique within the image (`Iptc4xmpExt:rId`). + pub identifier: Option, + /// Region name, `x-default` alternative (`Iptc4xmpExt:Name`). + pub name: Option, + /// What the region depicts (`Iptc4xmpExt:rCtype`). + pub content_types: Vec, + /// The role the region plays in the image (`Iptc4xmpExt:rRole`). + pub roles: Vec, + /// Every other property the region carries, verbatim (the standard's + /// "other metadata property"), so a read-modify-write does not drop it. + pub other: Vec, +} + +impl ImageRegion { + /// Reads the structure from an XMP value, or `None` if the value is not a structure. + #[must_use] + pub fn from_xmp(value: &XmpValue) -> Option { + structure(value).map(Self::from_fields) + } + + /// Reads the structure from an already-unwrapped field list. + fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); + Self { + boundary: r.nested( + ns::IPTC_EXT, + "RegionBoundary", + RegionBoundary::from_fields, + RegionBoundary::to_xmp, + ), + identifier: r.text(ns::IPTC_EXT, "rId"), + name: r.lang_alt(ns::IPTC_EXT, "Name"), + content_types: r.nested_array( + ns::IPTC_EXT, + "rCtype", + false, + Entity::from_fields, + Entity::to_xmp, + ), + roles: r.nested_array( + ns::IPTC_EXT, + "rRole", + false, + Entity::from_fields, + Entity::to_xmp, + ), + other: r.other(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields and appending + /// [`other`](Self::other) verbatim. + #[must_use] + pub fn to_xmp(&self) -> XmpValue { + let mut f = Vec::new(); + put_nested( + &mut f, + ns::IPTC_EXT, + "RegionBoundary", + self.boundary.as_ref().map(RegionBoundary::to_xmp), + ); + put_text(&mut f, ns::IPTC_EXT, "rId", self.identifier.as_ref()); + put_lang_alt(&mut f, ns::IPTC_EXT, "Name", self.name.as_ref()); + put_nested_array( + &mut f, + ns::IPTC_EXT, + "rCtype", + false, + self.content_types.iter().map(Entity::to_xmp).collect(), + ); + put_nested_array( + &mut f, + ns::IPTC_EXT, + "rRole", + false, + self.roles.iter().map(Entity::to_xmp).collect(), + ); + put_other(&mut f, &self.other); + XmpValue::Structured(f) + } +} + +// --- The accessors on the unified view --------------------------------------------------------- + +/// The RDF container an array setter puts its items in (XMP Part 1 §6.3.4). +type Container = fn(Vec) -> XmpArray; + +/// The container kind a property's value is already held in — what an array setter keeps, so that +/// one legal container kind is never rewritten to another. A property that is not an array, or no +/// property at all, gives the `rdf:Bag` the standard specifies for these properties. +fn container_of(value: Option<&XmpValue>) -> Container { + match value { + Some(XmpValue::Array(XmpArray::Seq(_))) => XmpArray::Seq, + Some(XmpValue::Array(XmpArray::Alt(_))) => XmpArray::Alt, + _ => XmpArray::Bag, + } +} + +/// One top-level property the typed view projects, and the rule that keeps that projection +/// lossless. +/// +/// This is [`Reader`] one level up. Inside a structure the retention list is the type's `other` +/// field; at the top level it is the graph itself, so *retained* here means the accessor pair +/// leaves the property exactly where it found it. The gate is the same one: the read reports a +/// value only when the property the setter will emit for it [reproduces](reproduces) the property +/// that was read. +/// +/// `R` is what the read yields and `W` what the setter takes — `Vec` and `[T]` for a property +/// holding an array of structures, one type for a property holding a single structure. +struct Projection { + /// The property's namespace URI. + ns: &'static str, + /// The property's local name. + name: &'static str, + /// Reads the typed value out of the property's value, or `None` when there is none to read. + parse: fn(&XmpValue) -> Option, + /// The value the setter emits for a typed value, in the container kind the property already + /// carries, or `None` when there is nothing to write. + emit: fn(&W, Container) -> Option, +} + +impl, W: ?Sized> Projection { + /// The typed value of the property, or `None` when it is absent, holds nothing the model + /// reads, or could not be written back as it stands. + /// + /// Two top-level properties of one name are never read: only one of them could be written + /// back, which is why [`Reader::read`] refuses the same shape inside a structure. + fn read(&self, xmp: &XmpMeta) -> Option { + let mut matches = xmp + .properties + .iter() + .filter(|property| property.namespace == self.ns && property.name == self.name); + let property = matches.next()?; + if matches.next().is_some() { + return None; + } + let value = (self.parse)(&property.value)?; + let emitted = XmpProperty::new( + self.ns, + self.name, + (self.emit)(value.borrow(), container_of(Some(&property.value)))?, + ); + reproduces(property, &emitted).then_some(value) + } + + /// Replaces the property with `value`, keeping the RDF container kind it already carries. + /// + /// Writing nothing removes the property — but only when [`read`](Self::read) reported one. A + /// property the typed view does not report is one the caller was never shown, so the pair does + /// not destroy it. + fn write(&self, xmp: &mut XmpMeta, value: &W) { + let container = container_of(xmp.get(self.ns, self.name).map(|property| &property.value)); + match (self.emit)(value, container) { + Some(value) => xmp.set(XmpProperty::new(self.ns, self.name, value)), + None if self.read(xmp).is_some() => drop(xmp.remove(self.ns, self.name)), + None => {} + } + } +} + +/// Every structure an array property holds, which is none at all for a value holding none. +/// +/// Never `None`: whether there is anything here to write is [`emit_structures`]'s decision, taken +/// once. A read that finds no structure emits nothing, and a read that emits nothing reports +/// nothing (see [`Projection::read`]). +fn parse_structures(value: &XmpValue, parse: fn(&[XmpProperty]) -> T) -> Option> { + Some(structures(value).into_iter().map(parse).collect()) +} + +/// The array value an array setter emits for `values`, or `None` when there is nothing to write: +/// an empty array says nothing a missing property does not. +fn emit_structures( + values: &[T], + container: Container, + to_xmp: fn(&T) -> XmpValue, +) -> Option { + (!values.is_empty()).then(|| { + XmpValue::Array(container( + values + .iter() + .map(|value| XmpItem::new(to_xmp(value))) + .collect(), + )) + }) +} + +/// The `Iptc4xmpCore:CreatorContactInfo` projection: a single structure, so no container applies. +const CONTACT_INFO: Projection = Projection { + ns: ns::IPTC_CORE, + name: "CreatorContactInfo", + parse: |value| structure(value).map(CreatorContactInfo::from_fields), + emit: |info, _| { + let value = info.to_xmp(); + (!structure(&value).is_some_and(<[XmpProperty]>::is_empty)).then_some(value) + }, +}; + +/// The `Iptc4xmpExt:ImageRegion` projection. +const IMAGE_REGIONS: Projection, [ImageRegion]> = Projection { + ns: ns::IPTC_EXT, + name: "ImageRegion", + parse: |value| parse_structures(value, ImageRegion::from_fields), + emit: |values, container| emit_structures(values, container, ImageRegion::to_xmp), +}; + +/// The `Iptc4xmpExt:ArtworkOrObject` projection. +const ARTWORK_OR_OBJECTS: Projection, [ArtworkOrObject]> = Projection { + ns: ns::IPTC_EXT, + name: "ArtworkOrObject", + parse: |value| parse_structures(value, ArtworkOrObject::from_fields), + emit: |values, container| emit_structures(values, container, ArtworkOrObject::to_xmp), +}; + +/// The `plus:Licensor` projection. +const LICENSORS: Projection, [Licensor]> = Projection { + ns: ns::PLUS, + name: "Licensor", + parse: |value| parse_structures(value, Licensor::from_fields), + emit: |values, container| emit_structures(values, container, Licensor::to_xmp), +}; + +impl PhotoMetadata { + /// The creator's contact details (`Iptc4xmpCore:CreatorContactInfo`), or `None` when the + /// property is absent, holds no structure, or could not be written back as it stands (see the + /// [module docs](self)). + #[must_use] + pub fn creator_contact_info(&self) -> Option { + CONTACT_INFO.read(&self.xmp) + } + + /// Sets the creator's contact details (`Iptc4xmpCore:CreatorContactInfo`); a block with no + /// fields at all removes the property, as an empty slice does for the array accessors — but + /// only a property this pair reported. + pub fn set_creator_contact_info(&mut self, info: &CreatorContactInfo) { + CONTACT_INFO.write(&mut self.xmp, info); + } + + /// The image regions (`Iptc4xmpExt:ImageRegion`), in the order the graph holds them, or an + /// empty list when the property is absent, holds no structure, or could not be written back as + /// it stands (see the [module docs](self)). + #[must_use] + pub fn image_regions(&self) -> Vec { + IMAGE_REGIONS.read(&self.xmp).unwrap_or_default() + } + + /// Sets the image regions (`Iptc4xmpExt:ImageRegion`); an existing array keeps its container + /// kind, and an empty slice removes a property this pair reported. + pub fn set_image_regions(&mut self, regions: &[ImageRegion]) { + IMAGE_REGIONS.write(&mut self.xmp, regions); + } + + /// The artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`), on the same + /// terms as [`image_regions`](Self::image_regions). + #[must_use] + pub fn artwork_or_objects(&self) -> Vec { + ARTWORK_OR_OBJECTS.read(&self.xmp).unwrap_or_default() + } + + /// Sets the artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`); an existing + /// array keeps its container kind, and an empty slice removes a property this pair reported. + pub fn set_artwork_or_objects(&mut self, artworks: &[ArtworkOrObject]) { + ARTWORK_OR_OBJECTS.write(&mut self.xmp, artworks); + } + + /// The licensors of the image (`plus:Licensor`), on the same terms as + /// [`image_regions`](Self::image_regions). + #[must_use] + pub fn licensors(&self) -> Vec { + LICENSORS.read(&self.xmp).unwrap_or_default() + } + + /// Sets the licensors of the image (`plus:Licensor`); an existing array keeps its container + /// kind, and an empty slice removes a property this pair reported. + pub fn set_licensors(&mut self, licensors: &[Licensor]) { + LICENSORS.write(&mut self.xmp, licensors); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_value(s: &str) -> XmpValue { + XmpValue::Simple(s.to_owned()) + } + + /// A structure's `from_xmp` → `to_xmp` round trip, named for the type it converts. + type Trip = (&'static str, XmpValue, fn(&XmpValue) -> Option); + + /// The field of `fields` named `ns:name`, for assertions about a field's container kind. + fn field<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a XmpProperty> { + fields.iter().find(|p| p.namespace == ns && p.name == name) + } + + /// A contact block whose eight values are all distinct, so a field read from the wrong + /// property is visible. + fn contact() -> CreatorContactInfo { + CreatorContactInfo { + address: Some("1 Rue Test".to_owned()), + city: Some("Lyon".to_owned()), + country: Some("France".to_owned()), + postal_code: Some("69000".to_owned()), + region: Some("Rhône".to_owned()), + email: Some("a@example.org".to_owned()), + phone: Some("+33 1 23".to_owned()), + web_url: Some("https://example.org/".to_owned()), + other: Vec::new(), + } + } + + #[test] + fn creator_contact_info_round_trips_every_field() { + let info = contact(); + let value = info.to_xmp(); + assert_eq!(CreatorContactInfo::from_xmp(&value), Some(info)); + // Each field is its own `Iptc4xmpCore:Ci*` property; absent fields are not written. + let fields = structure(&value).unwrap(); + assert_eq!(fields.len(), 8); + assert!(fields.iter().all(|p| p.namespace == ns::IPTC_CORE)); + assert_eq!( + Reader::new(fields).text(ns::IPTC_CORE, "CiAdrPcode"), + Some("69000".to_owned()) + ); + let sparse = CreatorContactInfo { + city: Some("Lyon".to_owned()), + ..CreatorContactInfo::default() + }; + assert_eq!(structure(&sparse.to_xmp()).unwrap().len(), 1); + } + + #[test] + fn from_xmp_rejects_a_non_structure_value() { + let text = text_value("not a structure"); + assert_eq!(CreatorContactInfo::from_xmp(&text), None); + assert_eq!(ArtworkOrObject::from_xmp(&text), None); + assert_eq!(Licensor::from_xmp(&text), None); + assert_eq!(ImageRegion::from_xmp(&text), None); + assert_eq!(RegionBoundary::from_xmp(&text), None); + assert_eq!(RegionBoundaryPoint::from_xmp(&text), None); + assert_eq!(Entity::from_xmp(&text), None); + } + + #[test] + fn artwork_or_object_round_trips_every_field() { + let art = ArtworkOrObject { + title: Some("Sunflowers".to_owned()), + creator_names: vec!["Van Gogh".to_owned(), "Studio".to_owned()], + creator_identifiers: vec!["urn:a".to_owned(), "urn:b".to_owned()], + date_created: Some("1888-08".to_owned()), + circa_date_created: Some("circa 1888".to_owned()), + copyright_notice: Some("Public domain".to_owned()), + current_copyright_owner_name: Some("Owner".to_owned()), + current_copyright_owner_identifier: Some("urn:owner".to_owned()), + current_licensor_name: Some("Licensor".to_owned()), + current_licensor_identifier: Some("urn:licensor".to_owned()), + content_description: Some("Vase with flowers".to_owned()), + contribution_description: Some("Restored 1980".to_owned()), + physical_description: Some("Oil on canvas".to_owned()), + source: Some("National Gallery".to_owned()), + source_inventory_number: Some("NG3863".to_owned()), + source_inventory_url: Some("https://example.org/NG3863".to_owned()), + style_periods: vec!["Post-Impressionism".to_owned()], + other: Vec::new(), + }; + let value = art.to_xmp(); + assert_eq!(ArtworkOrObject::from_xmp(&value), Some(art)); + + let fields = structure(&value).unwrap(); + assert_eq!(fields.len(), 17); + // Creator names and identifiers are ordered (they correspond pairwise); style periods are + // an unordered bag. + assert!(matches!( + field(fields, ns::IPTC_EXT, "AOCreator").unwrap().value, + XmpValue::Array(XmpArray::Seq(_)) + )); + assert!(matches!( + field(fields, ns::IPTC_EXT, "AOCreatorId").unwrap().value, + XmpValue::Array(XmpArray::Seq(_)) + )); + assert!(matches!( + field(fields, ns::IPTC_EXT, "AOStylePeriod").unwrap().value, + XmpValue::Array(XmpArray::Bag(_)) + )); + // The four descriptive fields are language alternatives, not plain text. + for name in [ + "AOTitle", + "AOContentDescription", + "AOContributionDescription", + "AOPhysicalDescription", + ] { + assert!( + matches!( + field(fields, ns::IPTC_EXT, name).unwrap().value, + XmpValue::Array(XmpArray::Alt(_)) + ), + "{name} must be a language alternative" + ); + } + } + + #[test] + fn licensor_round_trips_every_field() { + let licensor = Licensor { + identifier: Some("urn:licensor".to_owned()), + name: Some("Agence gamut".to_owned()), + address: Some("2 Rue Test".to_owned()), + address_detail: Some("Floor 3".to_owned()), + city: Some("Paris".to_owned()), + region: Some("Île-de-France".to_owned()), + postal_code: Some("75001".to_owned()), + country: Some("France".to_owned()), + telephone_type1: Some("work".to_owned()), + telephone1: Some("+33 1 11".to_owned()), + telephone_type2: Some("cell".to_owned()), + telephone2: Some("+33 6 22".to_owned()), + email: Some("licence@example.org".to_owned()), + web_url: Some("https://example.org/licence".to_owned()), + other: Vec::new(), + }; + let value = licensor.to_xmp(); + assert_eq!(Licensor::from_xmp(&value), Some(licensor)); + let fields = structure(&value).unwrap(); + assert_eq!(fields.len(), 14); + // Licensor is a PLUS structure, so every field lives in the PLUS namespace. + assert!(fields.iter().all(|p| p.namespace == ns::PLUS)); + } + + #[test] + fn image_region_round_trips_and_keeps_unmodeled_properties() { + let region = ImageRegion { + boundary: Some(RegionBoundary { + shape: Some("rectangle".to_owned()), + unit: Some("relative".to_owned()), + x: Some(0.25), + y: Some(0.5), + width: Some(0.125), + height: Some(0.0625), + radius: None, + ..RegionBoundary::default() + }), + identifier: Some("region-1".to_owned()), + name: Some("Face".to_owned()), + content_types: vec![Entity { + identifiers: vec!["https://cv.iptc.org/newscodes/imageregiontype/human".to_owned()], + name: Some("Human".to_owned()), + ..Entity::default() + }], + roles: vec![Entity { + identifiers: vec![ + "https://cv.iptc.org/newscodes/imageregionrole/subjectArea".to_owned(), + ], + name: Some("Subject area".to_owned()), + ..Entity::default() + }], + // The standard lets a region carry any other property; this one must survive. + other: vec![XmpProperty::new( + ns::IPTC_EXT, + "PersonInImage", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::simple("Ada")])), + )], + }; + let value = region.to_xmp(); + assert_eq!(ImageRegion::from_xmp(&value), Some(region)); + + let fields = structure(&value).unwrap(); + assert_eq!(fields.len(), 6); + assert!(field(fields, ns::IPTC_EXT, "PersonInImage").is_some()); + // The two Entity arrays are unordered bags; the boundary is a plain structure. + for name in ["rCtype", "rRole"] { + assert!(matches!( + field(fields, ns::IPTC_EXT, name).unwrap().value, + XmpValue::Array(XmpArray::Bag(_)) + )); + } + assert!(matches!( + field(fields, ns::IPTC_EXT, "RegionBoundary").unwrap().value, + XmpValue::Structured(_) + )); + // The entity identifier is `xmp:Identifier`, not an IPTC-namespaced property. + let entity = structures(&field(fields, ns::IPTC_EXT, "rCtype").unwrap().value)[0]; + assert!(field(entity, ns::XMP, "Identifier").is_some()); + } + + #[test] + fn entity_round_trips_its_identifiers_and_name() { + // An Entity reached through an ImageRegion is read field-list-first; this is the public + // value-level conversion, which nothing else exercises with a structure value. + let entity = Entity { + identifiers: vec!["urn:a".to_owned(), "urn:b".to_owned()], + name: Some("Human".to_owned()), + ..Entity::default() + }; + assert_eq!(Entity::from_xmp(&entity.to_xmp()), Some(entity)); + } + + #[test] + fn polygon_boundary_keeps_its_vertices_in_order() { + let boundary = RegionBoundary { + shape: Some("polygon".to_owned()), + unit: Some("pixel".to_owned()), + vertices: vec![ + RegionBoundaryPoint { + x: Some(0.0), + y: Some(10.0), + ..RegionBoundaryPoint::default() + }, + RegionBoundaryPoint { + x: Some(20.0), + y: Some(30.0), + ..RegionBoundaryPoint::default() + }, + ], + ..RegionBoundary::default() + }; + let value = boundary.to_xmp(); + assert_eq!(RegionBoundary::from_xmp(&value), Some(boundary)); + // A polygon's edges follow the vertex sequence, so the array must be a Seq. + let fields = structure(&value).unwrap(); + assert!(matches!( + field(fields, ns::IPTC_EXT, "rbVertices").unwrap().value, + XmpValue::Array(XmpArray::Seq(_)) + )); + } + + #[test] + fn a_coordinate_that_is_not_a_number_reads_as_absent() { + // Honest read: the typed view does not invent a number it cannot parse. Where the raw + // value goes instead is `a_field_the_model_cannot_read_is_kept_verbatim`. + let value = XmpValue::Structured(vec![ + XmpProperty::new(ns::IPTC_EXT, "rbX", text_value("halfway")), + XmpProperty::new(ns::IPTC_EXT, "rbY", text_value(" 4.5 ")), + ]); + let point = RegionBoundaryPoint::from_xmp(&value).unwrap(); + assert_eq!(point.x, None); + assert_eq!(point.y, Some(4.5)); + } + + #[test] + fn a_default_entry_tagged_in_another_case_is_still_the_default() { + // `X-Default` and `x-default` are one tag (XMP Part 1 §8.2.2.4), so the entry is read and + // written back in the canonical case rather than kept as a language of its own. + let value = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + XmpValue::Array(XmpArray::Alt(vec![XmpItem::lang_text( + "X-Default", + "Sunflowers", + )])), + )]); + let art = ArtworkOrObject::from_xmp(&value).unwrap(); + assert_eq!(art.title.as_deref(), Some("Sunflowers")); + assert_eq!( + art.to_xmp(), + XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + lang_alt_value("Sunflowers"), + )]) + ); + } + + #[test] + fn accessors_round_trip_through_the_unified_view() { + let mut pm = PhotoMetadata::new(); + assert_eq!(pm.creator_contact_info(), None); + assert!(pm.image_regions().is_empty()); + assert!(pm.artwork_or_objects().is_empty()); + assert!(pm.licensors().is_empty()); + + let info = contact(); + let region = ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + }; + let art = ArtworkOrObject { + title: Some("Sunflowers".to_owned()), + ..ArtworkOrObject::default() + }; + let licensor = Licensor { + name: Some("Agence gamut".to_owned()), + ..Licensor::default() + }; + pm.set_creator_contact_info(&info); + pm.set_image_regions(std::slice::from_ref(®ion)); + pm.set_artwork_or_objects(std::slice::from_ref(&art)); + pm.set_licensors(std::slice::from_ref(&licensor)); + + assert_eq!(pm.creator_contact_info(), Some(info)); + assert_eq!(pm.image_regions(), vec![region]); + assert_eq!(pm.artwork_or_objects(), vec![art]); + assert_eq!(pm.licensors(), vec![licensor]); + assert_eq!(pm.xmp.properties.len(), 4); + + // Setting an empty slice removes the property rather than leaving an empty array. + pm.set_image_regions(&[]); + pm.set_artwork_or_objects(&[]); + pm.set_licensors(&[]); + assert!(pm.image_regions().is_empty()); + assert_eq!(pm.xmp.properties.len(), 1); + } + + #[test] + fn plus_licensors_survive_extraction_from_a_full_xmp_graph() { + // plus: is in IPTC_NAMESPACES, so PhotoMetadata::from_xmp must keep plus:Licensor while + // still dropping a non-IPTC property. + let mut pm = PhotoMetadata::new(); + pm.set_licensors(&[Licensor { + name: Some("Agence gamut".to_owned()), + ..Licensor::default() + }]); + let mut graph = pm.to_xmp(); + graph.set(XmpProperty::new( + ns::XMP, + "CreatorTool", + text_value("something else"), + )); + let extracted = PhotoMetadata::from_xmp(&graph); + assert_eq!(extracted.licensors().len(), 1); + assert_eq!(extracted.xmp.properties.len(), 1); + } + + /// `value` (a structure) with one vendor-namespace field appended. + fn with_vendor_field(value: &XmpValue) -> XmpValue { + let mut fields = structure(value).expect("a structure value").to_vec(); + fields.push(XmpProperty::new( + "http://example.org/vendor/", + "Tint", + text_value("warm"), + )); + XmpValue::Structured(fields) + } + + #[test] + fn every_structure_keeps_the_field_it_does_not_model() { + // A read-modify-write must not drop a vendor extension, inside a nested structure as much + // as beside it. Each conversion is checked as a fixed point over its own canonical output + // plus one foreign field, so a type that silently replaced the graph value fails here. + let art = ArtworkOrObject { + title: Some("Sunflowers".to_owned()), + ..ArtworkOrObject::default() + }; + let licensor = Licensor { + name: Some("Agence gamut".to_owned()), + ..Licensor::default() + }; + let region = ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + }; + let entity = Entity { + name: Some("Human".to_owned()), + ..Entity::default() + }; + let point = RegionBoundaryPoint { + x: Some(1.0), + y: Some(2.0), + ..RegionBoundaryPoint::default() + }; + let boundary = RegionBoundary { + shape: Some("circle".to_owned()), + radius: Some(0.5), + ..RegionBoundary::default() + }; + let trips: [Trip; 7] = [ + ("CreatorContactInfo", contact().to_xmp(), |v| { + Some(CreatorContactInfo::from_xmp(v)?.to_xmp()) + }), + ("ArtworkOrObject", art.to_xmp(), |v| { + Some(ArtworkOrObject::from_xmp(v)?.to_xmp()) + }), + ("Licensor", licensor.to_xmp(), |v| { + Some(Licensor::from_xmp(v)?.to_xmp()) + }), + ("ImageRegion", region.to_xmp(), |v| { + Some(ImageRegion::from_xmp(v)?.to_xmp()) + }), + ("Entity", entity.to_xmp(), |v| { + Some(Entity::from_xmp(v)?.to_xmp()) + }), + ("RegionBoundaryPoint", point.to_xmp(), |v| { + Some(RegionBoundaryPoint::from_xmp(v)?.to_xmp()) + }), + ("RegionBoundary", boundary.to_xmp(), |v| { + Some(RegionBoundary::from_xmp(v)?.to_xmp()) + }), + ]; + for (name, canonical, round_trip) in trips { + let input = with_vendor_field(&canonical); + assert_eq!( + round_trip(&input).as_ref(), + Some(&input), + "{name} did not keep its unmodelled field" + ); + } + } + + #[test] + fn a_retained_field_never_duplicates_a_modelled_one() { + // The retention list is public, so a caller can put a modelled name in it. Emitting both + // would make a structure carrying two `rId` fields, which is ill-formed and reads back as + // only one of them. + let region = ImageRegion { + identifier: Some("r1".to_owned()), + other: vec![XmpProperty::new(ns::IPTC_EXT, "rId", text_value("r2"))], + ..ImageRegion::default() + }; + let value = region.to_xmp(); + let fields = structure(&value).unwrap(); + assert_eq!(fields.iter().filter(|p| p.name == "rId").count(), 1); + // The modelled value is the authority, and the output is a fixed point. + assert_eq!( + Reader::new(fields).text(ns::IPTC_EXT, "rId"), + Some("r1".to_owned()) + ); + assert_eq!( + ImageRegion::from_xmp(&value).map(|r| r.to_xmp()), + Some(value.clone()) + ); + } + + #[test] + fn reproduction_is_equality_apart_from_the_two_re_spellings() { + // The relation the retention rule is built on, stated on its own: the same value spelled + // another way reproduces a field, and nothing else does. + let coordinate = |text: &str| XmpProperty::new(ns::IPTC_EXT, "rbX", text_value(text)); + assert!(reproduces(&coordinate(" 0.50 "), &coordinate("0.5"))); + assert!(!reproduces(&coordinate("0.5"), &coordinate("0.25"))); + assert!(!reproduces(&coordinate("left"), &coordinate("right"))); + // A value of another kind is another value, and a field of another name is another field. + assert!(!reproduces( + &coordinate("0.5"), + &XmpProperty::new(ns::IPTC_EXT, "rbX", XmpValue::Uri("0.5".to_owned())) + )); + assert!(!reproduces( + &coordinate("0.5"), + &XmpProperty::new(ns::IPTC_EXT, "rbY", text_value("0.5")) + )); + assert!(!reproduces( + &coordinate("0.5"), + &XmpProperty::new(ns::XMP, "rbX", text_value("0.5")) + )); + } + + #[test] + fn a_uri_reproduces_only_a_uri_of_the_same_target() { + // `rdf:resource` is a value kind of its own (XMP Part 1 §6.3.2), so a URL field is + // reproduced only by emitting the same URI again — the clause that makes a `Uri` field + // consumable at all rather than retained. + let url = |target: &str| { + XmpProperty::new( + ns::IPTC_EXT, + "AOSourceInvURL", + XmpValue::Uri(target.to_owned()), + ) + }; + assert!(reproduces( + &url("https://example.test/a"), + &url("https://example.test/a") + )); + assert!(!reproduces( + &url("https://example.test/a"), + &url("https://example.test/b") + )); + } + + #[test] + fn a_structure_reproduces_only_when_every_field_does() { + // A structure's fields are an unordered set (Part 1 §6.3.3), so each one has to be found + // in the other structure; counting them is not enough. + let creator = |name: &str| { + XmpProperty::new( + ns::IPTC_EXT, + "AOCreator", + structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "Name", + text_value(name), + )]), + ) + }; + assert!(reproduces(&creator("Human"), &creator("Human"))); + assert!(!reproduces(&creator("Human"), &creator("Another"))); + } + + #[test] + fn a_qualifier_the_writer_does_not_emit_is_a_difference() { + // The writer builds its properties with no qualifiers at all, so a qualified field is + // reproduced only by a field carrying the same qualifier — matching them pairwise, not + // by count. + let city = || XmpProperty::new(ns::IPTC_EXT, "CiAdrCity", text_value("Paris")); + assert!(reproduces( + &qualified(city(), "fr"), + &qualified(city(), "fr") + )); + assert!(!reproduces(&qualified(city(), "fr"), &city())); + assert!(!reproduces( + &qualified(city(), "fr"), + &qualified(city(), "en") + )); + } + + #[test] + fn only_an_xml_lang_qualifier_is_matched_case_insensitively() { + // XMP Part 1 §8.2.2.4 matches language tags case-insensitively. No other qualifier is + // matched that way: neither the `xml:` namespace on its own nor the name `lang` on its + // own makes one, and an ordinary qualifier is compared by value. + let city = || XmpProperty::new(ns::IPTC_EXT, "CiAdrCity", text_value("Paris")); + let with = |namespace: &str, name: &str, text: &str| { + let mut property = city(); + property + .qualifiers + .push(XmpProperty::new(namespace, name, text_value(text))); + property + }; + assert!(reproduces( + &qualified(city(), "x-default"), + &qualified(city(), "X-Default") + )); + assert!(!reproduces( + &with(XML_NAMESPACE, "space", "preserve"), + &with(XML_NAMESPACE, "space", "PRESERVE") + )); + assert!(!reproduces( + &with(ns::IPTC_EXT, "lang", "fr"), + &with(ns::IPTC_EXT, "lang", "FR") + )); + assert!(!reproduces( + &with(ns::IPTC_EXT, "role", "editor"), + &with(ns::IPTC_EXT, "role", "author") + )); + } + + /// One shape a modelled field can arrive in, with both rules' machinery attached to it. + struct Shape { + /// What the shape is — the label the enumeration is published under. + label: &'static str, + /// The field as the graph holds it. + field: XmpProperty, + /// Runs the modelling read for that field, discarding the value: what is under test is + /// whether the reader consumed the field, not what it parsed. + read: fn(&mut Reader<'_>), + /// Whether the typed read parses a value out of it — the rule retention used to be decided + /// by, before the writer had a say. + parses: fn(&XmpValue) -> bool, + /// The owning type's `from_xmp` -> `to_xmp`. + trip: fn(&XmpValue) -> Option, + } + + fn structured(fields: Vec) -> XmpValue { + XmpValue::Structured(fields) + } + + fn qualified(mut property: XmpProperty, lang: &str) -> XmpProperty { + property + .qualifiers + .push(XmpProperty::new(XML_NAMESPACE, "lang", text_value(lang))); + property + } + + fn entity() -> XmpValue { + Entity { + name: Some("Human".to_owned()), + ..Entity::default() + } + .to_xmp() + } + + fn vertex() -> XmpValue { + RegionBoundaryPoint { + x: Some(1.0), + ..RegionBoundaryPoint::default() + } + .to_xmp() + } + + /// Every shape the module's fidelity rule is stated over: the canonical form of each modelled + /// field kind, and every departure from it a graph can carry. + /// + /// Each shape is a single field of the type that models it, so `trip` is that type's + /// read-modify-write over exactly this one field. + fn shapes() -> Vec { + let contact: fn(&XmpValue) -> Option = + |v| Some(CreatorContactInfo::from_xmp(v)?.to_xmp()); + let artwork: fn(&XmpValue) -> Option = + |v| Some(ArtworkOrObject::from_xmp(v)?.to_xmp()); + let region: fn(&XmpValue) -> Option = + |v| Some(ImageRegion::from_xmp(v)?.to_xmp()); + let point: fn(&XmpValue) -> Option = + |v| Some(RegionBoundaryPoint::from_xmp(v)?.to_xmp()); + let boundary: fn(&XmpValue) -> Option = + |v| Some(RegionBoundary::from_xmp(v)?.to_xmp()); + + let text: fn(&mut Reader<'_>) = |r| { + r.text(ns::IPTC_CORE, "CiUrlWork"); + }; + let text_parses: fn(&XmpValue) -> bool = |v| parse_text(v).is_some(); + let lang_alt: fn(&mut Reader<'_>) = |r| { + r.lang_alt(ns::IPTC_EXT, "AOTitle"); + }; + let lang_alt_parses: fn(&XmpValue) -> bool = |v| parse_lang_alt(v).is_some(); + let bag: fn(&mut Reader<'_>) = |r| { + r.list(ns::IPTC_EXT, "AOStylePeriod", false); + }; + let seq: fn(&mut Reader<'_>) = |r| { + r.list(ns::IPTC_EXT, "AOCreator", true); + }; + let list_parses: fn(&XmpValue) -> bool = |v| parse_list(v).is_some(); + let number: fn(&mut Reader<'_>) = |r| { + r.number(ns::IPTC_EXT, "rbX"); + }; + let number_parses: fn(&XmpValue) -> bool = |v| parse_number(v).is_some(); + let nested: fn(&mut Reader<'_>) = |r| { + r.nested( + ns::IPTC_EXT, + "RegionBoundary", + RegionBoundary::from_fields, + RegionBoundary::to_xmp, + ); + }; + let nested_parses: fn(&XmpValue) -> bool = |v| structure(v).is_some(); + let nested_bag: fn(&mut Reader<'_>) = |r| { + r.nested_array( + ns::IPTC_EXT, + "rCtype", + false, + Entity::from_fields, + Entity::to_xmp, + ); + }; + let nested_seq: fn(&mut Reader<'_>) = |r| { + r.nested_array( + ns::IPTC_EXT, + "rbVertices", + true, + RegionBoundaryPoint::from_fields, + RegionBoundaryPoint::to_xmp, + ); + }; + let nested_array_parses: fn(&XmpValue) -> bool = |v| !structures(v).is_empty(); + + let shape = |label, field, read, parses, trip| Shape { + label, + field, + read, + parses, + trip, + }; + let ext = |name, value| XmpProperty::new(ns::IPTC_EXT, name, value); + vec![ + // --- the canonical form of each field kind: read, and written back unchanged -------- + shape( + "text: element text", + XmpProperty::new( + ns::IPTC_CORE, + "CiUrlWork", + text_value("https://example.org/"), + ), + text, + text_parses, + contact, + ), + shape( + "lang alt: one x-default entry", + ext("AOTitle", lang_alt_value("Sunflowers")), + lang_alt, + lang_alt_parses, + artwork, + ), + shape( + "list: a bag of text", + ext( + "AOStylePeriod", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::simple("Baroque")])), + ), + bag, + list_parses, + artwork, + ), + shape( + "list: a seq of text", + ext( + "AOCreator", + XmpValue::Array(XmpArray::Seq(vec![XmpItem::simple("Van Gogh")])), + ), + seq, + list_parses, + artwork, + ), + shape( + "number: a decimal", + ext("rbX", text_value("0.25")), + number, + number_parses, + point, + ), + shape( + "nested: a structure", + ext( + "RegionBoundary", + structured(vec![ext("rbShape", text_value("circle"))]), + ), + nested, + nested_parses, + region, + ), + shape( + "nested array: a bag of structures", + ext( + "rCtype", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::new(entity())])), + ), + nested_bag, + nested_array_parses, + region, + ), + shape( + "nested array: a seq of structures", + ext( + "rbVertices", + XmpValue::Array(XmpArray::Seq(vec![XmpItem::new(vertex())])), + ), + nested_seq, + nested_array_parses, + boundary, + ), + // --- departures from it: parsed by the typed read, but not writable back as they are + shape( + "text: a URL held as rdf:resource", + XmpProperty::new( + ns::IPTC_CORE, + "CiUrlWork", + XmpValue::Uri("https://example.org/".to_owned()), + ), + text, + text_parses, + contact, + ), + shape( + "text: a value carrying a qualifier", + qualified( + XmpProperty::new( + ns::IPTC_CORE, + "CiUrlWork", + text_value("https://example.org/"), + ), + "fr", + ), + text, + text_parses, + contact, + ), + shape( + "lang alt: another language beside the default", + ext( + "AOTitle", + XmpValue::Array(XmpArray::Alt(vec![ + XmpItem::lang_text(X_DEFAULT, "Sunflowers"), + XmpItem::lang_text("fr", "Tournesols"), + ])), + ), + lang_alt, + lang_alt_parses, + artwork, + ), + shape( + "lang alt: plain text where an alternative belongs", + ext("AOTitle", text_value("Sunflowers")), + lang_alt, + lang_alt_parses, + artwork, + ), + shape( + "list: an rdf:Alt where an array belongs", + ext( + "AOStylePeriod", + XmpValue::Array(XmpArray::Alt(vec![ + XmpItem::lang_text(X_DEFAULT, "Baroque"), + XmpItem::lang_text("fr", "baroque"), + ])), + ), + bag, + list_parses, + artwork, + ), + shape( + "list: an rdf:Seq where an rdf:Bag belongs", + ext( + "AOStylePeriod", + XmpValue::Array(XmpArray::Seq(vec![XmpItem::simple("Baroque")])), + ), + bag, + list_parses, + artwork, + ), + shape( + "list: an item that is not text", + ext( + "AOStylePeriod", + XmpValue::Array(XmpArray::Bag(vec![ + XmpItem::simple("Baroque"), + XmpItem::new(structured(vec![ext("Nested", text_value("v"))])), + ])), + ), + bag, + list_parses, + artwork, + ), + shape( + "list: an item held as rdf:resource", + ext( + "AOStylePeriod", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::new(XmpValue::Uri( + "https://example.org/".to_owned(), + ))])), + ), + bag, + list_parses, + artwork, + ), + shape( + "number: text with no XMP Real value", + ext("rbX", text_value("NaN")), + number, + number_parses, + point, + ), + shape( + "nested: a structure carrying a qualifier", + qualified( + ext( + "RegionBoundary", + structured(vec![ext("rbShape", text_value("circle"))]), + ), + "fr", + ), + nested, + nested_parses, + region, + ), + shape( + "nested array: a bare structure where an array belongs", + ext("rCtype", entity()), + nested_bag, + nested_array_parses, + region, + ), + shape( + "nested array: an rdf:Bag where an rdf:Seq belongs", + ext( + "rbVertices", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::new(vertex())])), + ), + nested_seq, + nested_array_parses, + boundary, + ), + shape( + "nested array: an item that is not a structure", + ext( + "rCtype", + XmpValue::Array(XmpArray::Bag(vec![ + XmpItem::new(entity()), + XmpItem::simple("not an entity"), + ])), + ), + nested_bag, + nested_array_parses, + region, + ), + ] + } + + #[test] + fn every_shape_survives_a_read_modify_write_unchanged() { + // The law the module states: reading a structure and writing it back changes nothing. + // A shape the writer reproduces goes out as the model's own output; one it cannot is kept + // verbatim. Either way the graph that comes out is the graph that went in. + for shape in shapes() { + let input = structured(vec![shape.field]); + assert_eq!( + (shape.trip)(&input).as_ref(), + Some(&input), + "{}: a read-modify-write did not give the field back", + shape.label + ); + } + } + + #[test] + fn retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back() { + // Derived, not listed: a shape is retained under the new rule and would have been consumed + // — and so destroyed — under the old one exactly when the typed read parses a value out of + // it and the reader still leaves it alone. This is the enumeration the module documents. + let mut destroyed = Vec::new(); + for shape in shapes() { + let fields = [shape.field]; + let mut reader = Reader::new(&fields); + (shape.read)(&mut reader); + if (shape.parses)(&fields[0].value) && !reader.other().is_empty() { + destroyed.push(shape.label); + } + } + assert_eq!( + destroyed, + [ + "text: a URL held as rdf:resource", + "text: a value carrying a qualifier", + "lang alt: another language beside the default", + "lang alt: plain text where an alternative belongs", + "list: an rdf:Alt where an array belongs", + "list: an rdf:Seq where an rdf:Bag belongs", + "list: an item that is not text", + "list: an item held as rdf:resource", + "number: text with no XMP Real value", + "nested: a structure carrying a qualifier", + "nested array: a bare structure where an array belongs", + "nested array: an rdf:Bag where an rdf:Seq belongs", + "nested array: an item that is not a structure", + ] + ); + } + + #[test] + fn a_non_finite_coordinate_is_neither_written_nor_destroyed() { + // NaN and the infinities are not values of the XMP Real type: writing one would put a + // value in the graph that no reader can take back as a number. + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let value = RegionBoundaryPoint { + x: Some(bad), + y: Some(1.5), + ..RegionBoundaryPoint::default() + } + .to_xmp(); + let fields = structure(&value).unwrap(); + assert!( + field(fields, ns::IPTC_EXT, "rbX").is_none(), + "{bad} was written to the graph" + ); + // The finite sibling is still written, so the skip is per value, not per structure. + assert_eq!(Reader::new(fields).number(ns::IPTC_EXT, "rbY"), Some(1.5)); + } + // The same values arriving *from the graph* must not be destroyed either: the read parses + // them, so a rule that consumed whatever it could read would drop them on the way out. + for stated in ["NaN", "inf", "-inf", "1e400"] { + let input = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rbX", + text_value(stated), + )]); + let point = RegionBoundaryPoint::from_xmp(&input).expect("a structure value"); + assert_eq!(point.x, None, "{stated} was reported as a coordinate"); + assert_eq!(point.to_xmp(), input, "{stated} was destroyed"); + } + } + + #[test] + fn a_coordinate_keeps_its_value_when_it_is_respelled() { + // The one lexical change the writer makes to a number it consumed: ` 0.50 ` comes back as + // `0.5`, the same value, and doing it again changes nothing more. + let input = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rbX", + text_value(" 0.50 "), + )]); + let point = RegionBoundaryPoint::from_xmp(&input).expect("a structure value"); + assert_eq!(point.x, Some(0.5)); + let written = point.to_xmp(); + assert_eq!( + written, + XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rbX", + text_value("0.5"), + )]) + ); + assert_eq!( + RegionBoundaryPoint::from_xmp(&written).map(|p| p.to_xmp()), + Some(written.clone()) + ); + } + + #[test] + fn two_fields_of_one_name_are_both_kept() { + // Only one of them could be written back, so neither is read and both are retained: the + // structure is ill-formed, and silently halving it would be a loss the caller cannot see. + let input = XmpValue::Structured(vec![ + XmpProperty::new(ns::IPTC_EXT, "rId", text_value("r1")), + XmpProperty::new(ns::IPTC_EXT, "rId", text_value("r2")), + ]); + let region = ImageRegion::from_xmp(&input).expect("a structure value"); + assert_eq!(region.identifier, None); + assert_eq!(region.to_xmp(), input); + } + + #[test] + fn a_field_the_model_cannot_read_is_kept_verbatim() { + // The defect this closes: a field whose *name* the model owns but whose *value* the typed + // read rejects used to be neither parsed nor retained, so it vanished from the graph. It + // reads as absent — and is written back unchanged, because the read consumed nothing. + let structured = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "Nested", + text_value("v"), + )]); + let cases: [Trip; 3] = [ + // An identifier holding a structure where text belongs. + ( + "rId", + XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rId", + structured.clone(), + )]), + |v| Some(ImageRegion::from_xmp(v)?.to_xmp()), + ), + // A coordinate carrying non-numeric text. + ( + "rbX", + XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rbX", + text_value("halfway"), + )]), + |v| Some(RegionBoundaryPoint::from_xmp(v)?.to_xmp()), + ), + // A language alternative with no `x-default` entry: relabelling another language as + // the default would destroy the only text the field has. + ( + "AOTitle", + XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + XmpValue::Array(XmpArray::Alt(vec![XmpItem::lang_text("fr", "Tournesols")])), + )]), + |v| Some(ArtworkOrObject::from_xmp(v)?.to_xmp()), + ), + ]; + for (name, input, round_trip) in cases { + assert_eq!( + round_trip(&input).as_ref(), + Some(&input), + "{name}: an unreadable value must survive a read-modify-write" + ); + } + // ...and the typed field reads as absent rather than as an invented value. + let region = ImageRegion::from_xmp(&XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rId", + structured, + )])); + assert_eq!(region.and_then(|r| r.identifier), None); + } + + #[test] + fn a_language_alternative_keeps_the_languages_beside_the_default() { + // The model holds one string, so writing this field back would keep the default and + // destroy the French entry. It is therefore not read, and the whole alternative survives. + let value = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + XmpValue::Array(XmpArray::Alt(vec![ + XmpItem::lang_text(X_DEFAULT, "Sunflowers"), + XmpItem::lang_text("fr", "Tournesols"), + ])), + )]); + let art = ArtworkOrObject::from_xmp(&value).unwrap(); + assert_eq!(art.title, None); + assert_eq!(art.to_xmp(), value); + } + + #[test] + fn a_retained_field_is_written_when_the_modelled_field_is_absent() { + // The retained field is then the only copy of that name, so dropping it as a namesake + // would destroy it: there is no duplicate to avoid. + let region = ImageRegion { + identifier: None, + other: vec![XmpProperty::new(ns::IPTC_EXT, "rId", text_value("r2"))], + ..ImageRegion::default() + }; + let value = region.to_xmp(); + let fields = structure(&value).expect("a structure value"); + assert_eq!( + Reader::new(fields).text(ns::IPTC_EXT, "rId"), + Some("r2".to_owned()) + ); + } + + #[test] + fn setting_an_array_keeps_the_container_kind_the_property_already_has() { + // Rewriting one legal container kind to another throws away what that kind states — a + // `Seq`'s order, an `Alt`'s "these are alternatives" — and the setter has the existing + // property in front of it. A property that is not an array becomes the standard Bag. + let region = ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + }; + let kinds: [fn(Vec) -> XmpArray; 3] = + [XmpArray::Bag, XmpArray::Seq, XmpArray::Alt]; + for kind in kinds { + let mut pm = PhotoMetadata::new(); + pm.xmp.set(XmpProperty::new( + ns::IPTC_EXT, + "ImageRegion", + XmpValue::Array(kind(vec![XmpItem::new(region.to_xmp())])), + )); + pm.set_image_regions(std::slice::from_ref(®ion)); + assert_eq!( + pm.xmp + .get(ns::IPTC_EXT, "ImageRegion") + .expect("the property") + .value, + XmpValue::Array(kind(vec![XmpItem::new(region.to_xmp())])) + ); + } + // Nothing to keep: the standard Bag. + let mut pm = PhotoMetadata::new(); + pm.set_image_regions(std::slice::from_ref(®ion)); + assert!(matches!( + pm.xmp + .get(ns::IPTC_EXT, "ImageRegion") + .expect("the property") + .value, + XmpValue::Array(XmpArray::Bag(_)) + )); + } + + #[test] + fn reading_an_array_and_setting_it_back_keeps_every_member() { + // A member carrying no field at all is still a member: dropping it would shift every + // later member's index, so `image_regions()` -> `set_image_regions()` would not be the + // identity. `rbVertices` never dropped one, and the top-level setters now agree with it. + let mut pm = PhotoMetadata::new(); + let region = ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + }; + let regions = vec![ImageRegion::default(), region]; + pm.set_image_regions(®ions); + assert_eq!(pm.image_regions(), regions); + pm.set_image_regions(&pm.image_regions()); + assert_eq!(pm.image_regions(), regions); + } + + #[test] + fn an_empty_contact_block_removes_the_property() { + // All four setters agree: nothing to say removes the property, rather than leaving behind + // an empty structure a reader would report as "present but blank". + let mut pm = PhotoMetadata::new(); + pm.set_creator_contact_info(&contact()); + pm.set_creator_contact_info(&CreatorContactInfo::default()); + assert_eq!(pm.creator_contact_info(), None); + assert!(pm.xmp.properties.is_empty()); + } + + #[test] + fn a_bare_structure_where_an_array_belongs_reads_as_absent_at_both_levels() { + // Seen in the wild: a single structure written where the standard puts a Bag. Writing it + // back would normalise it into a Bag, so it is not read — one rule, at both levels, and + // the property is left where it lies either way. + let licensor = Licensor { + name: Some("Agence gamut".to_owned()), + ..Licensor::default() + }; + let mut pm = PhotoMetadata::new(); + pm.xmp + .set(XmpProperty::new(ns::PLUS, "Licensor", licensor.to_xmp())); + assert_eq!(pm.licensors(), Vec::new()); + + // Inside a structure the same shape reads the same way: `rCtype` reads as nothing. + let region = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rCtype", + Entity { + name: Some("Human".to_owned()), + ..Entity::default() + } + .to_xmp(), + )]); + let read = ImageRegion::from_xmp(®ion).expect("a structure value"); + assert_eq!(read.content_types, Vec::new()); + } + + #[test] + fn an_array_member_the_model_cannot_write_back_makes_the_property_read_as_absent() { + // Hostile/odd input: a Bag holding plain text where a structure is expected. Reporting the + // one region the model does read would invite a setter call that drops the other member, + // so the property reads as absent instead — and it is not fatal. + let mut pm = PhotoMetadata::new(); + pm.xmp.set(XmpProperty::new( + ns::IPTC_EXT, + "ImageRegion", + XmpValue::Array(XmpArray::Bag(vec![ + XmpItem::simple("not a region"), + XmpItem::new( + ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + } + .to_xmp(), + ), + ])), + )); + assert_eq!(pm.image_regions(), Vec::new()); + // A non-array property yields nothing at all rather than a bogus entry. + pm.xmp.set(XmpProperty::new( + ns::PLUS, + "Licensor", + text_value("not an array"), + )); + assert!(pm.licensors().is_empty()); + } + + #[test] + fn two_top_level_properties_of_one_name_are_neither_read_nor_lost() { + // Only one of the two could be written back, so neither is read — the rule `Reader::read` + // already takes inside a structure — and the setter therefore has nothing to remove. + let named = |name: &str| { + XmpProperty::new( + ns::PLUS, + "Licensor", + XmpValue::Array(XmpArray::Bag(vec![XmpItem::new( + Licensor { + name: Some(name.to_owned()), + ..Licensor::default() + } + .to_xmp(), + )])), + ) + }; + let mut pm = PhotoMetadata::new(); + pm.xmp.properties.push(named("Agence gamut")); + pm.xmp.properties.push(named("Another agency")); + let before = pm.xmp.clone(); + assert_eq!(pm.licensors(), Vec::new()); + pm.set_licensors(&pm.licensors()); + assert_eq!(pm.xmp, before); + } + + /// One top-level property the typed view projects, for the sweep below. + struct TopProperty { + /// The accessor pair under test, for the failure message. + label: &'static str, + /// The property's namespace URI. + ns: &'static str, + /// The property's local name. + name: &'static str, + /// A structure value whose every field the property's own type reads. + member: fn() -> XmpValue, + /// Reads the property through its accessor and writes what it read straight back. + trip: fn(&mut PhotoMetadata), + } + + /// The four accessor pairs [`PhotoMetadata`] exposes for a structured IPTC property. + fn top_properties() -> Vec { + vec![ + TopProperty { + label: "creator_contact_info", + ns: ns::IPTC_CORE, + name: "CreatorContactInfo", + member: || contact().to_xmp(), + trip: |pm| { + let info = pm.creator_contact_info().unwrap_or_default(); + pm.set_creator_contact_info(&info); + }, + }, + TopProperty { + label: "image_regions", + ns: ns::IPTC_EXT, + name: "ImageRegion", + member: || { + ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + } + .to_xmp() + }, + trip: |pm| pm.set_image_regions(&pm.image_regions()), + }, + TopProperty { + label: "artwork_or_objects", + ns: ns::IPTC_EXT, + name: "ArtworkOrObject", + member: || { + ArtworkOrObject { + title: Some("Sunflowers".to_owned()), + ..ArtworkOrObject::default() + } + .to_xmp() + }, + trip: |pm| pm.set_artwork_or_objects(&pm.artwork_or_objects()), + }, + TopProperty { + label: "licensors", + ns: ns::PLUS, + name: "Licensor", + member: || { + Licensor { + name: Some("Agence gamut".to_owned()), + ..Licensor::default() + } + .to_xmp() + }, + trip: |pm| pm.set_licensors(&pm.licensors()), + }, + ] + } + + /// Every shape a top-level property's *value* can arrive in: the canonical form the model + /// writes, and every departure from it a graph can carry — a container kind it does not write, + /// a member it cannot read or cannot write back, and a value that is not an array at all. + fn top_level_values(member: &XmpValue) -> Vec<(&'static str, XmpValue)> { + let bag = |items: Vec| XmpValue::Array(XmpArray::Bag(items)); + let one = || XmpItem::new(member.clone()); + let vendor = XmpValue::Structured(vec![XmpProperty::new( + "http://example.org/vendor/", + "Tint", + text_value("warm"), + )]); + vec![ + ("a bag of one structure", bag(vec![one()])), + ( + "a seq of one structure", + XmpValue::Array(XmpArray::Seq(vec![one()])), + ), + ( + "an alt of one structure", + XmpValue::Array(XmpArray::Alt(vec![one()])), + ), + ("a bag of two structures", bag(vec![one(), one()])), + ( + "a bag of a structure the model does not name", + bag(vec![XmpItem::new(vendor)]), + ), + ( + "a bag of a structure with no field at all", + bag(vec![XmpItem::new(XmpValue::Structured(Vec::new()))]), + ), + ( + "a bag holding a member that is not a structure", + bag(vec![one(), XmpItem::simple("not a structure")]), + ), + ( + "a bag holding a member held as rdf:resource", + bag(vec![XmpItem::new(XmpValue::Uri( + "https://example.org/".to_owned(), + ))]), + ), + ( + "a bag holding a member that carries a qualifier", + bag(vec![XmpItem { + value: member.clone(), + qualifiers: vec![XmpProperty::new(XML_NAMESPACE, "lang", text_value("fr"))], + }]), + ), + ("an empty bag", bag(Vec::new())), + ("a bare structure", member.clone()), + ( + "a structure with no field at all", + XmpValue::Structured(Vec::new()), + ), + ("simple text", text_value("not a structure")), + ( + "a value held as rdf:resource", + XmpValue::Uri("https://example.org/".to_owned()), + ), + ( + "a language alternative of text", + XmpValue::Array(XmpArray::Alt(vec![ + XmpItem::lang_text(X_DEFAULT, "Sunflowers"), + XmpItem::lang_text("fr", "Tournesols"), + ])), + ), + ] + } + + /// Every qualifier list a top-level property can carry. + fn top_level_qualifiers() -> Vec<(&'static str, Vec)> { + let lang = || XmpProperty::new(XML_NAMESPACE, "lang", text_value("fr")); + let vendor = || XmpProperty::new("http://example.org/vendor/", "Note", text_value("n")); + vec![ + ("no qualifier", Vec::new()), + ("an xml:lang qualifier", vec![lang()]), + ("a vendor qualifier", vec![vendor()]), + ("two qualifiers", vec![lang(), vendor()]), + ] + } + + #[test] + fn every_top_level_shape_survives_a_read_modify_write_unchanged() { + // The same law as `every_shape_survives_a_read_modify_write_unchanged`, one level up, and + // generated rather than listed: every accessor pair crossed with every value shape and + // every qualifier list. A shape the setter reproduces goes back out as the model's own + // output; one it cannot is left where it lies. Either way the graph that comes out — the + // property under test and the unrelated one beside it — is the graph that went in. + let neighbour = XmpProperty::new(ns::PHOTOSHOP, "Headline", text_value("Breaking")); + let mut crossed = 0_usize; + for property in top_properties() { + let member = (property.member)(); + for (value_label, value) in top_level_values(&member) { + for (qualifier_label, qualifiers) in top_level_qualifiers() { + let mut pm = PhotoMetadata::new(); + pm.xmp.properties.push(XmpProperty { + namespace: property.ns.to_owned(), + name: property.name.to_owned(), + value: value.clone(), + qualifiers, + }); + pm.xmp.properties.push(neighbour.clone()); + let before = pm.xmp.clone(); + (property.trip)(&mut pm); + assert_eq!( + pm.xmp, before, + "{}: {value_label}, {qualifier_label}: a read-modify-write did not give \ + the graph back", + property.label + ); + crossed += 1; + } + } + } + // The cross is what makes this a sweep rather than a spot check: 4 pairs x 15 value + // shapes x 4 qualifier lists. + assert_eq!(crossed, 240); + } +} diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index 88520862..bcdd41cb 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -29,9 +29,9 @@ pub enum IimFieldKind { Graphic, /// Binary data — raw octets that are not text (e.g. the two-octet record-version number). Binary, - /// A date in the `CCYYMMDD` form (IPTC-IIM 4.2 dataset 2:55). + /// A date in the eight-octet `CCYYMMDD` form (e.g. IPTC-IIM 4.2 dataset 2:55 Date Created). Date, - /// A time in the `HHMMSS±HHMM` form (IPTC-IIM 4.2 dataset 2:60). + /// A time in the eleven-octet `HHMMSS±HHMM` form (e.g. IPTC-IIM 4.2 dataset 2:60 Time Created). Time, } @@ -59,27 +59,73 @@ pub struct IimTagInfo { use IimFieldKind::{Binary, Date, Graphic, Time}; -/// The known IIM datasets gamut models: the descriptive Application-record fields that map to IPTC -/// Photo Metadata, plus the structural datasets (record/model versions, coded character set). +/// Every IIM dataset gamut names: the complete Envelope (record 1) and Application (record 2) +/// dataset sets of IPTC-IIM 4.2 chapters 5 and 6, plus `7:10` Size Mode. /// -/// Sourced from the IPTC-IIM 4.2 dataset definitions; cross-checked against the IPTC Photo Metadata -/// technical reference (`references/iptc/iptc-pmd-techreference_2025.1.json`) for the mapped subset. +/// Sourced from the IPTC-IIM 4.2 dataset definitions (`references/iptc/iim-4.2.pdf`); the subset +/// IPTC Photo Metadata maps to XMP is additionally cross-checked against the machine-readable +/// technical reference (`references/iptc/iptc-pmd-techreference_2025.1.json`) by +/// `tests/techreference.rs`. +/// +/// A dataset is named when IIM 4.2 states a maximum value length that is determinate *and* fits +/// [`IimTagInfo::max_octets`]'s `u16`. Exactly six do not, and are deliberately absent: `2:202` +/// ObjectData Preview Data (256000 octets, beyond `u16`) and `7:20`, `7:90`, `7:95`, `8:10` and +/// `9:10`, whose value is "a binary number" of unstated width. `7:10` Size Mode is the one dataset +/// outside chapters 5 and 6 that *is* named, because the spec fixes it at "one octet" (IIM 4.2 +/// Ch. 11). Records 3 (a separate publication), 4 and 5 (not allocated) and 6 (IIM 4.2 Appendix F +/// defines method identifiers, not datasets) carry no dataset definitions in the vendored spec at +/// all. Every unmodeled dataset in any record still round-trips byte-exact. #[rustfmt::skip] const KNOWN_TAGS: &[IimTagInfo] = &[ - // Envelope record (1). + // Envelope record (1) — IIM 4.2 Chapter 5. IimTagInfo { record: 1, dataset: 0, name: "Model Version", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 1, dataset: 5, name: "Destination", repeatable: true, max_octets: 1024, kind: Graphic }, + IimTagInfo { record: 1, dataset: 20, name: "File Format", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 1, dataset: 22, name: "File Format Version", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 1, dataset: 30, name: "Service Identifier", repeatable: false, max_octets: 10, kind: Graphic }, + IimTagInfo { record: 1, dataset: 40, name: "Envelope Number", repeatable: false, max_octets: 8, kind: Graphic }, + IimTagInfo { record: 1, dataset: 50, name: "Product I.D.", repeatable: true, max_octets: 32, kind: Graphic }, + IimTagInfo { record: 1, dataset: 60, name: "Envelope Priority", repeatable: false, max_octets: 1, kind: Graphic }, + IimTagInfo { record: 1, dataset: 70, name: "Date Sent", repeatable: false, max_octets: 8, kind: Date }, + IimTagInfo { record: 1, dataset: 80, name: "Time Sent", repeatable: false, max_octets: 11, kind: Time }, IimTagInfo { record: 1, dataset: 90, name: "Coded Character Set", repeatable: false, max_octets: 32, kind: Binary }, - // Application record (2). + IimTagInfo { record: 1, dataset: 100, name: "UNO", repeatable: false, max_octets: 80, kind: Graphic }, + IimTagInfo { record: 1, dataset: 120, name: "ARM Identifier", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 1, dataset: 122, name: "ARM Version", repeatable: false, max_octets: 2, kind: Binary }, + // Application record (2) — IIM 4.2 Chapter 6. IimTagInfo { record: 2, dataset: 0, name: "Record Version", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 2, dataset: 3, name: "Object Type Reference", repeatable: false, max_octets: 67, kind: Graphic }, // 68 per the IIM 4.2 wire form (3-digit reference number + ':' + up to 64 octets of text); the // PMD tech-reference JSON's IIMmaxbytes records 64 (text only) — see tests/techreference.rs. IimTagInfo { record: 2, dataset: 4, name: "Object Attribute Reference", repeatable: true, max_octets: 68, kind: Graphic }, IimTagInfo { record: 2, dataset: 5, name: "Object Name", repeatable: false, max_octets: 64, kind: Graphic }, + IimTagInfo { record: 2, dataset: 7, name: "Edit Status", repeatable: false, max_octets: 64, kind: Graphic }, + IimTagInfo { record: 2, dataset: 8, name: "Editorial Update", repeatable: false, max_octets: 2, kind: Graphic }, + IimTagInfo { record: 2, dataset: 10, name: "Urgency", repeatable: false, max_octets: 1, kind: Graphic }, IimTagInfo { record: 2, dataset: 12, name: "Subject Reference", repeatable: true, max_octets: 236, kind: Graphic }, + IimTagInfo { record: 2, dataset: 15, name: "Category", repeatable: false, max_octets: 3, kind: Graphic }, + IimTagInfo { record: 2, dataset: 20, name: "Supplemental Category", repeatable: true, max_octets: 32, kind: Graphic }, + IimTagInfo { record: 2, dataset: 22, name: "Fixture Identifier", repeatable: false, max_octets: 32, kind: Graphic }, IimTagInfo { record: 2, dataset: 25, name: "Keywords", repeatable: true, max_octets: 64, kind: Graphic }, + IimTagInfo { record: 2, dataset: 26, name: "Content Location Code", repeatable: true, max_octets: 3, kind: Graphic }, + IimTagInfo { record: 2, dataset: 27, name: "Content Location Name", repeatable: true, max_octets: 64, kind: Graphic }, + IimTagInfo { record: 2, dataset: 30, name: "Release Date", repeatable: false, max_octets: 8, kind: Date }, + IimTagInfo { record: 2, dataset: 35, name: "Release Time", repeatable: false, max_octets: 11, kind: Time }, + IimTagInfo { record: 2, dataset: 37, name: "Expiration Date", repeatable: false, max_octets: 8, kind: Date }, + IimTagInfo { record: 2, dataset: 38, name: "Expiration Time", repeatable: false, max_octets: 11, kind: Time }, IimTagInfo { record: 2, dataset: 40, name: "Special Instructions", repeatable: false, max_octets: 256, kind: Graphic }, + IimTagInfo { record: 2, dataset: 42, name: "Action Advised", repeatable: false, max_octets: 2, kind: Graphic }, + // 2:45, 2:47 and 2:50 take the formats of 1:30, 1:70 and 1:40 respectively (IIM 4.2 Ch. 6). + IimTagInfo { record: 2, dataset: 45, name: "Reference Service", repeatable: true, max_octets: 10, kind: Graphic }, + IimTagInfo { record: 2, dataset: 47, name: "Reference Date", repeatable: true, max_octets: 8, kind: Date }, + IimTagInfo { record: 2, dataset: 50, name: "Reference Number", repeatable: true, max_octets: 8, kind: Graphic }, IimTagInfo { record: 2, dataset: 55, name: "Date Created", repeatable: false, max_octets: 8, kind: Date }, IimTagInfo { record: 2, dataset: 60, name: "Time Created", repeatable: false, max_octets: 11, kind: Time }, + IimTagInfo { record: 2, dataset: 62, name: "Digital Creation Date", repeatable: false, max_octets: 8, kind: Date }, + IimTagInfo { record: 2, dataset: 63, name: "Digital Creation Time", repeatable: false, max_octets: 11, kind: Time }, + IimTagInfo { record: 2, dataset: 65, name: "Originating Program", repeatable: false, max_octets: 32, kind: Graphic }, + IimTagInfo { record: 2, dataset: 70, name: "Program Version", repeatable: false, max_octets: 10, kind: Graphic }, + IimTagInfo { record: 2, dataset: 75, name: "Object Cycle", repeatable: false, max_octets: 1, kind: Graphic }, IimTagInfo { record: 2, dataset: 80, name: "By-line", repeatable: true, max_octets: 32, kind: Graphic }, IimTagInfo { record: 2, dataset: 85, name: "By-line Title", repeatable: true, max_octets: 32, kind: Graphic }, IimTagInfo { record: 2, dataset: 90, name: "City", repeatable: false, max_octets: 32, kind: Graphic }, @@ -92,8 +138,24 @@ const KNOWN_TAGS: &[IimTagInfo] = &[ IimTagInfo { record: 2, dataset: 110, name: "Credit", repeatable: false, max_octets: 32, kind: Graphic }, IimTagInfo { record: 2, dataset: 115, name: "Source", repeatable: false, max_octets: 32, kind: Graphic }, IimTagInfo { record: 2, dataset: 116, name: "Copyright Notice", repeatable: false, max_octets: 128, kind: Graphic }, + IimTagInfo { record: 2, dataset: 118, name: "Contact", repeatable: true, max_octets: 128, kind: Graphic }, IimTagInfo { record: 2, dataset: 120, name: "Caption/Abstract", repeatable: false, max_octets: 2000, kind: Graphic }, IimTagInfo { record: 2, dataset: 122, name: "Writer/Editor", repeatable: true, max_octets: 32, kind: Graphic }, + IimTagInfo { record: 2, dataset: 125, name: "Rasterized Caption", repeatable: false, max_octets: 7360, kind: Binary }, + IimTagInfo { record: 2, dataset: 130, name: "Image Type", repeatable: false, max_octets: 2, kind: Graphic }, + IimTagInfo { record: 2, dataset: 131, name: "Image Orientation", repeatable: false, max_octets: 1, kind: Graphic }, + IimTagInfo { record: 2, dataset: 135, name: "Language Identifier", repeatable: false, max_octets: 3, kind: Graphic }, + IimTagInfo { record: 2, dataset: 150, name: "Audio Type", repeatable: false, max_octets: 2, kind: Graphic }, + IimTagInfo { record: 2, dataset: 151, name: "Audio Sampling Rate", repeatable: false, max_octets: 6, kind: Graphic }, + IimTagInfo { record: 2, dataset: 152, name: "Audio Sampling Resolution", repeatable: false, max_octets: 2, kind: Graphic }, + IimTagInfo { record: 2, dataset: 153, name: "Audio Duration", repeatable: false, max_octets: 6, kind: Graphic }, + IimTagInfo { record: 2, dataset: 154, name: "Audio Outcue", repeatable: false, max_octets: 64, kind: Graphic }, + IimTagInfo { record: 2, dataset: 200, name: "ObjectData Preview File Format", repeatable: false, max_octets: 2, kind: Binary }, + IimTagInfo { record: 2, dataset: 201, name: "ObjectData Preview File Format Version", repeatable: false, max_octets: 2, kind: Binary }, + // Pre-ObjectData Descriptor record (7) — IIM 4.2 Chapter 11. Only 7:10 states a determinate + // length ("Mandatory, not repeatable, one octet"); 7:20, 7:90 and 7:95 are "a binary number" + // of unstated width, as are 8:10 and 9:10. + IimTagInfo { record: 7, dataset: 10, name: "Size Mode", repeatable: false, max_octets: 1, kind: Binary }, ]; impl IimTagInfo { @@ -120,7 +182,7 @@ impl IimTagInfo { #[derive(Debug, Clone, PartialEq, Eq)] pub struct IimDataSet { /// The record number this dataset belongs to (IPTC-IIM 4.2 §1.1: 1 = Envelope, 2 = Application; - /// records 3–9 round-trip as raw datasets). + /// records 3–9 are unnamed apart from `7:10` and round-trip as raw datasets). pub record: u8, /// The dataset number within the record. pub dataset: u8, @@ -259,6 +321,8 @@ impl IimBlock { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; fn ds(record: u8, dataset: u8, data: &[u8]) -> IimDataSet { @@ -296,18 +360,288 @@ mod tests { fn known_tags_are_well_formed() { for t in KNOWN_TAGS { assert!( - t.record == 1 || t.record == 2, + matches!(t.record, 1 | 2 | 7), "unexpected record {}", t.record ); assert!(!t.name.is_empty()); assert!(t.max_octets > 0); - // No duplicate (record, dataset) entries. + // No duplicate (record, dataset) entries, and no duplicate names. let count = KNOWN_TAGS .iter() .filter(|o| o.record == t.record && o.dataset == t.dataset) .count(); assert_eq!(count, 1, "duplicate tag {}:{}", t.record, t.dataset); + let named = KNOWN_TAGS.iter().filter(|o| o.name == t.name).count(); + assert_eq!(named, 1, "duplicate name {}", t.name); + } + // The table is ordered by (record, dataset), so a transcription inserted in the wrong + // place is visible rather than merely unsorted. + assert!( + KNOWN_TAGS + .windows(2) + .all(|w| (w[0].record, w[0].dataset) < (w[1].record, w[1].dataset)), + "KNOWN_TAGS must be ordered by (record, dataset)" + ); + } + + #[test] + fn date_and_time_datasets_carry_their_fixed_iim_form_lengths() { + // IIM 4.2 fixes both forms: a date is CCYYMMDD (8 octets), a time HHMMSS±HHMM (11). A + // mis-transcribed length on any of the nine date/time datasets shows up here. + for t in KNOWN_TAGS { + match t.kind { + Date => assert_eq!(t.max_octets, 8, "{}:{} {}", t.record, t.dataset, t.name), + Time => assert_eq!(t.max_octets, 11, "{}:{} {}", t.record, t.dataset, t.name), + Binary | Graphic => {} + } + } + // Both kinds are actually present, so the loop above is not vacuous. + assert!(KNOWN_TAGS.iter().any(|t| t.kind == Date)); + assert!(KNOWN_TAGS.iter().any(|t| t.kind == Time)); + } + + #[test] + fn only_datasets_with_a_determinate_octet_maximum_are_named() { + // `max_octets` is a `u16` and can only state a determinate maximum, so the six datasets + // IIM 4.2 gives none it can hold are absent: `2:202` ObjectData Preview Data (256000 + // octets, beyond `u16`) and 7:20/7:90/7:95/8:10/9:10 ("a binary number" of unstated + // width). + for (record, dataset) in [(2, 202), (7, 20), (7, 90), (7, 95), (8, 10), (9, 10)] { + assert!( + IimTagInfo::lookup(record, dataset).is_none(), + "{record}:{dataset} is named but has no octet maximum `max_octets` can state" + ); + } + // 7:10 Size Mode is the boundary case that keeps the criterion honest: it is outside + // chapters 5 and 6, but IIM 4.2 Ch. 11 fixes it at one octet, so it is named. + assert_eq!(IimTagInfo::lookup(7, 10).map(|t| t.max_octets), Some(1)); + // Everything else in chapters 5 and 6 is named: 14 Envelope + 56 Application datasets. + assert_eq!(KNOWN_TAGS.iter().filter(|t| t.record == 1).count(), 14); + assert_eq!(KNOWN_TAGS.iter().filter(|t| t.record == 2).count(), 56); + } + + /// The text of a vendored file, relative to the workspace root. + fn vendored(relative: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(relative); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("vendored file {relative} must be readable: {e}")) + } + + /// The octet that stands in for a string literal in [`index_string_literals`]'s output, on + /// both sides of the literal's index. It cannot occur in C++ source outside a literal. + const LITERAL: char = '\u{1}'; + + /// `src` with every string literal replaced by a numbered placeholder, so a comma or a brace + /// inside a C++ description cannot be mistaken for row punctuation. + fn index_string_literals(src: &str) -> String { + let mut out = String::new(); + let mut count = 0usize; + let mut chars = src.chars(); + while let Some(c) = chars.next() { + if c != '"' { + out.push(c); + continue; + } + let mut literal = String::new(); + while let Some(c) = chars.next() { + match c { + '\\' => literal.extend(chars.next()), + '"' => break, + _ => literal.push(c), + } + } + out.push(LITERAL); + out.push_str(&count.to_string()); + out.push(LITERAL); + count += 1; + } + out + } + + /// The `static constexpr uint16_t NAME = N;` constants of an exiv2 header. + fn cpp_constants(header: &str) -> BTreeMap<&str, u16> { + header + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("static constexpr uint16_t ")?; + let (name, value) = rest.split_once(" = ")?; + Some((name, value.trim().trim_end_matches(';').parse().ok()?)) + }) + .collect() + } + + /// The body of the `constexpr DataSet [] = { ... };` array in exiv2's `datasets.cpp`. + fn cpp_array<'a>(source: &'a str, name: &str) -> &'a str { + let marker = format!("constexpr DataSet {name}[] = {{"); + let start = source + .find(&marker) + .unwrap_or_else(|| panic!("exiv2 defines {name}")) + + marker.len(); + let end = source[start..] + .find("\n};") + .unwrap_or_else(|| panic!("{name} is terminated")); + &source[start..start + end] + } + + /// One row of exiv2's own IIM dataset table. + struct Exiv2DataSet { + /// Whether the dataset may repeat within a record. + repeatable: bool, + /// The maximum value length in octets. + max: u32, + /// exiv2's name for the value type (`string`, `date`, `unsignedShort`, ...). + kind: String, + } + + /// exiv2's own IIM dataset table, read from the vendored `third_party/exiv2` sources. + /// + /// Each row of `datasets.cpp` is `{IptcDataSets::, "name", N_("title"), + /// N_("description"), mandatory, repeatable, minbytes, maxbytes, type, record, "photoshop"}`, + /// and `datasets.hpp` gives ``'s dataset number. The trailing fields are counted from + /// the end, because a description spans an unpredictable number of concatenated literals. The + /// sentinel row closing each array numbers itself `0xffff` rather than a symbol, so it drops + /// out of the constant lookup. + fn exiv2_dataset_table() -> BTreeMap<(u8, u8), Exiv2DataSet> { + let header = vendored("third_party/exiv2/include/exiv2/datasets.hpp"); + let numbers = cpp_constants(&header); + let source = index_string_literals(&vendored("third_party/exiv2/src/datasets.cpp")); + let mut table = BTreeMap::new(); + for array in ["envelopeRecord", "application2Record"] { + for chunk in cpp_array(&source, array).split('}') { + let Some(open) = chunk.rfind('{') else { + continue; + }; + let fields: Vec<&str> = chunk[open + 1..].split(',').map(str::trim).collect(); + let Some(last) = fields.len().checked_sub(1).filter(|&n| n >= 7) else { + continue; + }; + let symbol = fields[0].strip_prefix("IptcDataSets::"); + let record = fields[last - 1].strip_prefix("IptcDataSets::"); + let (Some(symbol), Some(record)) = (symbol, record) else { + continue; + }; + let (Some(&dataset), Some(&record)) = (numbers.get(symbol), numbers.get(record)) + else { + continue; + }; + let row = Exiv2DataSet { + repeatable: fields[last - 5] == "true", + max: fields[last - 3].parse().expect("maxbytes is a number"), + kind: fields[last - 2].trim_start_matches("Exiv2::").to_owned(), + }; + table.insert((record as u8, dataset as u8), row); + } + } + table + } + + /// The IIM 4.2 DataSet names, keyed by `(record, dataset)`, as extracted from the standard's + /// own PDF. + /// + /// `references/iptc/iim-4.2.pdf` sets every DataSet's name in a column of its own, which + /// `pdftotext -bbox-layout` recovers by position. That extraction is not run here — `pdftotext` + /// is a system package the workspace toolchain does not provision — so its output is committed + /// beside this crate's tests and read back, with the command that produces it recorded in + /// `tests/data/extract-iim-names.py`. + fn standard_dataset_names() -> BTreeMap<(u8, u8), &'static str> { + include_str!("../tests/data/iim-4.2-dataset-names.tsv") + .lines() + .filter(|line| !line.starts_with('#')) + .map(|line| { + let mut columns = line.split('\t'); + let mut number = || { + columns + .next() + .and_then(|c| c.parse().ok()) + .expect("a record and dataset number") + }; + let (record, dataset) = (number(), number()); + ((record, dataset), columns.next().expect("a name")) + }) + .collect() + } + + #[test] + fn tag_table_names_match_the_standards_own_dataset_names() { + // Drift guard on the one column no round trip and no oracle can see. exiv2 titles 28 of + // the 70 datasets it shares with gamut more briefly than IIM 4.2 names them, so comparing + // names against exiv2 could only ever pin those 28 to a second hand transcription of the + // same page. These names come from the standard's own name column instead, so a mistyped + // name fails here even if it is mistyped the same way twice. + let standard = standard_dataset_names(); + for t in KNOWN_TAGS { + assert_eq!( + standard.get(&(t.record, t.dataset)).copied(), + Some(t.name), + "{}:{} is not named that in IIM 4.2", + t.record, + t.dataset + ); + } + // ...and the only datasets the standard names that gamut does not are the six with no + // octet maximum `max_octets` can state (see the test above, and issue #539). + let unnamed: Vec<(u8, u8)> = standard + .keys() + .copied() + .filter(|&(record, dataset)| IimTagInfo::lookup(record, dataset).is_none()) + .collect(); + assert_eq!( + unnamed, + [(2, 202), (7, 20), (7, 90), (7, 95), (8, 10), (9, 10)] + ); + } + + #[test] + fn tag_table_matches_the_exiv2_dataset_table() { + // Second drift guard, on the columns the standard states in prose rather than in a column: + // exiv2 — the crate's differential oracle — carries its own transcription of the same spec + // chapters, so comparing octet maximum, repeatability and value type against it catches a + // slipped digit. Names are pinned to the standard itself, not here: exiv2 abbreviates too + // many of them to compare. + let exiv2 = exiv2_dataset_table(); + assert!( + exiv2.len() > 60, + "exiv2 table parsed as {} rows", + exiv2.len() + ); + for t in KNOWN_TAGS { + let Some(row) = exiv2.get(&(t.record, t.dataset)) else { + // exiv2 tables records 1 and 2 only; 7:10 comes from the standard alone. + assert_eq!( + ((t.record, t.dataset), t.name), + ((7, 10), "Size Mode"), + "a dataset outside exiv2's table must be 7:10 Size Mode" + ); + continue; + }; + let where_ = format!("{}:{} {}", t.record, t.dataset, t.name); + assert_eq!(u32::from(t.max_octets), row.max, "{where_}: octet maximum"); + assert_eq!(t.repeatable, row.repeatable, "{where_}: repeatability"); + let expected = match row.kind.as_str() { + // exiv2 types 1:90 Coded Character Set as a string; IIM 4.2 Ch. 5 makes it ISO + // 2022 escape sequences, which are control characters, not graphic ones — so + // gamut calls it Binary. Pinned here rather than merely documented. + _ if (t.record, t.dataset) == (1, 90) => Binary, + "string" => Graphic, + "unsignedShort" | "undefined" => Binary, + "date" => Date, + "time" => Time, + other => panic!("{where_}: unmapped exiv2 value type {other}"), + }; + assert_eq!(t.kind, expected, "{where_}: value kind"); + } + // ...and nothing exiv2 documents is missing, apart from the one row `u16` cannot state. + for &(record, dataset) in exiv2.keys() { + if (record, dataset) == (2, 202) { + continue; + } + assert!( + IimTagInfo::lookup(record, dataset).is_some(), + "exiv2 documents {record}:{dataset} but gamut does not name it" + ); } } diff --git a/crates/gamut-iptc/src/lib.rs b/crates/gamut-iptc/src/lib.rs index c4a3c5f0..07be22e7 100644 --- a/crates/gamut-iptc/src/lib.rs +++ b/crates/gamut-iptc/src/lib.rs @@ -71,15 +71,17 @@ //! [`xmp`]); parsing and serializing the XMP packet bytes is [`gamut_xmp`]'s responsibility (issue //! #34). Exotic ISO 2022 character sets beyond Latin-1 and UTF-8 are reported as //! [`crate::IptcError::Unsupported`] rather than mis-decoded (see [`charset`]). The typed -//! accessors cover every scalar/list IPTC **Core** property; the structured -//! `Iptc4xmpCore:CreatorContactInfo` and the IPTC **Extension** structures (image regions, -//! artwork, licensors) have no typed model and pass through [`PhotoMetadata::xmp`] as raw -//! values. Scalar-shaped IIM datasets that repeat on the wire (`2:04`, `2:85`) reconcile their -//! first value only; IIM records 3–9 have no named tags — both still round-trip byte-exact. See -//! `STATUS.md` for the full v1 deferral list. +//! accessors cover every scalar/list IPTC **Core** property, plus the structured +//! `Iptc4xmpCore:CreatorContactInfo` and the most-used IPTC **Extension** structures — image +//! regions, artwork/object and licensors (see [`extension`]); the remaining Extension structures +//! pass through [`PhotoMetadata::xmp`] as raw values. Scalar-shaped IIM datasets that repeat on the +//! wire (`2:04`, `2:85`) reconcile their first value only; the IIM tag table names every dataset +//! IIM 4.2 gives a determinate octet maximum for — all of records 1 and 2 bar `2:202`, plus `7:10` +//! Size Mode — and everything else round-trips byte-exact without a name. See `STATUS.md` for the full deferral list. #![forbid(unsafe_code)] pub mod charset; +pub mod extension; pub mod iim; pub mod irb; pub mod photo_metadata; @@ -93,6 +95,10 @@ mod reconcile; pub use charset::IimCharset; pub use error::{IptcError, Result}; +pub use extension::{ + ArtworkOrObject, CreatorContactInfo, Entity, ImageRegion, Licensor, RegionBoundary, + RegionBoundaryPoint, +}; /// The XMP value model this crate's API speaks ([`XmpMeta`](gamut_xmp::XmpMeta), /// [`XmpProperty`](gamut_xmp::XmpProperty), …). /// diff --git a/crates/gamut-iptc/src/schema.rs b/crates/gamut-iptc/src/schema.rs index 44bfb778..7ba45b1f 100644 --- a/crates/gamut-iptc/src/schema.rs +++ b/crates/gamut-iptc/src/schema.rs @@ -25,16 +25,34 @@ pub mod ns { pub const IPTC_CORE: &str = WellKnownNs::Iptc4XmpCore.uri(); /// IPTC Photo Metadata Extension — `Iptc4xmpExt:`. pub const IPTC_EXT: &str = WellKnownNs::Iptc4XmpExt.uri(); + /// PLUS (Picture Licensing Universal System) Licensing Data Format — `plus:`. + /// + /// The IPTC Extension schema embeds PLUS 1.2 properties under their own namespace rather than + /// re-declaring them; `plus:Licensor` (see [`crate::extension::Licensor`]) is the one gamut + /// models. Declared here rather than taken from [`gamut_xmp::WellKnownNs`], which does not + /// carry PLUS. + pub const PLUS: &str = "http://ns.useplus.org/ldf/xmp/1.0/"; + /// XMP basic — `xmp:`. + /// + /// Not an IPTC namespace, and deliberately absent from [`IPTC_NAMESPACES`]; it appears only + /// *inside* IPTC Extension structures, as the `xmp:Identifier` field of an + /// [`Entity`](crate::extension::Entity). + pub const XMP: &str = WellKnownNs::Xmp.uri(); } /// The namespaces gamut treats as IPTC-relevant when extracting [`crate::PhotoMetadata`] from a full /// XMP graph (see [`crate::PhotoMetadata::from_xmp`]). +/// +/// [`ns::PLUS`] is included because the IPTC Extension schema defines several of its own properties +/// — `plus:Licensor` among them — in the PLUS namespace; [`ns::XMP`] is not, because `xmp:` is a +/// general-purpose namespace whose properties are not IPTC's. pub const IPTC_NAMESPACES: &[&str] = &[ ns::DC, ns::PHOTOSHOP, ns::XMP_RIGHTS, ns::IPTC_CORE, ns::IPTC_EXT, + ns::PLUS, ]; /// How an IPTC field is shaped as an XMP value. @@ -233,5 +251,15 @@ mod tests { assert_eq!(ns::XMP_RIGHTS, "http://ns.adobe.com/xap/1.0/rights/"); assert_eq!(ns::IPTC_CORE, "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"); assert_eq!(ns::IPTC_EXT, "http://iptc.org/std/Iptc4xmpExt/2008-02-29/"); + assert_eq!(ns::PLUS, "http://ns.useplus.org/ldf/xmp/1.0/"); + assert_eq!(ns::XMP, "http://ns.adobe.com/xap/1.0/"); + } + + #[test] + fn iptc_namespaces_carries_plus_but_not_xmp_basic() { + // The IPTC Extension defines properties in the PLUS namespace, so a graph filtered by + // IPTC_NAMESPACES must keep them; xmp: is general-purpose and must not be swept in. + assert!(IPTC_NAMESPACES.contains(&ns::PLUS)); + assert!(!IPTC_NAMESPACES.contains(&ns::XMP)); } } diff --git a/crates/gamut-iptc/tests/data/extract-iim-names.py b/crates/gamut-iptc/tests/data/extract-iim-names.py new file mode 100755 index 00000000..ce27a134 --- /dev/null +++ b/crates/gamut-iptc/tests/data/extract-iim-names.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Extract the IIM 4.2 DataSet NAME column from the standard's own PDF. + +Run from the workspace root, with `pdftotext` (poppler-utils) on PATH: + + python3 crates/gamut-iptc/tests/data/extract-iim-names.py references/iptc/iim-4.2.pdf \ + > crates/gamut-iptc/tests/data/iim-4.2-dataset-names.tsv + +`pdftotext` is a system package the workspace toolchain does not provision, so the result is +committed as a derived artefact and `iim.rs` pins `KNOWN_TAGS` against it. Re-run this after +changing the vendored PDF; never hand-edit the table it produces. + +IIM 4.2 lays every DataSet out as three columns: the `record:dataset` number at the left margin, +the DataSet name beside it, and the type/length statement and description to the right. +`pdftotext -bbox-layout` keeps each word's position on the page, so the name column is recovered +by geometry rather than by guessing where wrapped plain text belongs. A name set over several +lines is rejoined from the name-column lines that follow it, across a page break if need be; a +trailing '-' or '/' joins without a space, and a '-' the typesetter inserted only to hyphenate is +dropped when the standard's own index spells the name without it. +""" +import html +import re +import subprocess +import sys + +ROW = re.compile(r"^(\d+):(\d+)$") +BODY = (60.0, 725.0) # the page body, excluding the running header and footer +NUMBER_COL = 72.0 # left margin: the `record:dataset` column +NAME_COL = (110.0, 180.0) # the DataSet name column: no name word starts right of 167 +SAME_LINE = 1.5 # baselines this close are one typeset line +SKIP = 2 # description lines a name may be set around before it has ended + + +def body_lines(pdf): + """Every body line of the document in reading order, as (page, [(x, word), ...]).""" + xml = subprocess.run(["pdftotext", "-bbox-layout", pdf, "-"], + check=True, capture_output=True, text=True).stdout + for page, text in enumerate(re.split(r"(?=]*>([^<]*)', + text)) + line, base = [], None + for y, x, t in words: + if base is not None and y - base > SAME_LINE: + yield page, base, sorted(line) + line = [] + base = y if not line else base + line.append((x, t)) + if line: + yield page, base, sorted(line) + + +def cell(words): + return [t for x, t in words if NAME_COL[0] <= x < NAME_COL[1]] + + +def rows(lines): + """Each DataSet row as ((record, dataset), [name words]). + + A row's number sits alone at the left margin with its name beside it; running text that merely + begins with a `record:dataset` reference flows on through the gutter between the two columns, + and Appendix F's table header carries another number where the name belongs. + """ + for i, (_, _, words) in enumerate(lines): + if not words or abs(words[0][0] - NUMBER_COL) > 1.0 or not ROW.match(words[0][1]): + continue + if any(NUMBER_COL + 18 < x < NAME_COL[0] for x, _ in words): + continue + name = cell(words) + if not name or ROW.match(name[0]): + continue + skipped = 0 + for page, _, more in lines[i + 1:]: + if abs(more[0][0] - NUMBER_COL) <= 1.0: + break + rest = cell(more) + if rest: + name += rest + skipped = 0 + else: + skipped += 1 + if skipped > SKIP: + break + yield ROW.match(words[0][1]).groups(), name + + +def join(parts, index): + out = parts[0] + for part in parts[1:]: + out = out + part if out.endswith(("-", "/")) else out + " " + part + if "-" not in out or out in index: + return out + hits = [v for v in (out[:i] + out[i + 1:] for i, c in enumerate(out) if c == "-") + if v in index] + if len(hits) != 1: + raise SystemExit(f"cannot resolve the hyphen in {out!r} against the index") + return hits[0] + + +def main(pdf): + lines = [(p, y, w) for p, y, w in body_lines(pdf) if BODY[0] <= y <= BODY[1]] + index, seen = set(), False + for _, _, words in lines: # the index spells every wrapped name out on one line + seen = seen or any(t == "INDEX" for _, t in words) + if seen: + index.add(" ".join(t for x, t in words if x < 400)) + named = {} + for (record, dataset), parts in rows(lines): + key, name = (int(record), int(dataset)), join(parts, index) + if named.setdefault(key, name) != name: + raise SystemExit(f"{key} is named both {named[key]!r} and {name!r}") + print("# IIM 4.2 DataSet names, derived from references/iptc/iim-4.2.pdf. Never hand-edit:\n" + "# regenerate with the extract-iim-names.py beside this file, as its docstring records.\n" + "# Columns: record, dataset, and the DataSet's name as the standard's NAME column sets it.") + for (record, dataset), name in sorted(named.items()): + print(f"{record}\t{dataset}\t{name}") + + +main(sys.argv[1]) diff --git a/crates/gamut-iptc/tests/data/iim-4.2-dataset-names.tsv b/crates/gamut-iptc/tests/data/iim-4.2-dataset-names.tsv new file mode 100644 index 00000000..2e0812ef --- /dev/null +++ b/crates/gamut-iptc/tests/data/iim-4.2-dataset-names.tsv @@ -0,0 +1,80 @@ +# IIM 4.2 DataSet names, derived from references/iptc/iim-4.2.pdf. Never hand-edit: +# regenerate with the extract-iim-names.py beside this file, as its docstring records. +# Columns: record, dataset, and the DataSet's name as the standard's NAME column sets it. +1 0 Model Version +1 5 Destination +1 20 File Format +1 22 File Format Version +1 30 Service Identifier +1 40 Envelope Number +1 50 Product I.D. +1 60 Envelope Priority +1 70 Date Sent +1 80 Time Sent +1 90 Coded Character Set +1 100 UNO +1 120 ARM Identifier +1 122 ARM Version +2 0 Record Version +2 3 Object Type Reference +2 4 Object Attribute Reference +2 5 Object Name +2 7 Edit Status +2 8 Editorial Update +2 10 Urgency +2 12 Subject Reference +2 15 Category +2 20 Supplemental Category +2 22 Fixture Identifier +2 25 Keywords +2 26 Content Location Code +2 27 Content Location Name +2 30 Release Date +2 35 Release Time +2 37 Expiration Date +2 38 Expiration Time +2 40 Special Instructions +2 42 Action Advised +2 45 Reference Service +2 47 Reference Date +2 50 Reference Number +2 55 Date Created +2 60 Time Created +2 62 Digital Creation Date +2 63 Digital Creation Time +2 65 Originating Program +2 70 Program Version +2 75 Object Cycle +2 80 By-line +2 85 By-line Title +2 90 City +2 92 Sub-location +2 95 Province/State +2 100 Country/Primary Location Code +2 101 Country/Primary Location Name +2 103 Original Transmission Reference +2 105 Headline +2 110 Credit +2 115 Source +2 116 Copyright Notice +2 118 Contact +2 120 Caption/Abstract +2 122 Writer/Editor +2 125 Rasterized Caption +2 130 Image Type +2 131 Image Orientation +2 135 Language Identifier +2 150 Audio Type +2 151 Audio Sampling Rate +2 152 Audio Sampling Resolution +2 153 Audio Duration +2 154 Audio Outcue +2 200 ObjectData Preview File Format +2 201 ObjectData Preview File Format Version +2 202 ObjectData Preview Data +7 10 Size Mode +7 20 Max Subfile Size +7 90 ObjectData Size Announced +7 95 Maximum ObjectData Size +8 10 Subfile +9 10 Confirmed ObjectData Size diff --git a/crates/gamut-iptc/tests/oracle.rs b/crates/gamut-iptc/tests/oracle.rs index bcefaaad..9a6b6760 100644 --- a/crates/gamut-iptc/tests/oracle.rs +++ b/crates/gamut-iptc/tests/oracle.rs @@ -96,3 +96,56 @@ fn exiv2_rejects_garbage_but_accepts_gamut_output() { let bytes = fixture().encode().unwrap(); assert!(gamut_iptc_oracle::parse_iim(&bytes).is_some()); } + +/// A stream spanning the Envelope record and the Application-record datasets *outside* the +/// PMD-mapped subset — the ones gamut's tag table names but never projects from XMP. The existing +/// fixture is record 2 only, so this is the record-1 leg and the wide dataset numbers. +fn wide_fixture() -> IimBlock { + IimBlock { + datasets: vec![ + // Envelope record. + ds(1, 0, &[0, 4]), // Model Version = 4 + ds(1, 20, &[0, 3]), // File Format = 3 (TIFF) + ds(1, 22, &[0, 1]), // File Format Version = 1 + ds(1, 30, b"gamut"), // Service Identifier + ds(1, 40, b"00000001"), // Envelope Number + ds(1, 70, b"19900127"), // Date Sent + ds(1, 80, b"133015+0100"), // Time Sent + // Application record, beyond the XMP-mapped datasets. + ds(2, 0, &[0, 4]), // Record Version = 4 + ds(2, 10, b"5"), // Urgency + ds(2, 30, b"19900127"), // Release Date + ds(2, 35, b"090000-0500"), // Release Time + ds(2, 65, b"gamut"), // Originating Program + ds(2, 70, b"1.0"), // Program Version + ds(2, 118, b"news@example.org"), // Contact + ds(2, 131, b"L"), // Image Orientation + ds(2, 135, b"en"), // Language Identifier + ds(2, 151, b"044100"), // Audio Sampling Rate + ds(2, 200, &[0, 3]), // ObjectData Preview File Format + ], + } +} + +#[test] +fn gamut_and_exiv2_agree_on_the_datasets_outside_the_xmp_mapping() { + let block = wide_fixture(); + let bytes = block.encode().unwrap(); + + let exiv2 = gamut_iptc_oracle::parse_iim(&bytes).expect("exiv2 parses the wide stream"); + assert_eq!(exiv2.len(), block.datasets.len()); + for (o, g) in exiv2.iter().zip(&block.datasets) { + assert_eq!( + (o.record, o.tag, &o.value), + (u16::from(g.record), u16::from(g.dataset), &g.data), + "mismatch at {}:{}", + g.record, + g.dataset + ); + } + + // exiv2 re-encodes the same datasets (order is exiv2's own, so compare as multisets). + let exiv2_bytes = gamut_iptc_oracle::reencode_iim(&bytes).expect("exiv2 re-encodes the stream"); + let reparsed = IimBlock::parse(&exiv2_bytes).expect("gamut parses exiv2's output"); + assert_eq!(multiset(&reparsed), multiset(&block)); +} diff --git a/crates/gamut-iptc/tests/techreference.rs b/crates/gamut-iptc/tests/techreference.rs index fd1558c1..4a699311 100644 --- a/crates/gamut-iptc/tests/techreference.rs +++ b/crates/gamut-iptc/tests/techreference.rs @@ -2,14 +2,20 @@ //! technical reference vendored in `references/iptc/iptc-pmd-techreference_2025.1.json`. //! //! [`gamut_iptc::schema::FIELD_MAP`] and the [`gamut_iptc::IimTagInfo`] table are transcribed from -//! that file (the `ipmd_top` entries carrying an `IIMid`). These tests re-derive the mapping from -//! the JSON at test time and compare, so any transcription slip — or a future IPTC release changing -//! the reference — fails loudly instead of silently drifting. The versioned filename makes bumping -//! to a new IPTC edition a deliberate act that re-runs this gate. +//! that file (the `ipmd_top` entries carrying an `IIMid`), and so are the typed structures of +//! [`gamut_iptc::extension`] (the `ipmd_struct` entries). These tests re-derive both from the JSON +//! at test time and compare, so any transcription slip — or a future IPTC release changing the +//! reference — fails loudly instead of silently drifting. The versioned filename makes bumping to a +//! new IPTC edition a deliberate act that re-runs this gate. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use gamut_iptc::extension::{ + ArtworkOrObject, CreatorContactInfo, Entity, ImageRegion, Licensor, RegionBoundary, + RegionBoundaryPoint, +}; use gamut_iptc::schema::{FIELD_MAP, XmpShape, ns}; +use gamut_iptc::xmp::{XmpProperty, XmpValue}; use gamut_iptc::{IimTagInfo, PhotoMetadata}; use serde_json::Value; @@ -58,10 +64,37 @@ fn ns_prefix(uri: &str) -> &'static str { _ if uri == ns::XMP_RIGHTS => "xmpRights", _ if uri == ns::IPTC_CORE => "Iptc4xmpCore", _ if uri == ns::IPTC_EXT => "Iptc4xmpExt", - _ => panic!("FIELD_MAP references a namespace outside the IPTC set: {uri}"), + _ if uri == ns::PLUS => "plus", + _ if uri == ns::XMP => "xmp", + _ => panic!("gamut references a namespace outside the IPTC set: {uri}"), } } +/// The `XMPid`s the reference gives the fields of the `ipmd_struct` entry named `structure`, +/// skipping the wildcard `$anypmdproperty` row (which has no identity of its own). +fn struct_field_ids(doc: &Value, structure: &str) -> BTreeSet { + doc["ipmd_struct"][structure] + .as_object() + .unwrap_or_else(|| panic!("ipmd_struct has no {structure} entry")) + .values() + .filter_map(|field| match field["XMPid"].as_str() { + Some("") | None => None, + Some(id) => Some(id.to_owned()), + }) + .collect() +} + +/// The `prefix:name` of every field a structure value carries. +fn emitted_field_ids(value: &XmpValue) -> BTreeSet { + let XmpValue::Structured(fields) = value else { + panic!("to_xmp must produce a structure value"); + }; + fields + .iter() + .map(|p: &XmpProperty| format!("{}:{}", ns_prefix(&p.namespace), p.name)) + .collect() +} + /// The IIM↔XMP mapping must be a bijection between FIELD_MAP and the JSON's IIMid-bearing rows: /// no missing rows, no extra rows, no XMPid mismatch. #[test] @@ -167,3 +200,207 @@ fn shapes_match_techreference_occurrence_and_type() { } } } + +/// A [`CreatorContactInfo`] with every field set, so `to_xmp` emits the whole structure. +/// +/// The extension structures are `#[non_exhaustive]` — a future IPTC edition adding a field must +/// not be a breaking change — so a downstream caller builds one from [`Default`] and assigns. +fn full_contact() -> CreatorContactInfo { + let mut it = CreatorContactInfo::default(); + it.address = Some("1 Rue Test".to_owned()); + it.city = Some("Lyon".to_owned()); + it.country = Some("France".to_owned()); + it.postal_code = Some("69000".to_owned()); + it.region = Some("Rhône".to_owned()); + it.email = Some("a@example.org".to_owned()); + it.phone = Some("+33 1 23".to_owned()); + it.web_url = Some("https://example.org/".to_owned()); + it +} + +/// An [`ArtworkOrObject`] with every field set. +fn full_artwork() -> ArtworkOrObject { + let mut it = ArtworkOrObject::default(); + it.title = Some("Sunflowers".to_owned()); + it.creator_names = vec!["Van Gogh".to_owned()]; + it.creator_identifiers = vec!["urn:creator".to_owned()]; + it.date_created = Some("1888-08".to_owned()); + it.circa_date_created = Some("circa 1888".to_owned()); + it.copyright_notice = Some("Public domain".to_owned()); + it.current_copyright_owner_name = Some("Owner".to_owned()); + it.current_copyright_owner_identifier = Some("urn:owner".to_owned()); + it.current_licensor_name = Some("Licensor".to_owned()); + it.current_licensor_identifier = Some("urn:licensor".to_owned()); + it.content_description = Some("Vase with flowers".to_owned()); + it.contribution_description = Some("Restored 1980".to_owned()); + it.physical_description = Some("Oil on canvas".to_owned()); + it.source = Some("National Gallery".to_owned()); + it.source_inventory_number = Some("NG3863".to_owned()); + it.source_inventory_url = Some("https://example.org/NG3863".to_owned()); + it.style_periods = vec!["Post-Impressionism".to_owned()]; + it +} + +/// A [`Licensor`] with every field set. +fn full_licensor() -> Licensor { + let mut it = Licensor::default(); + it.identifier = Some("urn:licensor".to_owned()); + it.name = Some("Agence gamut".to_owned()); + it.address = Some("2 Rue Test".to_owned()); + it.address_detail = Some("Floor 3".to_owned()); + it.city = Some("Paris".to_owned()); + it.region = Some("Île-de-France".to_owned()); + it.postal_code = Some("75001".to_owned()); + it.country = Some("France".to_owned()); + it.telephone_type1 = Some("work".to_owned()); + it.telephone1 = Some("+33 1 11".to_owned()); + it.telephone_type2 = Some("cell".to_owned()); + it.telephone2 = Some("+33 6 22".to_owned()); + it.email = Some("licence@example.org".to_owned()); + it.web_url = Some("https://example.org/licence".to_owned()); + it +} + +/// An [`Entity`] with every field set. +fn full_entity() -> Entity { + let mut it = Entity::default(); + it.identifiers = vec!["https://cv.iptc.org/newscodes/imageregiontype/human".to_owned()]; + it.name = Some("Human".to_owned()); + it +} + +/// A [`RegionBoundaryPoint`] with both coordinates set. +fn full_point() -> RegionBoundaryPoint { + let mut it = RegionBoundaryPoint::default(); + it.x = Some(1.0); + it.y = Some(2.0); + it +} + +/// A [`RegionBoundary`] with every field set. The shapes are mutually exclusive in practice, but +/// the reference defines all seven scalars plus the vertex list on the one structure. +fn full_boundary() -> RegionBoundary { + let mut it = RegionBoundary::default(); + it.shape = Some("polygon".to_owned()); + it.unit = Some("relative".to_owned()); + it.x = Some(0.25); + it.y = Some(0.5); + it.width = Some(0.125); + it.height = Some(0.0625); + it.radius = Some(0.1); + it.vertices = vec![full_point()]; + it +} + +/// An [`ImageRegion`] with every modelled field set and no extra properties. +fn full_region() -> ImageRegion { + let mut it = ImageRegion::default(); + it.boundary = Some(full_boundary()); + it.identifier = Some("region-1".to_owned()); + it.name = Some("Face".to_owned()); + it.content_types = vec![full_entity()]; + it.roles = vec![full_entity()]; + it +} + +/// Each typed structure must emit exactly the fields the reference's `ipmd_struct` entry defines — +/// no invented field, none missed, and none under a mistyped namespace prefix. +#[test] +fn extension_structures_match_techreference_field_sets() { + let doc = techreference(); + let cases: [(&str, XmpValue); 7] = [ + ("CreatorContactInfo", full_contact().to_xmp()), + ("ArtworkOrObject", full_artwork().to_xmp()), + ("Licensor", full_licensor().to_xmp()), + ("Entity", full_entity().to_xmp()), + ("RegionBoundaryPoint", full_point().to_xmp()), + ("RegionBoundary", full_boundary().to_xmp()), + ("ImageRegion", full_region().to_xmp()), + ]; + for (structure, value) in cases { + assert_eq!( + emitted_field_ids(&value), + struct_field_ids(&doc, structure), + "{structure} fields drifted from the tech reference" + ); + } +} + +/// The typed structures must sit on the `ipmd_top` properties the reference names, with the +/// structure type it names — a projection hung on the wrong property would still round-trip. +#[test] +fn extension_accessors_target_the_techreference_top_properties() { + let doc = techreference(); + let top = doc["ipmd_top"].as_object().expect("ipmd_top is an object"); + // (reference key, expected XMPid, expected structure name) + let expected = [ + ( + "creatorContactInfo", + "Iptc4xmpCore:CreatorContactInfo", + "CreatorContactInfo", + ), + ("imageRegion", "Iptc4xmpExt:ImageRegion", "ImageRegion"), + ( + "artworkOrObjects", + "Iptc4xmpExt:ArtworkOrObject", + "ArtworkOrObject", + ), + ("licensors", "plus:Licensor", "Licensor"), + ]; + for (key, xmp_id, structure) in expected { + let entry = &top[key]; + assert_eq!(entry["XMPid"].as_str(), Some(xmp_id), "{key} XMPid"); + assert_eq!( + entry["dataformat"].as_str(), + Some(structure), + "{key} structure" + ); + } + + // The accessors write those exact properties: four values in, four properties out. + let mut pm = PhotoMetadata::new(); + pm.set_creator_contact_info(&full_contact()); + pm.set_image_regions(&[full_region()]); + pm.set_artwork_or_objects(&[full_artwork()]); + pm.set_licensors(&[full_licensor()]); + let written: BTreeSet = pm + .xmp + .properties + .iter() + .map(|p| format!("{}:{}", ns_prefix(&p.namespace), p.name)) + .collect(); + let named: BTreeSet = expected.iter().map(|&(_, id, _)| id.to_owned()).collect(); + assert_eq!(written, named); +} + +/// Every structured property gamut models is XMP-only in the reference — no `IIMid`, and so no row +/// in `FIELD_MAP`. That is what makes a structured field unable to conflict with the legacy +/// carrier, so it is pinned to the reference rather than merely documented. +#[test] +fn modelled_structured_properties_carry_no_iim_counterpart() { + let doc = techreference(); + let top = doc["ipmd_top"].as_object().expect("ipmd_top is an object"); + for key in [ + "creatorContactInfo", + "imageRegion", + "artworkOrObjects", + "licensors", + ] { + assert!( + top[key].get("IIMid").is_none(), + "{key} has an IIM counterpart, so it can conflict and needs a reconciliation rule" + ); + } + // ...and none of them is in the IIM<->XMP map, whose rows are exactly the IIMid-bearing ones. + for name in [ + "CreatorContactInfo", + "ImageRegion", + "ArtworkOrObject", + "Licensor", + ] { + assert!( + !FIELD_MAP.iter().any(|row| row.xmp.name == name), + "{name} is reconciled but has no IIM dataset" + ); + } +}