From e6b5f2120c24fe384e6354c3409de61a54ad69b6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:32:05 +0800 Subject: [PATCH] M11: PDF/A verify says where a mistyped XMP property is, as written, and its type (6.6.2.3.1) A 6.6.2.3.1 issue now names the metadata stream that holds the property and carries its ObjRef in Issue::object, so a property mistyped in two streams is two issues. Wordings that name several things use named holes, filled in one pass. The message names the value type the XMP Specification gives the property (Integer, Lang Alt, seq ResourceEvent, Dimensions), and, when the packet writes the property with another prefix than the preferred one or none, the name as written. Tests also show that a property is known by its namespace URI, and that a property a predefined namespace doesn't define, or a namespace that isn't predefined, is left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 102 ++++++++++++++++---- crates/preflight/src/tests.rs | 122 +++++++++++++++++++++--- crates/preflight/src/xmp_types.rs | 149 +++++++++++++++++++----------- parity/acrobat-features.toml | 2 +- 4 files changed, 295 insertions(+), 80 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 590df20a14..1cbe9a6ed3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -64,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, } @@ -87,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; @@ -104,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 @@ -114,7 +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 {} doesn't have the value type the XMP Specification gives it", + "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", @@ -429,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. @@ -463,8 +510,30 @@ 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. - for name in xmp_types::mistyped_in(&objs) { - issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); + let 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) { @@ -522,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 adc4ac57b4..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; @@ -504,7 +506,7 @@ fn mistyped(body: &str) -> Vec { r#""# ); let parsed = xmp::parse(&xmp).expect("parses"); - xmp_types::mistyped(&parsed).into_iter().collect() + xmp_types::mistyped(&parsed).into_keys().map(|(name, _)| name).collect() } /// [`mistyped`] for the property `p` written as an element holding `v`. @@ -659,7 +661,7 @@ fn xmp_value_types_are_reported_by_verify() { 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, each once, in name order; not fixable. + // 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( @@ -677,11 +679,19 @@ fn xmp_value_types_are_reported_by_verify() { assert_eq!( messages, [ - "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it", - "The XMP property crs:Exposure doesn't have the value type the XMP Specification gives it" + "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" ] ); - assert!(found.iter().all(|i| !i.fixable)); + 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!( @@ -706,7 +716,97 @@ fn xmp_value_types_are_reported_by_verify() { ], "", ); - assert_eq!(issues(&doc).len(), 1); + 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` diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 333b39ab12..3563a671db 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -16,7 +16,7 @@ //! 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::BTreeSet; +use std::collections::BTreeMap; use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; use roxmltree::Node; @@ -48,7 +48,8 @@ enum Ty { Seq(&'static Ty), Bag(&'static Ty), Alt(&'static Ty), - Struct(&'static [Field]), + /// 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. @@ -142,7 +143,7 @@ const RESOURCE_REF: &[Field] = &[ /// Part 2 Table 10. const VERSION: &[Field] = &[ (ST_VER, "comments", Text), - (ST_VER, "event", Ty::Struct(RESOURCE_EVENT)), + (ST_VER, "event", Ty::Struct("ResourceEvent", RESOURCE_EVENT)), (ST_VER, "modifier", Text), (ST_VER, "modifyDate", Date), (ST_VER, "version", Text), @@ -151,16 +152,16 @@ const VERSION: &[Field] = &[ 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)), (XMP_DM, "useFileBeatsMarker", Boolean)]; + &[(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)]); +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(CUE_POINT_PARAM))), + (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), @@ -173,10 +174,10 @@ const MARKER: &[Field] = &[ ]; /// Part 2 Table 17. const MEDIA: &[Field] = &[ - (XMP_DM, "duration", Ty::Struct(TIME)), + (XMP_DM, "duration", Ty::Struct("Time", TIME)), (XMP_DM, "managed", Boolean), (XMP_DM, "path", Text), - (XMP_DM, "startTime", Ty::Struct(TIME)), + (XMP_DM, "startTime", Ty::Struct("Time", TIME)), (XMP_DM, "track", Text), (XMP_DM, "webStatement", Text), ]; @@ -191,8 +192,12 @@ const TIMECODE: &[Field] = &[(XMP_DM, "timeFormat", Text), (XMP_DM, "timeValue", /// 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))), (XMP_DM, "trackName", Text), (XMP_DM, "trackType", Text)]; +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. @@ -236,7 +241,7 @@ const SCHEMAS: &[Schema] = &[ ("ModifyDate", Date), ("Nickname", Text), ("Rating", Real), - ("Thumbnails", Ty::Alt(&Ty::Struct(THUMBNAIL))), + ("Thumbnails", Ty::Alt(&Ty::Struct("Thumbnail", THUMBNAIL))), ], }, // ISO 16684-1 Table 6. @@ -250,12 +255,12 @@ const SCHEMAS: &[Schema] = &[ uri: "http://ns.adobe.com/xap/1.0/mm/", prefix: "xmpMM", properties: &[ - ("DerivedFrom", Ty::Struct(RESOURCE_REF)), + ("DerivedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), ("DocumentID", Ty::Guid), - ("History", Ty::Seq(&Ty::Struct(RESOURCE_EVENT))), - ("Ingredients", Ty::Bag(&Ty::Struct(RESOURCE_REF))), + ("History", Ty::Seq(&Ty::Struct("ResourceEvent", RESOURCE_EVENT))), + ("Ingredients", Ty::Bag(&Ty::Struct("ResourceRef", RESOURCE_REF))), ("InstanceID", Ty::Guid), - ("ManagedFrom", Ty::Struct(RESOURCE_REF)), + ("ManagedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), ("Manager", Text), ("ManageTo", Text), ("ManageUI", Text), @@ -265,22 +270,22 @@ const SCHEMAS: &[Schema] = &[ ("RenditionClass", Text), ("RenditionParams", Text), ("VersionID", Text), - ("Versions", Ty::Seq(&Ty::Struct(VERSION))), + ("Versions", Ty::Seq(&Ty::Struct("Version", VERSION))), ("LastURL", Text), - ("RenditionOf", Ty::Struct(RESOURCE_REF)), + ("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)))] }, + 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))), - ("Fonts", Ty::Bag(&Ty::Struct(FONT))), - ("MaxPageSize", Ty::Struct(DIMENSIONS)), + ("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)), ], @@ -293,14 +298,14 @@ const SCHEMAS: &[Schema] = &[ ("absPeakAudioFilePath", Text), ("album", Text), ("altTapeName", Text), - ("altTimecode", Ty::Struct(TIMECODE)), + ("altTimecode", Ty::Struct("Timecode", TIMECODE)), ("artist", Text), ("audioModDate", Date), ("audioChannelType", Text), ("audioCompressor", Text), ("audioSampleRate", Integer), ("audioSampleType", Text), - ("beatSpliceParams", Ty::Struct(BEAT_SPLICE_STRETCH)), + ("beatSpliceParams", Ty::Struct("BeatSpliceStretch", BEAT_SPLICE_STRETCH)), ("cameraAngle", Text), ("cameraLabel", Text), ("cameraModel", Text), @@ -308,31 +313,31 @@ const SCHEMAS: &[Schema] = &[ ("client", Text), ("comment", Text), ("composer", Text), - ("contributedMedia", Ty::Bag(&Ty::Struct(MEDIA))), + ("contributedMedia", Ty::Bag(&Ty::Struct("Media", MEDIA))), ("copyright", Text), ("director", Text), ("directorPhotography", Text), - ("duration", Ty::Struct(TIME)), + ("duration", Ty::Struct("Time", TIME)), ("engineer", Text), ("fileDataRate", Rational), ("genre", Text), ("good", Boolean), ("instrument", Text), - ("introTime", Ty::Struct(TIME)), + ("introTime", Ty::Struct("Time", TIME)), ("key", Text), ("logComment", Text), ("loop", Boolean), ("numberOfBeats", Real), - ("markers", Ty::Seq(&Ty::Struct(MARKER))), + ("markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), ("metadataModDate", Date), - ("outCue", Ty::Struct(TIME)), + ("outCue", Ty::Struct("Time", TIME)), ("projectName", Text), - ("projectRef", Ty::Struct(PROJECT_LINK)), + ("projectRef", Ty::Struct("ProjectLink", PROJECT_LINK)), ("pullDown", Text), ("relativePeakAudioFilePath", Text), - ("relativeTimestamp", Ty::Struct(TIME)), + ("relativeTimestamp", Ty::Struct("Time", TIME)), ("releaseDate", Date), - ("resampleParams", Ty::Struct(RESAMPLE_STRETCH)), + ("resampleParams", Ty::Struct("ResampleStretch", RESAMPLE_STRETCH)), ("scaleType", Text), ("scene", Text), ("shotDate", Date), @@ -342,23 +347,23 @@ const SCHEMAS: &[Schema] = &[ ("shotNumber", Text), ("shotSize", Text), ("speakerPlacement", Text), - ("startTimecode", Ty::Struct(TIMECODE)), + ("startTimecode", Ty::Struct("Timecode", TIMECODE)), ("stretchMode", Text), ("takeNumber", Integer), ("tapeName", Text), ("tempo", Real), - ("timeScaleParams", Ty::Struct(TIME_SCALE_STRETCH)), + ("timeScaleParams", Ty::Struct("TimeScaleStretch", TIME_SCALE_STRETCH)), ("timeSignature", Text), ("trackNumber", Integer), - ("Tracks", Ty::Bag(&Ty::Struct(TRACK))), + ("Tracks", Ty::Bag(&Ty::Struct("Track", TRACK))), ("videoAlphaMode", Text), - ("videoAlphaPremultipleColor", Ty::Struct(COLORANT)), + ("videoAlphaPremultipleColor", Ty::Struct("Colorant", COLORANT)), ("videoAlphaUnityIsTransparent", Boolean), ("videoColorSpace", Text), ("videoCompressor", Text), ("videoFieldOrder", Text), ("videoFrameRate", Text), - ("videoFrameSize", Ty::Struct(DIMENSIONS)), + ("videoFrameSize", Ty::Struct("Dimensions", DIMENSIONS)), ("videoModDate", Date), ("videoPixelDepth", Text), ("videoPixelAspectRatio", Rational), @@ -386,7 +391,7 @@ const SCHEMAS: &[Schema] = &[ ("Country", Text), ("Credit", Text), ("DateCreated", Date), - ("DocumentAncestors", Ty::Bag(&Ty::Struct(ANCESTOR))), + ("DocumentAncestors", Ty::Bag(&Ty::Struct("Ancestor", ANCESTOR))), ("Headline", Text), ("History", Text), ("ICCProfile", Text), @@ -394,7 +399,7 @@ const SCHEMAS: &[Schema] = &[ ("Source", Text), ("State", Text), ("SupplementalCategories", Ty::Bag(&Text)), - ("TextLayers", Ty::Seq(&Ty::Struct(LAYER))), + ("TextLayers", Ty::Seq(&Ty::Struct("Layer", LAYER))), ("TransmissionReference", Text), ("Urgency", Integer), ], @@ -449,12 +454,11 @@ const SCHEMAS: &[Schema] = &[ }, ]; -/// The properties of predefined schemas whose values don't have their type, in every metadata -/// stream among the indirect objects `objs` (ISO 32000-1 14.3.2), as `prefix:name` in name order. -pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet { - let mut out = BTreeSet::new(); +/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that +/// parses as XML, 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 (_, o) in objs { + for (r, o) in objs { let Object::Stream(s) = &**o else { continue }; if s.dict.name(b"Type") != Some(b"Metadata") { continue; @@ -465,10 +469,9 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; let text = unicode(&bytes); if let Some(parsed) = crate::xmp::parse(&text) { - out.extend(mistyped(&parsed)); + f(*r, &parsed); } } - out } /// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or @@ -487,17 +490,20 @@ fn unicode(bytes: &[u8]) -> String { } } -/// The properties of predefined schemas in the packet `doc` whose values don't have their type, -/// as `prefix:name` in name order. -pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeSet { - let mut out = BTreeSet::new(); +/// 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()) { - out.insert(format!("{}:{}", schema.prefix, a.name())); + 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) { @@ -505,13 +511,52 @@ pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeSet { if let Some((schema, ty)) = name.namespace().and_then(|ns| property(ns, name.name())) && !fits(ty, &p, 0) { - out.insert(format!("{}:{}", schema.prefix, name.name())); + 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)) @@ -583,7 +628,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { }; array(e, &children, kind).is_some_and(|items| items.iter().all(|i| fits(*item, i, deeper))) } - Ty::Struct(fields) => { + 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()), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 37791fbe1e..ceb59539e9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8003,7 +8003,7 @@ 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/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/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +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]]