diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 8d8b1b0550..1cbe9a6ed3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -151,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 {property} in metadata stream {object} 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", @@ -509,15 +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 (object, name) in xmp_types::mistyped_in(&objs) { - let values = vec![("{property}", name), ("{object}", object.to_string())]; - located_issue( - "6.6.2.3.1", - "The XMP property {property} in metadata stream {object} doesn't have the value type the XMP Specification gives it", - values, - object, - 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) { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b4b9f8009d..93273fad0e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,7 +507,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`. @@ -680,9 +680,9 @@ fn xmp_value_types_are_reported_by_verify() { assert_eq!( messages, [ - "The XMP property crs:Contrast in metadata stream 3 0 R doesn't have the value type the XMP Specification gives it", - "The XMP property crs:Exposure in metadata stream 3 0 R doesn't have the value type the XMP Specification gives it", - "The XMP property crs:Contrast in metadata stream 5 0 R 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" ] ); let objects: Vec> = found.iter().map(|i| i.object).collect(); @@ -692,7 +692,7 @@ fn xmp_value_types_are_reported_by_verify() { // 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!(found[0].said(&reordered).starts_with("In 3 0 R, crs:Contrast doesn't"), "{}", found[0].said(&reordered)); + 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!( @@ -808,6 +808,90 @@ fn wordings_fill_their_holes_once() { 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")]); + // The TIFF and EXIF types, as the 2005 edition writes them. + assert_eq!(types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N">"#), [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational")]); + assert_eq!( + types(">8firedo"), + [t("exif:Flash", "Flash"), t("exif:OECF", "OECF/SFR"), t("tiff:BitsPerSample", "seq Integer")] + ); +} + +/// 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] diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 9f925741d0..cd24e59a13 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -20,7 +20,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; @@ -54,7 +54,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. @@ -148,7 +149,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), @@ -157,16 +158,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), @@ -179,10 +180,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), ]; @@ -197,8 +198,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. @@ -255,7 +260,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. @@ -269,12 +274,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), @@ -284,22 +289,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)), ], @@ -312,14 +317,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), @@ -327,31 +332,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), @@ -361,23 +366,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), @@ -405,7 +410,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), @@ -413,7 +418,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), ], @@ -520,7 +525,7 @@ const SCHEMAS: &[Schema] = &[ ("ExposureProgram", Integer), ("SpectralSensitivity", Text), ("ISOSpeedRatings", Ty::Seq(&Integer)), - ("OECF", Ty::Struct(OECF_SFR)), + ("OECF", Ty::Struct("OECF/SFR", OECF_SFR)), ("ShutterSpeedValue", Rational), ("ApertureValue", Rational), ("BrightnessValue", Rational), @@ -529,11 +534,11 @@ const SCHEMAS: &[Schema] = &[ ("SubjectDistance", Rational), ("MeteringMode", Integer), ("LightSource", Integer), - ("Flash", Ty::Struct(FLASH)), + ("Flash", Ty::Struct("Flash", FLASH)), ("FocalLength", Rational), ("SubjectArea", Ty::Seq(&Integer)), ("FlashEnergy", Rational), - ("SpatialFrequencyResponse", Ty::Struct(OECF_SFR)), + ("SpatialFrequencyResponse", Ty::Struct("OECF/SFR", OECF_SFR)), ("FocalPlaneXResolution", Rational), ("FocalPlaneYResolution", Rational), ("FocalPlaneResolutionUnit", Integer), @@ -542,7 +547,7 @@ const SCHEMAS: &[Schema] = &[ ("SensingMethod", Integer), ("FileSource", Integer), ("SceneType", Integer), - ("CFAPattern", Ty::Struct(CFA_PATTERN)), + ("CFAPattern", Ty::Struct("CFAPattern", CFA_PATTERN)), ("CustomRendered", Integer), ("ExposureMode", Integer), ("WhiteBalance", Integer), @@ -553,7 +558,7 @@ const SCHEMAS: &[Schema] = &[ ("Contrast", Integer), ("Saturation", Integer), ("Sharpness", Integer), - ("DeviceSettingDescription", Ty::Struct(DEVICE_SETTINGS)), + ("DeviceSettingDescription", Ty::Struct("DeviceSettings", DEVICE_SETTINGS)), ("SubjectDistanceRange", Integer), ("ImageUniqueID", Text), ("GPSVersionID", Text), @@ -588,11 +593,9 @@ const SCHEMAS: &[Schema] = &[ Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] }, ]; -/// The properties of predefined schemas whose values don't have their type, in every metadata -/// stream among the indirect objects `objs` (ISO 32000-1 14.3.2): the stream and the property as -/// `prefix:name`, by stream in the order of `objs` and by name within one. -pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> Vec<(ObjRef, String)> { - let mut out = Vec::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 (r, o) in objs { let Object::Stream(s) = &**o else { continue }; @@ -605,10 +608,9 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> Vec<(Obj 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).into_iter().map(|name| (*r, name))); + f(*r, &parsed); } } - out } /// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or @@ -627,17 +629,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) { @@ -645,13 +650,53 @@ 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`, `GPSCoordinate`, `Lang Alt`, +/// `seq Rational`, a structure by its type's name, such as `Flash`. +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(), + Ty::GpsCoordinate => "GPSCoordinate".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)) @@ -723,7 +768,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 7371f9ff78..1a10b56a10 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/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "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/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "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 tiff, exif and aux from the September 2005 XMP Specification, with their structures), 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]]