From 1f3fc2ce926c25199830c5e0bd26843df313645b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:20:56 +0800 Subject: [PATCH 1/8] 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 From 509610ec4548864642c670f4239a00f30f8018fe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:21:34 +0800 Subject: [PATCH 2/8] M11: parity: PDF/A verify checks XMP extension schema descriptions (6.6.2.3.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7e47202493..d3ca9e78e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3: required entries of the schema, property, value type and field descriptions, and their prefixes), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 7afa1e8b2483b218b831d161258638782c7ca3b8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:29:20 +0800 Subject: [PATCH 3/8] M11: PDF/A verify requires the pdfaExtension prefix too, citing TechNote 0009 (6.6.2.3.3) All five extension schema container namespaces are written with their own prefixes, pdfaExtension included (PDF Association TechNote 0009, 4.1). The module comment cites TechNote 0009 section 4 for the schemas, entries and prefixes, and says where PDF/A-2 asks more than it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 12 +++++++++++ crates/preflight/src/xmp_extension.rs | 30 ++++++++++++++++----------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index ddd2ce3502..aa2e1d9af4 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -853,6 +853,18 @@ fn xmp_extension_schema_descriptions_are_complete() { let schema = schema_without("").replace(&format!("{own}:"), "other:"); assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); } + // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). + let xmp = concat!( + r#""#, + r#""#, + r#"Shttp://e/"#, + r#"e"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(&xmp::parse(xmp).expect("parses")); + assert!(found.missing.is_empty()); + assert_eq!(found.prefixes.into_iter().collect::>(), ["pdfaExtension"]); // Through verify, under 6.6.2.3.3 and not fixable. let xmp = concat!( r#""#, diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 4b683c6461..ddb596297d 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -2,15 +2,19 @@ //! 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. +//! The schemas, their entries and their prefixes are those of PDF Association TechNote 0009, "XMP +//! Extension Schemas in PDF/A-1" (2008), section 4, for PDF/A-2 6.6.2.3.3: +//! - a schema (`pdfaSchema`, 4.3) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional. TechNote 0009, written for PDF/A-1, calls `schema` an +//! optional description; PDF/A-2 requires it, as its conformance tests show; +//! - a property (`pdfaProperty`, 4.4) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`, 4.5) has `type`, `namespaceURI`, `prefix`, `description` and +//! `field`. TechNote 0009 calls `field` optional; PDF/A-2 requires it, as its conformance tests +//! show; +//! - a field (`pdfaField`, 4.6) has `name`, `valueType` and `description`; +//! - each of the five namespaces is written with its own prefix (`pdfaExtension`, `pdfaSchema`, +//! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace +//! prefixes are not only preferred, but required" (4.1). //! //! An entry counts as present written as an element or as an attribute of the description. @@ -25,8 +29,9 @@ 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")]; +/// Each container namespace with its required prefix (TechNote 0009, 4.1). +const PREFIXES: [(&str, &str); 5] = + [(EXTENSION, "pdfaExtension"), (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)] @@ -67,7 +72,8 @@ impl Findings { } } - /// Notes description namespaces written in `container` with another prefix than their own. + /// Notes container namespaces written in `container` (itself included) 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) { From 5d1f6b79aaae77a108e829d3c51985dca9b358d5 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:30:33 +0800 Subject: [PATCH 4/8] M11: parity: 6.6.2.3.3 notes cite TechNote 0009 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d3ca9e78e1..f77b233931 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3: required entries of the schema, property, value type and field descriptions, and their prefixes), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 5241619b969b2f31d98841380c45713a0c52b622 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 17:04:40 +0800 Subject: [PATCH 5/8] M11: PDF/A verify reads every repeated extension schema list and wants a Bag container (6.6.2.3.3) A schema's property or value type list, or a value type's field list, written more than once is read each time; and a pdfaExtension:schemas that isn't one rdf:Bag (TechNote 0009, 4.2) is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 4 ++++ crates/preflight/src/tests.rs | 21 +++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 17 ++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d323606661..d32d01b9c4 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -118,6 +118,7 @@ pub const WORDINGS: &[&str] = &[ "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", + "An XMP extension schema container isn't an rdf:Bag", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -478,6 +479,9 @@ pub fn verify(doc: &Document, level: Level) -> Vec { for entry in extension.missing { issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false); } + if extension.not_bag { + issue("6.6.2.3.3", "An XMP extension schema container isn't an rdf:Bag", None, 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); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0a7a0eec20..b49c135955 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -853,6 +853,27 @@ fn xmp_extension_schema_descriptions_are_complete() { let schema = schema_without("").replace(&format!("{own}:"), "other:"); assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); } + // A second property list is read as well as the first. + let complete = schema_without(""); + let second = complete.replacen( + "", + r#"p"#, + 1, + ); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); + // The container is an rdf:Bag (TechNote 0009, 4.2). + let container = |inner: &str| { + let xmp = format!( + r#"{inner}"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(&xmp::parse(&xmp).expect("parses")); + found.not_bag + }; + let schema = r#"Shttp://e/e"#; + assert!(!container(&format!("{schema}"))); + assert!(container(&format!("{schema}"))); + assert!(container(r#""#)); // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). let xmp = concat!( r#""#, diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index ddb596297d..5778cebc8d 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -40,6 +40,8 @@ pub(crate) struct Findings { pub(crate) missing: BTreeSet, /// Description namespaces written with another prefix, by their own prefix, in name order. pub(crate) prefixes: BTreeSet<&'static str>, + /// A `pdfaExtension:schemas` isn't one `rdf:Bag` (TechNote 0009, 4.2: "bag Schema"). + pub(crate) not_bag: bool, } impl Findings { @@ -48,14 +50,18 @@ impl Findings { let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); for container in containers { self.prefixes_in(doc, &container); + let children: Vec> = container.children().filter(Node::is_element).collect(); + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) { + self.not_bag = true; + } for schema in items(&container) { self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); - for property in entry(&schema, SCHEMA, "property").iter().flat_map(items) { + for property in entries(&schema, SCHEMA, "property").flat_map(|e| items(&e)) { self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); } - for value_type in entry(&schema, SCHEMA, "valueType").iter().flat_map(items) { + for value_type in entries(&schema, SCHEMA, "valueType").flat_map(|e| items(&e)) { self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); - for field in entry(&value_type, TYPE, "field").iter().flat_map(items) { + for field in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)) { self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); } } @@ -107,6 +113,11 @@ fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name } +/// Every element entry `name` of `ns` in the description `d`: a list written twice is read twice. +fn entries<'a, 'i>(d: &Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator> { + d.children().filter(move |c| is(c, ns, 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)) From 97d054e14fb6b551f44271d418802ead0835eabd Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 17:05:05 +0800 Subject: [PATCH 6/8] M11: tests: the second extension property list is the incomplete one (6.6.2.3.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b49c135955..0e58de20b1 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -856,8 +856,8 @@ fn xmp_extension_schema_descriptions_are_complete() { // A second property list is read as well as the first. let complete = schema_without(""); let second = complete.replacen( - "", - r#"p"#, + "", + r#"p"#, 1, ); assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); From 1820ba241e197d5e2d3b57b70711e9c5da2e9126 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 06:53:21 +0800 Subject: [PATCH 7/8] M11: PDF/A verify says where each XMP extension schema finding is (optimize.pdfa-validate) 6.6.2.3.3 findings are now reported per description instead of once per entry name: each names the schema by its pdfaSchema:namespaceURI (or #n), the property, value type or field by its own name (a field as type/name), and the metadata stream, with Issue::object set. A misused prefix names the namespace URI and the prefix written (or that none was); a container that isn't one rdf:Bag names what it holds instead. Findings are capped at 1000 per document and repeated names at 200 characters. 6.6.2.3.1 issues now name the type the XMP Specification gives the property, as the specification writes it (Integer, seq Rational, Lang Alt, Flash); structure types carry their names for this. Co-Authored-By: Claude Opus 5.5 --- crates/preflight/src/lib.rs | 88 ++++++++++--- crates/preflight/src/tests.rs | 169 ++++++++++++++++++++++--- crates/preflight/src/xmp_extension.rs | 174 +++++++++++++++++++------- crates/preflight/src/xmp_types.rs | 126 +++++++++++-------- parity/acrobat-features.toml | 2 +- 5 files changed, 436 insertions(+), 123 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 9ad872446b..df4d5f4805 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -152,10 +152,13 @@ pub const WORDINGS: &[&str] = &[ "The document has no XMP metadata", "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", - "The XMP property {property} in metadata stream {object} 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", - "An XMP extension schema container isn't an rdf:Bag", + "The XMP property {property} in metadata stream {object} should have the value type {type}", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -516,27 +519,82 @@ pub fn verify(doc: &Document, level: Level) -> Vec { // every metadata stream. let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default()); xmp_types::for_each_packet(&objs, |object, packet| { - mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|name| (object, name))); - extension.add(packet); + mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|(name, ty)| (object, name, ty))); + extension.add(object, packet); }); - for (object, name) in mistyped { - let values = vec![("{property}", name), ("{object}", object.to_string())]; + for (object, name, ty) in mistyped { + let values = vec![("{property}", name), ("{object}", object.to_string()), ("{type}", ty)]; located_issue( "6.6.2.3.1", - "The XMP property {property} in metadata stream {object} doesn't have the value type the XMP Specification gives it", + "The XMP property {property} in metadata stream {object} should have the value type {type}", values, object, 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 m in extension.missing { + let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; + match m.item { + None => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + values, + m.object, + false, + ), + Some(item) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + values, + m.object, + false, + ); + } + } } - if extension.not_bag { - issue("6.6.2.3.3", "An XMP extension schema container isn't an rdf:Bag", None, None, false); + for c in extension.not_bag { + let object = c.object; + if c.found.is_empty() { + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + vec![("{object}", object.to_string())], + object, + false, + ); + } else { + let values = vec![("{object}", object.to_string()), ("{found}", c.found)]; + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + values, + object, + 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); + for p in extension.prefixes { + let mut values = vec![("{object}", p.object.to_string()), ("{namespace}", p.namespace.to_owned()), ("{prefix}", p.own.to_owned())]; + if p.written.is_empty() { + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + values, + p.object, + false, + ); + } else { + values.push(("{written}", p.written)); + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", + values, + p.object, + false, + ); + } } let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 66537dda09..b0b1414896 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,7 +507,7 @@ fn mistyped(body: &str) -> Vec { r#""# ); let parsed = xmp::parse(&xmp).expect("parses"); - xmp_types::mistyped(&parsed).into_iter().collect() + xmp_types::mistyped(&parsed).into_keys().collect() } /// [`mistyped`] for the property `p` written as an element holding `v`. @@ -680,9 +680,9 @@ fn xmp_value_types_are_reported_by_verify() { assert_eq!( messages, [ - "The XMP property crs:Contrast in metadata stream 3 0 R doesn't have the value type the XMP Specification gives it", - "The XMP property crs:Exposure in metadata stream 3 0 R doesn't have the value type the XMP Specification gives it", - "The XMP property crs:Contrast in metadata stream 5 0 R doesn't have the value type the XMP Specification gives it" + "The XMP property crs:Contrast in metadata stream 3 0 R should have the value type Integer", + "The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real", + "The XMP property crs:Contrast in metadata stream 5 0 R should have the value type Integer" ] ); let objects: Vec> = found.iter().map(|i| i.object).collect(); @@ -692,7 +692,7 @@ fn xmp_value_types_are_reported_by_verify() { // what the document wrote goes in as it stands. let reordered = |w: &str| w.replace("The XMP property {property} in metadata stream {object}", "In {object}, {property}"); assert_eq!(found[0].said(&in_english), found[0].message); - assert!(found[0].said(&reordered).starts_with("In 3 0 R, crs:Contrast doesn't"), "{}", found[0].said(&reordered)); + assert_eq!(found[0].said(&reordered), "In 3 0 R, crs:Contrast should have the value type Integer"); // A UTF-16 packet, either byte order, is read. for big in [true, false] { let xmp = format!( @@ -779,6 +779,12 @@ fn xmp_exif_schemas_have_their_value_types() { /// 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 found = extension_findings(schema, prefixes); + (found.missing.into_iter().map(|m| m.entry).collect(), found.prefixes.into_iter().map(|p| p.own).collect()) +} + +/// [`extension`]'s findings in full, as if from the metadata stream `3 0 R`. +fn extension_findings(schema: &str, prefixes: [&str; 4]) -> xmp_extension::Findings { let [s, p, t, f] = prefixes; let xmp = format!( concat!( @@ -797,8 +803,8 @@ fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static st ); 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()) + found.add(ObjRef::new(3, 0), &parsed); + found } const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; @@ -871,20 +877,23 @@ fn xmp_extension_schema_descriptions_are_complete() { r#"p"#, 1, ); - assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]); // The container is an rdf:Bag (TechNote 0009, 4.2). let container = |inner: &str| { let xmp = format!( r#"{inner}"# ); let mut found = xmp_extension::Findings::default(); - found.add(&xmp::parse(&xmp).expect("parses")); - found.not_bag + found.add(ObjRef::new(3, 0), &xmp::parse(&xmp).expect("parses")); + found.not_bag.into_iter().map(|c| c.found).collect::>() }; let schema = r#"Shttp://e/e"#; - assert!(!container(&format!("{schema}"))); - assert!(container(&format!("{schema}"))); - assert!(container(r#""#)); + assert!(container(&format!("{schema}")).is_empty()); + // What it holds instead is named as the packet writes it. + assert_eq!(container(&format!("{schema}")), ["rdf:Seq"]); + assert_eq!(container(r#""#), ["rdf:Description"]); + assert_eq!(container(&format!("{schema}")), ["rdf:Bag, rdf:Bag"]); + assert_eq!(container(""), [""]); // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). let xmp = concat!( r#""#, @@ -894,9 +903,10 @@ fn xmp_extension_schema_descriptions_are_complete() { r#"e"# ); let mut found = xmp_extension::Findings::default(); - found.add(&xmp::parse(xmp).expect("parses")); + found.add(ObjRef::new(3, 0), &xmp::parse(xmp).expect("parses")); assert!(found.missing.is_empty()); - assert_eq!(found.prefixes.into_iter().collect::>(), ["pdfaExtension"]); + let written: Vec<(&str, &str, &str)> = found.prefixes.iter().map(|p| (p.namespace, p.own, p.written.as_str())).collect(); + assert_eq!(written, [("http://www.aiim.org/pdfa/ns/extension/", "pdfaExtension", "ext")]); // Through verify, under 6.6.2.3.3 and not fixable. let xmp = concat!( r#""#, @@ -909,10 +919,137 @@ fn xmp_extension_schema_descriptions_are_complete() { 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_eq!(issues[0].message, "The XMP extension schema #1 in metadata stream 3 0 R has no pdfaSchema:namespaceURI entry"); + assert_eq!(issues[0].object, Some(ObjRef::new(3, 0))); assert!(!issues[0].fixable); } +/// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's +/// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. +#[test] +fn xmp_extension_findings_say_where_they_are() { + // Two schemas lacking the same entries are two findings, each with its schema. + let schema = |uri: &str, property: &str| { + format!( + concat!( + r#"S{uri}"#, + r#"e{property}"#, + r#""# + ), + uri = uri, + property = property + ) + }; + let named = "pText"; + let two = format!(r#"{}{}"#, schema("http://a.example/", named), schema("http://b.example/", "")); + let found = extension_findings(&two, OWN); + let said: Vec<(&str, Option<&str>, &str)> = found.missing.iter().map(|m| (m.schema.as_str(), m.item.as_deref(), m.entry.as_str())).collect(); + assert_eq!( + said, + [ + ("http://a.example/", Some("p"), "pdfaProperty:category"), + ("http://a.example/", Some("p"), "pdfaProperty:description"), + ("http://b.example/", Some("#1"), "pdfaProperty:name"), + ("http://b.example/", Some("#1"), "pdfaProperty:valueType"), + ("http://b.example/", Some("#1"), "pdfaProperty:category"), + ("http://b.example/", Some("#1"), "pdfaProperty:description"), + ] + ); + assert!(found.missing.iter().all(|m| m.object == ObjRef::new(3, 0))); + // A field is named by its value type and its own name. + let field = schema_without("pdfaField:description") + .replace(">x", ">Dim") + .replace(">x", ">w"); + let found = extension_findings(&field, OWN); + assert_eq!(found.missing.iter().map(|m| m.item.as_deref()).collect::>(), [Some("Dim/w")]); + // A name the packet gives is repeated only so far. + let long = format!("http://example.com/{}", "a".repeat(400)); + let found = extension_findings(&schema(&long, ""), OWN); + assert!(found.missing[0].schema.ends_with('…') && found.missing[0].schema.chars().count() == 201, "{}", found.missing[0].schema); + // So many findings are enough. + let many = vec![schema("http://m.example/", ""); 400].join(r#""#); + assert_eq!(extension_findings(&many, OWN).missing.len(), 1000); + + // Through verify: each wording, with the stream it is in. + let verify_packet = |description: &str| -> Vec { + let xmp = format!( + concat!( + r#""#, + r#""# + ), + description + ); + 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], ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect() + }; + let ns = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#""#; + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema("http://a.example/", named) + )), + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:category entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:description entry", + ] + ); + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema( + "http://a.example/", + &format!("{named}externald") + ) + )), + ["The XMP extension schema container in metadata stream 3 0 R holds rdf:Seq, not one rdf:Bag"] + ); + assert_eq!( + verify_packet(&format!(r#"{ns}>"#)), + ["The XMP extension schema container in metadata stream 3 0 R holds no rdf:Bag"] + ); + let other = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:s="http://www.aiim.org/pdfa/ns/schema#""#; + assert_eq!( + verify_packet(&format!( + r#"{other}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# with the prefix s, not pdfaSchema"] + ); + let default = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/""#; + assert_eq!( + verify_packet(&format!( + r#"{default}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# without a prefix, not with pdfaSchema"] + ); +} + +/// PDF/A-2 6.6.2.3.1: a mistyped property is reported with the type the XMP Specification gives it, +/// as the specification writes it. +#[test] +fn xmp_mistyped_properties_name_their_type() { + let types = |body: &str| -> Vec<(String, String)> { + let xmp = format!( + r#""# + ); + xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_iter().collect() + }; + let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned()); + assert_eq!( + types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N" xmp:CreateDate="never">"#), + [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational"), t("xmp:CreateDate", "Date"),] + ); + assert_eq!( + types(">8T"), + [t("dc:title", "Lang Alt"), t("tiff:BitsPerSample", "seq Integer")] + ); + assert_eq!( + types(">firedh"), + [t("exif:Flash", "Flash"), t("xmpMM:History", "seq ResourceEvent")] + ); + assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); +} + /// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its /// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's /// preferred prefix. diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 5778cebc8d..248c73093c 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -18,8 +18,7 @@ //! //! An entry counts as present written as an element or as an attribute of the description. -use std::collections::BTreeSet; - +use pdfcraft_cos::ObjRef; use roxmltree::{Document, Node}; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; @@ -33,80 +32,171 @@ const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; const PREFIXES: [(&str, &str); 5] = [(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; -/// What the extension schema descriptions of a document break of 6.6.2.3.3. +/// How many findings a document's packets are read for. A document past this many has plainly +/// failed the clause, and listing every one of a hostile packet's descriptions helps nobody. +const MAX_FINDINGS: usize = 1000; +/// How much of a name the packet gives (a namespace URI, a property) a finding repeats. +const MAX_SHOWN_CHARS: usize = 200; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3, in the order the +/// metadata streams and their descriptions come. #[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>, - /// A `pdfaExtension:schemas` isn't one `rdf:Bag` (TechNote 0009, 4.2: "bag Schema"). - pub(crate) not_bag: bool, + /// Entries descriptions lack. + pub(crate) missing: Vec, + /// Description namespaces written with another prefix, each once per stream and prefix. + pub(crate) prefixes: Vec, + /// `pdfaExtension:schemas` containers that aren't one `rdf:Bag` (TechNote 0009, 4.2: "bag + /// Schema"). + pub(crate) not_bag: Vec, +} + +/// A description without an entry it needs. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Missing { + /// The metadata stream. + pub(crate) object: ObjRef, + /// The schema the description is in: its `pdfaSchema:namespaceURI`, or `#n` for the n-th + /// schema of its container when it has none. + pub(crate) schema: String, + /// The property, value type or field description, when it is one of those and not the schema + /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or + /// `#n` for the n-th of its list when it has no name. + pub(crate) item: Option, + /// The entry it lacks, as `prefix:name`. + pub(crate) entry: String, +} + +/// A description namespace written with another prefix than its own. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Prefix { + pub(crate) object: ObjRef, + pub(crate) namespace: &'static str, + /// The prefix it needs. + pub(crate) own: &'static str, + /// The prefix it is written with, empty when it is written as the default namespace. + pub(crate) written: String, +} + +/// A `pdfaExtension:schemas` container that isn't one `rdf:Bag`. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct NotBag { + pub(crate) object: ObjRef, + /// The elements it holds instead, by their names as the packet writes them, comma-separated; + /// empty when it holds none. + pub(crate) found: String, +} + +/// Where a description is: its stream, its schema and, below the schema, its own name. +struct Place { + object: ObjRef, + schema: String, + item: Option, } impl Findings { - /// Adds what the packet `doc` breaks. - pub(crate) fn add(&mut self, doc: &Document<'_>) { + /// Adds what the packet `doc`, from the metadata stream `object`, breaks. + pub(crate) fn add(&mut self, object: ObjRef, doc: &Document<'_>) { let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); for container in containers { - self.prefixes_in(doc, &container); + self.prefixes_in(object, doc, &container); let children: Vec> = container.children().filter(Node::is_element).collect(); - if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) { - self.not_bag = true; + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) && self.room() { + let found = children.iter().take(MAX_SHOWN_CHARS).map(|c| written_name(doc, c)).collect::>().join(", "); + self.not_bag.push(NotBag { object, found: shown(&found) }); } - for schema in items(&container) { - self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); - for property in entries(&schema, SCHEMA, "property").flat_map(|e| items(&e)) { - self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + for (n, schema) in items(&container).iter().enumerate() { + let id = value(schema, SCHEMA, "namespaceURI").unwrap_or_else(|| position(n)); + let at = |item: Option| Place { object, schema: id.clone(), item }; + self.need(&at(None), schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + let properties = entries(schema, SCHEMA, "property").flat_map(|e| items(&e)); + for (n, property) in properties.enumerate() { + let name = value(&property, PROPERTY, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(name)), &property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); } - for value_type in entries(&schema, SCHEMA, "valueType").flat_map(|e| items(&e)) { - self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); - for field in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)) { - self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); + let value_types = entries(schema, SCHEMA, "valueType").flat_map(|e| items(&e)); + for (n, value_type) in value_types.enumerate() { + let name = value(&value_type, TYPE, "type").unwrap_or_else(|| position(n)); + self.need(&at(Some(name.clone())), &value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for (n, field) in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)).enumerate() { + let field_name = value(&field, FIELD, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(format!("{name}/{field_name}"))), &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]) { + /// Whether another finding may be recorded (see [`MAX_FINDINGS`]). + fn room(&self) -> bool { + self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS + } + + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks. + fn need(&mut self, at: &Place, 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}")); + if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() && self.room() { + let Place { object, schema, item } = at; + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry: format!("{prefix}:{name}") }); } } } /// Notes container namespaces written in `container` (itself included) with another prefix /// than their own. - fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) { + fn prefixes_in(&mut self, object: ObjRef, 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 + let element = (n.tag_name().namespace(), text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()); + let attributes = n.attributes().map(|a| (a.namespace(), text.get(a.range_qname()).unwrap_or_default())); + for (ns, qname) in std::iter::once(element).chain(attributes) { + let Some(&(namespace, own)) = PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns) else { continue }; + let written = shown(written_prefix(qname)); + if written != own + && self.room() + && !self.prefixes.iter().any(|p| p.object == object && p.namespace == namespace && p.written == written) { - self.prefixes.insert(own); + self.prefixes.push(Prefix { object, namespace, own, written }); } } } } } -fn own_prefix(ns: Option<&str>) -> Option<&'static str> { - PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns).map(|(_, p)| *p) +/// `#n` for the 0-based position `n`, counted from 1. +fn position(n: usize) -> String { + format!("#{}", n.saturating_add(1)) } -/// 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) +/// At most [`MAX_SHOWN_CHARS`] of `s`, cut at a char boundary, with `…` when cut. +fn shown(s: &str) -> String { + let mut out: String = s.chars().take(MAX_SHOWN_CHARS).collect(); + if out.len() < s.len() { + out.push('…'); + } + out +} + +/// The text of the entry `name` of `ns` in the description `d`, written as an element or an +/// attribute, trimmed and [`shown`]; `None` when it is absent or blank. +fn value(d: &Node<'_, '_>, ns: &str, name: &str) -> Option { + let text = entry(d, ns, name).and_then(|e| e.text()).or_else(|| d.attribute((ns, name)))?.trim(); + (!text.is_empty()).then(|| shown(text)) +} + +/// The name of the element `n` as the packet writes it, prefix included. +fn written_name<'i>(doc: &Document<'i>, n: &Node<'_, '_>) -> &'i str { + qualified_name(doc.input_text().get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) +} + +/// The qualified name the text of a start tag (after its `<`) or of an attribute begins with. +fn qualified_name(text: &str) -> &str { + text.split(|c: char| c.is_whitespace() || c == '>' || c == '/' || c == '=').next().unwrap_or_default() +} + +/// The prefix of the qualified name `text` begins with, empty when it has none. +fn written_prefix(text: &str) -> &str { + qualified_name(text).split_once(':').map_or("", |(p, _)| p) } fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index f8658a3ad7..9e78be60f1 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -20,7 +20,7 @@ //! The other simple types are text. A field a structure doesn't define, and a property a //! namespace doesn't define, are left to the clauses about them. -use std::collections::BTreeSet; +use std::collections::BTreeMap; use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; use roxmltree::Node; @@ -54,7 +54,8 @@ enum Ty { Seq(&'static Ty), Bag(&'static Ty), Alt(&'static Ty), - Struct(&'static [Field]), + /// A structure: the name the specification gives its type, and its fields. + Struct(&'static str, &'static [Field]), /// A structure whose fields aren't given (`xmpMM:Pantry`). AnyStruct, /// Any of these: where editions of the specification differ. @@ -148,7 +149,7 @@ const RESOURCE_REF: &[Field] = &[ /// Part 2 Table 10. const VERSION: &[Field] = &[ (ST_VER, "comments", Text), - (ST_VER, "event", Ty::Struct(RESOURCE_EVENT)), + (ST_VER, "event", Ty::Struct("ResourceEvent", RESOURCE_EVENT)), (ST_VER, "modifier", Text), (ST_VER, "modifyDate", Date), (ST_VER, "version", Text), @@ -157,16 +158,16 @@ const VERSION: &[Field] = &[ const JOB: &[Field] = &[(ST_JOB, "id", Text), (ST_JOB, "name", Text), (ST_JOB, "url", Text)]; /// Part 2 Table 12. const BEAT_SPLICE_STRETCH: &[Field] = - &[(XMP_DM, "riseInDecibel", Real), (XMP_DM, "riseInTimeDuration", Ty::Struct(TIME)), (XMP_DM, "useFileBeatsMarker", Boolean)]; + &[(XMP_DM, "riseInDecibel", Real), (XMP_DM, "riseInTimeDuration", Ty::Struct("Time", TIME)), (XMP_DM, "useFileBeatsMarker", Boolean)]; /// Part 2 Table 13. const CUE_POINT_PARAM: &[Field] = &[(XMP_DM, "key", Text), (XMP_DM, "value", Text)]; /// A marker's start and duration: FrameCount (text) in Part 2 Table 16, a Time structure in the /// September 2005 edition that PDF/A-2 cites. Either is taken. -const FRAME_COUNT_OR_TIME: Ty = Ty::OneOf(&[Text, Ty::Struct(TIME)]); +const FRAME_COUNT_OR_TIME: Ty = Ty::OneOf(&[Text, Ty::Struct("Time", TIME)]); /// Part 2 Table 16. const MARKER: &[Field] = &[ (XMP_DM, "comment", Text), - (XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct(CUE_POINT_PARAM))), + (XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct("CuePointParam", CUE_POINT_PARAM))), (XMP_DM, "cuePointType", Text), (XMP_DM, "duration", FRAME_COUNT_OR_TIME), (XMP_DM, "location", Text), @@ -179,10 +180,10 @@ const MARKER: &[Field] = &[ ]; /// Part 2 Table 17. const MEDIA: &[Field] = &[ - (XMP_DM, "duration", Ty::Struct(TIME)), + (XMP_DM, "duration", Ty::Struct("Time", TIME)), (XMP_DM, "managed", Boolean), (XMP_DM, "path", Text), - (XMP_DM, "startTime", Ty::Struct(TIME)), + (XMP_DM, "startTime", Ty::Struct("Time", TIME)), (XMP_DM, "track", Text), (XMP_DM, "webStatement", Text), ]; @@ -197,8 +198,12 @@ const TIMECODE: &[Field] = &[(XMP_DM, "timeFormat", Text), (XMP_DM, "timeValue", /// Part 2 Table 22. const TIME_SCALE_STRETCH: &[Field] = &[(XMP_DM, "frameOverlappingPercentage", Real), (XMP_DM, "frameSize", Real), (XMP_DM, "quality", Text)]; /// Part 2 Table 23. -const TRACK: &[Field] = - &[(XMP_DM, "frameRate", Text), (XMP_DM, "markers", Ty::Seq(&Ty::Struct(MARKER))), (XMP_DM, "trackName", Text), (XMP_DM, "trackType", Text)]; +const TRACK: &[Field] = &[ + (XMP_DM, "frameRate", Text), + (XMP_DM, "markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), + (XMP_DM, "trackName", Text), + (XMP_DM, "trackType", Text), +]; /// Part 2 Table 31. const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; /// Part 2 Table 32. @@ -255,7 +260,7 @@ const SCHEMAS: &[Schema] = &[ ("ModifyDate", Date), ("Nickname", Text), ("Rating", Real), - ("Thumbnails", Ty::Alt(&Ty::Struct(THUMBNAIL))), + ("Thumbnails", Ty::Alt(&Ty::Struct("Thumbnail", THUMBNAIL))), ], }, // ISO 16684-1 Table 6. @@ -269,12 +274,12 @@ const SCHEMAS: &[Schema] = &[ uri: "http://ns.adobe.com/xap/1.0/mm/", prefix: "xmpMM", properties: &[ - ("DerivedFrom", Ty::Struct(RESOURCE_REF)), + ("DerivedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), ("DocumentID", Ty::Guid), - ("History", Ty::Seq(&Ty::Struct(RESOURCE_EVENT))), - ("Ingredients", Ty::Bag(&Ty::Struct(RESOURCE_REF))), + ("History", Ty::Seq(&Ty::Struct("ResourceEvent", RESOURCE_EVENT))), + ("Ingredients", Ty::Bag(&Ty::Struct("ResourceRef", RESOURCE_REF))), ("InstanceID", Ty::Guid), - ("ManagedFrom", Ty::Struct(RESOURCE_REF)), + ("ManagedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), ("Manager", Text), ("ManageTo", Text), ("ManageUI", Text), @@ -284,22 +289,22 @@ const SCHEMAS: &[Schema] = &[ ("RenditionClass", Text), ("RenditionParams", Text), ("VersionID", Text), - ("Versions", Ty::Seq(&Ty::Struct(VERSION))), + ("Versions", Ty::Seq(&Ty::Struct("Version", VERSION))), ("LastURL", Text), - ("RenditionOf", Ty::Struct(RESOURCE_REF)), + ("RenditionOf", Ty::Struct("ResourceRef", RESOURCE_REF)), ("SaveID", Integer), ], }, // Part 2 Table 27. - Schema { uri: "http://ns.adobe.com/xap/1.0/bj/", prefix: "xmpBJ", properties: &[("JobRef", Ty::Bag(&Ty::Struct(JOB)))] }, + Schema { uri: "http://ns.adobe.com/xap/1.0/bj/", prefix: "xmpBJ", properties: &[("JobRef", Ty::Bag(&Ty::Struct("Job", JOB)))] }, // Part 2 Table 28. Schema { uri: "http://ns.adobe.com/xap/1.0/t/pg/", prefix: "xmpTPg", properties: &[ - ("Colorants", Ty::Seq(&Ty::Struct(COLORANT))), - ("Fonts", Ty::Bag(&Ty::Struct(FONT))), - ("MaxPageSize", Ty::Struct(DIMENSIONS)), + ("Colorants", Ty::Seq(&Ty::Struct("Colorant", COLORANT))), + ("Fonts", Ty::Bag(&Ty::Struct("Font", FONT))), + ("MaxPageSize", Ty::Struct("Dimensions", DIMENSIONS)), ("NPages", Integer), ("PlateNames", Ty::Seq(&Text)), ], @@ -312,14 +317,14 @@ const SCHEMAS: &[Schema] = &[ ("absPeakAudioFilePath", Text), ("album", Text), ("altTapeName", Text), - ("altTimecode", Ty::Struct(TIMECODE)), + ("altTimecode", Ty::Struct("Timecode", TIMECODE)), ("artist", Text), ("audioModDate", Date), ("audioChannelType", Text), ("audioCompressor", Text), ("audioSampleRate", Integer), ("audioSampleType", Text), - ("beatSpliceParams", Ty::Struct(BEAT_SPLICE_STRETCH)), + ("beatSpliceParams", Ty::Struct("BeatSpliceStretch", BEAT_SPLICE_STRETCH)), ("cameraAngle", Text), ("cameraLabel", Text), ("cameraModel", Text), @@ -327,31 +332,31 @@ const SCHEMAS: &[Schema] = &[ ("client", Text), ("comment", Text), ("composer", Text), - ("contributedMedia", Ty::Bag(&Ty::Struct(MEDIA))), + ("contributedMedia", Ty::Bag(&Ty::Struct("Media", MEDIA))), ("copyright", Text), ("director", Text), ("directorPhotography", Text), - ("duration", Ty::Struct(TIME)), + ("duration", Ty::Struct("Time", TIME)), ("engineer", Text), ("fileDataRate", Rational), ("genre", Text), ("good", Boolean), ("instrument", Text), - ("introTime", Ty::Struct(TIME)), + ("introTime", Ty::Struct("Time", TIME)), ("key", Text), ("logComment", Text), ("loop", Boolean), ("numberOfBeats", Real), - ("markers", Ty::Seq(&Ty::Struct(MARKER))), + ("markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), ("metadataModDate", Date), - ("outCue", Ty::Struct(TIME)), + ("outCue", Ty::Struct("Time", TIME)), ("projectName", Text), - ("projectRef", Ty::Struct(PROJECT_LINK)), + ("projectRef", Ty::Struct("ProjectLink", PROJECT_LINK)), ("pullDown", Text), ("relativePeakAudioFilePath", Text), - ("relativeTimestamp", Ty::Struct(TIME)), + ("relativeTimestamp", Ty::Struct("Time", TIME)), ("releaseDate", Date), - ("resampleParams", Ty::Struct(RESAMPLE_STRETCH)), + ("resampleParams", Ty::Struct("ResampleStretch", RESAMPLE_STRETCH)), ("scaleType", Text), ("scene", Text), ("shotDate", Date), @@ -361,23 +366,23 @@ const SCHEMAS: &[Schema] = &[ ("shotNumber", Text), ("shotSize", Text), ("speakerPlacement", Text), - ("startTimecode", Ty::Struct(TIMECODE)), + ("startTimecode", Ty::Struct("Timecode", TIMECODE)), ("stretchMode", Text), ("takeNumber", Integer), ("tapeName", Text), ("tempo", Real), - ("timeScaleParams", Ty::Struct(TIME_SCALE_STRETCH)), + ("timeScaleParams", Ty::Struct("TimeScaleStretch", TIME_SCALE_STRETCH)), ("timeSignature", Text), ("trackNumber", Integer), - ("Tracks", Ty::Bag(&Ty::Struct(TRACK))), + ("Tracks", Ty::Bag(&Ty::Struct("Track", TRACK))), ("videoAlphaMode", Text), - ("videoAlphaPremultipleColor", Ty::Struct(COLORANT)), + ("videoAlphaPremultipleColor", Ty::Struct("Colorant", COLORANT)), ("videoAlphaUnityIsTransparent", Boolean), ("videoColorSpace", Text), ("videoCompressor", Text), ("videoFieldOrder", Text), ("videoFrameRate", Text), - ("videoFrameSize", Ty::Struct(DIMENSIONS)), + ("videoFrameSize", Ty::Struct("Dimensions", DIMENSIONS)), ("videoModDate", Date), ("videoPixelDepth", Text), ("videoPixelAspectRatio", Rational), @@ -405,7 +410,7 @@ const SCHEMAS: &[Schema] = &[ ("Country", Text), ("Credit", Text), ("DateCreated", Date), - ("DocumentAncestors", Ty::Bag(&Ty::Struct(ANCESTOR))), + ("DocumentAncestors", Ty::Bag(&Ty::Struct("Ancestor", ANCESTOR))), ("Headline", Text), ("History", Text), ("ICCProfile", Text), @@ -413,7 +418,7 @@ const SCHEMAS: &[Schema] = &[ ("Source", Text), ("State", Text), ("SupplementalCategories", Ty::Bag(&Text)), - ("TextLayers", Ty::Seq(&Ty::Struct(LAYER))), + ("TextLayers", Ty::Seq(&Ty::Struct("Layer", LAYER))), ("TransmissionReference", Text), ("Urgency", Integer), ], @@ -520,7 +525,7 @@ const SCHEMAS: &[Schema] = &[ ("ExposureProgram", Integer), ("SpectralSensitivity", Text), ("ISOSpeedRatings", Ty::Seq(&Integer)), - ("OECF", Ty::Struct(OECF_SFR)), + ("OECF", Ty::Struct("OECF/SFR", OECF_SFR)), ("ShutterSpeedValue", Rational), ("ApertureValue", Rational), ("BrightnessValue", Rational), @@ -529,11 +534,11 @@ const SCHEMAS: &[Schema] = &[ ("SubjectDistance", Rational), ("MeteringMode", Integer), ("LightSource", Integer), - ("Flash", Ty::Struct(FLASH)), + ("Flash", Ty::Struct("Flash", FLASH)), ("FocalLength", Rational), ("SubjectArea", Ty::Seq(&Integer)), ("FlashEnergy", Rational), - ("SpatialFrequencyResponse", Ty::Struct(OECF_SFR)), + ("SpatialFrequencyResponse", Ty::Struct("OECF/SFR", OECF_SFR)), ("FocalPlaneXResolution", Rational), ("FocalPlaneYResolution", Rational), ("FocalPlaneResolutionUnit", Integer), @@ -542,7 +547,7 @@ const SCHEMAS: &[Schema] = &[ ("SensingMethod", Integer), ("FileSource", Integer), ("SceneType", Integer), - ("CFAPattern", Ty::Struct(CFA_PATTERN)), + ("CFAPattern", Ty::Struct("CFAPattern", CFA_PATTERN)), ("CustomRendered", Integer), ("ExposureMode", Integer), ("WhiteBalance", Integer), @@ -553,7 +558,7 @@ const SCHEMAS: &[Schema] = &[ ("Contrast", Integer), ("Saturation", Integer), ("Sharpness", Integer), - ("DeviceSettingDescription", Ty::Struct(DEVICE_SETTINGS)), + ("DeviceSettingDescription", Ty::Struct("DeviceSettings", DEVICE_SETTINGS)), ("SubjectDistanceRange", Integer), ("ImageUniqueID", Text), ("GPSVersionID", Text), @@ -625,16 +630,16 @@ fn unicode(bytes: &[u8]) -> String { } /// 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(); +/// as `prefix:name` in name order, each with the type it should have as [`named`] gives it. +pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeMap { + let mut out = BTreeMap::new(); let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF"))); for d in descriptions { for a in d.attributes() { if let Some((schema, ty)) = a.namespace().and_then(|ns| property(ns, a.name())) && !simple(ty, a.value()) { - out.insert(format!("{}:{}", schema.prefix, a.name())); + out.insert(format!("{}:{}", schema.prefix, a.name()), named(ty)); } } for p in d.children().filter(Node::is_element) { @@ -642,13 +647,36 @@ pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeSet { if let Some((schema, ty)) = name.namespace().and_then(|ns| property(ns, name.name())) && !fits(ty, &p, 0) { - out.insert(format!("{}:{}", schema.prefix, name.name())); + out.insert(format!("{}:{}", schema.prefix, name.name()), named(ty)); } } } out } +/// `ty` as the XMP Specification writes value types: `Integer`, `GPSCoordinate`, `Lang Alt`, +/// `seq Rational`, a structure by its type's name, such as `Flash`. +fn named(ty: Ty) -> String { + let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item)); + match ty { + Text => "Text".into(), + Ty::Guid => "GUID".into(), + Integer => "Integer".into(), + Real => "Real".into(), + Boolean => "Boolean".into(), + Date => "Date".into(), + Rational => "Rational".into(), + Ty::GpsCoordinate => "GPSCoordinate".into(), + LangAlt => "Lang Alt".into(), + Ty::Seq(item) => array("seq", item), + Ty::Bag(item) => array("bag", item), + Ty::Alt(item) => array("alt", item), + Ty::Struct(name, _) => name.into(), + Ty::AnyStruct => "struct".into(), + Ty::OneOf(types) => types.iter().map(|t| named(*t)).collect::>().join(" or "), + } +} + fn property(ns: &str, name: &str) -> Option<(&'static Schema, Ty)> { let schema = SCHEMAS.iter().find(|s| s.uri == ns)?; schema.properties.iter().find(|(n, _)| *n == name).map(|(_, t)| (schema, *t)) @@ -720,7 +748,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { }; array(e, &children, kind).is_some_and(|items| items.iter().all(|i| fits(*item, i, deeper))) } - Ty::Struct(fields) => { + Ty::Struct(_, fields) => { let Some(holder) = structure(e, &children, resource, description) else { return false }; let attrs_ok = field_attributes(&holder).all(|a| match a.namespace().and_then(|ns| field(fields, ns, a.name())) { Some(t) => simple(t, a.value()), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f3ace636ea..3d8d9891d5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8003,7 +8003,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] From 8ca675b25d82cfb866bc4c02152025b06a424acc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:26:45 +0800 Subject: [PATCH 8/8] M11: PDF/A verify finds empty naming entries in XMP extension schema descriptions (6.6.2.3.3) A namespace URI, prefix, name, value type, type or category entry that is there but empty names nothing; it is now reported with its own wording. Descriptions stay free to be empty Text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 25 ++++++++-- crates/preflight/src/tests.rs | 70 +++++++++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 42 +++++++++++++--- parity/acrobat-features.toml | 2 +- 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index df4d5f4805..4168179fbc 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -155,6 +155,8 @@ pub const WORDINGS: &[&str] = &[ "The XMP property {property} in metadata stream {object} should have the value type {type}", "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", @@ -534,15 +536,22 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } for m in extension.missing { let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; - match m.item { - None => located_issue( + match (m.item, m.empty) { + (None, false) => located_issue( "6.6.2.3.3", "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", values, m.object, false, ), - Some(item) => { + (None, true) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + values, + m.object, + false, + ), + (Some(item), false) => { values.push(("{item}", item)); located_issue( "6.6.2.3.3", @@ -552,6 +561,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec { false, ); } + (Some(item), true) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + values, + m.object, + false, + ); + } } } for c in extension.not_bag { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b0b1414896..916cbb5cdf 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -924,6 +924,76 @@ fn xmp_extension_schema_descriptions_are_complete() { assert!(!issues[0].fixable); } +/// PDF/A-2 6.6.2.3.3: an entry that names something is incomplete when it is empty, while the +/// human-readable descriptions may be empty (TechNote 0009, 4.3 to 4.6). +#[test] +fn xmp_extension_naming_entries_are_not_empty() { + let complete = schema_without(""); + let emptied = |name: &str, value: &str, with: &str| { + let element = format!("<{name}>{value}"); + assert_eq!(complete.matches(&element).count(), 1, "{name}"); + complete.replace(&element, &format!("<{name}>{with}")) + }; + let said = |schema: &str| -> Vec<(String, bool)> { extension_findings(schema, OWN).missing.into_iter().map(|m| (m.entry, m.empty)).collect() }; + for (name, value) in [ + ("pdfaSchema:namespaceURI", "http://example.com/ns/"), + ("pdfaSchema:prefix", "ex"), + ("pdfaProperty:name", "x"), + ("pdfaProperty:valueType", "x"), + ("pdfaProperty:category", "x"), + ("pdfaType:type", "x"), + ("pdfaType:namespaceURI", "x"), + ("pdfaType:prefix", "x"), + ("pdfaField:name", "x"), + ("pdfaField:valueType", "x"), + ] { + assert_eq!(said(&emptied(name, value, "")), [(name.to_owned(), true)], "{name}"); + assert_eq!(said(&emptied(name, value, " \n\t ")), [(name.to_owned(), true)], "{name} blank"); + } + for (name, value) in + [("pdfaSchema:schema", "Some schema"), ("pdfaProperty:description", "x"), ("pdfaType:description", "x"), ("pdfaField:description", "x")] + { + assert_eq!(said(&emptied(name, value, "")), [], "{name}"); + } + // An empty element written shut, and an empty attribute, are empty too. + let shut = complete.replace("x", ""); + assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]); + let attribute = r#""#; + assert_eq!(said(&format!("{complete}{attribute}")), [("pdfaSchema:prefix".to_owned(), true)]); + // A URI given as an rdf:resource, or a value given as rdf:value, is a value. + let resource = + complete.replace("x", r#""#); + assert_eq!(said(&resource), []); + let value = complete.replace("x", r#""#); + assert_eq!(said(&value), []); + // A comment is not a value. + assert_eq!(said(&emptied("pdfaType:prefix", "x", "")), [("pdfaType:prefix".to_owned(), true)]); + + // Through verify, with their own wordings. + let xmp = concat!( + r#""#, + r#""#, + r#""#, + r#"Shttp://a.example/"#, + r#""#, + r#"Thttp://t.example/"#, + r#" d"#, + r#""#, + 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 messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R has an empty pdfaSchema:prefix entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes T with an empty pdfaType:prefix entry", + ] + ); +} + /// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's /// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. #[test] diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 248c73093c..92b3afa38e 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -16,7 +16,12 @@ //! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace //! prefixes are not only preferred, but required" (4.1). //! -//! An entry counts as present written as an element or as an attribute of the description. +//! An entry counts as present written as an element or as an attribute of the description. An +//! entry that names something is empty when it holds no value: a namespace URI or prefix, a +//! property, value type or field name, a value type reference, a property category. TechNote 0009 +//! asks for names that are valid XML names (4.4, 4.6) and a category that is internal or external +//! (4.4), and an empty prefix or namespace binds nothing. The human-readable descriptions +//! (`pdfaSchema:schema` and the `description` entries) are Text and may be empty. use pdfcraft_cos::ObjRef; use roxmltree::{Document, Node}; @@ -51,7 +56,7 @@ pub(crate) struct Findings { pub(crate) not_bag: Vec, } -/// A description without an entry it needs. +/// A description without an entry it needs, or with the entry empty. #[derive(Debug, PartialEq, Eq)] pub(crate) struct Missing { /// The metadata stream. @@ -63,8 +68,10 @@ pub(crate) struct Missing { /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or /// `#n` for the n-th of its list when it has no name. pub(crate) item: Option, - /// The entry it lacks, as `prefix:name`. + /// The entry it lacks or holds empty, as `prefix:name`. pub(crate) entry: String, + /// Whether the entry is there but empty, rather than absent. + pub(crate) empty: bool, } /// A description namespace written with another prefix than its own. @@ -132,12 +139,21 @@ impl Findings { self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS } - /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks. + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks, or holds + /// empty when they are entries that name something (see [`NAMING`]). fn need(&mut self, at: &Place, 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.room() { + let element = entry(d, ns, name); + let attribute = d.attribute((ns, *name)); + let empty = match (element, attribute) { + (None, None) => false, + (Some(e), _) => NAMING.contains(name) && is_empty(&e), + (None, Some(a)) => NAMING.contains(name) && a.trim().is_empty(), + }; + if (empty || (element.is_none() && attribute.is_none())) && self.room() { let Place { object, schema, item } = at; - self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry: format!("{prefix}:{name}") }); + let entry = format!("{prefix}:{name}"); + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry, empty }); } } } @@ -163,6 +179,20 @@ impl Findings { } } +/// The entries that name something, which an empty value leaves without a name: namespace URIs +/// and prefixes, property, value type and field names, value type references and the property +/// category. +const NAMING: [&str; 6] = ["namespaceURI", "prefix", "name", "type", "valueType", "category"]; + +/// Whether the element entry `e` holds no value: no element, no text but white space, and no +/// `rdf:resource` or `rdf:value` attribute with a value. +fn is_empty(e: &Node<'_, '_>) -> bool { + let blank = |v: Option<&str>| v.is_none_or(|v| v.trim().is_empty()); + !e.children().any(|c| c.is_element() || (c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))) + && blank(e.attribute((RDF, "resource"))) + && blank(e.attribute((RDF, "value"))) +} + /// `#n` for the 0-based position `n`, counted from 1. fn position(n: usize) -> String { format!("#{}", n.saturating_add(1)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3d8d9891d5..3d50d71b31 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8004,7 +8004,7 @@ status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert"