mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M11: PDF/A verify checks that predefined XMP properties have their value types (optimize.pdfa-validate) (#6220)
This commit is contained in:
1 parent
1f48670fce
commit
ee2398f710
5 files changed
+1228
-24
No files matched your search
+89
-13
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user