mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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:
1 parent
3c749e13df
commit
ba4e86c547
3 files changed
+145
-29
No files matched your search
@@ -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() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user