diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs
index e2a160904f..07b92dee8a 100644
--- a/crates/preflight/src/tests.rs
+++ b/crates/preflight/src/tests.rs
@@ -572,6 +572,12 @@ fn xmp_arrays_have_their_kind_and_item_types() {
assert_eq!(element("dc:creator", "A"), ["dc:creator"]);
assert_eq!(element("dc:date", "2016-02-01yesterday"), ["dc:date"]);
assert_eq!(element("dc:date", "yesterday"), ["dc:date"]);
+ // Only white space around an array's items.
+ assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]);
+ assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]);
+ assert!(element("dc:creator", "\n \n A\n \n").is_empty());
+ // An rdf:resource element is empty.
+ assert_eq!(mistyped(r#">x"#), ["dc:source"]);
// Language alternatives: an rdf:Alt whose items all carry xml:lang.
assert!(element("dc:title", r#"T"#).is_empty());
assert_eq!(element("dc:title", "T"), ["dc:title"]);
@@ -587,6 +593,11 @@ fn xmp_structures_have_their_field_types() {
assert!(mistyped(r#">"#).is_empty());
assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]);
assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]);
+ assert_eq!(
+ mistyped(r#">letter"#),
+ ["xmpMM:DerivedFrom"]
+ );
+ assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]);
// Predefined properties inside a Pantry item keep their types.
let pantry = |v: &str| {
mistyped(&format!(
@@ -617,6 +628,16 @@ fn xmp_structures_have_their_field_types() {
// rdf:value as an attribute of the description.
assert!(mistyped(r#">"#).is_empty());
assert_eq!(mistyped(r#">"#), ["crs:Contrast"]);
+ // rdf:value as an attribute of an empty property element.
+ assert!(mistyped(r#">"#).is_empty());
+ assert_eq!(mistyped(r#">"#), ["crs:Contrast"]);
+ // Qualifiers that are predefined properties keep their types.
+ assert_eq!(
+ mistyped(r#">5high"#),
+ ["crs:Contrast"]
+ );
+ assert_eq!(mistyped(r#">"#), ["crs:Contrast"]);
+ assert!(mistyped(r#">"#).is_empty());
// Nesting past any type's depth doesn't have the type.
let nested = |levels: usize| {
let open = r#""#.repeat(levels);
diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs
index 55f542929c..33b5d261d8 100644
--- a/crates/preflight/src/xmp_types.rs
+++ b/crates/preflight/src/xmp_types.rs
@@ -539,20 +539,27 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
[d] if is(d, RDF, "Description") => Some(*d),
_ => None,
};
- let fields_of = description.unwrap_or(*e);
- if (resource || description.is_some())
- && let Some(v) = fields_of.children().find(|c| is(c, RDF, "value"))
+ // `rdf:value` as an element of a `parseType="Resource"` node or `rdf:Description`, or as an
+ // attribute of the description or of an empty property element. Its qualifiers that are
+ // predefined properties keep their types too.
+ let node = if resource { Some(*e) } else { description };
+ if let Some(n) = node
+ && let Some(v) = n.children().find(|c| is(c, RDF, "value"))
{
- return fits(ty, &v, deeper);
+ return blank(e) && blank(&n) && fits(ty, &v, deeper) && predefined_ok(&n, deeper);
}
- if let Some(v) = description.and_then(|d| d.attribute((RDF, "value"))) {
- return simple(ty, v);
+ let attribute_node = description.or_else(|| (children.is_empty() && !resource).then_some(*e));
+ if let Some(n) = attribute_node
+ && let Some(v) = n.attribute((RDF, "value"))
+ {
+ return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper);
}
match ty {
Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => {
// `rdf:resource` makes the value a URI, which only a text type takes.
+ // The element is then empty.
if e.attribute((RDF, "resource")).is_some() {
- return matches!(ty, Ty::Text);
+ return matches!(ty, Ty::Text) && children.is_empty() && blank(e);
}
children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e))
}
@@ -585,20 +592,22 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
}
// A structure of any fields, such as a Pantry item: predefined properties in it still have
// their types.
- Ty::AnyStruct => {
- let Some(holder) = structure(e, &children, resource, description) else { return false };
- let attrs_ok =
- field_attributes(&holder).all(|a| a.namespace().and_then(|ns| property(ns, a.name())).is_none_or(|(_, t)| simple(t, a.value())));
- let elements_ok = holder.children().filter(Node::is_element).all(|f| {
- let n = f.tag_name();
- n.namespace().and_then(|ns| property(ns, n.name())).is_none_or(|(_, t)| fits(t, &f, deeper))
- });
- attrs_ok && elements_ok
- }
+ Ty::AnyStruct => structure(e, &children, resource, description).is_some_and(|holder| predefined_ok(&holder, deeper)),
Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)),
}
}
+/// Whether the fields of `holder`, as attributes and elements, that are predefined properties have
+/// their types.
+fn predefined_ok(holder: &Node<'_, '_>, depth: usize) -> bool {
+ let attrs_ok = field_attributes(holder).all(|a| a.namespace().and_then(|ns| property(ns, a.name())).is_none_or(|(_, t)| simple(t, a.value())));
+ let elements_ok = holder.children().filter(Node::is_element).all(|f| {
+ let n = f.tag_name();
+ n.namespace().and_then(|ns| property(ns, n.name())).is_none_or(|(_, t)| fits(t, &f, depth))
+ });
+ attrs_ok && elements_ok
+}
+
fn field(fields: &[Field], ns: &str, name: &str) -> Option {
fields.iter().find(|(n, l, _)| *n == ns && *l == name).map(|(_, _, t)| *t)
}
@@ -606,8 +615,9 @@ fn field(fields: &[Field], ns: &str, name: &str) -> Option {
/// The items of `e`'s value when it is an `rdf:` array.
fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Option>> {
match children {
- // Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element breaks the array.
- [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() => {
+ // Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element, or character data around
+ // them, breaks the array.
+ [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) => {
let items: Vec> = a.children().filter(Node::is_element).collect();
items.iter().all(|i| is(i, RDF, "li")).then_some(items)
}
@@ -622,13 +632,18 @@ fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool
return text(e).trim().is_empty().then_some(*e);
}
if let Some(d) = description {
- return Some(d);
+ return (blank(e) && blank(&d)).then_some(d);
}
// Fields written as attributes of the property element, with no content.
let empty = text(e).trim().is_empty();
(children.is_empty() && empty && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e)
}
+/// Whether `e` holds no character data but white space.
+fn blank(e: &Node<'_, '_>) -> bool {
+ text(e).trim().is_empty()
+}
+
/// All the character data directly in `e`, however comments or other nodes split it.
fn text(e: &Node<'_, '_>) -> String {
e.children().filter(Node::is_text).filter_map(|t| t.text()).collect()