From c86b161672570784879c88805ebdfd9e372a5dcc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:30:17 +0800 Subject: [PATCH] M11: PDF/A verify checks XMP qualifiers and empty value forms, and rejects stray text (6.6.2.3.1) Qualifiers beside an rdf:value that are predefined properties keep their types; rdf:value may be an attribute of an empty property element; an rdf:resource element is empty; and character data around array items or in an rdf:Description structure breaks the value, as it already did in a parseType="Resource" structure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 21 ++++++++++++ crates/preflight/src/xmp_types.rs | 55 ++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 20 deletions(-) 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()