M11: PDF/A verify checks that predefined XMP properties have their value types (optimize.pdfa-validate) (#6220)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 08:37:56 +08:00
1 parent 1f48670fce
commit ee2398f710
5 files changed
+1228 -24

No files matched your search

+89 -13
View File
@@ -23,6 +23,7 @@
pub mod icc;
mod xmp;
mod xmp_types;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
@@ -63,18 +64,25 @@ impl Level {
/// another language says it from [`pattern`](Issue::pattern) and [`value`](Issue::value) instead:
/// the pattern is wording of ours, listed in [`WORDINGS`], and the value — when there is one — is
/// the document's own name for something, a PDF keyword that reads the same in every language.
/// A wording that names more than one thing has a named hole, such as `{object}`, for each, so a
/// language can put them in its own order; [`values`](Issue::values) fills those.
/// [`Issue::said`] does that.
#[derive(Clone, Debug, PartialEq)]
pub struct Issue {
/// The ISO 19005-2 clause.
pub clause: &'static str,
pub message: String,
/// The problem as it is worded here, with `{}` where the document's own name goes.
/// The problem as it is worded here, with `{}` or a named hole where the document's own name goes.
pub pattern: &'static str,
/// What goes in the pattern's `{}`: an annotation subtype, an action, a font, a page number.
pub value: Option<String>,
/// What goes in each of the pattern's named holes, by the hole's name (`"{object}"`, braces
/// included).
pub values: Vec<(&'static str, String)>,
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// The indirect object the problem is in, when that is what locates it: a metadata stream.
pub object: Option<ObjRef>,
/// Whether [`convert`] can fix it.
pub fixable: bool,
}
@@ -86,14 +94,43 @@ impl Issue {
/// font called `{}` cannot turn into anything else. With [`in_english`] this is
/// [`message`](Issue::message).
pub fn said(&self, words: Words) -> String {
let said = words(self.pattern);
match &self.value {
Some(v) => said.replacen("{}", v, 1),
None => said,
}
fill(&words(self.pattern), self.value.as_deref(), &self.values)
}
}
/// `wording` with its holes filled: the first `{}` with `value`, and each named hole, such as
/// `{object}`, with its entry in `values`. A hole with nothing for it stays as it is written.
///
/// The values go in during one pass over the wording, so a value with braces in it is never read
/// as a hole.
fn fill(wording: &str, value: Option<&str>, values: &[(&str, String)]) -> String {
let mut out = String::with_capacity(wording.len().saturating_add(16));
let mut value = value;
let mut rest = wording;
while let Some(open) = rest.find('{') {
let (before, from_brace) = rest.split_at(open);
out.push_str(before);
// A brace with nothing closing it is just text. `}` is one byte, so the end of the hole is a
// char boundary; `get` rather than slicing all the same.
let Some(hole) = from_brace.find('}').and_then(|close| from_brace.get(..=close)) else {
out.push_str(from_brace);
return out;
};
if hole == "{}"
&& let Some(v) = value.take()
{
out.push_str(v);
} else if let Some((_, v)) = values.iter().find(|(name, _)| *name == hole) {
out.push_str(v);
} else {
out.push_str(hole);
}
rest = from_brace.get(hole.len()..).unwrap_or_default();
}
out.push_str(rest);
out
}
/// How an interface says one of our wordings in its own language: given the English in [`WORDINGS`],
/// the same wording in that language. [`in_english`] keeps the English.
pub type Words<'a> = &'a dyn Fn(&str) -> String;
@@ -103,7 +140,8 @@ pub fn in_english(wording: &str) -> String {
wording.to_owned()
}
/// Every problem [`verify`] can report, in English, with `{}` where the document's own name goes.
/// Every problem [`verify`] can report, in English, with `{}` or a named hole, such as `{object}`,
/// where the document's own name goes.
///
/// An interface that shows them in another language translates these, which is the whole reason they
/// are gathered here; `wordings_are_complete` fails if a rule says something that is not on the
@@ -113,6 +151,8 @@ pub const WORDINGS: &[&str] = &[
"The document has no XMP metadata",
"The metadata doesn't identify the file as {}",
"The title in the metadata and the document information differ",
"The XMP property {property} in metadata stream {object} should have the value type {type}",
"The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}",
// The document as a whole.
"The document is encrypted",
"Device colour is used, but there is no PDF/A output intent",
@@ -427,15 +467,24 @@ thread_local! {
/// Check `doc` against `level`.
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
let mut out = Vec::new();
// Shared by `issue` and `located_issue`, each of which takes the list out, adds one issue and
// puts it back.
let out = std::cell::Cell::new(Vec::new());
let push = |i: Issue| {
let mut list = out.take();
list.push(i);
out.set(list);
};
// Each problem is worded here and named by the document there: the wording goes on the list in
// `WORDINGS` for an interface to translate, and the name goes in its `{}` as it stands.
let mut issue = |clause: &'static str, pattern: &'static str, value: Option<String>, page: Option<usize>, fixable: bool| {
let message = match &value {
Some(v) => pattern.replacen("{}", v, 1),
None => pattern.to_owned(),
};
out.push(Issue { clause, message, pattern, value, page, fixable });
let message = fill(pattern, value.as_deref(), &[]);
push(Issue { clause, message, pattern, value, values: Vec::new(), page, object: None, fixable });
};
// A problem in one indirect object, worded with named holes.
let located_issue = |clause: &'static str, pattern: &'static str, values: Vec<(&'static str, String)>, object: ObjRef, fixable: bool| {
let message = fill(pattern, None, &values);
push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable });
};
let cat = catalog(doc);
// 6.1.3 / 6.6: identification and metadata.
@@ -460,6 +509,32 @@ 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.
let mut mistyped = Vec::new();
xmp_types::for_each_packet(&objs, |object, packet| {
mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty)));
});
for (object, name, written, ty) in mistyped {
let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)];
if written == name {
located_issue(
"6.6.2.3.1",
"The XMP property {property} in metadata stream {object} should have the value type {type}",
values,
object,
false,
);
} else {
values.push(("{written}", written));
located_issue(
"6.6.2.3.1",
"The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}",
values,
object,
false,
);
}
}
let used = uses_device_colour(doc, &objs);
match pdfa_intent_profile(doc) {
None if used.any() => {
@@ -516,6 +591,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
check_objects(doc, level, &objs, &page_of, &mut issue);
// One message per kind of problem is enough for repeated ones (as preflight summaries do).
let mut out = out.into_inner();
let mut seen = std::collections::HashSet::new();
out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page)));
out
+348 -5
View File
@@ -78,12 +78,14 @@ fn wordings_are_complete() {
let source = include_str!("lib.rs");
let mut said = Vec::new();
let mut rest = source;
while let Some(at) = rest.find("issue(\"") {
rest = &rest[at + 7..];
// `issue(` and `located_issue(`, written on one line or, when rustfmt breaks it, on several.
while let Some(at) = rest.find("issue(") {
rest = &rest[at + 6..];
let Some(clause) = rest.trim_start().strip_prefix('"') else { continue };
// Step over the clause, then take the wording that follows it.
let Some(end) = rest.find('"') else { break };
rest = &rest[end + 1..];
let Some(text) = rest.strip_prefix(", \"") else { continue };
let Some(end) = clause.find('"') else { break };
rest = &clause[end + 1..];
let Some(text) = rest.strip_prefix(',').map(str::trim_start).and_then(|r| r.strip_prefix('"')) else { continue };
let Some(end) = text.find('"') else { break };
said.push(&text[..end]);
rest = text;
@@ -488,3 +490,344 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() {
assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile");
}
}
const XMP_NS: &str = concat!(
r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#,
r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#,
r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#,
r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#,
r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/""#
);
/// The mistyped predefined properties of a packet whose one description holds `body` (its
/// attributes, then `>` and its elements).
fn mistyped(body: &str) -> Vec<String> {
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
let parsed = xmp::parse(&xmp).expect("parses");
xmp_types::mistyped(&parsed).into_keys().map(|(name, _)| name).collect()
}
/// [`mistyped`] for the property `p` written as an element holding `v`.
fn element(p: &str, v: &str) -> Vec<String> {
mistyped(&format!("><{p}>{v}</{p}>"))
}
/// PDF/A-2 6.6.2.3.1, simple values: their lexical forms (ISO 16684-1 8.2.1), as elements and attributes.
#[test]
fn xmp_simple_values_have_their_lexical_forms() {
// Simple types, as elements and as attributes.
assert!(element("crs:Contrast", "-25").is_empty());
assert_eq!(element("crs:Contrast", "25.0"), ["crs:Contrast"]);
assert_eq!(element("crs:Contrast", "4/1"), ["crs:Contrast"]);
assert!(element("crs:Exposure", "+.5").is_empty() && element("crs:Exposure", "5").is_empty());
assert_eq!(element("crs:Exposure", "5."), ["crs:Exposure"]);
assert_eq!(element("crs:Exposure", "1e3"), ["crs:Exposure"]);
assert!(element("pdf:Trapped", "True").is_empty());
assert_eq!(element("pdf:Trapped", "true"), ["pdf:Trapped"]);
assert_eq!(mistyped(r#"crs:AutoContrast="Yes">"#), ["crs:AutoContrast"]);
assert!(mistyped(r#"crs:AutoContrast="False">"#).is_empty());
for good in
["2016", "2016-02", "2016-02-01", "2016-02-01T13:19Z", "2016-02-01T13:19:21+01:00", "2016-02-01T13:19:21.5-08:00", "2016-02-01T13:19:21"]
{
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",
"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"]);
// A GUID is a simple value but not a URI (ISO 16684-1 8.2.2.3).
assert!(element("xmpMM:DocumentID", "uuid:1234").is_empty());
assert_eq!(mistyped(r#"><xmpMM:DocumentID rdf:resource="uuid:1234"/>"#), ["xmpMM:DocumentID"]);
assert_eq!(
mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:instanceID rdf:resource="uuid:1"/></xmpMM:DerivedFrom>"#),
["xmpMM:DerivedFrom"]
);
}
/// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative.
#[test]
fn xmp_arrays_have_their_kind_and_item_types() {
// Text can't be an array or a structure; a simple value can be given as rdf:resource.
assert_eq!(element("crs:RawFileName", "<rdf:Seq><rdf:li>a</rdf:li></rdf:Seq>"), ["crs:RawFileName"]);
assert_eq!(mistyped(r#"><crs:RawFileName rdf:parseType="Resource"><crs:Exposure>1</crs:Exposure></crs:RawFileName>"#), ["crs:RawFileName"]);
assert!(mistyped(r#"><dc:source rdf:resource="http://example.com/a"/>"#).is_empty());
// Arrays: the kind and every item.
assert!(element("dc:creator", "<rdf:Seq><rdf:li>A</rdf:li><rdf:li>B</rdf:li></rdf:Seq>").is_empty());
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"]);
// Only white space around an array's items.
assert_eq!(element("dc:creator", "stray<rdf:Seq><rdf:li>A</rdf:li></rdf:Seq>"), ["dc:creator"]);
assert_eq!(element("dc:creator", "<rdf:Seq>stray<rdf:li>A</rdf:li></rdf:Seq>"), ["dc:creator"]);
// rdf:parseType or rdf:resource on the property makes its value something other than the array.
assert_eq!(mistyped(r#"><dc:creator rdf:parseType="Resource"><rdf:Seq><rdf:li>A</rdf:li></rdf:Seq></dc:creator>"#), ["dc:creator"]);
assert_eq!(mistyped(r#"><dc:creator rdf:resource="http://example.com/a"><rdf:Seq><rdf:li>A</rdf:li></rdf:Seq></dc:creator>"#), ["dc:creator"]);
assert!(element("dc:creator", "\n <rdf:Seq>\n <rdf:li>A</rdf:li>\n </rdf:Seq>\n").is_empty());
// An rdf:resource element is empty.
assert_eq!(mistyped(r#"><dc:source rdf:resource="http://example.com/a"><rdf:Seq><rdf:li>x</rdf:li></rdf:Seq></dc:source>"#), ["dc:source"]);
// 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"]);
assert_eq!(element("dc:title", "T"), ["dc:title"]);
}
/// PDF/A-2 6.6.2.3.1, structures in their three RDF/XML forms, nested, and qualified values.
#[test]
fn xmp_structures_have_their_field_types() {
// Structures in their three forms, with each known field checked.
assert!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:documentID>d</stRef:documentID><stRef:lastModifyDate>2016</stRef:lastModifyDate></xmpMM:DerivedFrom>"#).is_empty());
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"]);
assert_eq!(
mistyped(r#"><xmpMM:DerivedFrom><rdf:Description stRef:documentID="d">letter</rdf:Description></xmpMM:DerivedFrom>"#),
["xmpMM:DerivedFrom"]
);
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom>letter<rdf:Description stRef:documentID="d"/></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"]
);
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom stRef:lastModifyDate="soon"/>"#), ["xmpMM:DerivedFrom"]);
assert_eq!(mistyped(r#"><xmpTPg:MaxPageSize rdf:parseType="Resource"><stDim:w>8.5 in</stDim:w></xmpTPg:MaxPageSize>"#), ["xmpTPg:MaxPageSize"]);
// Nested: an array of structures holding a structure.
let marker = |start: &str| format!(r#"><xmpDM:markers><rdf:Seq><rdf:li rdf:parseType="Resource">{start}</rdf:li></rdf:Seq></xmpDM:markers>"#);
assert!(
mistyped(&marker(
r#"<xmpDM:startTime rdf:parseType="Resource"><xmpDM:value>50</xmpDM:value><xmpDM:scale>1/25</xmpDM:scale></xmpDM:startTime>"#
))
.is_empty()
);
assert!(mistyped(&marker("<xmpDM:startTime>50f</xmpDM:startTime>")).is_empty());
assert_eq!(mistyped(&marker(r#"<xmpDM:startTime rdf:parseType="Resource"><xmpDM:value>5.5</xmpDM:value></xmpDM:startTime>"#)), ["xmpDM:markers"]);
// 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"]);
// rdf:value as an attribute of an empty property element.
assert!(mistyped(r#"><crs:Contrast rdf:value="5" xmp:Label="q"/>"#).is_empty());
assert_eq!(mistyped(r#"><crs:Contrast rdf:value="five" xmp:Label="q"/>"#), ["crs:Contrast"]);
// Qualifiers that are predefined properties keep their types.
assert_eq!(
mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><crs:Exposure>high</crs:Exposure></crs:Contrast>"#),
["crs:Contrast"]
);
assert_eq!(mistyped(r#"><crs:Contrast rdf:value="5" crs:Exposure="high"/>"#), ["crs:Contrast"]);
assert!(mistyped(r#"><crs:Contrast rdf:value="5" crs:Exposure="1.5"/>"#).is_empty());
// 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() {
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, once per metadata stream, by stream and then by name; 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 in metadata stream 3 0 R should have the value type Integer",
"The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real",
"The XMP property crs:Contrast in metadata stream 5 0 R should have the value type Integer"
]
);
let objects: Vec<Option<ObjRef>> = found.iter().map(|i| i.object).collect();
assert_eq!(objects, [Some(ObjRef::new(3, 0)), Some(ObjRef::new(3, 0)), Some(ObjRef::new(5, 0))]);
assert!(found.iter().all(|i| !i.fixable && i.page.is_none()));
// Said in another language, the property and the stream go where that language puts them, and
// what the document wrote goes in as it stands.
let reordered = |w: &str| w.replace("The XMP property {property} in metadata stream {object}", "In {object}, {property}");
assert_eq!(found[0].said(&in_english), found[0].message);
assert_eq!(found[0].said(&reordered), "In 3 0 R, crs:Contrast should have the value type Integer");
// A UTF-16 packet, either byte order, is read.
for big in [true, false] {
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 mut data = if big { vec![0xFE, 0xFF] } else { vec![0xFF, 0xFE] };
data.extend(xmp.encode_utf16().flat_map(|u| if big { u.to_be_bytes() } else { u.to_le_bytes() }));
let hex: String = data.iter().map(|b| format!("{b:02X}")).collect();
let stream = format!("<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
assert_eq!(issues(&doc).len(), 1, "big-endian: {big}");
}
// 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,
],
"",
);
let found = issues(&doc);
assert_eq!(found.iter().map(|i| i.object).collect::<Vec<_>>(), [Some(ObjRef::new(5, 0))]);
}
/// Holes are filled in one pass: the first `{}` from `value`, named holes from `values`, and what
/// has nothing for it, a value's own braces and a brace left open stay as they are.
#[test]
fn wordings_fill_their_holes_once() {
let values = [("{a}", "{b}".to_owned()), ("{b}", "B".to_owned())];
assert_eq!(fill("x {a} {b} {c} {} {}", Some("{a}"), &values), "x {b} B {c} {a} {}");
assert_eq!(fill("{}", None, &[]), "{}");
assert_eq!(fill("open { and é {a", Some("v"), &values), "open { and é {a");
assert_eq!(fill("é{}ü", Some("v"), &[]), "évü");
}
/// PDF/A-2 6.6.2.3.1: a mistyped property is reported with the type the XMP Specification gives it,
/// as the specification writes it.
#[test]
fn xmp_mistyped_properties_name_their_type() {
let types = |body: &str| -> Vec<(String, String)> {
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_iter().map(|((name, _), ty)| (name, ty)).collect()
};
let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned());
assert_eq!(
types(r#"crs:Contrast="high" crs:Exposure="bright" xmp:CreateDate="never">"#),
[t("crs:Contrast", "Integer"), t("crs:Exposure", "Real"), t("xmp:CreateDate", "Date")]
);
assert_eq!(
types("><xmpTPg:PlateNames>Cyan</xmpTPg:PlateNames><dc:title>T</dc:title>"),
[t("dc:title", "Lang Alt"), t("xmpTPg:PlateNames", "seq Text")]
);
assert_eq!(
types("><xmpTPg:MaxPageSize>A4</xmpTPg:MaxPageSize><xmpMM:History>h</xmpMM:History>"),
[t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")]
);
assert_eq!(types("><xmpMM:Pantry>p</xmpMM:Pantry>"), [t("xmpMM:Pantry", "bag struct")]);
}
/// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds
/// to it, and is named by the namespace's preferred prefix and as the packet writes it. A property
/// a predefined namespace doesn't define, and a namespace that isn't predefined, are left to the
/// clauses about them.
#[test]
fn xmp_properties_are_known_by_namespace_uri_and_named_as_written() {
let crs = "http://ns.adobe.com/camera-raw-settings/1.0/";
let found = |body: &str| -> Vec<(String, String)> {
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" {body}</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_keys().collect()
};
let n = |name: &str, written: &str| (name.to_owned(), written.to_owned());
// Another prefix for the namespace, as an attribute and as an element, and no prefix at all.
assert_eq!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="high">"#)), [n("crs:Contrast", "c:Contrast")]);
assert_eq!(found(&format!(r#"><c:Exposure xmlns:c="{crs}">bright</c:Exposure>"#)), [n("crs:Exposure", "c:Exposure")]);
assert_eq!(found(&format!(r#"><Exposure xmlns="{crs}">bright</Exposure>"#)), [n("crs:Exposure", "Exposure")]);
assert!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="5">"#)).is_empty());
// The preferred prefix is written as it is.
assert_eq!(found(r#"crs:Contrast="high">"#), [n("crs:Contrast", "crs:Contrast")]);
// A long prefix is repeated only so far.
let long = "p".repeat(100);
let said = found(&format!(r#"xmlns:{long}="{crs}" {long}:Contrast="high">"#));
assert_eq!(said.len(), 1);
assert_eq!(said[0].1, format!("{}…:Contrast", "p".repeat(64)));
// The preferred prefix bound to another namespace is not that namespace.
assert!(found(r#"><crs:Contrast xmlns:crs="http://example.com/not-crs/">high</crs:Contrast>"#).is_empty());
assert!(found(r#"xmlns:dc="http://example.com/not-dc/" dc:title="T">"#).is_empty());
// A property a predefined namespace doesn't define, with any value.
assert!(found(r#"crs:NotAProperty="high" dc:notAProperty="T">"#).is_empty());
assert!(found("><xmp:NotAProperty><rdf:Seq><rdf:li>x</rdf:li></rdf:Seq></xmp:NotAProperty>").is_empty());
// A namespace that isn't predefined, with a predefined property's local name.
assert!(found(r#"xmlns:ex="http://example.com/ns/" ex:Contrast="high">"#).is_empty());
assert!(found(r#"><ex:title xmlns:ex="http://example.com/ns/">T</ex:title>"#).is_empty());
// Through verify, the written name is said when it differs.
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF {XMP_NS}><rdf:Description rdf:about="" xmlns:c="{crs}" c:Contrast="high" crs:Exposure="bright"/></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.1").map(|i| i.message).collect();
assert_eq!(
messages,
[
"The XMP property crs:Contrast in metadata stream 3 0 R, written c:Contrast, should have the value type Integer",
"The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real",
]
);
}
/// PDF/A-2 6.6.2.3.1, the RDF/XML value forms XMP allows (ISO 16684-1 7.9.2.5): one `rdf:value`
/// in a qualified value, no parse type but `Resource`, and an `rdf:resource` element left empty.
#[test]
fn xmp_value_forms_are_those_xmp_allows() {
// A qualified value has exactly one rdf:value.
assert_eq!(
mistyped(r#"><crs:Contrast rdf:parseType="Resource"><rdf:value>5</rdf:value><rdf:value>five</rdf:value></crs:Contrast>"#),
["crs:Contrast"]
);
assert_eq!(
mistyped(r#"><crs:Contrast><rdf:Description rdf:value="five"><rdf:value>5</rdf:value></rdf:Description></crs:Contrast>"#),
["crs:Contrast"]
);
assert!(mistyped(r#"><crs:Contrast><rdf:Description><rdf:value>5</rdf:value></rdf:Description></crs:Contrast>"#).is_empty());
// Only parseType="Resource" has a place in XMP; an rdf:resource element holds no element.
assert_eq!(mistyped(r#"><crs:Contrast rdf:parseType="Literal">5</crs:Contrast>"#), ["crs:Contrast"]);
assert_eq!(mistyped(r#"><xmpMM:DerivedFrom rdf:parseType="Literal" stRef:documentID="d"/>"#), ["xmpMM:DerivedFrom"]);
assert_eq!(
mistyped(r#"><xmpMM:DerivedFrom rdf:resource="urn:example"><rdf:Description stRef:documentID="d"/></xmpMM:DerivedFrom>"#),
["xmpMM:DerivedFrom"]
);
}
+9 -4
View File
@@ -29,13 +29,18 @@ pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option<Stri
}
}
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
/// `xmp` parsed within the depth, attribute and node limits; `None` when it can't be.
pub(crate) fn parse(xmp: &str) -> Option<roxmltree::Document<'_>> {
if !xml_limits_ok(xmp) {
return Err(());
return None;
}
let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
roxmltree::Document::parse_with_options(xmp, options).ok()
}
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
let doc = parse(xmp).ok_or(())?;
for node in doc.descendants().filter(roxmltree::Node::is_element) {
if let Some(value) = node.attribute((namespace, local)) {
return Ok(Some(value.trim().to_owned()));
+780
View File
@@ -0,0 +1,780 @@
//! PDF/A-2 6.6.2.3.1: a property of a predefined XMP schema has the value type the XMP
//! Specification gives it.
//!
//! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and
//! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for
//! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and
//! 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` (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
//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it.
//! The other simple types are text. A field a structure doesn't define, and a property a
//! namespace doesn't define, are left to the clauses about them.
use std::collections::BTreeMap;
use pdfcraft_cos::{DecodeBudget, ObjRef, Object};
use roxmltree::Node;
/// 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";
/// A value type.
#[derive(Debug, Clone, Copy)]
enum Ty {
/// Any simple value: Text and the types derived from it (AgentName, Choice of Text, Locale,
/// MIMEType, ProperName, RenditionClass, URI, URL, XPath, and the like).
Text,
/// A GUID: any simple value, but not a URI (ISO 16684-1 8.2.2.3), so not an `rdf:resource`.
Guid,
Integer,
Real,
Boolean,
Date,
Rational,
/// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4).
LangAlt,
Seq(&'static Ty),
Bag(&'static Ty),
Alt(&'static Ty),
/// A structure: the name the specification gives its type, and its fields.
Struct(&'static str, &'static [Field]),
/// A structure whose fields aren't given (`xmpMM:Pantry`).
AnyStruct,
/// Any of these: where editions of the specification differ.
OneOf(&'static [Ty]),
}
/// A structure field: namespace, local name, type.
type Field = (&'static str, &'static str, Ty);
/// A predefined namespace: URI, preferred prefix, properties.
struct Schema {
uri: &'static str,
prefix: &'static str,
properties: &'static [(&'static str, Ty)],
}
use Ty::{Boolean, Date, Integer, LangAlt, Rational, Real, Text};
const ST_DIM: &str = "http://ns.adobe.com/xap/1.0/sType/Dimensions#";
// Part 2 1.2.2.3 prints the Font field namespace as "http:ns.adobe.com/xap/1.0/sType/Font#"; the
// other sType namespaces show the intended form.
const ST_FNT: &str = "http://ns.adobe.com/xap/1.0/sType/Font#";
const XMP_G: &str = "http://ns.adobe.com/xap/1.0/g/";
const XMP_G_IMG: &str = "http://ns.adobe.com/xap/1.0/g/img/";
const ST_EVT: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#";
const ST_REF: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#";
const ST_VER: &str = "http://ns.adobe.com/xap/1.0/sType/Version#";
const ST_JOB: &str = "http://ns.adobe.com/xap/1.0/sType/Job#";
const XMP_DM: &str = "http://ns.adobe.com/xmp/1.0/DynamicMedia/";
const PHOTOSHOP: &str = "http://ns.adobe.com/photoshop/1.0/";
/// Part 2 Table 3.
const COLORANT: &[Field] = &[
(XMP_G, "A", Integer),
(XMP_G, "B", Integer),
(XMP_G, "L", Real),
(XMP_G, "black", Real),
(XMP_G, "cyan", Real),
(XMP_G, "magenta", Real),
(XMP_G, "yellow", Real),
(XMP_G, "blue", Integer),
(XMP_G, "green", Integer),
(XMP_G, "red", Integer),
(XMP_G, "mode", Text),
(XMP_G, "swatchName", Text),
(XMP_G, "type", Text),
];
/// Part 2 Table 4.
const DIMENSIONS: &[Field] = &[(ST_DIM, "h", Real), (ST_DIM, "w", Real), (ST_DIM, "unit", Text)];
/// Part 2 Table 5.
const FONT: &[Field] = &[
(ST_FNT, "childFontFiles", Ty::Seq(&Text)),
(ST_FNT, "composite", Boolean),
(ST_FNT, "fontFace", Text),
(ST_FNT, "fontFamily", Text),
(ST_FNT, "fontFileName", Text),
(ST_FNT, "fontName", Text),
(ST_FNT, "fontType", Text),
(ST_FNT, "versionString", Text),
];
/// Part 2 Table 6.
const THUMBNAIL: &[Field] = &[(XMP_G_IMG, "format", Text), (XMP_G_IMG, "height", Integer), (XMP_G_IMG, "width", Integer), (XMP_G_IMG, "image", Text)];
/// Part 2 Table 8.
const RESOURCE_EVENT: &[Field] = &[
(ST_EVT, "action", Text),
(ST_EVT, "changed", Text),
(ST_EVT, "instanceID", Ty::Guid),
(ST_EVT, "parameters", Text),
(ST_EVT, "softwareAgent", Text),
(ST_EVT, "when", Date),
];
/// ISO 16684-1 Table 3 and Part 2 Table 9.
const RESOURCE_REF: &[Field] = &[
(ST_REF, "alternatePaths", Ty::Seq(&Text)),
(ST_REF, "documentID", Ty::Guid),
(ST_REF, "filePath", Text),
(ST_REF, "fromPart", Text),
(ST_REF, "instanceID", Ty::Guid),
(ST_REF, "lastModifyDate", Date),
(ST_REF, "manager", Text),
(ST_REF, "managerVariant", Text),
(ST_REF, "manageTo", Text),
(ST_REF, "manageUI", Text),
(ST_REF, "maskMarkers", Text),
(ST_REF, "partMapping", Text),
(ST_REF, "renditionClass", Text),
(ST_REF, "renditionParams", Text),
(ST_REF, "toPart", Text),
(ST_REF, "versionID", Text),
];
/// Part 2 Table 10.
const VERSION: &[Field] = &[
(ST_VER, "comments", Text),
(ST_VER, "event", Ty::Struct("ResourceEvent", RESOURCE_EVENT)),
(ST_VER, "modifier", Text),
(ST_VER, "modifyDate", Date),
(ST_VER, "version", Text),
];
/// Part 2 Table 11.
const JOB: &[Field] = &[(ST_JOB, "id", Text), (ST_JOB, "name", Text), (ST_JOB, "url", Text)];
/// Part 2 Table 12.
const BEAT_SPLICE_STRETCH: &[Field] =
&[(XMP_DM, "riseInDecibel", Real), (XMP_DM, "riseInTimeDuration", Ty::Struct("Time", TIME)), (XMP_DM, "useFileBeatsMarker", Boolean)];
/// Part 2 Table 13.
const CUE_POINT_PARAM: &[Field] = &[(XMP_DM, "key", Text), (XMP_DM, "value", Text)];
/// A marker's start and duration: FrameCount (text) in Part 2 Table 16, a Time structure in the
/// September 2005 edition that PDF/A-2 cites. Either is taken.
const FRAME_COUNT_OR_TIME: Ty = Ty::OneOf(&[Text, Ty::Struct("Time", TIME)]);
/// Part 2 Table 16.
const MARKER: &[Field] = &[
(XMP_DM, "comment", Text),
(XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct("CuePointParam", CUE_POINT_PARAM))),
(XMP_DM, "cuePointType", Text),
(XMP_DM, "duration", FRAME_COUNT_OR_TIME),
(XMP_DM, "location", Text),
(XMP_DM, "name", Text),
(XMP_DM, "probability", Real),
(XMP_DM, "speaker", Text),
(XMP_DM, "startTime", FRAME_COUNT_OR_TIME),
(XMP_DM, "target", Text),
(XMP_DM, "type", Text),
];
/// Part 2 Table 17.
const MEDIA: &[Field] = &[
(XMP_DM, "duration", Ty::Struct("Time", TIME)),
(XMP_DM, "managed", Boolean),
(XMP_DM, "path", Text),
(XMP_DM, "startTime", Ty::Struct("Time", TIME)),
(XMP_DM, "track", Text),
(XMP_DM, "webStatement", Text),
];
/// Part 2 Table 18.
const PROJECT_LINK: &[Field] = &[(XMP_DM, "path", Text), (XMP_DM, "type", Text)];
/// Part 2 Table 19.
const RESAMPLE_STRETCH: &[Field] = &[(XMP_DM, "quality", Text)];
/// Part 2 Table 20.
const TIME: &[Field] = &[(XMP_DM, "scale", Rational), (XMP_DM, "value", Integer)];
/// Part 2 Table 21.
const TIMECODE: &[Field] = &[(XMP_DM, "timeFormat", Text), (XMP_DM, "timeValue", Text)];
/// Part 2 Table 22.
const TIME_SCALE_STRETCH: &[Field] = &[(XMP_DM, "frameOverlappingPercentage", Real), (XMP_DM, "frameSize", Real), (XMP_DM, "quality", Text)];
/// Part 2 Table 23.
const TRACK: &[Field] = &[
(XMP_DM, "frameRate", Text),
(XMP_DM, "markers", Ty::Seq(&Ty::Struct("Marker", MARKER))),
(XMP_DM, "trackName", Text),
(XMP_DM, "trackType", Text),
];
/// Part 2 Table 31.
const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)];
/// Part 2 Table 32.
const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)];
const SCHEMAS: &[Schema] = &[
// ISO 16684-1 Table 4.
Schema {
uri: "http://purl.org/dc/elements/1.1/",
prefix: "dc",
properties: &[
("contributor", Ty::Bag(&Text)),
("coverage", Text),
("creator", Ty::Seq(&Text)),
("date", Ty::Seq(&Date)),
("description", LangAlt),
("format", Text),
("identifier", Text),
("language", Ty::Bag(&Text)),
("publisher", Ty::Bag(&Text)),
("relation", Ty::Bag(&Text)),
("rights", LangAlt),
("source", Text),
("subject", Ty::Bag(&Text)),
("title", LangAlt),
("type", Ty::Bag(&Text)),
],
},
// ISO 16684-1 Table 5, with Part 2 Table 24.
Schema {
uri: "http://ns.adobe.com/xap/1.0/",
prefix: "xmp",
properties: &[
("Advisory", Ty::Bag(&Text)),
("BaseURL", Text),
("CreateDate", Date),
("CreatorTool", Text),
("Identifier", Ty::Bag(&Text)),
("Label", Text),
("MetadataDate", Date),
("ModifyDate", Date),
("Nickname", Text),
("Rating", Real),
("Thumbnails", Ty::Alt(&Ty::Struct("Thumbnail", THUMBNAIL))),
],
},
// ISO 16684-1 Table 6.
Schema {
uri: "http://ns.adobe.com/xap/1.0/rights/",
prefix: "xmpRights",
properties: &[("Certificate", Text), ("Marked", Boolean), ("Owner", Ty::Bag(&Text)), ("UsageTerms", LangAlt), ("WebStatement", Text)],
},
// ISO 16684-1 Table 7, with Part 2 Tables 25 and 26.
Schema {
uri: "http://ns.adobe.com/xap/1.0/mm/",
prefix: "xmpMM",
properties: &[
("DerivedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)),
("DocumentID", Ty::Guid),
("History", Ty::Seq(&Ty::Struct("ResourceEvent", RESOURCE_EVENT))),
("Ingredients", Ty::Bag(&Ty::Struct("ResourceRef", RESOURCE_REF))),
("InstanceID", Ty::Guid),
("ManagedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)),
("Manager", Text),
("ManageTo", Text),
("ManageUI", Text),
("ManagerVariant", Text),
("OriginalDocumentID", Ty::Guid),
("Pantry", Ty::Bag(&Ty::AnyStruct)),
("RenditionClass", Text),
("RenditionParams", Text),
("VersionID", Text),
("Versions", Ty::Seq(&Ty::Struct("Version", VERSION))),
("LastURL", Text),
("RenditionOf", Ty::Struct("ResourceRef", RESOURCE_REF)),
("SaveID", Integer),
],
},
// Part 2 Table 27.
Schema { uri: "http://ns.adobe.com/xap/1.0/bj/", prefix: "xmpBJ", properties: &[("JobRef", Ty::Bag(&Ty::Struct("Job", JOB)))] },
// Part 2 Table 28.
Schema {
uri: "http://ns.adobe.com/xap/1.0/t/pg/",
prefix: "xmpTPg",
properties: &[
("Colorants", Ty::Seq(&Ty::Struct("Colorant", COLORANT))),
("Fonts", Ty::Bag(&Ty::Struct("Font", FONT))),
("MaxPageSize", Ty::Struct("Dimensions", DIMENSIONS)),
("NPages", Integer),
("PlateNames", Ty::Seq(&Text)),
],
},
// Part 2 Table 29.
Schema {
uri: XMP_DM,
prefix: "xmpDM",
properties: &[
("absPeakAudioFilePath", Text),
("album", Text),
("altTapeName", Text),
("altTimecode", Ty::Struct("Timecode", TIMECODE)),
("artist", Text),
("audioModDate", Date),
("audioChannelType", Text),
("audioCompressor", Text),
("audioSampleRate", Integer),
("audioSampleType", Text),
("beatSpliceParams", Ty::Struct("BeatSpliceStretch", BEAT_SPLICE_STRETCH)),
("cameraAngle", Text),
("cameraLabel", Text),
("cameraModel", Text),
("cameraMove", Text),
("client", Text),
("comment", Text),
("composer", Text),
("contributedMedia", Ty::Bag(&Ty::Struct("Media", MEDIA))),
("copyright", Text),
("director", Text),
("directorPhotography", Text),
("duration", Ty::Struct("Time", TIME)),
("engineer", Text),
("fileDataRate", Rational),
("genre", Text),
("good", Boolean),
("instrument", Text),
("introTime", Ty::Struct("Time", TIME)),
("key", Text),
("logComment", Text),
("loop", Boolean),
("numberOfBeats", Real),
("markers", Ty::Seq(&Ty::Struct("Marker", MARKER))),
("metadataModDate", Date),
("outCue", Ty::Struct("Time", TIME)),
("projectName", Text),
("projectRef", Ty::Struct("ProjectLink", PROJECT_LINK)),
("pullDown", Text),
("relativePeakAudioFilePath", Text),
("relativeTimestamp", Ty::Struct("Time", TIME)),
("releaseDate", Date),
("resampleParams", Ty::Struct("ResampleStretch", RESAMPLE_STRETCH)),
("scaleType", Text),
("scene", Text),
("shotDate", Date),
("shotDay", Text),
("shotLocation", Text),
("shotName", Text),
("shotNumber", Text),
("shotSize", Text),
("speakerPlacement", Text),
("startTimecode", Ty::Struct("Timecode", TIMECODE)),
("stretchMode", Text),
("takeNumber", Integer),
("tapeName", Text),
("tempo", Real),
("timeScaleParams", Ty::Struct("TimeScaleStretch", TIME_SCALE_STRETCH)),
("timeSignature", Text),
("trackNumber", Integer),
("Tracks", Ty::Bag(&Ty::Struct("Track", TRACK))),
("videoAlphaMode", Text),
("videoAlphaPremultipleColor", Ty::Struct("Colorant", COLORANT)),
("videoAlphaUnityIsTransparent", Boolean),
("videoColorSpace", Text),
("videoCompressor", Text),
("videoFieldOrder", Text),
("videoFrameRate", Text),
("videoFrameSize", Ty::Struct("Dimensions", DIMENSIONS)),
("videoModDate", Date),
("videoPixelDepth", Text),
("videoPixelAspectRatio", Rational),
("partOfCompilation", Boolean),
("lyrics", Text),
("discNumber", Text),
],
},
// Part 2 Table 30.
Schema {
uri: "http://ns.adobe.com/pdf/1.3/",
prefix: "pdf",
properties: &[("Keywords", Text), ("PDFVersion", Text), ("Producer", Text), ("Trapped", Boolean)],
},
// Part 2 Table 33.
Schema {
uri: PHOTOSHOP,
prefix: "photoshop",
properties: &[
("AuthorsPosition", Text),
("CaptionWriter", Text),
("Category", Text),
("City", Text),
("ColorMode", Integer),
("Country", Text),
("Credit", Text),
("DateCreated", Date),
("DocumentAncestors", Ty::Bag(&Ty::Struct("Ancestor", ANCESTOR))),
("Headline", Text),
("History", Text),
("ICCProfile", Text),
("Instructions", Text),
("Source", Text),
("State", Text),
("SupplementalCategories", Ty::Bag(&Text)),
("TextLayers", Ty::Seq(&Ty::Struct("Layer", LAYER))),
("TransmissionReference", Text),
("Urgency", Integer),
],
},
// Part 2 Table 34.
Schema {
uri: "http://ns.adobe.com/camera-raw-settings/1.0/",
prefix: "crs",
properties: &[
("AutoBrightness", Boolean),
("AutoContrast", Boolean),
("AutoExposure", Boolean),
("AutoShadows", Boolean),
("BlueHue", Integer),
("BlueSaturation", Integer),
("Brightness", Integer),
("CameraProfile", Text),
("ChromaticAberrationB", Integer),
("ChromaticAberrationR", Integer),
("ColorNoiseReduction", Integer),
("Contrast", Integer),
("CropTop", Real),
("CropLeft", Real),
("CropBottom", Real),
("CropRight", Real),
("CropAngle", Real),
("CropWidth", Real),
("CropHeight", Real),
("CropUnits", Integer),
("Exposure", Real),
("GreenHue", Integer),
("GreenSaturation", Integer),
("HasCrop", Boolean),
("HasSettings", Boolean),
("LuminanceSmoothing", Integer),
("RawFileName", Text),
("RedHue", Integer),
("RedSaturation", Integer),
("Saturation", Integer),
("Shadows", Integer),
("ShadowTint", Integer),
("Sharpness", Integer),
("Temperature", Integer),
("Tint", Integer),
("ToneCurve", Ty::Seq(&Text)),
("ToneCurveName", Text),
("Version", Text),
("VignetteAmount", Integer),
("VignetteMidpoint", Integer),
("WhiteBalance", Text),
],
},
];
/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that
/// parses as XML, and the stream's object, each decoded once within one allowance.
pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc<Object>)], mut f: impl FnMut(ObjRef, &roxmltree::Document<'_>)) {
let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0);
for (r, 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 = unicode(&bytes);
if let Some(parsed) = crate::xmp::parse(&text) {
f(*r, &parsed);
}
}
}
/// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or
/// little-endian, or UTF-8 (ISO 16684-1 7.3).
fn unicode(bytes: &[u8]) -> String {
let utf16 = |data: &[u8], big: bool| -> String {
let (units, _) = data.as_chunks::<2>();
let units = units.iter().map(|&[a, b]| if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
match bytes {
[0xFE, 0xFF, rest @ ..] => utf16(rest, true),
[0xFF, 0xFE, rest @ ..] => utf16(rest, false),
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
_ => String::from_utf8_lossy(bytes).into_owned(),
}
}
/// The properties of predefined schemas in the packet `doc` whose values don't have their type, in
/// name order: each by its name with the namespace's preferred prefix and by its name as the packet
/// writes it (see [`written`]), with the type it should have as [`named`] gives it.
pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeMap<(String, String), String> {
let mut out = BTreeMap::new();
let text = doc.input_text();
let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF")));
for d in descriptions {
for a in d.attributes() {
if let Some((schema, ty)) = a.namespace().and_then(|ns| property(ns, a.name()))
&& !simple(ty, a.value())
{
let qname = text.get(a.range_qname()).unwrap_or_default();
out.insert((format!("{}:{}", schema.prefix, a.name()), written(qname, a.name())), named(ty));
}
}
for p in d.children().filter(Node::is_element) {
let name = p.tag_name();
if let Some((schema, ty)) = name.namespace().and_then(|ns| property(ns, name.name()))
&& !fits(ty, &p, 0)
{
let qname = text.get(p.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default();
out.insert((format!("{}:{}", schema.prefix, name.name()), written(qname, name.name())), named(ty));
}
}
}
out
}
/// How long a prefix the packet writes is repeated.
const MAX_PREFIX_CHARS: usize = 64;
/// The property `local` as the packet writes it, from the text its qualified name begins
/// (`qname`): `prefix:local`, or `local` alone in the default namespace. A prefix past
/// [`MAX_PREFIX_CHARS`] is cut, with `…`.
fn written(qname: &str, local: &str) -> String {
let qname = qname.split(|c: char| c.is_whitespace() || matches!(c, '>' | '/' | '=')).next().unwrap_or_default();
let Some((prefix, _)) = qname.split_once(':') else { return local.to_owned() };
let mut out: String = prefix.chars().take(MAX_PREFIX_CHARS).collect();
if out.len() < prefix.len() {
out.push('…');
}
format!("{out}:{local}")
}
/// `ty` as the XMP Specification writes value types: `Integer`, `Lang Alt`, `seq Rational`, a
/// structure by its type's name, such as `Dimensions`.
fn named(ty: Ty) -> String {
let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item));
match ty {
Text => "Text".into(),
Ty::Guid => "GUID".into(),
Integer => "Integer".into(),
Real => "Real".into(),
Boolean => "Boolean".into(),
Date => "Date".into(),
Rational => "Rational".into(),
LangAlt => "Lang Alt".into(),
Ty::Seq(item) => array("seq", item),
Ty::Bag(item) => array("bag", item),
Ty::Alt(item) => array("alt", item),
Ty::Struct(name, _) => name.into(),
Ty::AnyStruct => "struct".into(),
Ty::OneOf(types) => types.iter().map(|t| named(*t)).collect::<Vec<_>>().join(" or "),
}
}
fn property(ns: &str, name: &str) -> Option<(&'static Schema, Ty)> {
let schema = SCHEMAS.iter().find(|s| s.uri == ns)?;
schema.properties.iter().find(|(n, _)| *n == name).map(|(_, t)| (schema, *t))
}
fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool {
n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name
}
/// The attributes of `n` that are fields or qualifiers, not RDF or XML syntax.
fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator<Item = roxmltree::Attribute<'a, 'i>> {
n.attributes().filter(|a| !matches!(a.namespace(), Some(RDF) | Some(XML)))
}
/// 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 false;
}
let deeper = depth + 1;
let children: Vec<Node<'_, '_>> = e.children().filter(Node::is_element).collect();
// A qualified value: `rdf:value` holds the value itself (ISO 16684-1 7.9.2.5).
let resource = e.attribute((RDF, "parseType")) == Some("Resource");
// `parseType="Resource"` is the only parse type XMP has (ISO 16684-1 7.9.2.5); an
// `rdf:resource` element is empty.
if e.attribute((RDF, "parseType")).is_some_and(|p| p != "Resource") || e.attribute((RDF, "resource")).is_some() && !children.is_empty() {
return false;
}
let description = match children.as_slice() {
[d] if is(d, RDF, "Description") => Some(*d),
_ => None,
};
// `rdf:value` as an element of a `parseType="Resource"` node or `rdf:Description`, or as an
// attribute of the description or of an empty property element. Its qualifiers that are
// predefined properties keep their types too.
let node = if resource { Some(*e) } else { description };
if let Some(n) = node {
// A qualified value has one `rdf:value`: an element, or an attribute in its place.
match n.children().filter(|c| is(c, RDF, "value")).collect::<Vec<_>>().as_slice() {
[] => {}
[v] => return n.attribute((RDF, "value")).is_none() && blank(e) && blank(&n) && fits(ty, v, deeper) && predefined_ok(&n, deeper),
_ => return false,
}
}
let attribute_node = description.or_else(|| (children.is_empty() && !resource).then_some(*e));
if let Some(n) = attribute_node
&& let Some(v) = n.attribute((RDF, "value"))
{
return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper);
}
match ty {
Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => {
// `rdf:resource` makes the value a URI, which only a text type takes.
// The element is then empty.
if e.attribute((RDF, "resource")).is_some() {
return matches!(ty, Ty::Text) && children.is_empty() && blank(e);
}
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)),
None => false,
},
Ty::Seq(item) | Ty::Bag(item) | Ty::Alt(item) => {
let kind = match ty {
Ty::Seq(_) => "Seq",
Ty::Bag(_) => "Bag",
_ => "Alt",
};
array(e, &children, kind).is_some_and(|items| items.iter().all(|i| fits(*item, i, deeper)))
}
Ty::Struct(_, fields) => {
let Some(holder) = structure(e, &children, resource, description) else { return false };
let attrs_ok = field_attributes(&holder).all(|a| match a.namespace().and_then(|ns| field(fields, ns, a.name())) {
Some(t) => simple(t, a.value()),
None => true,
});
let elements_ok = holder.children().filter(Node::is_element).all(|f| {
let n = f.tag_name();
match n.namespace().and_then(|ns| field(fields, ns, n.name())) {
Some(t) => fits(t, &f, deeper),
None => true,
}
});
attrs_ok && elements_ok
}
// A structure of any fields, such as a Pantry item: predefined properties in it still have
// their types.
Ty::AnyStruct => structure(e, &children, resource, description).is_some_and(|holder| predefined_ok(&holder, deeper)),
Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)),
}
}
/// Whether the fields of `holder`, as attributes and elements, that are predefined properties have
/// their types.
fn predefined_ok(holder: &Node<'_, '_>, depth: usize) -> bool {
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, depth))
});
attrs_ok && elements_ok
}
fn field(fields: &[Field], ns: &str, name: &str) -> Option<Ty> {
fields.iter().find(|(n, l, _)| *n == ns && *l == name).map(|(_, _, t)| *t)
}
/// 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, or character data around
// them, breaks the array, and so does `rdf:parseType` or `rdf:resource` on the property,
// which makes its value something else.
[a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) && !other_value_form(e) => {
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,
}
}
/// 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 {
// 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 (blank(e) && blank(&d)).then_some(d);
}
// Fields written as attributes of the property element, with no content.
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)
}
/// Whether `e` says its value is a resource or a literal of another kind, not the element it holds.
fn other_value_form(e: &Node<'_, '_>) -> bool {
e.attribute((RDF, "parseType")).is_some() || e.attribute((RDF, "resource")).is_some()
}
/// Whether `e` holds no character data but white space.
fn blank(e: &Node<'_, '_>) -> bool {
text(e).trim().is_empty()
}
/// 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`.
fn simple(ty: Ty, v: &str) -> bool {
match ty {
Ty::Integer => integer(v),
Ty::Real => real(v),
Ty::Boolean => matches!(v, "True" | "False"),
Ty::Date => date(v),
Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)),
Ty::Text | Ty::Guid => true,
Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)),
_ => false,
}
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// ISO 16684-1 8.2.1.3.
fn integer(v: &str) -> bool {
digits(v.strip_prefix(['+', '-']).unwrap_or(v))
}
/// ISO 16684-1 8.2.1.4: an integer part, a fraction part, or both.
fn real(v: &str) -> bool {
let v = v.strip_prefix(['+', '-']).unwrap_or(v);
match v.split_once('.') {
Some((i, f)) => (i.is_empty() || digits(i)) && digits(f),
None => digits(v),
}
}
/// ISO 16684-1 8.2.1.2: YYYY, YYYY-MM, YYYY-MM-DD, or a date with hh:mm, hh:mm:ss or hh:mm:ss.s
/// and an optional time zone (Z, +hh:mm, -hh:mm).
fn date(v: &str) -> bool {
let num = |s: &str, len: usize, max: u32| s.len() == len && digits(s) && s.parse::<u32>().is_ok_and(|n| n <= max);
let in_range = |s: &str, min: u32, max: u32| num(s, 2, max) && s.parse::<u32>().is_ok_and(|n| n >= min);
let (day, time) = match v.split_once('T') {
Some((d, t)) => (d, Some(t)),
None => (v, None),
};
let parts: Vec<&str> = day.split('-').collect();
let day_ok = match parts.as_slice() {
[y] => num(y, 4, 9999),
[y, m] => num(y, 4, 9999) && in_range(m, 1, 12),
[y, m, d] => num(y, 4, 9999) && in_range(m, 1, 12) && in_range(d, 1, 31),
_ => false,
};
let Some(time) = time else { return day_ok };
if parts.len() != 3 || !day_ok {
return false;
}
let (clock, zone) = match time.find(['Z', '+', '-']) {
Some(i) => (time.get(..i).unwrap_or_default(), time.get(i..).unwrap_or_default()),
None => (time, ""),
};
let zone_ok = match zone {
"" | "Z" => true,
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),
[h, m, s] => {
let (whole, fraction) = s.split_once('.').map_or((*s, None), |(w, f)| (w, Some(f)));
num(h, 2, 23) && num(m, 2, 59) && num(whole, 2, 59) && fraction.is_none_or(digits)
}
_ => false,
};
zone_ok && clock_ok
}
+2 -2
View File
@@ -8017,8 +8017,8 @@ milestone = "M11"
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/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, 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."
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_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "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 their structures; the tiff, exif and aux namespaces, whose tables XMP Part 2 defers to CIPA DC-010, are 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"