M11: PDF/A verify checks XMP value types in every metadata stream, more strictly (6.6.2.3.1)

Every metadata stream is read, within one decode allowance, not only the
catalog's. Predefined properties inside a Pantry item keep their types;
rdf:value may be an attribute; rdf:resource makes a URI, which only a
text type takes; a value's text is all its text nodes; an array item is
rdf:li; a parseType="Resource" structure has no character data; a time
zone is Z, +hh:mm or -hh:mm; and a value nested past every type's depth
doesn't have its type.

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 11:27:27 +08:00
1 parent 3c749e13df
commit ba4e86c547
3 files changed
+145 -29

No files matched your search

+4 -5
View File
@@ -449,11 +449,6 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
}
if let Some(parsed) = xmp::parse(&x) {
for name in xmp_types::mistyped(&parsed) {
issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false);
}
}
if let Some(title) = info(doc, "Title")
&& xmp::value(&x, "dc:title").is_some_and(|t| t != title)
{
@@ -467,6 +462,10 @@ 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) {
issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false);
}
let used = uses_device_colour(doc, &objs);
match pdfa_intent_profile(doc) {
None if used.any() => {
+77 -10
View File
@@ -531,11 +531,25 @@ fn xmp_simple_values_have_their_lexical_forms() {
{
assert!(element("xmp:CreateDate", good).is_empty(), "{good}");
}
for bad in ["16-02-01", "2016-13-01", "2016-02-32", "2016-02T13:19", "2016-02-01T25:00", "2016-02-01T13:19:21+1:00", "Feb 1 2016"] {
for bad in [
"16-02-01",
"2016-13-01",
"2016-02-32",
"2016-02T13:19",
"2016-02-01T25:00",
"2016-02-01T13:19:21+1:00",
"Feb 1 2016",
"2016-02-01T13:19Z01:00",
] {
assert_eq!(element("xmp:CreateDate", bad), ["xmp:CreateDate"], "{bad}");
}
assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty());
assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]);
// The whole text of a value counts, however a comment splits it.
assert_eq!(element("crs:Contrast", "5<!-- c -->.0"), ["crs:Contrast"]);
assert!(element("pdf:Trapped", "Tr<!-- c -->ue").is_empty());
// rdf:resource makes a URI, which only a text type takes.
assert_eq!(mistyped(r#"><crs:Contrast rdf:resource="5"/>"#), ["crs:Contrast"]);
}
/// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative.
@@ -550,6 +564,7 @@ fn xmp_arrays_have_their_kind_and_item_types() {
assert_eq!(element("dc:creator", "<rdf:Bag><rdf:li>A</rdf:li></rdf:Bag>"), ["dc:creator"]);
assert_eq!(element("dc:creator", "A"), ["dc:creator"]);
assert_eq!(element("dc:date", "<rdf:Seq><rdf:li>2016-02-01</rdf:li><rdf:li>yesterday</rdf:li></rdf:Seq>"), ["dc:date"]);
assert_eq!(element("dc:date", "<rdf:Seq><rdf:_1>yesterday</rdf:_1></rdf:Seq>"), ["dc:date"]);
// Language alternatives: an rdf:Alt whose items all carry xml:lang.
assert!(element("dc:title", r#"<rdf:Alt><rdf:li xml:lang="x-default">T</rdf:li></rdf:Alt>"#).is_empty());
assert_eq!(element("dc:title", "<rdf:Alt><rdf:li>T</rdf:li></rdf:Alt>"), ["dc:title"]);
@@ -564,6 +579,15 @@ fn xmp_structures_have_their_field_types() {
assert!(mistyped(r#"><xmpMM:DerivedFrom><rdf:Description stRef:documentID="d"/></xmpMM:DerivedFrom>"#).is_empty());
assert!(mistyped(r#"><xmpMM:DerivedFrom stRef:documentID="d"/>"#).is_empty());
assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]);
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource">letter</xmpMM:DerivedFrom>"#), ["xmpMM:DerivedFrom"]);
// Predefined properties inside a Pantry item keep their types.
let pantry = |v: &str| {
mistyped(&format!(
r#"><xmpMM:Pantry><rdf:Bag><rdf:li rdf:parseType="Resource"><crs:Contrast>{v}</crs:Contrast></rdf:li></rdf:Bag></xmpMM:Pantry>"#
))
};
assert!(pantry("5").is_empty());
assert_eq!(pantry("high"), ["xmpMM:Pantry"]);
assert_eq!(
mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:lastModifyDate>soon</stRef:lastModifyDate></xmpMM:DerivedFrom>"#),
["xmpMM:DerivedFrom"]
@@ -583,18 +607,61 @@ fn xmp_structures_have_their_field_types() {
// A qualified value is checked by its rdf:value.
assert!(mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><xmp:Label>q</xmp:Label></crs:Contrast>"#).is_empty());
assert_eq!(mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>five</rdf:value></crs:Contrast>"#), ["crs:Contrast"]);
// rdf:value as an attribute of the description.
assert!(mistyped(r#"><crs:Contrast><rdf:Description rdf:value="5" xmp:Label="q"/></crs:Contrast>"#).is_empty());
assert_eq!(mistyped(r#"><crs:Contrast><rdf:Description rdf:value="five" xmp:Label="q"/></crs:Contrast>"#), ["crs:Contrast"]);
// Nesting past any type's depth doesn't have the type.
let nested = |levels: usize| {
let open = r#"<rdf:value rdf:parseType="Resource">"#.repeat(levels);
let close = "</rdf:value>".repeat(levels);
mistyped(&format!(r#"><crs:Contrast rdf:parseType="Resource">{open}<rdf:value>5</rdf:value>{close}</crs:Contrast>"#))
};
assert!(nested(3).is_empty());
assert_eq!(nested(40), ["crs:Contrast"]);
}
/// PDF/A-2 6.6.2.3.1 through verify: one issue per mistyped property, not fixable.
#[test]
fn xmp_value_types_are_reported_by_verify() {
// Through verify: reported under 6.6.2.3.1, not fixable.
let xmp =
format!(r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" crs:Contrast="high"/></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.1").collect();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].message, "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it");
assert!(!issues[0].fixable);
let metadata = |body: &str| {
let xmp = format!(r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}/></rdf:RDF></x:xmpmeta>"#);
format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())
};
let issues = |doc: &Document| -> Vec<Issue> { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect() };
// Every mistyped property, each once, in name order; not fixable.
let catalog = metadata(r#"crs:Contrast="high" crs:Exposure="bright""#);
let page = metadata(r#"crs:Contrast="higher""#);
let doc = build(
&[
"<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>",
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
&catalog,
"<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>",
&page,
],
"",
);
let found = issues(&doc);
let messages: Vec<&str> = found.iter().map(|i| i.message.as_str()).collect();
assert_eq!(
messages,
[
"The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it",
"The XMP property crs:Exposure doesn't have the value type the XMP Specification gives it"
]
);
assert!(found.iter().all(|i| !i.fixable));
// A metadata stream of a page counts as well as the catalog's.
let good = metadata(r#"crs:Contrast="5""#);
let doc = build(
&[
"<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>",
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
&good,
"<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>",
&page,
],
"",
);
assert_eq!(issues(&doc).len(), 1);
}
+64 -14
View File
@@ -7,7 +7,8 @@
//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it
//! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or
//! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a
//! qualified value through its `rdf:value`.
//! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the
//! document is read, not only the catalog's, within one decode allowance.
//!
//! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or
//! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms
@@ -17,10 +18,14 @@
use std::collections::BTreeSet;
use pdfcraft_cos::{DecodeBudget, ObjRef, Object};
use roxmltree::Node;
/// How deep arrays and structures are followed.
/// How deep arrays, structures and qualifiers are followed. No type nests nearly this deep, so a
/// value nested deeper doesn't have its type.
const MAX_DEPTH: usize = 32;
/// The decoded bytes all metadata streams of a document may take together.
const MAX_METADATA_BYTES: usize = 64 << 20;
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
const XML: &str = "http://www.w3.org/XML/1998/namespace";
@@ -442,8 +447,30 @@ const SCHEMAS: &[Schema] = &[
},
];
/// The properties of predefined schemas in `doc` whose values don't have their type, as
/// `prefix:name` in name order.
/// 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();
let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0);
for (_, o) in objs {
let Object::Stream(s) = &**o else { continue };
if s.dict.name(b"Type") != Some(b"Metadata") {
continue;
}
if budget.remaining() == 0 {
break;
}
let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue };
let text = String::from_utf8_lossy(&bytes);
if let Some(parsed) = crate::xmp::parse(&text) {
out.extend(mistyped(&parsed));
}
}
out
}
/// 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<String> {
let mut out = BTreeSet::new();
let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF")));
@@ -484,7 +511,7 @@ fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator<Item = roxmltree:
/// Whether the value of the property or array item element `e` has type `ty`.
fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
if depth > MAX_DEPTH {
return true;
return false;
}
let deeper = depth + 1;
let children: Vec<Node<'_, '_>> = e.children().filter(Node::is_element).collect();
@@ -500,12 +527,16 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
{
return fits(ty, &v, deeper);
}
if let Some(v) = description.and_then(|d| d.attribute((RDF, "value"))) {
return simple(ty, v);
}
match ty {
Ty::Text | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => {
if let Some(uri) = e.attribute((RDF, "resource")) {
return simple(ty, uri);
// `rdf:resource` makes the value a URI, which only a text type takes.
if e.attribute((RDF, "resource")).is_some() {
return matches!(ty, Ty::Text);
}
children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, e.text().unwrap_or_default())
children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e))
}
Ty::LangAlt => match array(e, &children, "Alt") {
Some(items) => items.iter().all(|i| i.attribute((XML, "lang")).is_some() && fits(Ty::Text, i, deeper)),
@@ -534,7 +565,18 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool {
});
attrs_ok && elements_ok
}
Ty::AnyStruct => structure(e, &children, resource, description).is_some(),
// A structure of any fields, such as a Pantry item: predefined properties in it still have
// their types.
Ty::AnyStruct => {
let Some(holder) = structure(e, &children, resource, description) else { return false };
let attrs_ok =
field_attributes(&holder).all(|a| a.namespace().and_then(|ns| property(ns, a.name())).is_none_or(|(_, t)| simple(t, a.value())));
let elements_ok = holder.children().filter(Node::is_element).all(|f| {
let n = f.tag_name();
n.namespace().and_then(|ns| property(ns, n.name())).is_none_or(|(_, t)| fits(t, &f, deeper))
});
attrs_ok && elements_ok
}
Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)),
}
}
@@ -546,8 +588,10 @@ fn field(fields: &[Field], ns: &str, name: &str) -> Option<Ty> {
/// The items of `e`'s value when it is an `rdf:<kind>` array.
fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Option<Vec<Node<'a, 'i>>> {
match children {
// Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element breaks the array.
[a] if is(a, RDF, kind) && field_attributes(e).next().is_none() => {
Some(a.children().filter(Node::is_element).filter(|i| is(i, RDF, "li")).collect())
let items: Vec<Node<'a, 'i>> = a.children().filter(Node::is_element).collect();
items.iter().all(|i| is(i, RDF, "li")).then_some(items)
}
_ => None,
}
@@ -556,14 +600,20 @@ fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Opt
/// The node holding the fields of `e`'s value when it is a structure.
fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool, description: Option<Node<'a, 'i>>) -> Option<Node<'a, 'i>> {
if resource {
return Some(*e);
// A structure's fields are elements; character data has no place in it.
return text(e).trim().is_empty().then_some(*e);
}
if let Some(d) = description {
return Some(d);
}
// Fields written as attributes of the property element, with no content.
let text = e.text().unwrap_or_default().trim().is_empty();
(children.is_empty() && text && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e)
let empty = text(e).trim().is_empty();
(children.is_empty() && empty && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e)
}
/// All the character data directly in `e`, however comments or other nodes split it.
fn text(e: &Node<'_, '_>) -> String {
e.children().filter(Node::is_text).filter_map(|t| t.text()).collect()
}
/// Whether the simple value `v` has the simple type `ty`.
@@ -624,7 +674,7 @@ fn date(v: &str) -> bool {
};
let zone_ok = match zone {
"" | "Z" => true,
z => z.get(1..).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)),
z => z.strip_prefix(['+', '-']).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)),
};
let clock_ok = match clock.split(':').collect::<Vec<_>>().as_slice() {
[h, m] => num(h, 2, 23) && num(m, 2, 59),