From 50762603ebaaf70ec1a67712534b06b8c652c525 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:40:55 -0400 Subject: [PATCH 01/28] feat(iptc): name every length-determinate IIM 4.2 dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag table covered only the structural record-1 datasets and the record-2 datasets IPTC Photo Metadata maps to XMP — 26 of the 71 datasets exiv2 documents. Transcribe the complete Envelope and Application dataset definitions of IPTC-IIM 4.2 chapters 5 and 6 from references/iptc/iim-4.2.pdf: 14 record-1 and 56 record-2 entries with their names, repeatability and value-field octet maxima. The table is descriptive only — no FIELD_MAP row references a newly named dataset — so reading, merging and writing are unchanged and every unmodeled dataset still round-trips byte-exact. 2:202 ObjectData Preview Data and every dataset of records 7-9 stay unmodeled: IIM 4.2 states no determinate octet maximum for them (256000 octets for 2:202, "a binary number" of unstated width for the rest), and IimTagInfo::max_octets can only state one. Refs #422 --- crates/gamut-iptc/src/iim.rs | 117 ++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index 88520862..33b53727 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,72 @@ 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. /// -/// 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`. +/// +/// Datasets the spec gives no determinate octet maximum for are deliberately absent, because +/// [`IimTagInfo::max_octets`] can only state one: `2:202` ObjectData Preview Data (256000 octets, +/// beyond `u16`) and every dataset of records 7–9 (`7:20`, `7:90`, `7:95`, `8:10`, `9:10`), whose +/// value is "a binary number" of unstated width. 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 +137,20 @@ 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 }, ]; impl IimTagInfo { @@ -302,13 +359,55 @@ mod tests { ); 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 datasets_without_a_stated_maximum_stay_unmodeled() { + // `max_octets` can only state a determinate maximum, so the datasets IIM 4.2 gives none + // for are deliberately absent: `2:202` ObjectData Preview Data (256000 octets, beyond + // `u16`) and the records 7-9 datasets ("a binary number" of unstated width). + for (record, dataset) in [(2, 202), (7, 10), (7, 20), (7, 90), (7, 95), (8, 10), (9, 10)] { + assert!( + IimTagInfo::lookup(record, dataset).is_none(), + "{record}:{dataset} is modelled but has no stated octet maximum" + ); } + // The rest of IIM 4.2 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); } #[test] From d27f1e0a1b585437e30bc7b3f391ea5f939e6f93 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:48:44 -0400 Subject: [PATCH 02/28] feat(iptc): model the four most-used IPTC structured properties The structured Iptc4xmpCore:CreatorContactInfo and the IPTC Extension structures had no typed model and reached callers only as raw gamut-xmp values. Add `extension`, a typed projection over that graph in the shape gamut-exif's GpsInfo already uses - from_xmp/to_xmp per structure plus one accessor pair on PhotoMetadata: - CreatorContactInfo (Iptc4xmpCore:CreatorContactInfo) - ImageRegion (Iptc4xmpExt:ImageRegion), with RegionBoundary, RegionBoundaryPoint and Entity - ArtworkOrObject (Iptc4xmpExt:ArtworkOrObject) - Licensor (plus:Licensor) Field identities, container kinds and language-alternative shapes come from the IPTC Photo Metadata Standard 2025.1 and its machine-readable technical reference. An ImageRegion keeps every property it does not model verbatim, because the standard lets a region carry any other metadata property. Every one of these properties is XMP-only - none carries an IIMid, so none is in FIELD_MAP - and therefore can never be an IIM/XMP conflict; that is documented on the types rather than given a new policy knob, and ConflictPolicy is unchanged. The PLUS namespace joins IPTC_NAMESPACES, since the IPTC Extension defines plus:Licensor and its siblings there and PhotoMetadata::from_xmp would otherwise drop them; xmp: deliberately does not, being general-purpose. Refs #422 --- crates/gamut-iptc/src/extension.rs | 1264 ++++++++++++++++++++++++++++ crates/gamut-iptc/src/iim.rs | 10 +- crates/gamut-iptc/src/lib.rs | 18 +- crates/gamut-iptc/src/schema.rs | 28 + 4 files changed, 1313 insertions(+), 7 deletions(-) create mode 100644 crates/gamut-iptc/src/extension.rs diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs new file mode 100644 index 00000000..0646e95e --- /dev/null +++ b/crates/gamut-iptc/src/extension.rs @@ -0,0 +1,1264 @@ +//! 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. 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 it is lossy where the graph is richer than the model: +//! +//! - language alternatives are read and written as their `x-default` alternative, as elsewhere in +//! the crate; +//! - a numeric field whose text does not parse as a number reads as absent (honest read — the raw +//! value is still in the graph); +//! - an [`ImageRegion`] keeps every field it does not model in [`ImageRegion::other`], because the +//! standard explicitly allows a region to carry any other metadata property, so a region +//! survives `from_xmp` → `to_xmp` intact. + +use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; + +use crate::photo_metadata::PhotoMetadata; +use crate::schema::ns; + +// --- Reading helpers over a structure's field list ------------------------------------------- + +/// The field of `fields` named `ns:name`, if present. +fn field<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a XmpProperty> { + fields.iter().find(|p| p.namespace == ns && p.name == name) +} + +/// The simple text of the field named `ns:name`. +fn text(fields: &[XmpProperty], ns: &str, name: &str) -> Option { + field(fields, ns, name)?.text().map(str::to_owned) +} + +/// The `x-default` (first) alternative of the language-alternative field named `ns:name`, +/// tolerating a plain simple value. +fn lang_alt(fields: &[XmpProperty], ns: &str, name: &str) -> Option { + match &field(fields, ns, name)?.value { + XmpValue::Array(XmpArray::Alt(items)) => items.iter().find_map(XmpItem::text), + value => value.text(), + } + .map(str::to_owned) +} + +/// Every simple item of the array field named `ns:name` (empty if absent or not an array). +fn list(fields: &[XmpProperty], ns: &str, name: &str) -> Vec { + match &field(fields, ns, name).map(|p| &p.value) { + Some(XmpValue::Array(array)) => array.texts().map(str::to_owned).collect(), + _ => Vec::new(), + } +} + +/// The field named `ns:name` parsed as an XMP `Real`; a value that does not parse reads as absent. +fn number(fields: &[XmpProperty], ns: &str, name: &str) -> Option { + text(fields, ns, name)?.trim().parse().ok() +} + +/// The structure fields of the single structured field named `ns:name`. +fn nested<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a [XmpProperty]> { + match &field(fields, ns, name)?.value { + XmpValue::Structured(inner) => Some(inner), + _ => None, + } +} + +/// The structure fields of every item of the array field named `ns:name`, skipping non-structures. +fn nested_array<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Vec<&'a [XmpProperty]> { + match &field(fields, ns, name).map(|p| &p.value) { + Some(XmpValue::Array(array)) => array + .items() + .iter() + .filter_map(|item| match &item.value { + XmpValue::Structured(inner) => Some(inner.as_slice()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +// --- Writing helpers ------------------------------------------------------------------------- + +/// Appends `ns:name` as simple text, unless the value is absent. +fn put_text(out: &mut Vec, ns: &str, name: &str, value: Option<&String>) { + if let Some(value) = value { + out.push(XmpProperty::new(ns, name, XmpValue::Simple(value.clone()))); + } +} + +/// 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>) { + if let Some(value) = value { + let items = vec![XmpItem::lang_text("x-default", value.clone())]; + out.push(XmpProperty::new( + ns, + name, + XmpValue::Array(XmpArray::Alt(items)), + )); + } +} + +/// 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]) { + if values.is_empty() { + return; + } + let items = values.iter().map(XmpItem::simple).collect(); + out.push(XmpProperty::new( + ns, + name, + XmpValue::Array(array(ordered, items)), + )); +} + +/// Appends `ns:name` as an XMP `Real`, unless the value is absent. +fn put_number(out: &mut Vec, ns: &str, name: &str, value: Option) { + if let Some(value) = value { + out.push(XmpProperty::new( + ns, + name, + XmpValue::Simple(value.to_string()), + )); + } +} + +/// Appends `ns:name` as a single structure value, unless absent. +fn put_nested(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 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, +) { + if values.is_empty() { + return; + } + let items = values.into_iter().map(XmpItem::new).collect(); + out.push(XmpProperty::new( + ns, + name, + XmpValue::Array(array(ordered, items)), + )); +} + +/// A `Seq` when `ordered`, otherwise a `Bag`. +fn array(ordered: bool, items: Vec) -> XmpArray { + if ordered { + XmpArray::Seq(items) + } else { + XmpArray::Bag(items) + } +} + +/// 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, + } +} + +// --- 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, +} + +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 { + Self { + address: text(f, ns::IPTC_CORE, "CiAdrExtadr"), + city: text(f, ns::IPTC_CORE, "CiAdrCity"), + country: text(f, ns::IPTC_CORE, "CiAdrCtry"), + postal_code: text(f, ns::IPTC_CORE, "CiAdrPcode"), + region: text(f, ns::IPTC_CORE, "CiAdrRegion"), + email: text(f, ns::IPTC_CORE, "CiEmailWork"), + phone: text(f, ns::IPTC_CORE, "CiTelWork"), + web_url: text(f, ns::IPTC_CORE, "CiUrlWork"), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + #[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()); + 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, +} + +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 { + Self { + title: lang_alt(f, ns::IPTC_EXT, "AOTitle"), + creator_names: list(f, ns::IPTC_EXT, "AOCreator"), + creator_identifiers: list(f, ns::IPTC_EXT, "AOCreatorId"), + date_created: text(f, ns::IPTC_EXT, "AODateCreated"), + circa_date_created: text(f, ns::IPTC_EXT, "AOCircaDateCreated"), + copyright_notice: text(f, ns::IPTC_EXT, "AOCopyrightNotice"), + current_copyright_owner_name: text(f, ns::IPTC_EXT, "AOCurrentCopyrightOwnerName"), + current_copyright_owner_identifier: text(f, ns::IPTC_EXT, "AOCurrentCopyrightOwnerId"), + current_licensor_name: text(f, ns::IPTC_EXT, "AOCurrentLicensorName"), + current_licensor_identifier: text(f, ns::IPTC_EXT, "AOCurrentLicensorId"), + content_description: lang_alt(f, ns::IPTC_EXT, "AOContentDescription"), + contribution_description: lang_alt(f, ns::IPTC_EXT, "AOContributionDescription"), + physical_description: lang_alt(f, ns::IPTC_EXT, "AOPhysicalDescription"), + source: text(f, ns::IPTC_EXT, "AOSource"), + source_inventory_number: text(f, ns::IPTC_EXT, "AOSourceInvNo"), + source_inventory_url: text(f, ns::IPTC_EXT, "AOSourceInvURL"), + style_periods: list(f, ns::IPTC_EXT, "AOStylePeriod"), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + /// + /// 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, + ); + 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, +} + +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 { + Self { + identifier: text(f, ns::PLUS, "LicensorID"), + name: text(f, ns::PLUS, "LicensorName"), + address: text(f, ns::PLUS, "LicensorStreetAddress"), + address_detail: text(f, ns::PLUS, "LicensorExtendedAddress"), + city: text(f, ns::PLUS, "LicensorCity"), + region: text(f, ns::PLUS, "LicensorRegion"), + postal_code: text(f, ns::PLUS, "LicensorPostalCode"), + country: text(f, ns::PLUS, "LicensorCountry"), + telephone_type1: text(f, ns::PLUS, "LicensorTelephoneType1"), + telephone1: text(f, ns::PLUS, "LicensorTelephone1"), + telephone_type2: text(f, ns::PLUS, "LicensorTelephoneType2"), + telephone2: text(f, ns::PLUS, "LicensorTelephone2"), + email: text(f, ns::PLUS, "LicensorEmail"), + web_url: text(f, ns::PLUS, "LicensorURL"), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + #[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()); + 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, +} + +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 { + Self { + identifiers: list(f, ns::XMP, "Identifier"), + name: lang_alt(f, ns::IPTC_EXT, "Name"), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + #[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()); + XmpValue::Structured(f) + } +} + +/// One vertex of a polygon region boundary (`Iptc4xmpExt:RegionBoundaryPoint`, IPTC Extension 1.8 +/// §12.8). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[non_exhaustive] +pub struct RegionBoundaryPoint { + /// X-axis coordinate (`Iptc4xmpExt:rbX`). + pub x: Option, + /// Y-axis coordinate (`Iptc4xmpExt:rbY`). + pub y: Option, +} + +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 { + Self { + x: number(f, ns::IPTC_EXT, "rbX"), + y: number(f, ns::IPTC_EXT, "rbY"), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + #[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); + 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, +} + +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 { + Self { + shape: text(f, ns::IPTC_EXT, "rbShape"), + unit: text(f, ns::IPTC_EXT, "rbUnit"), + x: number(f, ns::IPTC_EXT, "rbX"), + y: number(f, ns::IPTC_EXT, "rbY"), + width: number(f, ns::IPTC_EXT, "rbW"), + height: number(f, ns::IPTC_EXT, "rbH"), + radius: number(f, ns::IPTC_EXT, "rbRx"), + vertices: nested_array(f, ns::IPTC_EXT, "rbVertices") + .into_iter() + .map(RegionBoundaryPoint::from_fields) + .collect(), + } + } + + /// Writes the structure as an XMP value, omitting absent fields. + /// + /// 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(), + ); + 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"). + pub other: Vec, +} + +impl ImageRegion { + /// The five field names [`ImageRegion`] models; everything else lands in + /// [`other`](Self::other). + const MODELLED: [(&'static str, &'static str); 5] = [ + (ns::IPTC_EXT, "RegionBoundary"), + (ns::IPTC_EXT, "rId"), + (ns::IPTC_EXT, "Name"), + (ns::IPTC_EXT, "rCtype"), + (ns::IPTC_EXT, "rRole"), + ]; + + /// 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 { + Self { + boundary: nested(f, ns::IPTC_EXT, "RegionBoundary").map(RegionBoundary::from_fields), + identifier: text(f, ns::IPTC_EXT, "rId"), + name: lang_alt(f, ns::IPTC_EXT, "Name"), + content_types: entities(f, "rCtype"), + roles: entities(f, "rRole"), + other: f + .iter() + .filter(|p| { + !Self::MODELLED + .iter() + .any(|&(ns, name)| p.namespace == ns && p.name == name) + }) + .cloned() + .collect(), + } + } + + /// 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(), + ); + f.extend(self.other.iter().cloned()); + XmpValue::Structured(f) + } +} + +/// Every [`Entity`] of the `Iptc4xmpExt:` array field. +fn entities(fields: &[XmpProperty], name: &str) -> Vec { + nested_array(fields, ns::IPTC_EXT, name) + .into_iter() + .map(Entity::from_fields) + .collect() +} + +// --- The accessors on the unified view --------------------------------------------------------- + +/// Reads every structure of the `Bag`/`Seq` property `ns:name` through `parse`. +fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&XmpValue) -> Option) -> Vec { + match xmp.get_array(ns, name) { + Some(array) => array + .items() + .iter() + .filter_map(|i| parse(&i.value)) + .collect(), + None => Vec::new(), + } +} + +/// Replaces the `Bag` property `ns:name` with `values`, removing it when `values` is empty. +fn write_bag(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { + if values.is_empty() { + xmp.remove(ns, name); + return; + } + let items = values.into_iter().map(XmpItem::new).collect(); + xmp.set(XmpProperty::new( + ns, + name, + XmpValue::Array(XmpArray::Bag(items)), + )); +} + +impl PhotoMetadata { + /// The creator's contact details (`Iptc4xmpCore:CreatorContactInfo`). + #[must_use] + pub fn creator_contact_info(&self) -> Option { + CreatorContactInfo::from_xmp(&self.xmp.get(ns::IPTC_CORE, "CreatorContactInfo")?.value) + } + + /// Sets the creator's contact details (`Iptc4xmpCore:CreatorContactInfo`). + pub fn set_creator_contact_info(&mut self, info: &CreatorContactInfo) { + self.xmp.set(XmpProperty::new( + ns::IPTC_CORE, + "CreatorContactInfo", + info.to_xmp(), + )); + } + + /// The image regions (`Iptc4xmpExt:ImageRegion`), in the order the graph holds them. + #[must_use] + pub fn image_regions(&self) -> Vec { + read_array( + &self.xmp, + ns::IPTC_EXT, + "ImageRegion", + ImageRegion::from_xmp, + ) + } + + /// Sets the image regions (`Iptc4xmpExt:ImageRegion`, an unordered bag); an empty slice + /// removes the property. + pub fn set_image_regions(&mut self, regions: &[ImageRegion]) { + let values = regions.iter().map(ImageRegion::to_xmp).collect(); + write_bag(&mut self.xmp, ns::IPTC_EXT, "ImageRegion", values); + } + + /// The artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`). + #[must_use] + pub fn artwork_or_objects(&self) -> Vec { + read_array( + &self.xmp, + ns::IPTC_EXT, + "ArtworkOrObject", + ArtworkOrObject::from_xmp, + ) + } + + /// Sets the artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`, an + /// unordered bag); an empty slice removes the property. + pub fn set_artwork_or_objects(&mut self, artworks: &[ArtworkOrObject]) { + let values = artworks.iter().map(ArtworkOrObject::to_xmp).collect(); + write_bag(&mut self.xmp, ns::IPTC_EXT, "ArtworkOrObject", values); + } + + /// The licensors of the image (`plus:Licensor`). + #[must_use] + pub fn licensors(&self) -> Vec { + read_array(&self.xmp, ns::PLUS, "Licensor", Licensor::from_xmp) + } + + /// Sets the licensors of the image (`plus:Licensor`, an unordered bag); an empty slice removes + /// the property. + pub fn set_licensors(&mut self, licensors: &[Licensor]) { + let values = licensors.iter().map(Licensor::to_xmp).collect(); + write_bag(&mut self.xmp, ns::PLUS, "Licensor", values); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_value(s: &str) -> XmpValue { + XmpValue::Simple(s.to_owned()) + } + + /// 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()), + } + } + + #[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!( + text(fields, 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()], + }; + 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()), + }; + 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, + vertices: Vec::new(), + }), + 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()), + }], + roles: vec![Entity { + identifiers: vec![ + "https://cv.iptc.org/newscodes/imageregionrole/subjectArea".to_owned(), + ], + name: Some("Subject area".to_owned()), + }], + // 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 = nested_array(fields, ns::IPTC_EXT, "rCtype")[0]; + assert!(field(entity, ns::XMP, "Identifier").is_some()); + } + + #[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 { + x: Some(20.0), + y: Some(30.0), + }, + ], + ..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 raw value stays in the graph, but the typed view does not invent one. + 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 lang_alt_fields_read_a_plain_simple_value_too() { + // Non-conformant but seen in the wild: a Lang Alt field written as plain text. + let value = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + text_value("Sunflowers"), + )]); + let art = ArtworkOrObject::from_xmp(&value).unwrap(); + assert_eq!(art.title.as_deref(), Some("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); + } + + #[test] + fn a_non_structure_array_item_is_skipped_not_fatal() { + // Hostile/odd input: a Bag holding plain text where a structure is expected. + 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(), + ), + ])), + )); + let regions = pm.image_regions(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].identifier.as_deref(), Some("r1")); + // 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()); + } +} diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index 33b53727..1ef1ed15 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -399,7 +399,15 @@ mod tests { // `max_octets` can only state a determinate maximum, so the datasets IIM 4.2 gives none // for are deliberately absent: `2:202` ObjectData Preview Data (256000 octets, beyond // `u16`) and the records 7-9 datasets ("a binary number" of unstated width). - for (record, dataset) in [(2, 202), (7, 10), (7, 20), (7, 90), (7, 95), (8, 10), (9, 10)] { + for (record, dataset) in [ + (2, 202), + (7, 10), + (7, 20), + (7, 90), + (7, 95), + (8, 10), + (9, 10), + ] { assert!( IimTagInfo::lookup(record, dataset).is_none(), "{record}:{dataset} is modelled but has no stated octet maximum" diff --git a/crates/gamut-iptc/src/lib.rs b/crates/gamut-iptc/src/lib.rs index c4a3c5f0..2b557ba5 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, and everything else — including records 3–9 — +//! 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)); } } From 0473e4cb258786ac97837b6f8f3079acb689e1d5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:53:46 -0400 Subject: [PATCH 03/28] test(iptc): derive the structured-property field sets from the tech reference tests/techreference.rs already re-derived the IIM<->XMP mapping and the octet limits from references/iptc/iptc-pmd-techreference_2025.1.json. Extend it to the typed structures: a fully-populated instance of each is converted with to_xmp and its emitted field identities compared against the XMPid set the JSON's ipmd_struct entry defines, so an invented, missed or mis-prefixed field fails loudly rather than drifting. The four top-level properties are pinned to the ipmd_top XMPid and structure type the reference names, and their absence of an IIMid - what makes a structured property unable to conflict with the legacy carrier - is pinned there too rather than only documented. tests/oracle.rs gains an exiv2 differential over a stream spanning the Envelope record and the Application datasets outside the XMP-mapped subset; the existing fixture is record 2 only, so this is the first record-1 leg. Refs #422 --- crates/gamut-iptc/tests/oracle.rs | 53 +++++ crates/gamut-iptc/tests/techreference.rs | 249 ++++++++++++++++++++++- 2 files changed, 296 insertions(+), 6 deletions(-) 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" + ); + } +} From f34c62d9ac84e32251fc2b14532d653981616814 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:53:54 -0400 Subject: [PATCH 04/28] docs(iptc): record the delivered breadth and the two remaining deferrals STATUS.md and README.md still said no IPTC Extension structure had a typed model and that the tag table covered only the structural and PMD-mapped datasets. Replace both with what the crate now does, and state the two deferrals that remain with the issues tracking them: the eleven unmodelled Extension structures (#538) and the IIM datasets with no spec-stated octet maximum (#539). Also correct the records 3-9 deferral: IIM 4.2 defines no datasets for records 3-6 at all, so there is nothing there to name. Refs #422 --- crates/gamut-iptc/README.md | 15 +++++++++------ crates/gamut-iptc/STATUS.md | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index 2ce43078..d3255ff9 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -82,10 +82,12 @@ 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`). 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 +99,9 @@ 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. +- **IIM records 1–2 are tabled; everything else is preserved.** The tag table names every dataset + IIM 4.2 chapters 5 and 6 give a determinate octet maximum; 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..ae74355c 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -30,14 +30,39 @@ 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. +- **IIM tag table.** `iim::IimTagInfo` now names every dataset IPTC-IIM 4.2 chapters 5 and 6 give a + determinate octet maximum: 14 Envelope + 56 Application datasets. 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 is machine-readable only for 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`, which is not machine-readable; its guards are the + structural laws in `iim`'s own tests (ordering, uniqueness, the fixed date/time form lengths) and + the exiv2 differential in `tests/oracle.rs`, which cross-checks a stream spanning the newly named + Envelope and wide Application datasets. + +## 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 +- **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 spec-stated octet maximum** (`2:202` and every dataset of records 7–9): not + in the tag table, because `IimTagInfo::max_octets` is a `u16` and can only state a determinate + maximum — issue #539. 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 +75,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 From 764c793ad30016c4408faeb4d72fed37ec2b55d8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:18:18 -0400 Subject: [PATCH 05/28] test(iptc): read an Entity back from a structure value Entity::from_xmp was asserted only for the non-structure case, which a mutation replacing its body with None satisfies - the only mutant the incremental gate missed. Every path that reaches an Entity through an ImageRegion goes via from_fields, so nothing else exercised the public value-level conversion with a structure. Add the round-trip. Refs #422 --- crates/gamut-iptc/src/extension.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 0646e95e..0064eff5 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -1121,6 +1121,17 @@ mod tests { 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()), + }; + assert_eq!(Entity::from_xmp(&entity.to_xmp()), Some(entity)); + } + #[test] fn polygon_boundary_keeps_its_vertices_in_order() { let boundary = RegionBoundary { From 920a9f5ffb2a6671fea8dd6eba0b47c993a8b55d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:37:04 -0400 Subject: [PATCH 06/28] feat(iptc): name the one-octet 7:10 Size Mode dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag table's stated criterion is "every dataset IIM 4.2 gives a determinate octet maximum", but 7:10 Size Mode was excluded with the records 7-9 datasets whose value is "a binary number" of unstated width. IIM 4.2 Ch. 11 defines it as "Mandatory, not repeatable, one octet" — a determinate maximum, and trivially within `max_octets`'s `u16` — so the criterion did not hold and the four places that stated it disagreed with each other on how many datasets it excluded. Name it, and restate the criterion the same way everywhere: a dataset is named when the spec states a maximum that is determinate and fits a `u16`. Exactly six are not — 2:202 (256000 octets) and 7:20, 7:90, 7:95, 8:10, 9:10 — and the boundary case is now pinned by the test rather than only described. --- crates/gamut-iptc/README.md | 7 ++--- crates/gamut-iptc/STATUS.md | 17 ++++++------ crates/gamut-iptc/src/iim.rs | 51 ++++++++++++++++++------------------ crates/gamut-iptc/src/lib.rs | 4 +-- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index d3255ff9..b274471d 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -99,9 +99,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; everything else is preserved.** The tag table names every dataset - IIM 4.2 chapters 5 and 6 give a determinate octet maximum; 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 ae74355c..b3a2b8ec 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -40,10 +40,11 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe `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. -- **IIM tag table.** `iim::IimTagInfo` now names every dataset IPTC-IIM 4.2 chapters 5 and 6 give a - determinate octet maximum: 14 Envelope + 56 Application datasets. 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. +- **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 is machine-readable only for 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`, which is not machine-readable; its guards are the @@ -59,10 +60,10 @@ Intentional, documented skips — none lose data on round-trip: `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 spec-stated octet maximum** (`2:202` and every dataset of records 7–9): not - in the tag table, because `IimTagInfo::max_octets` is a `u16` and can only state a determinate - maximum — issue #539. They still round-trip byte-exact, as every unmodeled dataset in any record - does. +- **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. diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index 1ef1ed15..de5b6e9d 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -60,20 +60,21 @@ pub struct IimTagInfo { use IimFieldKind::{Binary, Date, Graphic, Time}; /// 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. +/// 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 (`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`. /// -/// Datasets the spec gives no determinate octet maximum for are deliberately absent, because -/// [`IimTagInfo::max_octets`] can only state one: `2:202` ObjectData Preview Data (256000 octets, -/// beyond `u16`) and every dataset of records 7–9 (`7:20`, `7:90`, `7:95`, `8:10`, `9:10`), whose -/// value is "a binary number" of unstated width. 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. +/// 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) — IIM 4.2 Chapter 5. @@ -151,6 +152,10 @@ const KNOWN_TAGS: &[IimTagInfo] = &[ 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 { @@ -177,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, @@ -353,7 +358,7 @@ 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 ); @@ -395,25 +400,21 @@ mod tests { } #[test] - fn datasets_without_a_stated_maximum_stay_unmodeled() { - // `max_octets` can only state a determinate maximum, so the datasets IIM 4.2 gives none - // for are deliberately absent: `2:202` ObjectData Preview Data (256000 octets, beyond - // `u16`) and the records 7-9 datasets ("a binary number" of unstated width). - for (record, dataset) in [ - (2, 202), - (7, 10), - (7, 20), - (7, 90), - (7, 95), - (8, 10), - (9, 10), - ] { + 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 modelled but has no stated octet maximum" + "{record}:{dataset} is named but has no octet maximum `max_octets` can state" ); } - // The rest of IIM 4.2 chapters 5 and 6 is named: 14 Envelope + 56 Application datasets. + // 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); } diff --git a/crates/gamut-iptc/src/lib.rs b/crates/gamut-iptc/src/lib.rs index 2b557ba5..07be22e7 100644 --- a/crates/gamut-iptc/src/lib.rs +++ b/crates/gamut-iptc/src/lib.rs @@ -76,8 +76,8 @@ //! 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, and everything else — including records 3–9 — -//! round-trips byte-exact without a name. See `STATUS.md` for the full deferral list. +//! 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; From f9b03b9dce3922b0ae14ce21142704de26f937d5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:37:15 -0400 Subject: [PATCH 07/28] test(iptc): pin the IIM tag table to exiv2's own dataset table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a hand transcription with nothing but ordering and uniqueness to catch a slipped digit: changing one maximum from 128 to 7 left the whole crate suite passing, because no test consults the table for a dataset outside the ~20 the PMD reference maps. exiv2 — already this crate's differential oracle — carries an independent transcription of the same two chapters. Parse it out of the vendored sources and compare every row's octet maximum, repeatability and value kind. All 70 rows agree today; the one deliberate divergence (1:90 Coded Character Set is ISO 2022 escape sequences, not the string exiv2 calls it) is pinned as an exception rather than merely documented, and the completeness leg names 2:202 as the only row exiv2 documents that a `u16` maximum cannot express. --- crates/gamut-iptc/STATUS.md | 10 ++- crates/gamut-iptc/src/iim.rs | 151 +++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index b3a2b8ec..9e3970b8 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -47,10 +47,12 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe the PMD-mapped subset — so reading, merging and writing are byte-for-byte unchanged by it. - **Authority.** The PMD tech reference is machine-readable only for 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`, which is not machine-readable; its guards are the - structural laws in `iim`'s own tests (ordering, uniqueness, the fixed date/time form lengths) and - the exiv2 differential in `tests/oracle.rs`, which cross-checks a stream spanning the newly named - Envelope and wide Application datasets. + of the record-1/2 table comes from `iim-4.2.pdf`, which is not machine-readable. Its guard is + `iim`'s own `tag_table_matches_the_exiv2_dataset_table`, which parses exiv2's independent + transcription of the same chapters out of the vendored `third_party/exiv2` sources and compares + every row's octet maximum, repeatability and value kind — a mis-transcribed maximum 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 it. ## Deferred / out of scope diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index de5b6e9d..47f48bbc 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -321,6 +321,8 @@ impl IimBlock { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; fn ds(record: u8, dataset: u8, data: &[u8]) -> IimDataSet { @@ -419,6 +421,155 @@ mod tests { 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}")) + } + + /// `src` with every string literal removed, so a comma or a brace inside a C++ description + /// cannot be mistaken for punctuation. + fn without_string_literals(src: &str) -> String { + let mut out = String::new(); + let mut chars = src.chars(); + while let Some(c) = chars.next() { + if c != '"' { + out.push(c); + continue; + } + while let Some(c) = chars.next() { + match c { + '\\' => drop(chars.next()), + '"' => break, + _ => {} + } + } + } + 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] + } + + /// exiv2's own IIM dataset table, read from the vendored `third_party/exiv2` sources as + /// `(record, dataset) -> (repeatable, maximum octets, value type)`. + /// + /// 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), (bool, u32, String)> { + let header = vendored("third_party/exiv2/include/exiv2/datasets.hpp"); + let numbers = cpp_constants(&header); + let source = without_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 repeatable = fields[last - 5] == "true"; + let max: u32 = fields[last - 3].parse().expect("maxbytes is a number"); + let kind = fields[last - 2].trim_start_matches("Exiv2::").to_owned(); + table.insert((record as u8, dataset as u8), (repeatable, max, kind)); + } + } + table + } + + #[test] + fn tag_table_matches_the_exiv2_dataset_table() { + // Drift guard. `iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a hand + // transcription with nothing but ordering and uniqueness to catch a slipped digit. exiv2 — + // the crate's differential oracle — carries its own independent transcription of the same + // spec chapters, so comparing the two tables catches exactly that. + let exiv2 = exiv2_dataset_table(); + assert!( + exiv2.len() > 60, + "exiv2 table parsed as {} rows", + exiv2.len() + ); + for t in KNOWN_TAGS { + let Some((repeatable, max, kind)) = exiv2.get(&(t.record, t.dataset)) else { + // exiv2 tables records 1 and 2 only; 7:10 comes from the PDF alone. + assert_eq!( + (t.record, t.dataset), + (7, 10), + "{}:{} {} is not in exiv2's table", + t.record, + t.dataset, + t.name + ); + continue; + }; + let where_ = format!("{}:{} {}", t.record, t.dataset, t.name); + assert_eq!(u32::from(t.max_octets), *max, "{where_}: octet maximum"); + assert_eq!(t.repeatable, *repeatable, "{where_}: repeatability"); + let expected = match 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" + ); + } + } + #[test] fn parse_single_standard_dataset() { // 0x1C, record 2, dataset 25, len 0x0003, "sky" From 64f0c4d05a13405501ec39bcfecf5f14ea7cb703 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:37:31 -0400 Subject: [PATCH 08/28] fix(iptc): keep the fields the extension structures do not model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ArtworkOrObject`, `Licensor` and `CreatorContactInfo` had no retention list, so their setters replaced the whole graph property: a structure carrying a vendor extension came back from a read-modify-write with that property gone, silently. Only `ImageRegion` kept what it could not name, because the standard sanctions extras there explicitly — but a real-world file carries them anywhere. Give all three the same `other` list `ImageRegion` has, filled by `from_xmp` and re-emitted verbatim by `to_xmp`. The types are `#[non_exhaustive]`, so the field is additive. Emitting the retained set now skips any field whose name the model already owns. The list is public, so a caller could put a modelled name in it and produce a structure with two fields of one name — ill-formed, and non-idempotent, since reading it back drops the second. The modelled value stays the authority. --- crates/gamut-iptc/README.md | 9 +- crates/gamut-iptc/STATUS.md | 4 +- crates/gamut-iptc/src/extension.rs | 209 ++++++++++++++++++++++++++--- 3 files changed, 200 insertions(+), 22 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index b274471d..90acd234 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -84,10 +84,11 @@ The v1 contract, stated precisely: (issue #34). - **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`). 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. + regions, artwork/object and licensors (`extension`). Each typed structure keeps the fields it does + not model in its `other` list and re-emits them verbatim, so reading one and writing it back never + drops a vendor extension. 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 diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index 9e3970b8..73a1f73a 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -39,7 +39,9 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe `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. + that, and each structure's field set, to the reference. Each keeps the fields it does not model in + its `other` list and re-emits them verbatim, so a read-modify-write does not drop a vendor + extension. - **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 diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 0064eff5..f26beb2d 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -43,9 +43,17 @@ //! the crate; //! - a numeric field whose text does not parse as a number reads as absent (honest read — the raw //! value is still in the graph); -//! - an [`ImageRegion`] keeps every field it does not model in [`ImageRegion::other`], because the -//! standard explicitly allows a region to carry any other metadata property, so a region -//! survives `from_xmp` → `to_xmp` intact. +//! - field *order* within a structure is not preserved: a structure is re-emitted in the model's +//! field order, with the retained fields last. Values, and the relative order of an array's +//! items, are preserved. +//! +//! Nothing else is dropped by a read-modify-write. Every type here keeps the fields it does not +//! model in its `other` list — [`ImageRegion`] because the standard explicitly allows a region to +//! carry any other metadata property, the other three so that a vendor extension in a real-world +//! file survives being read and written back — and re-emits them verbatim after the fields it does +//! model. A retained field whose name the model *does* own is dropped rather than emitted twice: a +//! structure carrying two fields of one name is ill-formed and does not read back, so the modelled +//! value stays the authority. use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; @@ -198,6 +206,32 @@ fn structure(value: &XmpValue) -> Option<&[XmpProperty]> { } } +// --- Verbatim retention of the fields a model does not name ----------------------------------- + +/// Whether `p` is one of the `(namespace, name)` fields a model names. +fn is_modelled(p: &XmpProperty, modelled: &[(&str, &str)]) -> bool { + modelled + .iter() + .any(|&(ns, name)| p.namespace == ns && p.name == name) +} + +/// Every field of `f` the model does not name, cloned for verbatim retention. +fn unmodelled(f: &[XmpProperty], modelled: &[(&str, &str)]) -> Vec { + f.iter() + .filter(|p| !is_modelled(p, modelled)) + .cloned() + .collect() +} + +/// Appends the retained fields, dropping any whose name the model owns. +/// +/// The retention list is public, so a caller can put a modelled name in it; emitting it as well +/// would produce a structure with two fields of one name, which is ill-formed and does not read +/// back (see the [module docs](self)). +fn put_unmodelled(out: &mut Vec, modelled: &[(&str, &str)], other: &[XmpProperty]) { + out.extend(other.iter().filter(|p| !is_modelled(p, modelled)).cloned()); +} + // --- Creator's contact info ------------------------------------------------------------------- /// The creator's contact details (`Iptc4xmpCore:CreatorContactInfo`, IPTC Core 1.5 §8.1). @@ -227,9 +261,24 @@ pub struct CreatorContactInfo { 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 { + /// The eight field names this type models; everything else lands in [`other`](Self::other). + const MODELLED: [(&'static str, &'static str); 8] = [ + (ns::IPTC_CORE, "CiAdrExtadr"), + (ns::IPTC_CORE, "CiAdrCity"), + (ns::IPTC_CORE, "CiAdrCtry"), + (ns::IPTC_CORE, "CiAdrPcode"), + (ns::IPTC_CORE, "CiAdrRegion"), + (ns::IPTC_CORE, "CiEmailWork"), + (ns::IPTC_CORE, "CiTelWork"), + (ns::IPTC_CORE, "CiUrlWork"), + ]; + /// 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 { @@ -247,10 +296,12 @@ impl CreatorContactInfo { email: text(f, ns::IPTC_CORE, "CiEmailWork"), phone: text(f, ns::IPTC_CORE, "CiTelWork"), web_url: text(f, ns::IPTC_CORE, "CiUrlWork"), + other: unmodelled(f, &Self::MODELLED), } } - /// Writes the structure as an XMP value, omitting absent fields. + /// 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(); @@ -267,6 +318,7 @@ impl CreatorContactInfo { 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_unmodelled(&mut f, &Self::MODELLED, &self.other); XmpValue::Structured(f) } } @@ -316,9 +368,34 @@ pub struct ArtworkOrObject { 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 { + /// The seventeen field names this type models; everything else lands in + /// [`other`](Self::other). + const MODELLED: [(&'static str, &'static str); 17] = [ + (ns::IPTC_EXT, "AOTitle"), + (ns::IPTC_EXT, "AOCreator"), + (ns::IPTC_EXT, "AOCreatorId"), + (ns::IPTC_EXT, "AODateCreated"), + (ns::IPTC_EXT, "AOCircaDateCreated"), + (ns::IPTC_EXT, "AOCopyrightNotice"), + (ns::IPTC_EXT, "AOCurrentCopyrightOwnerName"), + (ns::IPTC_EXT, "AOCurrentCopyrightOwnerId"), + (ns::IPTC_EXT, "AOCurrentLicensorName"), + (ns::IPTC_EXT, "AOCurrentLicensorId"), + (ns::IPTC_EXT, "AOContentDescription"), + (ns::IPTC_EXT, "AOContributionDescription"), + (ns::IPTC_EXT, "AOPhysicalDescription"), + (ns::IPTC_EXT, "AOSource"), + (ns::IPTC_EXT, "AOSourceInvNo"), + (ns::IPTC_EXT, "AOSourceInvURL"), + (ns::IPTC_EXT, "AOStylePeriod"), + ]; + /// 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 { @@ -345,10 +422,12 @@ impl ArtworkOrObject { source_inventory_number: text(f, ns::IPTC_EXT, "AOSourceInvNo"), source_inventory_url: text(f, ns::IPTC_EXT, "AOSourceInvURL"), style_periods: list(f, ns::IPTC_EXT, "AOStylePeriod"), + other: unmodelled(f, &Self::MODELLED), } } - /// Writes the structure as an XMP value, omitting absent fields. + /// 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 @@ -445,6 +524,7 @@ impl ArtworkOrObject { false, &self.style_periods, ); + put_unmodelled(&mut f, &Self::MODELLED, &self.other); XmpValue::Structured(f) } } @@ -490,9 +570,31 @@ pub struct Licensor { 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 { + /// The fourteen field names this type models; everything else lands in + /// [`other`](Self::other). + const MODELLED: [(&'static str, &'static str); 14] = [ + (ns::PLUS, "LicensorID"), + (ns::PLUS, "LicensorName"), + (ns::PLUS, "LicensorStreetAddress"), + (ns::PLUS, "LicensorExtendedAddress"), + (ns::PLUS, "LicensorCity"), + (ns::PLUS, "LicensorRegion"), + (ns::PLUS, "LicensorPostalCode"), + (ns::PLUS, "LicensorCountry"), + (ns::PLUS, "LicensorTelephoneType1"), + (ns::PLUS, "LicensorTelephone1"), + (ns::PLUS, "LicensorTelephoneType2"), + (ns::PLUS, "LicensorTelephone2"), + (ns::PLUS, "LicensorEmail"), + (ns::PLUS, "LicensorURL"), + ]; + /// 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 { @@ -516,10 +618,12 @@ impl Licensor { telephone2: text(f, ns::PLUS, "LicensorTelephone2"), email: text(f, ns::PLUS, "LicensorEmail"), web_url: text(f, ns::PLUS, "LicensorURL"), + other: unmodelled(f, &Self::MODELLED), } } - /// Writes the structure as an XMP value, omitting absent fields. + /// 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(); @@ -572,6 +676,7 @@ impl Licensor { ); put_text(&mut f, ns::PLUS, "LicensorEmail", self.email.as_ref()); put_text(&mut f, ns::PLUS, "LicensorURL", self.web_url.as_ref()); + put_unmodelled(&mut f, &Self::MODELLED, &self.other); XmpValue::Structured(f) } } @@ -754,7 +859,7 @@ pub struct ImageRegion { /// 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"). + /// "other metadata property"), so a read-modify-write does not drop it. pub other: Vec, } @@ -783,15 +888,7 @@ impl ImageRegion { name: lang_alt(f, ns::IPTC_EXT, "Name"), content_types: entities(f, "rCtype"), roles: entities(f, "rRole"), - other: f - .iter() - .filter(|p| { - !Self::MODELLED - .iter() - .any(|&(ns, name)| p.namespace == ns && p.name == name) - }) - .cloned() - .collect(), + other: unmodelled(f, &Self::MODELLED), } } @@ -822,7 +919,7 @@ impl ImageRegion { false, self.roles.iter().map(Entity::to_xmp).collect(), ); - f.extend(self.other.iter().cloned()); + put_unmodelled(&mut f, &Self::MODELLED, &self.other); XmpValue::Structured(f) } } @@ -949,6 +1046,7 @@ mod tests { 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(), } } @@ -1004,6 +1102,7 @@ mod tests { 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)); @@ -1058,6 +1157,7 @@ mod tests { 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)); @@ -1243,6 +1343,81 @@ mod tests { 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. 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() + }; + /// A structure's `from_xmp` → `to_xmp` round trip, named for the type it converts. + type Trip = (&'static str, XmpValue, fn(&XmpValue) -> Option); + let trips: [Trip; 4] = [ + ("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()) + }), + ]; + 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!(text(fields, ns::IPTC_EXT, "rId"), Some("r1".to_owned())); + assert_eq!( + ImageRegion::from_xmp(&value).map(|r| r.to_xmp()), + Some(value.clone()) + ); + } + #[test] fn a_non_structure_array_item_is_skipped_not_fatal() { // Hostile/odd input: a Bag holding plain text where a structure is expected. From 5306b1d9b925254d70b2e98d478844f1b5518100 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:37:44 -0400 Subject: [PATCH 09/28] feat(iptc): read a bare structure where a sequence is expected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files in the wild write a single structure where the standard puts a Bag of them — one `plus:Licensor`, one `Iptc4xmpExt:rCtype` — and gamut read those as nothing at all, because the accessor asked the graph for an array and took `None` for empty. Read it as the sequence's single element, at both levels, through one helper. Writing is unchanged and still emits the Bag, so a read-modify-write normalises the shape rather than propagating it. That is the crate's posture elsewhere: strict on write, honest and lenient on read. --- crates/gamut-iptc/src/extension.rs | 82 +++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index f26beb2d..4e836771 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -54,6 +54,13 @@ //! model. A retained field whose name the model *does* own is dropped rather than emitted twice: a //! structure carrying two fields of one name is ill-formed and does not read back, so the modelled //! value stays the authority. +//! +//! # Lenient on read, strict on write +//! +//! Reading accepts the shapes seen in the wild: a bare structure written where the standard puts an +//! array of structures reads as that array's single element, and a language alternative written as +//! plain text reads as its text. Writing always emits the standard form — the array, and the +//! language alternative — so a read-modify-write normalises rather than propagates. use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; @@ -105,16 +112,23 @@ fn nested<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a [Xm /// The structure fields of every item of the array field named `ns:name`, skipping non-structures. fn nested_array<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Vec<&'a [XmpProperty]> { - match &field(fields, ns, name).map(|p| &p.value) { - Some(XmpValue::Array(array)) => array + field(fields, ns, name) + .map(|p| structures(&p.value)) + .unwrap_or_default() +} + +/// 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| match &item.value { - XmpValue::Structured(inner) => Some(inner.as_slice()), - _ => None, - }) + .filter_map(|item| structure(&item.value)) .collect(), - _ => Vec::new(), + single => structure(single).into_iter().collect(), } } @@ -934,14 +948,11 @@ fn entities(fields: &[XmpProperty], name: &str) -> Vec { // --- The accessors on the unified view --------------------------------------------------------- -/// Reads every structure of the `Bag`/`Seq` property `ns:name` through `parse`. -fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&XmpValue) -> Option) -> Vec { - match xmp.get_array(ns, name) { - Some(array) => array - .items() - .iter() - .filter_map(|i| parse(&i.value)) - .collect(), +/// Reads every structure of the `Bag`/`Seq` property `ns:name` through `parse`, tolerating a bare +/// structure written where the array should be (see the [module docs](self)). +fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&[XmpProperty]) -> T) -> Vec { + match xmp.get(ns, name) { + Some(property) => structures(&property.value).into_iter().map(parse).collect(), None => Vec::new(), } } @@ -983,7 +994,7 @@ impl PhotoMetadata { &self.xmp, ns::IPTC_EXT, "ImageRegion", - ImageRegion::from_xmp, + ImageRegion::from_fields, ) } @@ -1001,7 +1012,7 @@ impl PhotoMetadata { &self.xmp, ns::IPTC_EXT, "ArtworkOrObject", - ArtworkOrObject::from_xmp, + ArtworkOrObject::from_fields, ) } @@ -1015,7 +1026,7 @@ impl PhotoMetadata { /// The licensors of the image (`plus:Licensor`). #[must_use] pub fn licensors(&self) -> Vec { - read_array(&self.xmp, ns::PLUS, "Licensor", Licensor::from_xmp) + read_array(&self.xmp, ns::PLUS, "Licensor", Licensor::from_fields) } /// Sets the licensors of the image (`plus:Licensor`, an unordered bag); an empty slice removes @@ -1418,6 +1429,41 @@ mod tests { ); } + #[test] + fn a_bare_structure_reads_as_a_one_element_sequence() { + // Seen in the wild: a single structure written where the standard puts a Bag. Lenient on + // read, strict on write — the Bag comes back on the way out. + 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![licensor]); + + // A nested array field is read the same way: one `rCtype` structure, not a Bag of them. + let entity = Entity { + name: Some("Human".to_owned()), + ..Entity::default() + }; + let region = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "rCtype", + entity.to_xmp(), + )]); + assert_eq!( + ImageRegion::from_xmp(®ion).map(|r| r.content_types), + Some(vec![entity]) + ); + + pm.set_licensors(&pm.licensors()); + assert!(matches!( + pm.xmp.get(ns::PLUS, "Licensor").unwrap().value, + XmpValue::Array(XmpArray::Bag(_)) + )); + } + #[test] fn a_non_structure_array_item_is_skipped_not_fatal() { // Hostile/odd input: a Bag holding plain text where a structure is expected. From 313060006169f2d0c5f2ed407431814a5010855a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:37:55 -0400 Subject: [PATCH 10/28] fix(iptc): never write a value the declared type has no room for Two emitters could put something into the graph that is not a value of the property's type, both reachable from safe public API. A non-finite coordinate was written as the text `NaN`, `inf` or `-inf`, none of which is an XMP `Real`; nothing reads it back as a number. The emitter is infallible by design and matching its sibling's shape matters more than a newtype here, so the value is skipped and the omission is documented. Whether emission should become fallible instead is filed separately. And a `CreatorContactInfo` with nothing in it wrote an empty structure, which a reader reports as "present but blank", where the three array setters remove the property. All four now agree: nothing to say removes the property. --- crates/gamut-iptc/src/extension.rs | 58 +++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 4e836771..104fe31b 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -60,7 +60,10 @@ //! Reading accepts the shapes seen in the wild: a bare structure written where the standard puts an //! array of structures reads as that array's single element, and a language alternative written as //! plain text reads as its text. Writing always emits the standard form — the array, and the -//! language alternative — so a read-modify-write normalises rather than propagates. +//! language alternative — so a read-modify-write normalises rather than propagates. One value is +//! dropped rather than normalised: a non-finite coordinate (`NaN`, `±inf`) is not a value of the +//! XMP `Real` type, so it is skipped on emit instead of being written as text nothing can read +//! back as a number. use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; @@ -166,9 +169,12 @@ fn put_list(out: &mut Vec, ns: &str, name: &str, ordered: bool, val )); } -/// Appends `ns:name` as an XMP `Real`, unless the value is absent. +/// Appends `ns:name` as an XMP `Real`, unless the value is absent or non-finite. +/// +/// `NaN` and the infinities are not values of the XMP `Real` type, so they are skipped rather than +/// written as text (see the [module docs](self)). fn put_number(out: &mut Vec, ns: &str, name: &str, value: Option) { - if let Some(value) = value { + if let Some(value) = value.filter(|v| v.is_finite()) { out.push(XmpProperty::new( ns, name, @@ -978,13 +984,16 @@ impl PhotoMetadata { CreatorContactInfo::from_xmp(&self.xmp.get(ns::IPTC_CORE, "CreatorContactInfo")?.value) } - /// Sets the creator's contact details (`Iptc4xmpCore:CreatorContactInfo`). + /// 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. pub fn set_creator_contact_info(&mut self, info: &CreatorContactInfo) { - self.xmp.set(XmpProperty::new( - ns::IPTC_CORE, - "CreatorContactInfo", - info.to_xmp(), - )); + let value = info.to_xmp(); + if structure(&value).is_some_and(<[XmpProperty]>::is_empty) { + self.xmp.remove(ns::IPTC_CORE, "CreatorContactInfo"); + return; + } + self.xmp + .set(XmpProperty::new(ns::IPTC_CORE, "CreatorContactInfo", value)); } /// The image regions (`Iptc4xmpExt:ImageRegion`), in the order the graph holds them. @@ -1429,6 +1438,37 @@ mod tests { ); } + #[test] + fn a_non_finite_coordinate_is_not_written() { + // 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), + } + .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!(number(fields, ns::IPTC_EXT, "rbY"), Some(1.5)); + } + } + + #[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_reads_as_a_one_element_sequence() { // Seen in the wild: a single structure written where the standard puts a Bag. Lenient on From ad04f7b583444f93e138f302c7154ca00ad74524 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:51:17 -0400 Subject: [PATCH 11/28] fix(iptc): drop an array member that carries no field at all The four structured-property setters judged emptiness differently: an empty block removed the property, but an empty *member* of a bag was written as a blank element that a reader reports as present-but-blank. The array setters now skip a member with no field at all, and remove the property when nothing is left, matching the setter that already did. --- crates/gamut-iptc/src/extension.rs | 31 +++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 104fe31b..a04aea5a 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -963,13 +963,22 @@ fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&[XmpProperty]) } } -/// Replaces the `Bag` property `ns:name` with `values`, removing it when `values` is empty. +/// Replaces the `Bag` property `ns:name` with `values`, skipping a value that carries no field at +/// all and removing the property when nothing is left to write. +/// +/// A member with nothing to say is not written, for the reason +/// [`PhotoMetadata::set_creator_contact_info`] does not write an empty structure: a reader would +/// otherwise report it as present but blank. fn write_bag(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { - if values.is_empty() { + let items: Vec = values + .into_iter() + .filter(|value| !structure(value).is_some_and(<[XmpProperty]>::is_empty)) + .map(XmpItem::new) + .collect(); + if items.is_empty() { xmp.remove(ns, name); return; } - let items = values.into_iter().map(XmpItem::new).collect(); xmp.set(XmpProperty::new( ns, name, @@ -1458,6 +1467,22 @@ mod tests { } } + #[test] + fn an_array_element_with_nothing_to_say_is_not_written() { + // The four setters agree: a member that carries no field at all is not written, rather + // than left in the array as an element a reader reports as present but blank. + let mut pm = PhotoMetadata::new(); + let region = ImageRegion { + identifier: Some("r1".to_owned()), + ..ImageRegion::default() + }; + pm.set_image_regions(&[ImageRegion::default(), region.clone()]); + assert_eq!(pm.image_regions(), vec![region]); + // Nothing but empty members leaves no property at all, as an empty slice does. + pm.set_image_regions(&[ImageRegion::default()]); + assert!(pm.xmp.properties.is_empty()); + } + #[test] fn an_empty_contact_block_removes_the_property() { // All four setters agree: nothing to say removes the property, rather than leaving behind From 26549eaadbc948955a090e7c49f0d68293127278 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:51:58 -0400 Subject: [PATCH 12/28] fix(iptc): retain an extension field the typed read cannot consume A field whose name a structure models but whose value the typed reader rejects was neither parsed nor retained: it read as absent, and the excluded-because- modelled rule then dropped it on write, so an identifier carrying a structure and a coordinate carrying non-numeric text both vanished from the graph. Retention is now decided by consumption rather than by a list of modelled names. A field the read took no value from stays in the type's `other` list whether the model names it or not, which also gives the three nested structures - RegionBoundary, RegionBoundaryPoint and Entity - the retention the four top-level ones already had, so a vendor field inside a boundary survives as one beside it does. On write, a retained field is dropped as a namesake only when the modelled field was actually emitted; when it was not, it is the only copy of that name and is written. --- crates/gamut-iptc/src/extension.rs | 598 +++++++++++++++++------------ 1 file changed, 362 insertions(+), 236 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index a04aea5a..0fe33a32 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -20,10 +20,11 @@ //! | [`Licensor`] | `plus:Licensor` | `Bag` of structures | //! //! [`RegionBoundary`], [`RegionBoundaryPoint`] and [`Entity`] are the nested structures -//! [`ImageRegion`] is built from. 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. +//! [`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 //! @@ -37,87 +38,162 @@ //! //! # Fidelity //! -//! A typed view is a projection, so it is lossy where the graph is richer than the model: +//! A typed view is a projection, so the model is narrower than the graph — but reading a structure +//! and writing it back does not lose what the model cannot express. Every type here keeps, in its +//! `other` list, **every field its typed read took no value from**, and re-emits it verbatim after +//! the fields it does model. One rule covers both ways a field falls outside the model: //! -//! - language alternatives are read and written as their `x-default` alternative, as elsewhere in -//! the crate; -//! - a numeric field whose text does not parse as a number reads as absent (honest read — the raw -//! value is still in the graph); -//! - field *order* within a structure is not preserved: a structure is re-emitted in the model's -//! field order, with the retained fields last. Values, and the relative order of an array's -//! items, are preserved. +//! - 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. Such a field reads as absent, because the typed view +//! will not invent a value for it, and is written back unchanged rather than dropped. //! -//! Nothing else is dropped by a read-modify-write. Every type here keeps the fields it does not -//! model in its `other` list — [`ImageRegion`] because the standard explicitly allows a region to -//! carry any other metadata property, the other three so that a vendor extension in a real-world -//! file survives being read and written back — and re-emits them verbatim after the fields it does -//! model. A retained field whose name the model *does* own is dropped rather than emitted twice: a -//! structure carrying two fields of one name is ill-formed and does not read back, so the modelled -//! value stays the authority. +//! 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. Values, and the relative order of an array's items, are preserved. +//! - **the other languages of a language alternative**: the model reads one entry and writes it +//! back as the only entry, so a `dc:title`-style field carrying `en` and `fr` alongside the +//! default keeps only what was read. +//! - **an array item of the wrong kind, when the field also holds a right one**: a `Bag` of text +//! holding one structure, or a `Bag` of structures holding one text, is read as the items the +//! model can take and re-emitted as those. A field holding *only* items of the wrong kind is read +//! as nothing, and so is kept verbatim. +//! - **a non-finite coordinate**: `NaN` and the infinities are not values of the XMP `Real` type, so +//! a coordinate set to one through the typed API is skipped on emit rather than written as text +//! nothing can read back as a number. +//! +//! 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. //! //! # Lenient on read, strict on write //! //! Reading accepts the shapes seen in the wild: a bare structure written where the standard puts an //! array of structures reads as that array's single element, and a language alternative written as //! plain text reads as its text. Writing always emits the standard form — the array, and the -//! language alternative — so a read-modify-write normalises rather than propagates. One value is -//! dropped rather than normalised: a non-finite coordinate (`NaN`, `±inf`) is not a value of the -//! XMP `Real` type, so it is skipped on emit instead of being written as text nothing can read -//! back as a number. +//! language alternative — so a read-modify-write normalises rather than propagates. use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; use crate::photo_metadata::PhotoMetadata; use crate::schema::ns; -// --- Reading helpers over a structure's field list ------------------------------------------- +// --- Reading a structure's field list, tracking what the read consumed ------------------------ -/// The field of `fields` named `ns:name`, if present. -fn field<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a XmpProperty> { - fields.iter().find(|p| p.namespace == ns && p.name == name) +/// A structure's field list under a typed read, remembering which fields the read took a value +/// from. +/// +/// Retention is decided by *consumption* rather than by a list of modelled names, so the two ways +/// a field can fall outside the model are handled by one rule: a field the model does not name and +/// a field it names but cannot read are both left unconsumed, and both end up in the type's `other` +/// list verbatim (see the [module docs](self)). +struct Reader<'a> { + /// The fields being read. + fields: &'a [XmpProperty], + /// Whether the read took a value from the field at the same index. + used: Vec, } -/// The simple text of the field named `ns:name`. -fn text(fields: &[XmpProperty], ns: &str, name: &str) -> Option { - field(fields, ns, name)?.text().map(str::to_owned) -} +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()], + } + } -/// The `x-default` (first) alternative of the language-alternative field named `ns:name`, -/// tolerating a plain simple value. -fn lang_alt(fields: &[XmpProperty], ns: &str, name: &str) -> Option { - match &field(fields, ns, name)?.value { - XmpValue::Array(XmpArray::Alt(items)) => items.iter().find_map(XmpItem::text), - value => value.text(), + /// Reads the field named `ns:name` through `read`, marking it consumed only if `read` yields a + /// value. + fn read( + &mut self, + ns: &str, + name: &str, + read: impl FnOnce(&'a XmpValue) -> Option, + ) -> Option { + let fields = self.fields; + let index = fields + .iter() + .position(|p| p.namespace == ns && p.name == name)?; + let value = read(&fields[index].value)?; + self.used[index] = true; + Some(value) } - .map(str::to_owned) -} -/// Every simple item of the array field named `ns:name` (empty if absent or not an array). -fn list(fields: &[XmpProperty], ns: &str, name: &str) -> Vec { - match &field(fields, ns, name).map(|p| &p.value) { - Some(XmpValue::Array(array)) => array.texts().map(str::to_owned).collect(), - _ => Vec::new(), + /// The simple text of the field named `ns:name`. + fn text(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, |value| value.text().map(str::to_owned)) } -} -/// The field named `ns:name` parsed as an XMP `Real`; a value that does not parse reads as absent. -fn number(fields: &[XmpProperty], ns: &str, name: &str) -> Option { - text(fields, ns, name)?.trim().parse().ok() -} + /// The `x-default` (first) entry of the language-alternative field named `ns:name`, tolerating + /// a plain simple value. + fn lang_alt(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, |value| { + match value { + XmpValue::Array(XmpArray::Alt(items)) => items.iter().find_map(XmpItem::text), + simple => simple.text(), + } + .map(str::to_owned) + }) + } -/// The structure fields of the single structured field named `ns:name`. -fn nested<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Option<&'a [XmpProperty]> { - match &field(fields, ns, name)?.value { - XmpValue::Structured(inner) => Some(inner), - _ => None, + /// Every simple item of the array field named `ns:name` (empty if absent, not an array, or + /// holding no simple item — in which case the field stays unconsumed). + fn list(&mut self, ns: &str, name: &str) -> Vec { + self.read(ns, name, |value| match value { + XmpValue::Array(array) => { + let texts: Vec = array.texts().map(str::to_owned).collect(); + (!texts.is_empty()).then_some(texts) + } + _ => None, + }) + .unwrap_or_default() } -} -/// The structure fields of every item of the array field named `ns:name`, skipping non-structures. -fn nested_array<'a>(fields: &'a [XmpProperty], ns: &str, name: &str) -> Vec<&'a [XmpProperty]> { - field(fields, ns, name) - .map(|p| structures(&p.value)) + /// The field named `ns:name` parsed as an XMP `Real`; a value that does not parse reads as + /// absent and stays unconsumed. + fn number(&mut self, ns: &str, name: &str) -> Option { + self.read(ns, name, |value| value.text()?.trim().parse().ok()) + } + + /// The single structured field named `ns:name`, read through `parse`. + fn nested( + &mut self, + ns: &str, + name: &str, + parse: impl FnOnce(&[XmpProperty]) -> T, + ) -> Option { + self.read(ns, name, |value| structure(value).map(parse)) + } + + /// Every structure of the array field named `ns:name`, read through `parse` (empty if absent or + /// holding no structure, in which case the field stays unconsumed). + fn nested_array( + &mut self, + ns: &str, + name: &str, + parse: impl Fn(&[XmpProperty]) -> T, + ) -> Vec { + self.read(ns, name, |value| { + let parsed: Vec = structures(value).into_iter().map(parse).collect(); + (!parsed.is_empty()).then_some(parsed) + }) .unwrap_or_default() + } + + /// Every field the read took nothing from, 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. @@ -226,30 +302,24 @@ fn structure(value: &XmpValue) -> Option<&[XmpProperty]> { } } -// --- Verbatim retention of the fields a model does not name ----------------------------------- +// --- Verbatim retention of the fields a typed read did not consume --------------------------- -/// Whether `p` is one of the `(namespace, name)` fields a model names. -fn is_modelled(p: &XmpProperty, modelled: &[(&str, &str)]) -> bool { - modelled - .iter() - .any(|&(ns, name)| p.namespace == ns && p.name == name) -} - -/// Every field of `f` the model does not name, cloned for verbatim retention. -fn unmodelled(f: &[XmpProperty], modelled: &[(&str, &str)]) -> Vec { - f.iter() - .filter(|p| !is_modelled(p, modelled)) - .cloned() - .collect() -} - -/// Appends the retained fields, dropping any whose name the model owns. +/// Appends the retained fields, skipping one whose `(namespace, name)` a field already emitted +/// carries. /// -/// The retention list is public, so a caller can put a modelled name in it; emitting it as well -/// would produce a structure with two fields of one name, which is ill-formed and does not read -/// back (see the [module docs](self)). -fn put_unmodelled(out: &mut Vec, modelled: &[(&str, &str)], other: &[XmpProperty]) { - out.extend(other.iter().filter(|p| !is_modelled(p, modelled)).cloned()); +/// 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 is dropped, but only when the modelled field was actually emitted. +/// When it was not, the retained field is the only copy of that name and is written. +fn put_other(out: &mut Vec, other: &[XmpProperty]) { + for property in other { + if !out + .iter() + .any(|p| p.namespace == property.namespace && p.name == property.name) + { + out.push(property.clone()); + } + } } // --- Creator's contact info ------------------------------------------------------------------- @@ -287,18 +357,6 @@ pub struct CreatorContactInfo { } impl CreatorContactInfo { - /// The eight field names this type models; everything else lands in [`other`](Self::other). - const MODELLED: [(&'static str, &'static str); 8] = [ - (ns::IPTC_CORE, "CiAdrExtadr"), - (ns::IPTC_CORE, "CiAdrCity"), - (ns::IPTC_CORE, "CiAdrCtry"), - (ns::IPTC_CORE, "CiAdrPcode"), - (ns::IPTC_CORE, "CiAdrRegion"), - (ns::IPTC_CORE, "CiEmailWork"), - (ns::IPTC_CORE, "CiTelWork"), - (ns::IPTC_CORE, "CiUrlWork"), - ]; - /// 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 { @@ -307,16 +365,17 @@ impl CreatorContactInfo { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - address: text(f, ns::IPTC_CORE, "CiAdrExtadr"), - city: text(f, ns::IPTC_CORE, "CiAdrCity"), - country: text(f, ns::IPTC_CORE, "CiAdrCtry"), - postal_code: text(f, ns::IPTC_CORE, "CiAdrPcode"), - region: text(f, ns::IPTC_CORE, "CiAdrRegion"), - email: text(f, ns::IPTC_CORE, "CiEmailWork"), - phone: text(f, ns::IPTC_CORE, "CiTelWork"), - web_url: text(f, ns::IPTC_CORE, "CiUrlWork"), - other: unmodelled(f, &Self::MODELLED), + 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(), } } @@ -338,7 +397,7 @@ impl CreatorContactInfo { 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_unmodelled(&mut f, &Self::MODELLED, &self.other); + put_other(&mut f, &self.other); XmpValue::Structured(f) } } @@ -394,28 +453,6 @@ pub struct ArtworkOrObject { } impl ArtworkOrObject { - /// The seventeen field names this type models; everything else lands in - /// [`other`](Self::other). - const MODELLED: [(&'static str, &'static str); 17] = [ - (ns::IPTC_EXT, "AOTitle"), - (ns::IPTC_EXT, "AOCreator"), - (ns::IPTC_EXT, "AOCreatorId"), - (ns::IPTC_EXT, "AODateCreated"), - (ns::IPTC_EXT, "AOCircaDateCreated"), - (ns::IPTC_EXT, "AOCopyrightNotice"), - (ns::IPTC_EXT, "AOCurrentCopyrightOwnerName"), - (ns::IPTC_EXT, "AOCurrentCopyrightOwnerId"), - (ns::IPTC_EXT, "AOCurrentLicensorName"), - (ns::IPTC_EXT, "AOCurrentLicensorId"), - (ns::IPTC_EXT, "AOContentDescription"), - (ns::IPTC_EXT, "AOContributionDescription"), - (ns::IPTC_EXT, "AOPhysicalDescription"), - (ns::IPTC_EXT, "AOSource"), - (ns::IPTC_EXT, "AOSourceInvNo"), - (ns::IPTC_EXT, "AOSourceInvURL"), - (ns::IPTC_EXT, "AOStylePeriod"), - ]; - /// 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 { @@ -424,25 +461,26 @@ impl ArtworkOrObject { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - title: lang_alt(f, ns::IPTC_EXT, "AOTitle"), - creator_names: list(f, ns::IPTC_EXT, "AOCreator"), - creator_identifiers: list(f, ns::IPTC_EXT, "AOCreatorId"), - date_created: text(f, ns::IPTC_EXT, "AODateCreated"), - circa_date_created: text(f, ns::IPTC_EXT, "AOCircaDateCreated"), - copyright_notice: text(f, ns::IPTC_EXT, "AOCopyrightNotice"), - current_copyright_owner_name: text(f, ns::IPTC_EXT, "AOCurrentCopyrightOwnerName"), - current_copyright_owner_identifier: text(f, ns::IPTC_EXT, "AOCurrentCopyrightOwnerId"), - current_licensor_name: text(f, ns::IPTC_EXT, "AOCurrentLicensorName"), - current_licensor_identifier: text(f, ns::IPTC_EXT, "AOCurrentLicensorId"), - content_description: lang_alt(f, ns::IPTC_EXT, "AOContentDescription"), - contribution_description: lang_alt(f, ns::IPTC_EXT, "AOContributionDescription"), - physical_description: lang_alt(f, ns::IPTC_EXT, "AOPhysicalDescription"), - source: text(f, ns::IPTC_EXT, "AOSource"), - source_inventory_number: text(f, ns::IPTC_EXT, "AOSourceInvNo"), - source_inventory_url: text(f, ns::IPTC_EXT, "AOSourceInvURL"), - style_periods: list(f, ns::IPTC_EXT, "AOStylePeriod"), - other: unmodelled(f, &Self::MODELLED), + title: r.lang_alt(ns::IPTC_EXT, "AOTitle"), + creator_names: r.list(ns::IPTC_EXT, "AOCreator"), + creator_identifiers: r.list(ns::IPTC_EXT, "AOCreatorId"), + 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"), + other: r.other(), } } @@ -544,7 +582,7 @@ impl ArtworkOrObject { false, &self.style_periods, ); - put_unmodelled(&mut f, &Self::MODELLED, &self.other); + put_other(&mut f, &self.other); XmpValue::Structured(f) } } @@ -596,25 +634,6 @@ pub struct Licensor { } impl Licensor { - /// The fourteen field names this type models; everything else lands in - /// [`other`](Self::other). - const MODELLED: [(&'static str, &'static str); 14] = [ - (ns::PLUS, "LicensorID"), - (ns::PLUS, "LicensorName"), - (ns::PLUS, "LicensorStreetAddress"), - (ns::PLUS, "LicensorExtendedAddress"), - (ns::PLUS, "LicensorCity"), - (ns::PLUS, "LicensorRegion"), - (ns::PLUS, "LicensorPostalCode"), - (ns::PLUS, "LicensorCountry"), - (ns::PLUS, "LicensorTelephoneType1"), - (ns::PLUS, "LicensorTelephone1"), - (ns::PLUS, "LicensorTelephoneType2"), - (ns::PLUS, "LicensorTelephone2"), - (ns::PLUS, "LicensorEmail"), - (ns::PLUS, "LicensorURL"), - ]; - /// 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 { @@ -623,22 +642,23 @@ impl Licensor { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - identifier: text(f, ns::PLUS, "LicensorID"), - name: text(f, ns::PLUS, "LicensorName"), - address: text(f, ns::PLUS, "LicensorStreetAddress"), - address_detail: text(f, ns::PLUS, "LicensorExtendedAddress"), - city: text(f, ns::PLUS, "LicensorCity"), - region: text(f, ns::PLUS, "LicensorRegion"), - postal_code: text(f, ns::PLUS, "LicensorPostalCode"), - country: text(f, ns::PLUS, "LicensorCountry"), - telephone_type1: text(f, ns::PLUS, "LicensorTelephoneType1"), - telephone1: text(f, ns::PLUS, "LicensorTelephone1"), - telephone_type2: text(f, ns::PLUS, "LicensorTelephoneType2"), - telephone2: text(f, ns::PLUS, "LicensorTelephone2"), - email: text(f, ns::PLUS, "LicensorEmail"), - web_url: text(f, ns::PLUS, "LicensorURL"), - other: unmodelled(f, &Self::MODELLED), + 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(), } } @@ -696,7 +716,7 @@ impl Licensor { ); put_text(&mut f, ns::PLUS, "LicensorEmail", self.email.as_ref()); put_text(&mut f, ns::PLUS, "LicensorURL", self.web_url.as_ref()); - put_unmodelled(&mut f, &Self::MODELLED, &self.other); + put_other(&mut f, &self.other); XmpValue::Structured(f) } } @@ -714,6 +734,9 @@ pub struct Entity { 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 { @@ -725,31 +748,38 @@ impl Entity { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - identifiers: list(f, ns::XMP, "Identifier"), - name: lang_alt(f, ns::IPTC_EXT, "Name"), + identifiers: r.list(ns::XMP, "Identifier"), + name: r.lang_alt(ns::IPTC_EXT, "Name"), + other: r.other(), } } - /// Writes the structure as an XMP value, omitting absent fields. + /// 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, Copy, Default, PartialEq)] +#[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 { @@ -761,18 +791,22 @@ impl RegionBoundaryPoint { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - x: number(f, ns::IPTC_EXT, "rbX"), - y: number(f, ns::IPTC_EXT, "rbY"), + 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. + /// 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) } } @@ -802,6 +836,9 @@ pub struct RegionBoundary { 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 { @@ -813,22 +850,22 @@ impl RegionBoundary { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - shape: text(f, ns::IPTC_EXT, "rbShape"), - unit: text(f, ns::IPTC_EXT, "rbUnit"), - x: number(f, ns::IPTC_EXT, "rbX"), - y: number(f, ns::IPTC_EXT, "rbY"), - width: number(f, ns::IPTC_EXT, "rbW"), - height: number(f, ns::IPTC_EXT, "rbH"), - radius: number(f, ns::IPTC_EXT, "rbRx"), - vertices: nested_array(f, ns::IPTC_EXT, "rbVertices") - .into_iter() - .map(RegionBoundaryPoint::from_fields) - .collect(), + 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", RegionBoundaryPoint::from_fields), + other: r.other(), } } - /// Writes the structure as an XMP value, omitting absent fields. + /// 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. @@ -852,6 +889,7 @@ impl RegionBoundary { .map(RegionBoundaryPoint::to_xmp) .collect(), ); + put_other(&mut f, &self.other); XmpValue::Structured(f) } } @@ -884,16 +922,6 @@ pub struct ImageRegion { } impl ImageRegion { - /// The five field names [`ImageRegion`] models; everything else lands in - /// [`other`](Self::other). - const MODELLED: [(&'static str, &'static str); 5] = [ - (ns::IPTC_EXT, "RegionBoundary"), - (ns::IPTC_EXT, "rId"), - (ns::IPTC_EXT, "Name"), - (ns::IPTC_EXT, "rCtype"), - (ns::IPTC_EXT, "rRole"), - ]; - /// 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 { @@ -902,13 +930,14 @@ impl ImageRegion { /// Reads the structure from an already-unwrapped field list. fn from_fields(f: &[XmpProperty]) -> Self { + let mut r = Reader::new(f); Self { - boundary: nested(f, ns::IPTC_EXT, "RegionBoundary").map(RegionBoundary::from_fields), - identifier: text(f, ns::IPTC_EXT, "rId"), - name: lang_alt(f, ns::IPTC_EXT, "Name"), - content_types: entities(f, "rCtype"), - roles: entities(f, "rRole"), - other: unmodelled(f, &Self::MODELLED), + boundary: r.nested(ns::IPTC_EXT, "RegionBoundary", RegionBoundary::from_fields), + identifier: r.text(ns::IPTC_EXT, "rId"), + name: r.lang_alt(ns::IPTC_EXT, "Name"), + content_types: r.nested_array(ns::IPTC_EXT, "rCtype", Entity::from_fields), + roles: r.nested_array(ns::IPTC_EXT, "rRole", Entity::from_fields), + other: r.other(), } } @@ -939,19 +968,11 @@ impl ImageRegion { false, self.roles.iter().map(Entity::to_xmp).collect(), ); - put_unmodelled(&mut f, &Self::MODELLED, &self.other); + put_other(&mut f, &self.other); XmpValue::Structured(f) } } -/// Every [`Entity`] of the `Iptc4xmpExt:` array field. -fn entities(fields: &[XmpProperty], name: &str) -> Vec { - nested_array(fields, ns::IPTC_EXT, name) - .into_iter() - .map(Entity::from_fields) - .collect() -} - // --- The accessors on the unified view --------------------------------------------------------- /// Reads every structure of the `Bag`/`Seq` property `ns:name` through `parse`, tolerating a bare @@ -1063,6 +1084,14 @@ mod tests { 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 { @@ -1089,7 +1118,7 @@ mod tests { assert_eq!(fields.len(), 8); assert!(fields.iter().all(|p| p.namespace == ns::IPTC_CORE)); assert_eq!( - text(fields, ns::IPTC_CORE, "CiAdrPcode"), + Reader::new(fields).text(ns::IPTC_CORE, "CiAdrPcode"), Some("69000".to_owned()) ); let sparse = CreatorContactInfo { @@ -1207,19 +1236,21 @@ mod tests { width: Some(0.125), height: Some(0.0625), radius: None, - vertices: Vec::new(), + ..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( @@ -1246,8 +1277,9 @@ mod tests { XmpValue::Structured(_) )); // The entity identifier is `xmp:Identifier`, not an IPTC-namespaced property. - let entity = nested_array(fields, ns::IPTC_EXT, "rCtype")[0]; - assert!(field(entity, ns::XMP, "Identifier").is_some()); + let entities = + Reader::new(fields).nested_array(ns::IPTC_EXT, "rCtype", <[XmpProperty]>::to_vec); + assert!(field(&entities[0], ns::XMP, "Identifier").is_some()); } #[test] @@ -1257,6 +1289,7 @@ mod tests { 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)); } @@ -1270,10 +1303,12 @@ mod tests { RegionBoundaryPoint { x: Some(0.0), y: Some(10.0), + ..RegionBoundaryPoint::default() }, RegionBoundaryPoint { x: Some(20.0), y: Some(30.0), + ..RegionBoundaryPoint::default() }, ], ..RegionBoundary::default() @@ -1290,7 +1325,8 @@ mod tests { #[test] fn a_coordinate_that_is_not_a_number_reads_as_absent() { - // Honest read: the raw value stays in the graph, but the typed view does not invent one. + // 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 ")), @@ -1385,9 +1421,9 @@ mod tests { #[test] fn every_structure_keeps_the_field_it_does_not_model() { - // A read-modify-write must not drop a vendor extension. 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. + // 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() @@ -1400,9 +1436,21 @@ mod tests { identifier: Some("r1".to_owned()), ..ImageRegion::default() }; - /// A structure's `from_xmp` → `to_xmp` round trip, named for the type it converts. - type Trip = (&'static str, XmpValue, fn(&XmpValue) -> Option); - let trips: [Trip; 4] = [ + 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()) }), @@ -1415,6 +1463,15 @@ mod tests { ("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); @@ -1440,7 +1497,10 @@ mod tests { 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!(text(fields, ns::IPTC_EXT, "rId"), Some("r1".to_owned())); + 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()) @@ -1455,6 +1515,7 @@ mod tests { let value = RegionBoundaryPoint { x: Some(bad), y: Some(1.5), + ..RegionBoundaryPoint::default() } .to_xmp(); let fields = structure(&value).unwrap(); @@ -1463,8 +1524,73 @@ mod tests { "{bad} was written to the graph" ); // The finite sibling is still written, so the skip is per value, not per structure. - assert_eq!(number(fields, ns::IPTC_EXT, "rbY"), Some(1.5)); + assert_eq!(Reader::new(fields).number(ns::IPTC_EXT, "rbY"), Some(1.5)); + } + } + + #[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; 2] = [ + // 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()), + ), + ]; + 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_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] From 6322fdc7904c65e126de663b5930e9fba495b44d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:52:16 -0400 Subject: [PATCH 13/28] fix(iptc): read a language alternative by its x-default tag The helper took the first entry of an alternative list and re-emitted it labelled `x-default`, so a list a writer had ordered with a French entry first came back with the French text standing in for the default and the real default gone. The entry is now found by its `xml:lang` qualifier, compared case-insensitively, as XMP Part 1 8.2.2.4 requires and as `XmpMeta::get_lang_alt` already does for a top-level property. A list with no default entry at all is not read: it reads as absent and, having consumed nothing, is kept verbatim, so the languages it does carry survive instead of one of them being relabelled. --- crates/gamut-iptc/src/extension.rs | 62 +++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 0fe33a32..9a2fa277 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -46,16 +46,18 @@ //! - 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. Such a field reads as absent, because the typed view -//! will not invent a value for it, and is written back unchanged rather than dropped. +//! holding a structure where text belongs, a language alternative with no `x-default` entry. Such +//! a field reads as absent, because the typed view will not invent a value for it, and is written +//! back unchanged rather than dropped. //! //! 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. Values, and the relative order of an array's items, are preserved. -//! - **the other languages of a language alternative**: the model reads one entry and writes it -//! back as the only entry, so a `dc:title`-style field carrying `en` and `fr` alongside the -//! default keeps only what was read. +//! - **the other languages of a modelled language alternative**: the model reads the `x-default` +//! entry and writes it back as the only entry, so a `dc:title`-style field carrying `en` and `fr` +//! alongside the default keeps only the default. (A field with *no* default entry is not read at +//! all, so its languages survive verbatim.) //! - **an array item of the wrong kind, when the field also holds a right one**: a `Bag` of text //! holding one structure, or a `Bag` of structures holding one text, is read as the items the //! model can take and re-emitted as those. A field holding *only* items of the wrong kind is read @@ -83,6 +85,9 @@ use crate::schema::ns; // --- Reading a structure's field list, tracking what the read consumed ------------------------ +/// 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 took a value /// from. /// @@ -128,12 +133,23 @@ impl<'a> Reader<'a> { self.read(ns, name, |value| value.text().map(str::to_owned)) } - /// The `x-default` (first) entry of the language-alternative field named `ns:name`, tolerating - /// a plain simple value. + /// The `x-default` entry of the language-alternative field named `ns:name`, 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 is *not* read: it reads as absent and is kept + /// verbatim, rather than having another language relabelled as the default. fn lang_alt(&mut self, ns: &str, name: &str) -> Option { self.read(ns, name, |value| { match value { - XmpValue::Array(XmpArray::Alt(items)) => items.iter().find_map(XmpItem::text), + XmpValue::Array(XmpArray::Alt(items)) => items + .iter() + .find(|item| { + item.lang() + .is_some_and(|l| l.eq_ignore_ascii_case(X_DEFAULT)) + }) + .and_then(XmpItem::text), simple => simple.text(), } .map(str::to_owned) @@ -1538,7 +1554,7 @@ mod tests { "Nested", text_value("v"), )]); - let cases: [Trip; 2] = [ + let cases: [Trip; 3] = [ // An identifier holding a structure where text belongs. ( "rId", @@ -1559,6 +1575,17 @@ mod tests { )]), |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!( @@ -1576,6 +1603,23 @@ mod tests { assert_eq!(region.and_then(|r| r.identifier), None); } + #[test] + fn a_language_alternative_is_read_by_its_tag_not_its_position() { + // A conforming writer puts `x-default` first, but nothing in the graph enforces it. Reading + // by position would take the French text and re-emit it labelled as the default. + let value = XmpValue::Structured(vec![XmpProperty::new( + ns::IPTC_EXT, + "AOTitle", + XmpValue::Array(XmpArray::Alt(vec![ + XmpItem::lang_text("fr", "Tournesols"), + XmpItem::lang_text("X-Default", "Sunflowers"), + ])), + )]); + let art = ArtworkOrObject::from_xmp(&value).unwrap(); + // The tag match is case-insensitive, as XMP Part 1 §8.2.2.4 requires. + assert_eq!(art.title.as_deref(), Some("Sunflowers")); + } + #[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 From 8e077b94d2129f45f045f2fb5288ff490d95ee4e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:54:29 -0400 Subject: [PATCH 14/28] test(iptc): compare dataset names against exiv2's table too The drift guard compared octet maxima, repeatability and value kind but not names, because the C++ parse threw string literals away before splitting a row into fields - so a mistyped dataset name survived the whole suite. Literals are now indexed rather than discarded, which keeps a description's commas out of the field split while leaving the title readable. exiv2 titles 28 of the 70 datasets more briefly than IIM 4.2 names them ("Caption" for "Caption/Abstract"). Those rows pin both spellings, in the shape the octet-limit exceptions already use, so every name in the table is pinned to something: 42 to exiv2's title directly, 28 to a self-invalidating pair, and 7:10 Size Mode - which exiv2 does not carry - to the spec's own spelling. --- crates/gamut-iptc/src/iim.rs | 143 ++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 27 deletions(-) diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index 47f48bbc..d95ee924 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -430,25 +430,44 @@ mod tests { .unwrap_or_else(|e| panic!("vendored file {relative} must be readable: {e}")) } - /// `src` with every string literal removed, so a comma or a brace inside a C++ description - /// cannot be mistaken for punctuation. - fn without_string_literals(src: &str) -> String { + /// 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 its index into the returned literals, so a comma + /// or a brace inside a C++ description cannot be mistaken for punctuation while the literal + /// itself stays readable. + fn index_string_literals(src: &str) -> (String, Vec) { let mut out = String::new(); + let mut literals: Vec = Vec::new(); 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 { - '\\' => drop(chars.next()), + '\\' => literal.extend(chars.next()), '"' => break, - _ => {} + _ => literal.push(c), } } + out.push(LITERAL); + out.push_str(&literals.len().to_string()); + out.push(LITERAL); + literals.push(literal); } - out + (out, literals) + } + + /// The first string literal `field` references, if it references one. + fn literal<'a>(field: &str, literals: &'a [String]) -> Option<&'a str> { + let start = field.find(LITERAL)? + LITERAL.len_utf8(); + let end = start + field[start..].find(LITERAL)?; + let index: usize = field[start..end].parse().ok()?; + literals.get(index).map(String::as_str) } /// The `static constexpr uint16_t NAME = N;` constants of an exiv2 header. @@ -476,8 +495,19 @@ mod tests { &source[start..start + end] } - /// exiv2's own IIM dataset table, read from the vendored `third_party/exiv2` sources as - /// `(record, dataset) -> (repeatable, maximum octets, value type)`. + /// One row of exiv2's own IIM dataset table. + struct Exiv2DataSet { + /// The dataset's title, exiv2's counterpart of [`IimTagInfo::name`]. + title: String, + /// 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"}`, @@ -485,10 +515,11 @@ mod tests { /// 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), (bool, u32, String)> { + 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 = without_string_literals(&vendored("third_party/exiv2/src/datasets.cpp")); + let (source, literals) = + 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('}') { @@ -508,21 +539,64 @@ mod tests { else { continue; }; - let repeatable = fields[last - 5] == "true"; - let max: u32 = fields[last - 3].parse().expect("maxbytes is a number"); - let kind = fields[last - 2].trim_start_matches("Exiv2::").to_owned(); - table.insert((record as u8, dataset as u8), (repeatable, max, kind)); + let Some(title) = literal(fields[2], &literals) else { + continue; + }; + let row = Exiv2DataSet { + title: title.to_owned(), + 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 datasets exiv2 titles more briefly than IIM 4.2 names them. Each row pins BOTH + /// spellings — `((record, dataset), the IIM 4.2 name gamut carries, exiv2's title)` — so it + /// self-invalidates if either side changes, and every name in the table is pinned to something + /// either way. + #[rustfmt::skip] + const NAME_ABBREVIATIONS: &[((u8, u8), &str, &str)] = &[ + ((1, 22), "File Format Version", "File Version"), + ((1, 30), "Service Identifier", "Service Id"), + ((1, 50), "Product I.D.", "Product Id"), + ((1, 90), "Coded Character Set", "Character Set"), + ((1, 100), "UNO", "Unique Name Object"), + ((2, 3), "Object Type Reference", "Object Type"), + ((2, 4), "Object Attribute Reference", "Object Attribute"), + ((2, 12), "Subject Reference", "Subject"), + ((2, 22), "Fixture Identifier", "Fixture Id"), + ((2, 26), "Content Location Code", "Location Code"), + ((2, 27), "Content Location Name", "Location Name"), + ((2, 38), "Expiration Time", "ExpirationTime"), + ((2, 62), "Digital Creation Date", "Digitization Date"), + ((2, 63), "Digital Creation Time", "Digitization Time"), + ((2, 65), "Originating Program", "Program"), + ((2, 92), "Sub-location", "Sub Location"), + ((2, 95), "Province/State", "Province State"), + ((2, 100), "Country/Primary Location Code", "Country Code"), + ((2, 101), "Country/Primary Location Name", "Country Name"), + ((2, 103), "Original Transmission Reference", "Transmission Reference"), + ((2, 116), "Copyright Notice", "Copyright"), + ((2, 120), "Caption/Abstract", "Caption"), + ((2, 122), "Writer/Editor", "Writer"), + ((2, 135), "Language Identifier", "Language"), + ((2, 151), "Audio Sampling Rate", "Audio Rate"), + ((2, 152), "Audio Sampling Resolution", "Audio Resolution"), + ((2, 200), "ObjectData Preview File Format", "Preview Format"), + ((2, 201), "ObjectData Preview File Format Version", "Preview Version"), + ]; + #[test] fn tag_table_matches_the_exiv2_dataset_table() { // Drift guard. `iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a hand - // transcription with nothing but ordering and uniqueness to catch a slipped digit. exiv2 — - // the crate's differential oracle — carries its own independent transcription of the same - // spec chapters, so comparing the two tables catches exactly that. + // transcription with nothing but ordering and uniqueness to catch a slipped digit or a + // mistyped name. exiv2 — the crate's differential oracle — carries its own independent + // transcription of the same spec chapters, so comparing the two tables column for column + // (name, octet maximum, repeatability, value type) catches exactly that. let exiv2 = exiv2_dataset_table(); assert!( exiv2.len() > 60, @@ -530,22 +604,30 @@ mod tests { exiv2.len() ); for t in KNOWN_TAGS { - let Some((repeatable, max, kind)) = exiv2.get(&(t.record, t.dataset)) else { + let Some(row) = exiv2.get(&(t.record, t.dataset)) else { // exiv2 tables records 1 and 2 only; 7:10 comes from the PDF alone. assert_eq!( - (t.record, t.dataset), - (7, 10), - "{}:{} {} is not in exiv2's table", - t.record, - t.dataset, - t.name + ((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), *max, "{where_}: octet maximum"); - assert_eq!(t.repeatable, *repeatable, "{where_}: repeatability"); - let expected = match kind.as_str() { + assert_eq!(u32::from(t.max_octets), row.max, "{where_}: octet maximum"); + assert_eq!(t.repeatable, row.repeatable, "{where_}: repeatability"); + // exiv2 abbreviates some titles, so those rows pin both spellings instead of matching. + match NAME_ABBREVIATIONS + .iter() + .find(|&&(key, ..)| key == (t.record, t.dataset)) + { + Some(&(_, name, title)) => { + assert_eq!(t.name, name, "{where_}: dataset name"); + assert_eq!(row.title, title, "{where_}: exiv2 title"); + } + None => assert_eq!(t.name, row.title, "{where_}: dataset name"), + } + 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. @@ -568,6 +650,13 @@ mod tests { "exiv2 documents {record}:{dataset} but gamut does not name it" ); } + // A stale abbreviation would silently stop pinning anything, so every row must be live. + for &(key, ..) in NAME_ABBREVIATIONS { + assert!( + KNOWN_TAGS.iter().any(|t| (t.record, t.dataset) == key) && exiv2.contains_key(&key), + "{key:?} is pinned as an abbreviation but is not in both tables" + ); + } } #[test] From cb59eaffa7e1239ed96203dbad9525b6a3990ad2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:03:03 -0400 Subject: [PATCH 15/28] docs(iptc): state what the guard compares and what retention covers The drift guard now compares dataset names as well as octet maxima, repeatability and value kind, and retention covers a modelled field whose value the typed read cannot take, not just a field the model does not name. --- crates/gamut-iptc/README.md | 7 ++++--- crates/gamut-iptc/STATUS.md | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index 90acd234..1cec3570 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -84,9 +84,10 @@ The v1 contract, stated precisely: (issue #34). - **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`). Each typed structure keeps the fields it does - not model in its `other` list and re-emits them verbatim, so reading one and writing it back never - drops a vendor extension. The remaining Extension structures (locations, persons, + regions, artwork/object and licensors (`extension`). Each typed structure keeps in its `other` + list every field its typed read took no value from — one the model does not name, and equally one + it names but cannot read — and re-emits it verbatim, so reading one and writing it back drops + neither a vendor extension nor a value the model cannot express. 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, diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index 73a1f73a..e2179589 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -39,9 +39,10 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe `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. Each keeps the fields it does not model in - its `other` list and re-emits them verbatim, so a read-modify-write does not drop a vendor - extension. + that, and each structure's field set, to the reference. Each of the seven types keeps in its + `other` list every field its typed read took no value from — a field the model does not name, and + equally one it names but cannot read — and re-emits it verbatim, so a read-modify-write does not + drop a vendor extension or a value the model cannot express. - **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 @@ -52,8 +53,10 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe of the record-1/2 table comes from `iim-4.2.pdf`, which is not machine-readable. Its guard is `iim`'s own `tag_table_matches_the_exiv2_dataset_table`, which parses exiv2's independent transcription of the same chapters out of the vendored `third_party/exiv2` sources and compares - every row's octet maximum, repeatability and value kind — a mis-transcribed maximum fails there, - which no round trip can see. The structural laws (ordering, uniqueness, the fixed date/time form + every row column for column — dataset name, octet maximum, repeatability and value kind. A + mis-transcribed maximum or a mistyped name fails there, which no round trip can see. exiv2 titles + 28 of the 70 shared datasets more briefly than IIM 4.2 names them; those rows pin both spellings, + so every name in the table is pinned either way. The structural laws (ordering, uniqueness, the fixed date/time form lengths) and the exiv2 wire differential in `tests/oracle.rs` sit alongside it. ## Deferred / out of scope From 007d7ba29f2f7e6c613fc505d44221c014d27d8e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:27:41 -0400 Subject: [PATCH 16/28] fix(iptc): decide retention by what the writer can reproduce A typed read consumed a field whenever it could parse a value out of it, which is a claim about the read, not about the round trip. Three shapes parsed cleanly and were then written back as something else, so the field was neither modelled nor retained: a URL held as `rdf:resource` came back as element text, a value's qualifiers were dropped, and a coordinate stated as `NaN`, an infinity or `1e400` vanished entirely - `put_number` refuses to write a value the XMP `Real` type has no form for, and by then the field had already been marked consumed. Consume a field only when the property the writer will emit for the value read reproduces the field that was read - value, container kind, qualifiers and all. Every `parse_*` now sits beside the `*_value` that writes its result, and the reader compares against that very value, so the two cannot drift apart. Two lexical re-spellings still count as reproducing a field, because both are idempotent and value-preserving: a number's form, and the case of an `x-default` language tag. The rule subsumes the read-side special cases and closes the shapes nobody had named: an `rdf:Alt` or `rdf:Seq` handed to a list field, an array item of a kind the model skips, a bare structure where an array belongs, and two fields of one name are all retained now instead of being normalised away. `put_other` no longer drops a retained field because an earlier *retained* field shares its name - only a modelled one it was actually emitted beside can displace it. The one-field shape corpus states the law over every shape at once, and the enumeration of what retention newly covers is derived from it rather than listed by hand. --- crates/gamut-iptc/src/extension.rs | 1003 ++++++++++++++++++++++------ 1 file changed, 814 insertions(+), 189 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 9a2fa277..37fcc68f 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -39,66 +39,75 @@ //! # Fidelity //! //! A typed view is a projection, so the model is narrower than the graph — but reading a structure -//! and writing it back does not lose what the model cannot express. Every type here keeps, in its -//! `other` list, **every field its typed read took no value from**, and re-emits it verbatim after -//! the fields it does model. One rule covers both ways a field falls outside the model: +//! 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. Such -//! a field reads as absent, because the typed view will not invent a value for it, and is written -//! back unchanged rather than dropped. +//! 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 +//! with entries beyond the default, an `rdf:Seq` or `rdf:Alt` where the model writes an +//! `rdf:Bag`, 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. +//! +//! 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. Values, and the relative order of an array's items, are preserved. -//! - **the other languages of a modelled language alternative**: the model reads the `x-default` -//! entry and writes it back as the only entry, so a `dc:title`-style field carrying `en` and `fr` -//! alongside the default keeps only the default. (A field with *no* default entry is not read at -//! all, so its languages survive verbatim.) -//! - **an array item of the wrong kind, when the field also holds a right one**: a `Bag` of text -//! holding one structure, or a `Bag` of structures holding one text, is read as the items the -//! model can take and re-emitted as those. A field holding *only* items of the wrong kind is read -//! as nothing, and so is kept verbatim. -//! - **a non-finite coordinate**: `NaN` and the infinities are not values of the XMP `Real` type, so -//! a coordinate set to one through the typed API is skipped on emit rather than written as text -//! nothing can read back as a number. +//! - **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. //! //! 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. //! -//! # Lenient on read, strict on write +//! # Lenient at the top level, exact inside a structure //! -//! Reading accepts the shapes seen in the wild: a bare structure written where the standard puts an -//! array of structures reads as that array's single element, and a language alternative written as -//! plain text reads as its text. Writing always emits the standard form — the array, and the -//! language alternative — so a read-modify-write normalises rather than propagates. +//! [`PhotoMetadata`]'s array accessors accept the shapes seen in the wild: a bare structure written +//! where the standard puts an array of structures reads as that array's single element. Nothing is +//! at risk there, because reading a property never rewrites it. Inside a structure the same +//! leniency would normalise the shape away on the way out, so it is not taken — such a field is +//! retained instead, and reads as absent. -use gamut_xmp::{XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; +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, tracking what the read consumed ------------------------ +// --- 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 took a value -/// from. +/// A structure's field list under a typed read, remembering which fields the read consumed. /// -/// Retention is decided by *consumption* rather than by a list of modelled names, so the two ways -/// a field can fall outside the model are handled by one rule: a field the model does not name and -/// a field it names but cannot read are both left unconsumed, and both end up in the type's `other` -/// list verbatim (see the [module docs](self)). +/// 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 took a value from the field at the same index. + /// Whether the read consumed the field at the same index. used: Vec, } @@ -111,96 +120,100 @@ impl<'a> Reader<'a> { } } - /// Reads the field named `ns:name` through `read`, marking it consumed only if `read` yields a - /// value. + /// 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, - read: impl FnOnce(&'a XmpValue) -> Option, + parse: impl FnOnce(&'a XmpValue) -> Option, + write: impl FnOnce(&T) -> Option, ) -> Option { let fields = self.fields; - let index = fields + let mut matches = fields .iter() - .position(|p| p.namespace == ns && p.name == name)?; - let value = read(&fields[index].value)?; + .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, |value| value.text().map(str::to_owned)) + self.read(ns, name, parse_text, |value| Some(text_value(value))) } - /// The `x-default` entry of the language-alternative field named `ns:name`, 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 is *not* read: it reads as absent and is kept - /// verbatim, rather than having another language relabelled as the default. + /// 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, |value| { - match value { - XmpValue::Array(XmpArray::Alt(items)) => items - .iter() - .find(|item| { - item.lang() - .is_some_and(|l| l.eq_ignore_ascii_case(X_DEFAULT)) - }) - .and_then(XmpItem::text), - simple => simple.text(), - } - .map(str::to_owned) + self.read(ns, name, parse_lang_alt, |value| { + Some(lang_alt_value(value)) }) } - /// Every simple item of the array field named `ns:name` (empty if absent, not an array, or - /// holding no simple item — in which case the field stays unconsumed). - fn list(&mut self, ns: &str, name: &str) -> Vec { - self.read(ns, name, |value| match value { - XmpValue::Array(array) => { - let texts: Vec = array.texts().map(str::to_owned).collect(); - (!texts.is_empty()).then_some(texts) - } - _ => None, - }) - .unwrap_or_default() + /// 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`; a value that does not parse reads as - /// absent and stays unconsumed. + /// The field named `ns:name` parsed as an XMP `Real`. fn number(&mut self, ns: &str, name: &str) -> Option { - self.read(ns, name, |value| value.text()?.trim().parse().ok()) + self.read(ns, name, parse_number, |value| number_value(*value)) } - /// The single structured field named `ns:name`, read through `parse`. + /// 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)) + 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` (empty if absent or - /// holding no structure, in which case the field stays unconsumed). + /// 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) - }) + 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 took nothing from, cloned for verbatim retention. + /// Every field the read did not consume, cloned for verbatim retention. fn other(self) -> Vec { let Self { fields, used } = self; fields @@ -227,59 +240,224 @@ fn structures(value: &XmpValue) -> Vec<&[XmpProperty]> { } } +/// 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` as simple text, unless the value is absent. -fn put_text(out: &mut Vec, ns: &str, name: &str, value: Option<&String>) { +/// 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, XmpValue::Simple(value.clone()))); + 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>) { - if let Some(value) = value { - let items = vec![XmpItem::lang_text("x-default", value.clone())]; - out.push(XmpProperty::new( - ns, - name, - XmpValue::Array(XmpArray::Alt(items)), - )); - } + 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]) { - if values.is_empty() { - return; - } - let items = values.iter().map(XmpItem::simple).collect(); - out.push(XmpProperty::new( - ns, - name, - XmpValue::Array(array(ordered, items)), - )); + put(out, ns, name, list_value(ordered, values)); } -/// Appends `ns:name` as an XMP `Real`, unless the value is absent or non-finite. -/// -/// `NaN` and the infinities are not values of the XMP `Real` type, so they are skipped rather than -/// written as text (see the [module docs](self)). +/// 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) { - if let Some(value) = value.filter(|v| v.is_finite()) { - out.push(XmpProperty::new( - ns, - name, - XmpValue::Simple(value.to_string()), - )); - } + 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) { - if let Some(value) = value { - out.push(XmpProperty::new(ns, name, value)); - } + put(out, ns, name, value); } /// Appends `ns:name` as an array of structure values, unless the list is empty. @@ -290,46 +468,24 @@ fn put_nested_array( ordered: bool, values: Vec, ) { - if values.is_empty() { - return; - } - let items = values.into_iter().map(XmpItem::new).collect(); - out.push(XmpProperty::new( - ns, - name, - XmpValue::Array(array(ordered, items)), - )); -} - -/// A `Seq` when `ordered`, otherwise a `Bag`. -fn array(ordered: bool, items: Vec) -> XmpArray { - if ordered { - XmpArray::Seq(items) - } else { - XmpArray::Bag(items) - } -} - -/// 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, - } + 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 field already emitted -/// carries. +/// 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 is dropped, but only when the modelled field was actually emitted. -/// When it was not, the retained field is the only copy of that name and is written. +/// 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 + if !out[..modelled] .iter() .any(|p| p.namespace == property.namespace && p.name == property.name) { @@ -480,8 +636,8 @@ impl ArtworkOrObject { let mut r = Reader::new(f); Self { title: r.lang_alt(ns::IPTC_EXT, "AOTitle"), - creator_names: r.list(ns::IPTC_EXT, "AOCreator"), - creator_identifiers: r.list(ns::IPTC_EXT, "AOCreatorId"), + 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"), @@ -495,7 +651,7 @@ impl ArtworkOrObject { 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"), + style_periods: r.list(ns::IPTC_EXT, "AOStylePeriod", false), other: r.other(), } } @@ -766,7 +922,7 @@ impl Entity { fn from_fields(f: &[XmpProperty]) -> Self { let mut r = Reader::new(f); Self { - identifiers: r.list(ns::XMP, "Identifier"), + identifiers: r.list(ns::XMP, "Identifier", false), name: r.lang_alt(ns::IPTC_EXT, "Name"), other: r.other(), } @@ -875,7 +1031,13 @@ impl RegionBoundary { 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", RegionBoundaryPoint::from_fields), + vertices: r.nested_array( + ns::IPTC_EXT, + "rbVertices", + true, + RegionBoundaryPoint::from_fields, + RegionBoundaryPoint::to_xmp, + ), other: r.other(), } } @@ -948,11 +1110,28 @@ impl ImageRegion { fn from_fields(f: &[XmpProperty]) -> Self { let mut r = Reader::new(f); Self { - boundary: r.nested(ns::IPTC_EXT, "RegionBoundary", RegionBoundary::from_fields), + 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", Entity::from_fields), - roles: r.nested_array(ns::IPTC_EXT, "rRole", Entity::from_fields), + 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(), } } @@ -1007,20 +1186,14 @@ fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&[XmpProperty]) /// [`PhotoMetadata::set_creator_contact_info`] does not write an empty structure: a reader would /// otherwise report it as present but blank. fn write_bag(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { - let items: Vec = values + let values: Vec = values .into_iter() .filter(|value| !structure(value).is_some_and(<[XmpProperty]>::is_empty)) - .map(XmpItem::new) .collect(); - if items.is_empty() { - xmp.remove(ns, name); - return; - } - xmp.set(XmpProperty::new( - ns, - name, - XmpValue::Array(XmpArray::Bag(items)), - )); + match nested_array_value(false, values) { + Some(value) => xmp.set(XmpProperty::new(ns, name, value)), + None => drop(xmp.remove(ns, name)), + } } impl PhotoMetadata { @@ -1293,9 +1466,8 @@ mod tests { XmpValue::Structured(_) )); // The entity identifier is `xmp:Identifier`, not an IPTC-namespaced property. - let entities = - Reader::new(fields).nested_array(ns::IPTC_EXT, "rCtype", <[XmpProperty]>::to_vec); - assert!(field(&entities[0], ns::XMP, "Identifier").is_some()); + let entity = structures(&field(fields, ns::IPTC_EXT, "rCtype").unwrap().value)[0]; + assert!(field(entity, ns::XMP, "Identifier").is_some()); } #[test] @@ -1353,15 +1525,27 @@ mod tests { } #[test] - fn lang_alt_fields_read_a_plain_simple_value_too() { - // Non-conformant but seen in the wild: a Lang Alt field written as plain text. + 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", - text_value("Sunflowers"), + 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] @@ -1523,8 +1707,398 @@ mod tests { ); } + /// 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_not_written() { + 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] { @@ -1542,6 +2116,57 @@ mod tests { // 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] @@ -1604,20 +2229,20 @@ mod tests { } #[test] - fn a_language_alternative_is_read_by_its_tag_not_its_position() { - // A conforming writer puts `x-default` first, but nothing in the graph enforces it. Reading - // by position would take the French text and re-emit it labelled as the default. + 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"), - XmpItem::lang_text("X-Default", "Sunflowers"), ])), )]); let art = ArtworkOrObject::from_xmp(&value).unwrap(); - // The tag match is case-insensitive, as XMP Part 1 §8.2.2.4 requires. - assert_eq!(art.title.as_deref(), Some("Sunflowers")); + assert_eq!(art.title, None); + assert_eq!(art.to_xmp(), value); } #[test] @@ -1677,20 +2302,20 @@ mod tests { .set(XmpProperty::new(ns::PLUS, "Licensor", licensor.to_xmp())); assert_eq!(pm.licensors(), vec![licensor]); - // A nested array field is read the same way: one `rCtype` structure, not a Bag of them. - let entity = Entity { - name: Some("Human".to_owned()), - ..Entity::default() - }; + // Inside a structure the same shape is kept verbatim instead, because writing it back + // would normalise it into a Bag: `rCtype` reads as nothing and survives untouched. let region = XmpValue::Structured(vec![XmpProperty::new( ns::IPTC_EXT, "rCtype", - entity.to_xmp(), + Entity { + name: Some("Human".to_owned()), + ..Entity::default() + } + .to_xmp(), )]); - assert_eq!( - ImageRegion::from_xmp(®ion).map(|r| r.content_types), - Some(vec![entity]) - ); + let read = ImageRegion::from_xmp(®ion).expect("a structure value"); + assert_eq!(read.content_types, Vec::new()); + assert_eq!(read.to_xmp(), region); pm.set_licensors(&pm.licensors()); assert!(matches!( From 8669de0d91367eb7527c9f3b0d862879655eedf8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:28:18 -0400 Subject: [PATCH 17/28] fix(iptc): keep every array member and the container kind on the way out The array setters dropped a member that carried no field at all, which shifted every later member's index, so `image_regions()` followed by `set_image_regions()` was not the identity; `put_nested_array`, writing the same kind of array one level down inside `rbVertices`, kept it. Idempotence is the contract the module states, so the two writers now agree on keeping it: nothing to say is a member's business, not the writer's, and only an empty slice removes the property. They also rewrote the property as an `rdf:Bag` whatever it had been, so a caller who wrote an `rdf:Seq` lost the one thing a Seq states that a Bag does not. The setter has the existing property in front of it, so it keeps that kind and falls back to the standard's Bag for anything that is not already an array. --- crates/gamut-iptc/src/extension.rs | 86 ++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 37fcc68f..fe34da04 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -86,6 +86,9 @@ //! at risk there, because reading a property never rewrites it. Inside a structure the same //! leniency would normalise the shape away on the way out, so it is not taken — such a field is //! retained instead, and reads as absent. +//! +//! The array setters write the standard form: an `rdf:Bag`, unless the property they replace is +//! already an `rdf:Seq`, whose order the caller may be relying on. use gamut_xmp::{XML_NAMESPACE, XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; @@ -1179,18 +1182,19 @@ fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&[XmpProperty]) } } -/// Replaces the `Bag` property `ns:name` with `values`, skipping a value that carries no field at -/// all and removing the property when nothing is left to write. +/// Replaces the array property `ns:name` with `values`, and removes it when there is nothing left +/// to write. /// -/// A member with nothing to say is not written, for the reason -/// [`PhotoMetadata::set_creator_contact_info`] does not write an empty structure: a reader would -/// otherwise report it as present but blank. -fn write_bag(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { - let values: Vec = values - .into_iter() - .filter(|value| !structure(value).is_some_and(<[XmpProperty]>::is_empty)) - .collect(); - match nested_array_value(false, values) { +/// The RDF container kind the property already carries is kept — an `rdf:Seq` a caller wrote for +/// its order stays a `Seq` — and anything else becomes the `rdf:Bag` the standard specifies. Every +/// value the caller passes is written, including one that carries no field at all, so that reading +/// an array and setting it back is the identity (see the [module docs](self)). +fn write_array(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { + let ordered = matches!( + xmp.get(ns, name).map(|property| &property.value), + Some(XmpValue::Array(XmpArray::Seq(_))) + ); + match nested_array_value(ordered, values) { Some(value) => xmp.set(XmpProperty::new(ns, name, value)), None => drop(xmp.remove(ns, name)), } @@ -1226,11 +1230,11 @@ impl PhotoMetadata { ) } - /// Sets the image regions (`Iptc4xmpExt:ImageRegion`, an unordered bag); an empty slice - /// removes the property. + /// Sets the image regions (`Iptc4xmpExt:ImageRegion`); an empty slice removes the property, + /// and an existing array keeps its container kind (see [`write_array`]). pub fn set_image_regions(&mut self, regions: &[ImageRegion]) { let values = regions.iter().map(ImageRegion::to_xmp).collect(); - write_bag(&mut self.xmp, ns::IPTC_EXT, "ImageRegion", values); + write_array(&mut self.xmp, ns::IPTC_EXT, "ImageRegion", values); } /// The artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`). @@ -1244,11 +1248,11 @@ impl PhotoMetadata { ) } - /// Sets the artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`, an - /// unordered bag); an empty slice removes the property. + /// Sets the artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`); an empty + /// slice removes the property, and an existing array keeps its container kind. pub fn set_artwork_or_objects(&mut self, artworks: &[ArtworkOrObject]) { let values = artworks.iter().map(ArtworkOrObject::to_xmp).collect(); - write_bag(&mut self.xmp, ns::IPTC_EXT, "ArtworkOrObject", values); + write_array(&mut self.xmp, ns::IPTC_EXT, "ArtworkOrObject", values); } /// The licensors of the image (`plus:Licensor`). @@ -1257,11 +1261,11 @@ impl PhotoMetadata { read_array(&self.xmp, ns::PLUS, "Licensor", Licensor::from_fields) } - /// Sets the licensors of the image (`plus:Licensor`, an unordered bag); an empty slice removes - /// the property. + /// Sets the licensors of the image (`plus:Licensor`); an empty slice removes the property, and + /// an existing array keeps its container kind. pub fn set_licensors(&mut self, licensors: &[Licensor]) { let values = licensors.iter().map(Licensor::to_xmp).collect(); - write_bag(&mut self.xmp, ns::PLUS, "Licensor", values); + write_array(&mut self.xmp, ns::PLUS, "Licensor", values); } } @@ -2263,19 +2267,45 @@ mod tests { } #[test] - fn an_array_element_with_nothing_to_say_is_not_written() { - // The four setters agree: a member that carries no field at all is not written, rather - // than left in the array as an element a reader reports as present but blank. + fn setting_an_array_keeps_the_container_kind_the_property_already_has() { + // Forcing an `rdf:Seq` a caller wrote back to an `rdf:Bag` throws away the one thing a Seq + // states that a Bag does not, and the setter has the existing property in front of it. A + // property that is not an array still becomes the standard Bag. let mut pm = PhotoMetadata::new(); let region = ImageRegion { identifier: Some("r1".to_owned()), ..ImageRegion::default() }; - pm.set_image_regions(&[ImageRegion::default(), region.clone()]); - assert_eq!(pm.image_regions(), vec![region]); - // Nothing but empty members leaves no property at all, as an empty slice does. - pm.set_image_regions(&[ImageRegion::default()]); - assert!(pm.xmp.properties.is_empty()); + pm.xmp.set(XmpProperty::new( + ns::IPTC_EXT, + "ImageRegion", + XmpValue::Array(XmpArray::Seq(vec![XmpItem::new(region.to_xmp())])), + )); + pm.set_image_regions(&pm.image_regions()); + assert!(matches!( + pm.xmp + .get(ns::IPTC_EXT, "ImageRegion") + .expect("the property") + .value, + XmpValue::Array(XmpArray::Seq(_)) + )); + } + + #[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] From bcfca0dcf415b8514ca4e60ac7398fecbde90a29 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:30:40 -0400 Subject: [PATCH 18/28] test(iptc): pin the dataset names to the standard's own name column `iim-4.2.pdf` was called not machine-readable, and the tag table's names were pinned to exiv2's titles on that basis. exiv2 abbreviates 28 of the 70 datasets it shares with gamut, so those rows had to pin both spellings from a hand-kept table - which pins each name to a copy of itself and lets a name mistyped the same way in both places through. The premise is false. The standard sets every DataSet's name in a column of its own, and `pdftotext -bbox-layout` recovers it by position: all 77 rows of chapters 5, 6 and 11-13, including the names set over two or three lines and across a page break. All 71 of gamut's rows agree with it today. `pdftotext` is a system package the toolchain does not provision, so the extraction runs out of band: `tests/data/extract-iim-names.py` writes `tests/data/iim-4.2-dataset-names.tsv`, the artefact is committed, and the test reads it back. Renaming 2:92 to "Sublocation" now fails; before, only a reviewer reading the PDF would have caught it. exiv2 keeps the columns the standard states in prose - octet maximum, repeatability and value kind - and loses the name comparison it could never make well. --- crates/gamut-iptc/STATUS.md | 29 ++-- crates/gamut-iptc/src/iim.rs | 152 ++++++++---------- .../tests/data/extract-iim-names.py | 119 ++++++++++++++ .../tests/data/iim-4.2-dataset-names.tsv | 80 +++++++++ 4 files changed, 287 insertions(+), 93 deletions(-) create mode 100755 crates/gamut-iptc/tests/data/extract-iim-names.py create mode 100644 crates/gamut-iptc/tests/data/iim-4.2-dataset-names.tsv diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index e2179589..146d43cd 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -48,16 +48,25 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe `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 is machine-readable only for 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`, which is not machine-readable. Its guard is - `iim`'s own `tag_table_matches_the_exiv2_dataset_table`, which parses exiv2's independent - transcription of the same chapters out of the vendored `third_party/exiv2` sources and compares - every row column for column — dataset name, octet maximum, repeatability and value kind. A - mis-transcribed maximum or a mistyped name fails there, which no round trip can see. exiv2 titles - 28 of the 70 shared datasets more briefly than IIM 4.2 names them; those rows pin both spellings, - so every name in the table is pinned either way. The structural laws (ordering, uniqueness, the fixed date/time form - lengths) and the exiv2 wire differential in `tests/oracle.rs` sit alongside 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 mistyped name fails there even if it were mistyped the same way twice. + - **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 diff --git a/crates/gamut-iptc/src/iim.rs b/crates/gamut-iptc/src/iim.rs index d95ee924..bcdd41cb 100644 --- a/crates/gamut-iptc/src/iim.rs +++ b/crates/gamut-iptc/src/iim.rs @@ -434,12 +434,11 @@ mod tests { /// 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 its index into the returned literals, so a comma - /// or a brace inside a C++ description cannot be mistaken for punctuation while the literal - /// itself stays readable. - fn index_string_literals(src: &str) -> (String, Vec) { + /// `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 literals: Vec = Vec::new(); + let mut count = 0usize; let mut chars = src.chars(); while let Some(c) = chars.next() { if c != '"' { @@ -455,19 +454,11 @@ mod tests { } } out.push(LITERAL); - out.push_str(&literals.len().to_string()); + out.push_str(&count.to_string()); out.push(LITERAL); - literals.push(literal); + count += 1; } - (out, literals) - } - - /// The first string literal `field` references, if it references one. - fn literal<'a>(field: &str, literals: &'a [String]) -> Option<&'a str> { - let start = field.find(LITERAL)? + LITERAL.len_utf8(); - let end = start + field[start..].find(LITERAL)?; - let index: usize = field[start..end].parse().ok()?; - literals.get(index).map(String::as_str) + out } /// The `static constexpr uint16_t NAME = N;` constants of an exiv2 header. @@ -497,8 +488,6 @@ mod tests { /// One row of exiv2's own IIM dataset table. struct Exiv2DataSet { - /// The dataset's title, exiv2's counterpart of [`IimTagInfo::name`]. - title: String, /// Whether the dataset may repeat within a record. repeatable: bool, /// The maximum value length in octets. @@ -518,8 +507,7 @@ mod tests { 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, literals) = - index_string_literals(&vendored("third_party/exiv2/src/datasets.cpp")); + 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('}') { @@ -539,11 +527,7 @@ mod tests { else { continue; }; - let Some(title) = literal(fields[2], &literals) else { - continue; - }; let row = Exiv2DataSet { - title: title.to_owned(), 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(), @@ -554,49 +538,69 @@ mod tests { table } - /// The datasets exiv2 titles more briefly than IIM 4.2 names them. Each row pins BOTH - /// spellings — `((record, dataset), the IIM 4.2 name gamut carries, exiv2's title)` — so it - /// self-invalidates if either side changes, and every name in the table is pinned to something - /// either way. - #[rustfmt::skip] - const NAME_ABBREVIATIONS: &[((u8, u8), &str, &str)] = &[ - ((1, 22), "File Format Version", "File Version"), - ((1, 30), "Service Identifier", "Service Id"), - ((1, 50), "Product I.D.", "Product Id"), - ((1, 90), "Coded Character Set", "Character Set"), - ((1, 100), "UNO", "Unique Name Object"), - ((2, 3), "Object Type Reference", "Object Type"), - ((2, 4), "Object Attribute Reference", "Object Attribute"), - ((2, 12), "Subject Reference", "Subject"), - ((2, 22), "Fixture Identifier", "Fixture Id"), - ((2, 26), "Content Location Code", "Location Code"), - ((2, 27), "Content Location Name", "Location Name"), - ((2, 38), "Expiration Time", "ExpirationTime"), - ((2, 62), "Digital Creation Date", "Digitization Date"), - ((2, 63), "Digital Creation Time", "Digitization Time"), - ((2, 65), "Originating Program", "Program"), - ((2, 92), "Sub-location", "Sub Location"), - ((2, 95), "Province/State", "Province State"), - ((2, 100), "Country/Primary Location Code", "Country Code"), - ((2, 101), "Country/Primary Location Name", "Country Name"), - ((2, 103), "Original Transmission Reference", "Transmission Reference"), - ((2, 116), "Copyright Notice", "Copyright"), - ((2, 120), "Caption/Abstract", "Caption"), - ((2, 122), "Writer/Editor", "Writer"), - ((2, 135), "Language Identifier", "Language"), - ((2, 151), "Audio Sampling Rate", "Audio Rate"), - ((2, 152), "Audio Sampling Resolution", "Audio Resolution"), - ((2, 200), "ObjectData Preview File Format", "Preview Format"), - ((2, 201), "ObjectData Preview File Format Version", "Preview Version"), - ]; + /// 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() { - // Drift guard. `iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a hand - // transcription with nothing but ordering and uniqueness to catch a slipped digit or a - // mistyped name. exiv2 — the crate's differential oracle — carries its own independent - // transcription of the same spec chapters, so comparing the two tables column for column - // (name, octet maximum, repeatability, value type) catches exactly that. + // 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, @@ -605,7 +609,7 @@ mod tests { ); 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 PDF alone. + // 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"), @@ -616,17 +620,6 @@ mod tests { 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"); - // exiv2 abbreviates some titles, so those rows pin both spellings instead of matching. - match NAME_ABBREVIATIONS - .iter() - .find(|&&(key, ..)| key == (t.record, t.dataset)) - { - Some(&(_, name, title)) => { - assert_eq!(t.name, name, "{where_}: dataset name"); - assert_eq!(row.title, title, "{where_}: exiv2 title"); - } - None => assert_eq!(t.name, row.title, "{where_}: dataset name"), - } 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 @@ -650,13 +643,6 @@ mod tests { "exiv2 documents {record}:{dataset} but gamut does not name it" ); } - // A stale abbreviation would silently stop pinning anything, so every row must be live. - for &(key, ..) in NAME_ABBREVIATIONS { - assert!( - KNOWN_TAGS.iter().any(|t| (t.record, t.dataset) == key) && exiv2.contains_key(&key), - "{key:?} is pinned as an abbreviation but is not in both tables" - ); - } } #[test] 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 From 3161d89b8a0c9989fa12d707813ff8765fb459ae Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:31:07 -0400 Subject: [PATCH 19/28] docs(iptc): state the fidelity rule the code now follows The crate docs described retention as covering "every field its typed read took no value from", which is the read-side rule the extension module no longer uses. Say what decides it: whether the property the writer will emit reproduces the field that was read, and what the two remaining differences are. --- crates/gamut-iptc/README.md | 8 ++++---- crates/gamut-iptc/STATUS.md | 13 +++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index 1cec3570..37b9aafe 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -84,10 +84,10 @@ The v1 contract, stated precisely: (issue #34). - **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`). Each typed structure keeps in its `other` - list every field its typed read took no value from — one the model does not name, and equally one - it names but cannot read — and re-emits it verbatim, so reading one and writing it back drops - neither a vendor extension nor a value the model cannot express. The remaining Extension structures (locations, persons, + 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 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, diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index 146d43cd..a6cd375a 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -39,10 +39,15 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe `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. Each of the seven types keeps in its - `other` list every field its typed read took no value from — a field the model does not name, and - equally one it names but cannot read — and re-emits it verbatim, so a read-modify-write does not - drop a vendor extension or a value the model cannot express. + 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 From e2813af9aa87de6b536f31076a30f65999a00b8f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:34:30 -0400 Subject: [PATCH 20/28] test(iptc): state the reproduction relation on its own The retention rule reads whether the writer would give the field back, so the relation that decides it needs a test that fails for that one reason. Replacing the numeric comparison with a blanket "any two texts match" left every round trip in the module passing, because the writer never emits a different text for a field it did not re-spell. --- crates/gamut-iptc/src/extension.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index fe34da04..67a10def 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -1711,6 +1711,29 @@ mod tests { ); } + #[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")) + )); + } + /// 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. From f1b5b80e6844894387bf2dc3b6a3d66f44bf03bc Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:37:32 -0400 Subject: [PATCH 21/28] docs(iptc): record what the reproduction check costs --- crates/gamut-iptc/src/extension.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 67a10def..ca7715a1 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -74,6 +74,11 @@ //! 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 From 85dbfea5ee0a004ab23b4d4152d253cddc6f2e13 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:40:34 -0400 Subject: [PATCH 22/28] docs(iptc): list the reads the projection gives up as a deferral --- crates/gamut-iptc/STATUS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index a6cd375a..ff8fbdda 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -77,6 +77,11 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe Intentional, documented skips — none lose data on round-trip: +- **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. - **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 From 64a4e1e84fd0d9bf73d1715cc2c58bbc6216a9f4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:44:04 -0400 Subject: [PATCH 23/28] docs(iptc): name the two shapes the fidelity list left out The module lists what retention covers; the derived enumeration in the tests covers thirteen shapes and the prose covered eleven. Plain text written where a language alternative belongs, and an rdf:Bag written where the model writes an rdf:Seq, were both missing. --- crates/gamut-iptc/src/extension.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index ca7715a1..d0bd367a 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -53,10 +53,14 @@ //! 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 -//! with entries beyond the default, an `rdf:Seq` or `rdf:Alt` where the model writes an -//! `rdf:Bag`, 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. +//! 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. From 7aee9f0f7a6730b1730aa8ccbf3be4c121d3ceea Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:25:36 -0400 Subject: [PATCH 24/28] test(iptc): state each clause of the reproduction relation on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relation deciding retention was pinned only by its numeric clause, so four of its parts were free to change without a test noticing: a URI matched any URI, a structure matched any structure of the same field count, a qualifier list matched any list of the same length, and every qualifier was treated as a language tag and matched case-insensitively. Each of those turns a field the writer cannot reproduce into a field marked consumed, which is the destruction the rule exists to stop. One test per clause, each failing for its own reason: the URI target, the structure's fields as an unordered set, the qualifier list matched pairwise, and the language tag as the one qualifier XMP Part 1 §8.2.2.4 matches without regard to case - neither the `xml:` namespace alone nor the name `lang` alone makes one. --- crates/gamut-iptc/src/extension.rs | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index d0bd367a..4bf1991f 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -1743,6 +1743,95 @@ mod tests { )); } + #[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. From 660c3bb363cff10061513051a8ac9baad832d4ff Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:32:37 -0400 Subject: [PATCH 25/28] fix(iptc): keep a top-level property the setter cannot reproduce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reproduction rule stopped at the structure boundary. Inside a structure a field is consumed only when the property the writer emits for the value read gives that field back; the four accessor pairs on `PhotoMetadata` applied no such gate, and the setter is the other half of the pair a caller reads through. Executed on a serialized packet, read then set-back silently dropped what a file's producer had put there: an array member that is not a structure, a qualifier on the property, a qualifier on an `rdf:li`, an `rdf:Alt` rewritten to `rdf:Bag`, a bare structure rewritten to a Bag, an array or structure holding nothing at all, and a qualifier on the contact-info property. Only `rdf:Seq` survived, and only because a test happened to pin it. The gate moves up: one `Projection` per property reads a value only when what the setter will emit reproduces the property that was read, and the setter removes a property only when the read reported one — the graph itself is the retention list a structure keeps in its `other` field. An array setter now keeps the container kind the property already carries, `rdf:Alt` as well as `rdf:Seq`, and two top-level properties of one name are neither read nor lost, as two fields of one name already were inside a structure. Retention, not merging: a setter handed values still replaces the property, so a caller that expects it to replace is not surprised. --- crates/gamut-iptc/src/extension.rs | 332 ++++++++++++++++++++--------- 1 file changed, 237 insertions(+), 95 deletions(-) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 4bf1991f..1560e74e 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -88,16 +88,27 @@ //! modelled value stays the authority when there is one, and the retained field is written when it //! is the only copy. //! -//! # Lenient at the top level, exact inside a structure +//! # The same rule at both levels //! -//! [`PhotoMetadata`]'s array accessors accept the shapes seen in the wild: a bare structure written -//! where the standard puts an array of structures reads as that array's single element. Nothing is -//! at risk there, because reading a property never rewrites it. Inside a structure the same -//! leniency would normalise the shape away on the way out, so it is not taken — such a field is -//! retained instead, and reads as absent. +//! [`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. //! -//! The array setters write the standard form: an `rdf:Bag`, unless the property they replace is -//! already an `rdf:Seq`, whose order the caller may be relying on. +//! 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. + +use std::borrow::Borrow; use gamut_xmp::{XML_NAMESPACE, XmpArray, XmpItem, XmpMeta, XmpProperty, XmpValue}; @@ -1182,99 +1193,197 @@ impl ImageRegion { // --- The accessors on the unified view --------------------------------------------------------- -/// Reads every structure of the `Bag`/`Seq` property `ns:name` through `parse`, tolerating a bare -/// structure written where the array should be (see the [module docs](self)). -fn read_array(xmp: &XmpMeta, ns: &str, name: &str, parse: fn(&[XmpProperty]) -> T) -> Vec { - match xmp.get(ns, name) { - Some(property) => structures(&property.value).into_iter().map(parse).collect(), - None => Vec::new(), +/// 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, } } -/// Replaces the array property `ns:name` with `values`, and removes it when there is nothing left -/// to write. +/// 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. /// -/// The RDF container kind the property already carries is kept — an `rdf:Seq` a caller wrote for -/// its order stays a `Seq` — and anything else becomes the `rdf:Bag` the standard specifies. Every -/// value the caller passes is written, including one that carries no field at all, so that reading -/// an array and setting it back is the identity (see the [module docs](self)). -fn write_array(xmp: &mut XmpMeta, ns: &str, name: &str, values: Vec) { - let ordered = matches!( - xmp.get(ns, name).map(|property| &property.value), - Some(XmpValue::Array(XmpArray::Seq(_))) - ); - match nested_array_value(ordered, values) { - Some(value) => xmp.set(XmpProperty::new(ns, name, value)), - None => drop(xmp.remove(ns, name)), +/// `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`). + /// 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 { - CreatorContactInfo::from_xmp(&self.xmp.get(ns::IPTC_CORE, "CreatorContactInfo")?.value) + 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. + /// 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) { - let value = info.to_xmp(); - if structure(&value).is_some_and(<[XmpProperty]>::is_empty) { - self.xmp.remove(ns::IPTC_CORE, "CreatorContactInfo"); - return; - } - self.xmp - .set(XmpProperty::new(ns::IPTC_CORE, "CreatorContactInfo", value)); + CONTACT_INFO.write(&mut self.xmp, info); } - /// The image regions (`Iptc4xmpExt:ImageRegion`), in the order the graph holds them. + /// 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 { - read_array( - &self.xmp, - ns::IPTC_EXT, - "ImageRegion", - ImageRegion::from_fields, - ) + IMAGE_REGIONS.read(&self.xmp).unwrap_or_default() } - /// Sets the image regions (`Iptc4xmpExt:ImageRegion`); an empty slice removes the property, - /// and an existing array keeps its container kind (see [`write_array`]). + /// 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]) { - let values = regions.iter().map(ImageRegion::to_xmp).collect(); - write_array(&mut self.xmp, ns::IPTC_EXT, "ImageRegion", values); + IMAGE_REGIONS.write(&mut self.xmp, regions); } - /// The artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`). + /// 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 { - read_array( - &self.xmp, - ns::IPTC_EXT, - "ArtworkOrObject", - ArtworkOrObject::from_fields, - ) + ARTWORK_OR_OBJECTS.read(&self.xmp).unwrap_or_default() } - /// Sets the artworks or objects shown in the image (`Iptc4xmpExt:ArtworkOrObject`); an empty - /// slice removes the property, and an existing array keeps its container kind. + /// 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]) { - let values = artworks.iter().map(ArtworkOrObject::to_xmp).collect(); - write_array(&mut self.xmp, ns::IPTC_EXT, "ArtworkOrObject", values); + ARTWORK_OR_OBJECTS.write(&mut self.xmp, artworks); } - /// The licensors of the image (`plus:Licensor`). + /// The licensors of the image (`plus:Licensor`), on the same terms as + /// [`image_regions`](Self::image_regions). #[must_use] pub fn licensors(&self) -> Vec { - read_array(&self.xmp, ns::PLUS, "Licensor", Licensor::from_fields) + LICENSORS.read(&self.xmp).unwrap_or_default() } - /// Sets the licensors of the image (`plus:Licensor`); an empty slice removes the property, and - /// an existing array keeps its container kind. + /// 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]) { - let values = licensors.iter().map(Licensor::to_xmp).collect(); - write_array(&mut self.xmp, ns::PLUS, "Licensor", values); + LICENSORS.write(&mut self.xmp, licensors); } } @@ -2389,26 +2498,40 @@ mod tests { #[test] fn setting_an_array_keeps_the_container_kind_the_property_already_has() { - // Forcing an `rdf:Seq` a caller wrote back to an `rdf:Bag` throws away the one thing a Seq - // states that a Bag does not, and the setter has the existing property in front of it. A - // property that is not an array still becomes the standard Bag. - let mut pm = PhotoMetadata::new(); + // 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() }; - pm.xmp.set(XmpProperty::new( - ns::IPTC_EXT, - "ImageRegion", - XmpValue::Array(XmpArray::Seq(vec![XmpItem::new(region.to_xmp())])), - )); - pm.set_image_regions(&pm.image_regions()); + 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::Seq(_)) + XmpValue::Array(XmpArray::Bag(_)) )); } @@ -2441,9 +2564,10 @@ mod tests { } #[test] - fn a_bare_structure_reads_as_a_one_element_sequence() { - // Seen in the wild: a single structure written where the standard puts a Bag. Lenient on - // read, strict on write — the Bag comes back on the way out. + 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() @@ -2451,10 +2575,9 @@ mod tests { let mut pm = PhotoMetadata::new(); pm.xmp .set(XmpProperty::new(ns::PLUS, "Licensor", licensor.to_xmp())); - assert_eq!(pm.licensors(), vec![licensor]); + assert_eq!(pm.licensors(), Vec::new()); - // Inside a structure the same shape is kept verbatim instead, because writing it back - // would normalise it into a Bag: `rCtype` reads as nothing and survives untouched. + // 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", @@ -2466,18 +2589,13 @@ mod tests { )]); let read = ImageRegion::from_xmp(®ion).expect("a structure value"); assert_eq!(read.content_types, Vec::new()); - assert_eq!(read.to_xmp(), region); - - pm.set_licensors(&pm.licensors()); - assert!(matches!( - pm.xmp.get(ns::PLUS, "Licensor").unwrap().value, - XmpValue::Array(XmpArray::Bag(_)) - )); } #[test] - fn a_non_structure_array_item_is_skipped_not_fatal() { - // Hostile/odd input: a Bag holding plain text where a structure is expected. + 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, @@ -2493,9 +2611,7 @@ mod tests { ), ])), )); - let regions = pm.image_regions(); - assert_eq!(regions.len(), 1); - assert_eq!(regions[0].identifier.as_deref(), Some("r1")); + 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, @@ -2504,4 +2620,30 @@ mod tests { )); 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); + } } From ff2b36ea64eb28530bd71525485b8f5874db05ce Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:32:45 -0400 Subject: [PATCH 26/28] test(iptc): cross every accessor pair with every shape a graph can carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thirteen-row table of shapes is only as complete as the shapes someone thought of, and the shapes this round found at the top level were not on it. So the pin is generated, not listed: the four accessor pairs crossed with fifteen value shapes and four qualifier lists — 240 cases — each asserting that reading the property and writing what was read straight back gives the whole graph back, the property under test and the unrelated one beside it. The two tests that pinned a read-side skip with no writer opposite are restated as what the rule now says, and the container-kind test covers all three kinds rather than the one that had a test. --- crates/gamut-iptc/src/extension.rs | 187 +++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index 1560e74e..d581b85d 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -2646,4 +2646,191 @@ mod tests { 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); + } } From 7d8eb755d0f00b5c22ebd0be37e120522d490a04 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:34:04 -0400 Subject: [PATCH 27/28] docs(iptc): say what the name guard does not catch "A mistyped name fails there even if it were mistyped the same way twice" is half-true. The guard compares `KNOWN_TAGS` against the committed extraction, and nothing re-derives the extraction from the PDF, so a name mistyped identically in both places passes. What carries that residual is the artefact's own never-hand-edit banner, not a gate; the gate that would close it is filed as The deferral list gains the top-level half of the fidelity rule, which now matches the structure-internal half: a property the setter could not write back reads as absent and is left untouched rather than rewritten. --- crates/gamut-iptc/README.md | 3 ++- crates/gamut-iptc/STATUS.md | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/gamut-iptc/README.md b/crates/gamut-iptc/README.md index 37b9aafe..a17b8c42 100644 --- a/crates/gamut-iptc/README.md +++ b/crates/gamut-iptc/README.md @@ -87,7 +87,8 @@ The v1 contract, stated precisely: 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 remaining Extension structures (locations, persons, + 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, diff --git a/crates/gamut-iptc/STATUS.md b/crates/gamut-iptc/STATUS.md index ff8fbdda..5b8cabb4 100644 --- a/crates/gamut-iptc/STATUS.md +++ b/crates/gamut-iptc/STATUS.md @@ -64,7 +64,11 @@ schema/tag tables are additionally pinned to the IPTC machine-readable tech refe 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 mistyped name fails there even if it were mistyped the same way twice. + 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 @@ -82,6 +86,14 @@ Intentional, documented skips — none lose data on round-trip: 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 From cd0d69691eff17ae477da7db679be386985e0a39 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 12:04:32 -0400 Subject: [PATCH 28/28] docs(iptc): say that a setter handed values replaces the property outright --- crates/gamut-iptc/src/extension.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/gamut-iptc/src/extension.rs b/crates/gamut-iptc/src/extension.rs index d581b85d..442927a4 100644 --- a/crates/gamut-iptc/src/extension.rs +++ b/crates/gamut-iptc/src/extension.rs @@ -107,6 +107,13 @@ //! 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;