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]]