diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 5fdec67fac..1cbe9a6ed3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -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, + /// 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, + /// The indirect object the problem is in, when that is what locates it: a metadata stream. + pub object: Option, /// 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 { - 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, page: Option, 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 { } // 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 { } 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 diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..e691adae84 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -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 { + let xmp = format!( + r#""# + ); + 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 { + mistyped(&format!("><{p}>{v}")) +} + +/// 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.0"), ["crs:Contrast"]); + assert!(element("pdf:Trapped", "True").is_empty()); + // rdf:resource makes a URI, which only a text type takes. + assert_eq!(mistyped(r#">"#), ["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"]); + assert_eq!( + mistyped(r#">"#), + ["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", "a"), ["crs:RawFileName"]); + assert_eq!(mistyped(r#">1"#), ["crs:RawFileName"]); + assert!(mistyped(r#">"#).is_empty()); + // Arrays: the kind and every item. + assert!(element("dc:creator", "AB").is_empty()); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:date", "2016-02-01yesterday"), ["dc:date"]); + assert_eq!(element("dc:date", "yesterday"), ["dc:date"]); + // Only white space around an array's items. + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + // rdf:parseType or rdf:resource on the property makes its value something other than the array. + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert!(element("dc:creator", "\n \n A\n \n").is_empty()); + // An rdf:resource element is empty. + assert_eq!(mistyped(r#">x"#), ["dc:source"]); + // Language alternatives: an rdf:Alt whose items all carry xml:lang. + assert!(element("dc:title", r#"T"#).is_empty()); + assert_eq!(element("dc:title", "T"), ["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#">d2016"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">letter"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + // Predefined properties inside a Pantry item keep their types. + let pantry = |v: &str| { + mistyped(&format!( + r#">{v}"# + )) + }; + assert!(pantry("5").is_empty()); + assert_eq!(pantry("high"), ["xmpMM:Pantry"]); + assert_eq!( + mistyped(r#">soon"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">8.5 in"#), ["xmpTPg:MaxPageSize"]); + // Nested: an array of structures holding a structure. + let marker = |start: &str| format!(r#">{start}"#); + assert!( + mistyped(&marker( + r#"501/25"# + )) + .is_empty() + ); + assert!(mistyped(&marker("50f")).is_empty()); + assert_eq!(mistyped(&marker(r#"5.5"#)), ["xmpDM:markers"]); + // A qualified value is checked by its rdf:value. + assert!(mistyped(r#">5q"#).is_empty()); + assert_eq!(mistyped(r#">five"#), ["crs:Contrast"]); + // rdf:value as an attribute of the description. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // rdf:value as an attribute of an empty property element. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // Qualifiers that are predefined properties keep their types. + assert_eq!( + mistyped(r#">5high"#), + ["crs:Contrast"] + ); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + assert!(mistyped(r#">"#).is_empty()); + // Nesting past any type's depth doesn't have the type. + let nested = |levels: usize| { + let open = r#""#.repeat(levels); + let close = "".repeat(levels); + mistyped(&format!(r#">{open}5{close}"#)) + }; + 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#""#); + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()) + }; + let issues = |doc: &Document| -> Vec { 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> = 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#""# + ); + 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::>(), [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#""# + ); + 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(">CyanT"), + [t("dc:title", "Lang Alt"), t("xmpTPg:PlateNames", "seq Text")] + ); + assert_eq!( + types(">A4h"), + [t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")] + ); + assert_eq!(types(">p"), [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#""# + ); + 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#">bright"#)), [n("crs:Exposure", "c:Exposure")]); + assert_eq!(found(&format!(r#">bright"#)), [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#">high"#).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(">x").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#">T"#).is_empty()); + + // Through verify, the written name is said when it differs. + let xmp = format!( + r#""# + ); + 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 = 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#">5five"#), + ["crs:Contrast"] + ); + assert_eq!( + mistyped(r#">5"#), + ["crs:Contrast"] + ); + assert!(mistyped(r#">5"#).is_empty()); + // Only parseType="Resource" has a place in XMP; an rdf:resource element holds no element. + assert_eq!(mistyped(r#">5"#), ["crs:Contrast"]); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">"#), + ["xmpMM:DerivedFrom"] + ); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 7a54d70a1b..b2ea91c2f6 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -29,13 +29,18 @@ pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option Result, ()> { +/// `xmp` parsed within the depth, attribute and node limits; `None` when it can't be. +pub(crate) fn parse(xmp: &str) -> Option> { 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, ()> { + 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())); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs new file mode 100644 index 0000000000..3563a671db --- /dev/null +++ b/crates/preflight/src/xmp_types.rs @@ -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)], 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::>().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> { + 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> = 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::>().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 { + fields.iter().find(|(n, l, _)| *n == ns && *l == name).map(|(_, _, t)| *t) +} + +/// The items of `e`'s value when it is an `rdf:` array. +fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Option>> { + 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> = 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>) -> Option> { + 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::().is_ok_and(|n| n <= max); + let in_range = |s: &str, min: u32, max: u32| num(s, 2, max) && s.parse::().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::>().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 +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9b341fcd8b..b2e651ec57 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"