From ba4e86c5477e2cfc72de377f36588dab33b9d013 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:27:27 +0800 Subject: [PATCH] M11: PDF/A verify checks XMP value types in every metadata stream, more strictly (6.6.2.3.1) Every metadata stream is read, within one decode allowance, not only the catalog's. Predefined properties inside a Pantry item keep their types; rdf:value may be an attribute; rdf:resource makes a URI, which only a text type takes; a value's text is all its text nodes; an array item is rdf:li; a parseType="Resource" structure has no character data; a time zone is Z, +hh:mm or -hh:mm; and a value nested past every type's depth doesn't have its type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 9 ++-- crates/preflight/src/tests.rs | 87 +++++++++++++++++++++++++++---- crates/preflight/src/xmp_types.rs | 78 ++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 29 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 8d6d1cd9c2..234c6c10b3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -449,11 +449,6 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } - if let Some(parsed) = xmp::parse(&x) { - for name in xmp_types::mistyped(&parsed) { - issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); - } - } if let Some(title) = info(doc, "Title") && xmp::value(&x, "dc:title").is_some_and(|t| t != title) { @@ -467,6 +462,10 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // 6.2.2: output intent when device colour is used. let objs = objects(doc); + // 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream. + for name in xmp_types::mistyped_in(&objs) { + issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); + } let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index ab1686be55..b18d033c9e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -531,11 +531,25 @@ fn xmp_simple_values_have_their_lexical_forms() { { assert!(element("xmp:CreateDate", good).is_empty(), "{good}"); } - for bad in ["16-02-01", "2016-13-01", "2016-02-32", "2016-02T13:19", "2016-02-01T25:00", "2016-02-01T13:19:21+1:00", "Feb 1 2016"] { + for bad in [ + "16-02-01", + "2016-13-01", + "2016-02-32", + "2016-02T13:19", + "2016-02-01T25:00", + "2016-02-01T13:19:21+1:00", + "Feb 1 2016", + "2016-02-01T13:19Z01:00", + ] { assert_eq!(element("xmp:CreateDate", bad), ["xmp:CreateDate"], "{bad}"); } assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty()); assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]); + // The whole text of a value counts, however a comment splits it. + assert_eq!(element("crs:Contrast", "5.0"), ["crs:Contrast"]); + assert!(element("pdf:Trapped", "True").is_empty()); + // rdf:resource makes a URI, which only a text type takes. + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); } /// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative. @@ -550,6 +564,7 @@ fn xmp_arrays_have_their_kind_and_item_types() { assert_eq!(element("dc:creator", "A"), ["dc:creator"]); 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"]); // 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"]); @@ -564,6 +579,15 @@ fn xmp_structures_have_their_field_types() { assert!(mistyped(r#">"#).is_empty()); assert!(mistyped(r#">"#).is_empty()); assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + // Predefined properties inside a Pantry item keep their types. + let pantry = |v: &str| { + mistyped(&format!( + r#">{v}"# + )) + }; + assert!(pantry("5").is_empty()); + assert_eq!(pantry("high"), ["xmpMM:Pantry"]); assert_eq!( mistyped(r#">soon"#), ["xmpMM:DerivedFrom"] @@ -583,18 +607,61 @@ fn xmp_structures_have_their_field_types() { // A qualified value is checked by its rdf:value. assert!(mistyped(r#">5q"#).is_empty()); assert_eq!(mistyped(r#">five"#), ["crs:Contrast"]); + // rdf:value as an attribute of the description. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // Nesting past any type's depth doesn't have the type. + let nested = |levels: usize| { + let open = r#""#.repeat(levels); + let close = "".repeat(levels); + mistyped(&format!(r#">{open}5{close}"#)) + }; + assert!(nested(3).is_empty()); + assert_eq!(nested(40), ["crs:Contrast"]); } /// PDF/A-2 6.6.2.3.1 through verify: one issue per mistyped property, not fixable. #[test] fn xmp_value_types_are_reported_by_verify() { - // Through verify: reported under 6.6.2.3.1, not fixable. - let xmp = - format!(r#""#); - let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); - let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); - let issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect(); - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].message, "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it"); - assert!(!issues[0].fixable); + let metadata = |body: &str| { + let xmp = format!(r#""#); + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()) + }; + let issues = |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect() }; + // Every mistyped property, each once, in name order; not fixable. + let catalog = metadata(r#"crs:Contrast="high" crs:Exposure="bright""#); + let page = metadata(r#"crs:Contrast="higher""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &catalog, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + let found = issues(&doc); + let messages: Vec<&str> = found.iter().map(|i| i.message.as_str()).collect(); + assert_eq!( + messages, + [ + "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it", + "The XMP property crs:Exposure doesn't have the value type the XMP Specification gives it" + ] + ); + assert!(found.iter().all(|i| !i.fixable)); + // A metadata stream of a page counts as well as the catalog's. + let good = metadata(r#"crs:Contrast="5""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &good, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + assert_eq!(issues(&doc).len(), 1); } diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index af7a703d07..d11c947926 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -7,7 +7,8 @@ //! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it //! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or //! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a -//! qualified value through its `rdf:value`. +//! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the +//! document is read, not only the catalog's, within one decode allowance. //! //! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or //! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms @@ -17,10 +18,14 @@ use std::collections::BTreeSet; +use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; use roxmltree::Node; -/// How deep arrays and structures are followed. +/// How deep arrays, structures and qualifiers are followed. No type nests nearly this deep, so a +/// value nested deeper doesn't have its type. const MAX_DEPTH: usize = 32; +/// The decoded bytes all metadata streams of a document may take together. +const MAX_METADATA_BYTES: usize = 64 << 20; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; const XML: &str = "http://www.w3.org/XML/1998/namespace"; @@ -442,8 +447,30 @@ const SCHEMAS: &[Schema] = &[ }, ]; -/// The properties of predefined schemas in `doc` whose values don't have their type, as -/// `prefix:name` in name order. +/// The properties of predefined schemas whose values don't have their type, in every metadata +/// stream among the indirect objects `objs` (ISO 32000-1 14.3.2), as `prefix:name` in name order. +pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); + for (_, o) in objs { + let Object::Stream(s) = &**o else { continue }; + if s.dict.name(b"Type") != Some(b"Metadata") { + continue; + } + if budget.remaining() == 0 { + break; + } + let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; + let text = String::from_utf8_lossy(&bytes); + if let Some(parsed) = crate::xmp::parse(&text) { + out.extend(mistyped(&parsed)); + } + } + out +} + +/// The properties of predefined schemas in the packet `doc` whose values don't have their type, +/// as `prefix:name` in name order. pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeSet { let mut out = BTreeSet::new(); let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF"))); @@ -484,7 +511,7 @@ fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator, depth: usize) -> bool { if depth > MAX_DEPTH { - return true; + return false; } let deeper = depth + 1; let children: Vec> = e.children().filter(Node::is_element).collect(); @@ -500,12 +527,16 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { { return fits(ty, &v, deeper); } + if let Some(v) = description.and_then(|d| d.attribute((RDF, "value"))) { + return simple(ty, v); + } match ty { Ty::Text | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { - if let Some(uri) = e.attribute((RDF, "resource")) { - return simple(ty, uri); + // `rdf:resource` makes the value a URI, which only a text type takes. + if e.attribute((RDF, "resource")).is_some() { + return matches!(ty, Ty::Text); } - children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, e.text().unwrap_or_default()) + children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e)) } Ty::LangAlt => match array(e, &children, "Alt") { Some(items) => items.iter().all(|i| i.attribute((XML, "lang")).is_some() && fits(Ty::Text, i, deeper)), @@ -534,7 +565,18 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { }); attrs_ok && elements_ok } - Ty::AnyStruct => structure(e, &children, resource, description).is_some(), + // 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::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)), } } @@ -546,8 +588,10 @@ 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() => { - Some(a.children().filter(Node::is_element).filter(|i| is(i, RDF, "li")).collect()) + let items: Vec> = a.children().filter(Node::is_element).collect(); + items.iter().all(|i| is(i, RDF, "li")).then_some(items) } _ => None, } @@ -556,14 +600,20 @@ fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Opt /// The node holding the fields of `e`'s value when it is a structure. fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool, description: Option>) -> Option> { if resource { - return Some(*e); + // A structure's fields are elements; character data has no place in it. + return text(e).trim().is_empty().then_some(*e); } if let Some(d) = description { return Some(d); } // Fields written as attributes of the property element, with no content. - let text = e.text().unwrap_or_default().trim().is_empty(); - (children.is_empty() && text && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e) + 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) +} + +/// 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() } /// Whether the simple value `v` has the simple type `ty`. @@ -624,7 +674,7 @@ fn date(v: &str) -> bool { }; let zone_ok = match zone { "" | "Z" => true, - z => z.get(1..).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)), + z => z.strip_prefix(['+', '-']).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)), }; let clock_ok = match clock.split(':').collect::>().as_slice() { [h, m] => num(h, 2, 23) && num(m, 2, 59),