M11: PDF/A verify finds empty naming entries in XMP extension schema descriptions (6.6.2.3.3)

A namespace URI, prefix, name, value type, type or category entry that is
there but empty names nothing; it is now reported with its own wording.
Descriptions stay free to be empty Text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-07 08:26:45 +08:00
1 parent 1820ba241e
commit 8ca675b25d
4 files changed
+129 -10

No files matched your search

+22 -3
View File
@@ -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<Issue> {
}
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<Issue> {
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 {
+70
View File
@@ -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}</{name}>");
assert_eq!(complete.matches(&element).count(), 1, "{name}");
complete.replace(&element, &format!("<{name}>{with}</{name}>"))
};
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("<pdfaType:prefix>x</pdfaType:prefix>", "<pdfaType:prefix/>");
assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]);
let attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:namespaceURI="http://e/" pdfaSchema:prefix=" "/>"#;
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("<pdfaType:namespaceURI>x</pdfaType:namespaceURI>", r#"<pdfaType:namespaceURI rdf:resource="http://t.example/"/>"#);
assert_eq!(said(&resource), []);
let value = complete.replace("<pdfaType:type>x</pdfaType:type>", r#"<pdfaType:type rdf:value="T"/>"#);
assert_eq!(said(&value), []);
// A comment is not a value.
assert_eq!(said(&emptied("pdfaType:prefix", "x", "<!-- t -->")), [("pdfaType:prefix".to_owned(), true)]);
// Through verify, with their own wordings.
let xmp = concat!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
r#"<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" "#,
r#"xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaType="http://www.aiim.org/pdfa/ns/type#">"#,
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:namespaceURI>http://a.example/</pdfaSchema:namespaceURI>"#,
r#"<pdfaSchema:prefix></pdfaSchema:prefix><pdfaSchema:valueType><rdf:Seq><rdf:li rdf:parseType="Resource">"#,
r#"<pdfaType:type>T</pdfaType:type><pdfaType:namespaceURI>http://t.example/</pdfaType:namespaceURI>"#,
r#"<pdfaType:prefix> </pdfaType:prefix><pdfaType:description>d</pdfaType:description>"#,
r#"<pdfaType:field><rdf:Seq/></pdfaType:field></rdf:li></rdf:Seq></pdfaSchema:valueType>"#,
r#"</rdf:li></rdf:Bag></pdfaExtension:schemas></rdf:Description></rdf:RDF></x:xmpmeta>"#
);
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<String> = 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]
+36 -6
View File
@@ -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<NotBag>,
}
/// 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<String>,
/// 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))
+1 -1
View File
@@ -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"