From 1820ba241e197d5e2d3b57b70711e9c5da2e9126 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 06:53:21 +0800 Subject: [PATCH] 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]]