mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
1 parent
af583b77f2
commit
1f3fc2ce92
4 files changed
+239
-8
No files matched your search
@@ -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() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user