From 1f3fc2ce926c25199830c5e0bd26843df313645b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:20:56 +0800 Subject: [PATCH] M11: PDF/A verify checks XMP extension schema descriptions (6.6.2.3.3) Every pdfaExtension:schemas description needs its entries: a schema its schema, namespaceURI and prefix; a property its name, valueType, category and description; a value type its type, namespaceURI, prefix, description and field; a field its name, valueType and description. Each description namespace is written with its own prefix. One pass over the metadata streams serves this check and the value types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 19 ++++- crates/preflight/src/tests.rs | 104 +++++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 114 ++++++++++++++++++++++++++ crates/preflight/src/xmp_types.rs | 10 +-- 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 crates/preflight/src/xmp_extension.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 234c6c10b3..d323606661 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -23,6 +23,7 @@ pub mod icc; mod xmp; +mod xmp_extension; mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -115,6 +116,8 @@ pub const WORDINGS: &[&str] = &[ "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", "The XMP property {} doesn't have the value type the XMP Specification gives it", + "An XMP extension schema description has no {} entry", + "An XMP extension schema description writes the {} namespace with another prefix", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -462,10 +465,22 @@ 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) { + // 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in + // every metadata stream. + let (mut mistyped, mut extension) = (std::collections::BTreeSet::new(), xmp_extension::Findings::default()); + xmp_types::for_each_packet(&objs, |packet| { + mistyped.extend(xmp_types::mistyped(packet)); + extension.add(packet); + }); + for name in mistyped { issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); } + for entry in extension.missing { + issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false); + } + for prefix in extension.prefixes { + issue("6.6.2.3.3", "An XMP extension schema description writes the {} namespace with another prefix", Some(prefix.to_owned()), 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 51b1e3d85b..ddd2ce3502 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -764,3 +764,107 @@ fn xmp_exif_schemas_have_their_value_types() { assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); } + +/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five +/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default). +fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static str>) { + let [s, p, t, f] = prefixes; + let xmp = format!( + concat!( + r#""#, + r#""#, + r#"{schema}"#, + r#""# + ), + s = s, + p = p, + t = t, + f = f, + schema = schema + ); + let parsed = xmp::parse(&xmp).expect("parses"); + let mut found = xmp_extension::Findings::default(); + found.add(&parsed); + (found.missing.into_iter().collect(), found.prefixes.into_iter().collect()) +} + +const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; + +/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on). +fn schema_without(drop: &str) -> String { + let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}") }; + let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"] + .iter() + .map(|n| entry(n, "x")) + .collect::(); + let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::(); + let field = entry("pdfaType:field", &format!(r#"{field}"#)); + let value_type = + ["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::(); + [ + entry("pdfaSchema:schema", "Some schema"), + entry("pdfaSchema:namespaceURI", "http://example.com/ns/"), + entry("pdfaSchema:prefix", "ex"), + entry("pdfaSchema:property", &format!(r#"{property}"#)), + entry("pdfaSchema:valueType", &format!(r#"{value_type}{field}"#)), + ] + .concat() +} + +/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its +/// four kinds, and writes each description namespace with its own prefix. +#[test] +fn xmp_extension_schema_descriptions_are_complete() { + assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![])); + // The property and value type lists of a schema are optional. + assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![])); + assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![])); + // Every other entry is required. + for required in [ + "pdfaSchema:schema", + "pdfaSchema:namespaceURI", + "pdfaSchema:prefix", + "pdfaProperty:name", + "pdfaProperty:valueType", + "pdfaProperty:category", + "pdfaProperty:description", + "pdfaType:type", + "pdfaType:namespaceURI", + "pdfaType:prefix", + "pdfaType:description", + "pdfaType:field", + "pdfaField:name", + "pdfaField:valueType", + "pdfaField:description", + ] { + assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}"); + } + // An entry may be an attribute of an rdf:Description item. + let as_attribute = r#""#; + assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![])); + let missing_attribute = r#""#; + assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]); + // Each namespace with its own prefix: the right URI under another prefix breaks the rule. + for (i, own) in OWN.iter().enumerate() { + let mut prefixes = OWN; + prefixes[i] = "other"; + let schema = schema_without("").replace(&format!("{own}:"), "other:"); + assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); + } + // Through verify, under 6.6.2.3.3 and not fixable. + let xmp = concat!( + r#""#, + r#""#, + r#"Se"#, + 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.3").collect(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "An XMP extension schema description has no pdfaSchema:namespaceURI entry"); + assert!(!issues[0].fixable); +} diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs new file mode 100644 index 0000000000..4b683c6461 --- /dev/null +++ b/crates/preflight/src/xmp_extension.rs @@ -0,0 +1,114 @@ +//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it +//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description +//! is complete. +//! +//! The entries each description needs, and the prefixes it is written with, are those of the +//! requirement as stated with the conformance corpus: +//! - a schema (`pdfaSchema`) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional; +//! - a property (`pdfaProperty`) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`) has `type`, `namespaceURI`, `prefix`, `description` and `field`; +//! - a field (`pdfaField`) has `name`, `valueType` and `description`; +//! - each of the four namespaces is written with its own prefix (`pdfaSchema`, `pdfaProperty`, +//! `pdfaType`, `pdfaField`), as a PDF/A reader matches them by prefix. +//! +//! An entry counts as present written as an element or as an attribute of the description. + +use std::collections::BTreeSet; + +use roxmltree::{Document, Node}; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/"; +const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#"; +const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#"; +const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#"; +const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; + +/// Each description namespace with its prefix. +const PREFIXES: [(&str, &str); 4] = [(SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Entries a description lacks, as `prefix:name`, in name order. + pub(crate) missing: BTreeSet, + /// Description namespaces written with another prefix, by their own prefix, in name order. + pub(crate) prefixes: BTreeSet<&'static str>, +} + +impl Findings { + /// Adds what the packet `doc` breaks. + pub(crate) fn add(&mut self, doc: &Document<'_>) { + let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); + for container in containers { + self.prefixes_in(doc, &container); + for schema in items(&container) { + self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + for property in entry(&schema, SCHEMA, "property").iter().flat_map(items) { + self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + } + for value_type in entry(&schema, SCHEMA, "valueType").iter().flat_map(items) { + self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for field in entry(&value_type, TYPE, "field").iter().flat_map(items) { + self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); + } + } + } + } + } + + /// Notes the entries of `ns` in `names` that the description `d` lacks. + fn need(&mut self, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { + for name in names { + if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() { + self.missing.insert(format!("{prefix}:{name}")); + } + } + } + + /// Notes description namespaces written in `container` with another prefix than their own. + fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) { + let text = doc.input_text(); + for n in container.descendants().filter(Node::is_element) { + if let Some(own) = own_prefix(n.tag_name().namespace()) + && written_prefix(text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) != own + { + self.prefixes.insert(own); + } + for a in n.attributes() { + if let Some(own) = own_prefix(a.namespace()) + && written_prefix(text.get(a.range_qname()).unwrap_or_default()) != own + { + self.prefixes.insert(own); + } + } + } + } +} + +fn own_prefix(ns: Option<&str>) -> Option<&'static str> { + PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns).map(|(_, p)| *p) +} + +/// The prefix of the qualified name `qname` starts with, empty when it has none. +fn written_prefix(qname: &str) -> &str { + let name = qname.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default(); + name.split_once(':').map_or("", |(p, _)| p) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// The element entry `name` of `ns` in the description `d`. +fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option> { + d.children().find(|c| is(c, ns, name)) +} + +/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or +/// `rdf:Description`. +fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec> { + let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() }; + array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect() +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 3e77332972..dcfbf26625 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -588,10 +588,9 @@ const SCHEMAS: &[Schema] = &[ Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] }, ]; -/// 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(); +/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that +/// parses as XML, each decoded once within one allowance. +pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc)], mut f: impl FnMut(&roxmltree::Document<'_>)) { let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); for (_, o) in objs { let Object::Stream(s) = &**o else { continue }; @@ -604,10 +603,9 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; let text = unicode(&bytes); if let Some(parsed) = crate::xmp::parse(&text) { - out.extend(mistyped(&parsed)); + f(&parsed); } } - out } /// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or