M11: PDF/A verify checks XMP extension schema descriptions (6.6.2.3.3)

Every pdfaExtension:schemas description needs its entries: a schema its
schema, namespaceURI and prefix; a property its name, valueType,
category and description; a value type its type, namespaceURI, prefix,
description and field; a field its name, valueType and description.
Each description namespace is written with its own prefix. One pass over
the metadata streams serves this check and the value types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 16:20:56 +08:00
1 parent af583b77f2
commit 1f3fc2ce92
4 files changed
+239 -8

No files matched your search

+17 -2
View File
@@ -23,6 +23,7 @@
pub mod icc;
mod xmp;
mod xmp_extension;
mod xmp_types;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
@@ -115,6 +116,8 @@ pub const WORDINGS: &[&str] = &[
"The metadata doesn't identify the file as {}",
"The title in the metadata and the document information differ",
"The XMP property {} doesn't have the value type the XMP Specification gives it",
"An XMP extension schema description has no {} entry",
"An XMP extension schema description writes the {} namespace with another prefix",
// The document as a whole.
"The document is encrypted",
"Device colour is used, but there is no PDF/A output intent",
@@ -462,10 +465,22 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
// 6.2.2: output intent when device colour is used.
let objs = objects(doc);
// 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream.
for name in xmp_types::mistyped_in(&objs) {
// 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in
// every metadata stream.
let (mut mistyped, mut extension) = (std::collections::BTreeSet::new(), xmp_extension::Findings::default());
xmp_types::for_each_packet(&objs, |packet| {
mistyped.extend(xmp_types::mistyped(packet));
extension.add(packet);
});
for name in mistyped {
issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false);
}
for entry in extension.missing {
issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false);
}
for prefix in extension.prefixes {
issue("6.6.2.3.3", "An XMP extension schema description writes the {} namespace with another prefix", Some(prefix.to_owned()), None, false);
}
let used = uses_device_colour(doc, &objs);
match pdfa_intent_profile(doc) {
None if used.any() => {
+104
View File
@@ -764,3 +764,107 @@ fn xmp_exif_schemas_have_their_value_types() {
assert_eq!(element("aux:Lens", "<rdf:Seq><rdf:li>50mm</rdf:li></rdf:Seq>"), ["aux:Lens"]);
assert_eq!(element("aux:SerialNumber", "<rdf:Bag><rdf:li>1</rdf:li></rdf:Bag>"), ["aux:SerialNumber"]);
}
/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five
/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default).
fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec<String>, Vec<&'static str>) {
let [s, p, t, f] = prefixes;
let xmp = format!(
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:{s}="http://www.aiim.org/pdfa/ns/schema#" xmlns:{p}="http://www.aiim.org/pdfa/ns/property#" "#,
r#"xmlns:{t}="http://www.aiim.org/pdfa/ns/type#" xmlns:{f}="http://www.aiim.org/pdfa/ns/field#">"#,
r#"<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">{schema}</rdf:li></rdf:Bag></pdfaExtension:schemas>"#,
r#"</rdf:Description></rdf:RDF></x:xmpmeta>"#
),
s = s,
p = p,
t = t,
f = f,
schema = schema
);
let parsed = xmp::parse(&xmp).expect("parses");
let mut found = xmp_extension::Findings::default();
found.add(&parsed);
(found.missing.into_iter().collect(), found.prefixes.into_iter().collect())
}
const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"];
/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on).
fn schema_without(drop: &str) -> String {
let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}</{name}>") };
let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]
.iter()
.map(|n| entry(n, "x"))
.collect::<String>();
let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::<String>();
let field = entry("pdfaType:field", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{field}</rdf:li></rdf:Seq>"#));
let value_type =
["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::<String>();
[
entry("pdfaSchema:schema", "Some schema"),
entry("pdfaSchema:namespaceURI", "http://example.com/ns/"),
entry("pdfaSchema:prefix", "ex"),
entry("pdfaSchema:property", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{property}</rdf:li></rdf:Seq>"#)),
entry("pdfaSchema:valueType", &format!(r#"<rdf:Seq><rdf:li rdf:parseType="Resource">{value_type}{field}</rdf:li></rdf:Seq>"#)),
]
.concat()
}
/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its
/// four kinds, and writes each description namespace with its own prefix.
#[test]
fn xmp_extension_schema_descriptions_are_complete() {
assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![]));
// The property and value type lists of a schema are optional.
assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![]));
assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![]));
// Every other entry is required.
for required in [
"pdfaSchema:schema",
"pdfaSchema:namespaceURI",
"pdfaSchema:prefix",
"pdfaProperty:name",
"pdfaProperty:valueType",
"pdfaProperty:category",
"pdfaProperty:description",
"pdfaType:type",
"pdfaType:namespaceURI",
"pdfaType:prefix",
"pdfaType:description",
"pdfaType:field",
"pdfaField:name",
"pdfaField:valueType",
"pdfaField:description",
] {
assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}");
}
// An entry may be an attribute of an rdf:Description item.
let as_attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:namespaceURI="http://e/" pdfaSchema:prefix="e"/>"#;
assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![]));
let missing_attribute = r#"</rdf:li><rdf:li><rdf:Description pdfaSchema:schema="S" pdfaSchema:prefix="e"/>"#;
assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]);
// Each namespace with its own prefix: the right URI under another prefix breaks the rule.
for (i, own) in OWN.iter().enumerate() {
let mut prefixes = OWN;
prefixes[i] = "other";
let schema = schema_without("").replace(&format!("{own}:"), "other:");
assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}");
}
// Through verify, under 6.6.2.3.3 and not fixable.
let xmp = concat!(
r#"<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#"><pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<pdfaSchema:schema>S</pdfaSchema:schema><pdfaSchema:prefix>e</pdfaSchema:prefix></rdf:li></rdf:Bag></pdfaExtension:schemas>"#,
r#"</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 issues: Vec<Issue> = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].message, "An XMP extension schema description has no pdfaSchema:namespaceURI entry");
assert!(!issues[0].fixable);
}
+114
View File
@@ -0,0 +1,114 @@
//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it
//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description
//! is complete.
//!
//! The entries each description needs, and the prefixes it is written with, are those of the
//! requirement as stated with the conformance corpus:
//! - a schema (`pdfaSchema`) has `schema`, `namespaceURI` and `prefix`; its `property` and
//! `valueType` lists are optional;
//! - a property (`pdfaProperty`) has `name`, `valueType`, `category` and `description`;
//! - a value type (`pdfaType`) has `type`, `namespaceURI`, `prefix`, `description` and `field`;
//! - a field (`pdfaField`) has `name`, `valueType` and `description`;
//! - each of the four namespaces is written with its own prefix (`pdfaSchema`, `pdfaProperty`,
//! `pdfaType`, `pdfaField`), as a PDF/A reader matches them by prefix.
//!
//! An entry counts as present written as an element or as an attribute of the description.
use std::collections::BTreeSet;
use roxmltree::{Document, Node};
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/";
const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#";
const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#";
const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#";
const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#";
/// Each description namespace with its prefix.
const PREFIXES: [(&str, &str); 4] = [(SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")];
/// What the extension schema descriptions of a document break of 6.6.2.3.3.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Findings {
/// Entries a description lacks, as `prefix:name`, in name order.
pub(crate) missing: BTreeSet<String>,
/// Description namespaces written with another prefix, by their own prefix, in name order.
pub(crate) prefixes: BTreeSet<&'static str>,
}
impl Findings {
/// Adds what the packet `doc` breaks.
pub(crate) fn add(&mut self, doc: &Document<'_>) {
let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas"));
for container in containers {
self.prefixes_in(doc, &container);
for schema in items(&container) {
self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]);
for property in entry(&schema, SCHEMA, "property").iter().flat_map(items) {
self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]);
}
for value_type in entry(&schema, SCHEMA, "valueType").iter().flat_map(items) {
self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]);
for field in entry(&value_type, TYPE, "field").iter().flat_map(items) {
self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]);
}
}
}
}
}
/// Notes the entries of `ns` in `names` that the description `d` lacks.
fn need(&mut self, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) {
for name in names {
if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() {
self.missing.insert(format!("{prefix}:{name}"));
}
}
}
/// Notes description namespaces written in `container` with another prefix than their own.
fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) {
let text = doc.input_text();
for n in container.descendants().filter(Node::is_element) {
if let Some(own) = own_prefix(n.tag_name().namespace())
&& written_prefix(text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) != own
{
self.prefixes.insert(own);
}
for a in n.attributes() {
if let Some(own) = own_prefix(a.namespace())
&& written_prefix(text.get(a.range_qname()).unwrap_or_default()) != own
{
self.prefixes.insert(own);
}
}
}
}
}
fn own_prefix(ns: Option<&str>) -> Option<&'static str> {
PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns).map(|(_, p)| *p)
}
/// The prefix of the qualified name `qname` starts with, empty when it has none.
fn written_prefix(qname: &str) -> &str {
let name = qname.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default();
name.split_once(':').map_or("", |(p, _)| p)
}
fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool {
n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name
}
/// The element entry `name` of `ns` in the description `d`.
fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option<Node<'a, 'i>> {
d.children().find(|c| is(c, ns, name))
}
/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or
/// `rdf:Description`.
fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec<Node<'a, 'i>> {
let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() };
array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect()
}
+4 -6
View File
@@ -588,10 +588,9 @@ const SCHEMAS: &[Schema] = &[
Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] },
];
/// The properties of predefined schemas whose values don't have their type, in every metadata
/// stream among the indirect objects `objs` (ISO 32000-1 14.3.2), as `prefix:name` in name order.
pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc<Object>)]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that
/// parses as XML, each decoded once within one allowance.
pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc<Object>)], mut f: impl FnMut(&roxmltree::Document<'_>)) {
let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0);
for (_, o) in objs {
let Object::Stream(s) = &**o else { continue };
@@ -604,10 +603,9 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc<Object>)]) -> BTreeSet
let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue };
let text = unicode(&bytes);
if let Some(parsed) = crate::xmp::parse(&text) {
out.extend(mistyped(&parsed));
f(&parsed);
}
}
out
}
/// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or