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"