From 2cf0554b4a34e6866e049bfda28b6d3167587120 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 01:05:37 +0800 Subject: [PATCH 01/72] M11: PDF/A verify asks only the fonts something draws with to be embedded A font that only the interactive form's default resources hold (/AcroForm /DR, ISO 32000-1 12.7.2) draws nothing, yet Verify PDF/A reported it as not embedded, so a compliant form failed PDF/A-2b and 3b on clause 6.2.11.4. Only the fonts a resource dictionary names (a page's, a form XObject's or appearance stream's, a pattern's or a Type 3 font's) are now checked; an appearance stream that draws with a /DR font names it in its own /Resources and still has to embed it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 33 +++++++++++++++++++++++++++------ crates/preflight/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..614240c023 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -329,6 +329,24 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) out } +/// The fonts the document draws with: those a resource dictionary names (`/Resources`, of a page or +/// a page tree node, a form XObject or an annotation's appearance stream, a pattern or a Type 3 +/// font). PDF/A asks that every font "used for rendering" be embedded (ISO 19005-2, 6.2.11.4.1). A +/// font that only the interactive form's default resources hold (`/AcroForm /DR`, ISO 32000-1 +/// 12.7.2) draws nothing, so it needn't be: an appearance stream that does draw with it names it in +/// its own `/Resources`. +fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (_, o) in objs { + let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; + let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; + if let Some(fonts) = fonts.as_dict() { + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref())); + } + } + out +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. @@ -514,7 +532,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - check_objects(doc, level, &objs, &page_of, &mut issue); + let drawing = drawing_fonts(doc, &objs); + check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); @@ -525,28 +544,29 @@ fn check_objects( doc: &Document, level: Level, objects: &[(ObjRef, std::sync::Arc)], + drawing: &std::collections::HashSet, page_of: &impl Fn(ObjRef) -> Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - check_one_object(doc, level, *r, d, o, page_of, issue); + check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } } fn check_one_object( doc: &Document, level: Level, - reference: ObjRef, dict: &Dict, object: &Object, - page_of: &impl Fn(ObjRef) -> Option, + draws: bool, + page: Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. @@ -555,7 +575,8 @@ fn check_one_object( if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { issue("6.5.2", "A form field has additional actions", None, None, true); } - if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + // A Type 0 font's descendant is checked through it. + if draws && (ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some()) { check_font(doc, dict, issue); } if let Object::Stream(stream) = object { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..6747fdd62a 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,31 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A asks for the fonts "used for rendering" to be embedded (ISO 19005-2, 6.2.11.4.1). A font +/// that only the form's default resources hold (`/AcroForm /DR`, ISO 32000-1 12.7.2) draws +/// nothing, as in veraPDF's 6-4-1-t02-pass-a; one that a widget's appearance stream draws with is +/// named in that stream's `/Resources`, and still has to be embedded. +#[test] +fn only_fonts_something_draws_with_must_be_embedded() { + let fonts = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.11.4").map(|i| i.message).collect() }; + let form = |appearance_fonts: &str| { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 4 0 R /ZaDb 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << >> /Annots [6 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", + &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + ], + "", + ) + }; + assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); + assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // A page's own font is checked as before. + assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); +} From abb409bac63e8f6820b4ccda3ed46374c2002b8a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 01:25:45 +0800 Subject: [PATCH 02/72] M11: a font named through a chain of references is the font PDF/A checks A /Font entry can point at an object that is nothing but a reference to another (ISO 32000-1 7.3.10). The fonts something draws with were recorded by the entry's own reference, so the font dictionary at the end of such a chain went unchecked. Follow the chain, as resolve does, up to 32 references. (CodeRabbit review on #6172.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 15 ++++++++++++++- crates/preflight/src/tests.rs | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 614240c023..799942fb85 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -341,12 +341,25 @@ fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> s let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; if let Some(fonts) = fonts.as_dict() { - out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref())); + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref()).map(|r| font_object(doc, r))); } } out } +/// How many references [`font_object`] follows, as the document's own `resolve` does. +const MAX_REFERENCE_HOPS: usize = 32; + +/// The object a font entry's reference `r` ends at: an object can be nothing but a reference to +/// another (ISO 32000-1 7.3.10), and the font dictionary is the last one in that chain. +fn font_object(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(next) = &*doc.get(r) else { break }; + r = *next; + } + r +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6747fdd62a..53f29e677e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,12 +507,16 @@ fn only_fonts_something_draws_with_must_be_embedded() { "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + // An object that is nothing but a reference to the Helvetica font (ISO 32000-1 7.3.10). + "4 0 R", ], "", ) }; assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // Named through a reference to it, it is still the font something draws with. + assert_eq!(fonts(&form("/Font << /Helv 8 0 R >>")), ["The font Helvetica isn't embedded"]); // A page's own font is checked as before. assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); } From 7c83ff748e23584aa57e26daf25d90cd05bc6dee Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:44:11 +0800 Subject: [PATCH 03/72] M11: Verify PDF/A checks the implementation limits (ISO 19005-2 6.1.13) PDF/A-2 6.1.13 keeps a file within the limits of ISO 32000-1 Annex C (Table C.1 and the page sizes after it). Verify PDF/A now reports an integer outside 32 bits, a string over 32767 bytes, a name over 127 bytes (counted decoded, so #xx escapes don't inflate it) in an object or in content, and a page boundary under 3 or over 14400 units, read after inheritance so a page's own box overrides its parent's. Character identifiers over 65535 are not checked yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 21 ++++++ crates/preflight/src/limits.rs | 113 +++++++++++++++++++++++++++++++++ crates/preflight/src/tests.rs | 40 ++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 crates/preflight/src/limits.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..63ceaf9aa1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod limits; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -115,6 +116,10 @@ pub const WORDINGS: &[&str] = &[ "The title in the metadata and the document information differ", // The document as a whole. "The document is encrypted", + "An integer is outside the 32-bit range", + "A string is longer than 32767 bytes", + "A name is longer than 127 bytes", + "Page {} has a boundary smaller than 3 or larger than 14400 units", "Device colour is used, but there is no PDF/A output intent", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", @@ -515,6 +520,22 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } check_objects(doc, level, &objs, &page_of, &mut issue); + // 6.1.13: the implementation limits of ISO 32000-1 Annex C. + let over = limits::exceeded(&objs, &referenced_content(doc, &objs)); + if over.int { + issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); + } + if over.string { + issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); + } + if over.name { + issue("6.1.13", "A name is longer than 127 bytes", None, None, false); + } + for (i, p) in pages.iter().enumerate() { + if limits::page_box_out_of_range(doc, &p.dict) { + issue("6.1.13", "Page {} has a boundary smaller than 3 or larger than 14400 units", Some((i + 1).to_string()), Some(i), false); + } + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs new file mode 100644 index 0000000000..0c86e78d46 --- /dev/null +++ b/crates/preflight/src/limits.rs @@ -0,0 +1,113 @@ +//! PDF/A-2 6.1.13: a file stays within the implementation limits of ISO 32000-1 Annex C (Table C.1 +//! and the page size limits after it): integers that fit in 32 bits, strings of at most 32767 +//! bytes, names of at most 127 bytes, and page boundaries between 3 and 14400 units wide and high. +//! +//! The values are read from the indirect objects and from the content streams, as parsed: a name +//! written with `#xx` escapes counts its decoded bytes. Character identifiers above 65535 (in a +//! CMap) and the real-number range are not checked here. + +use std::collections::HashSet; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// Table C.1: the smallest and largest integer values. +const MIN_INT: i64 = i32::MIN as i64; +const MAX_INT: i64 = i32::MAX as i64; +/// Table C.1: the longest string, in bytes. +const MAX_STRING: usize = 32767; +/// Table C.1: the longest name, in bytes. +const MAX_NAME: usize = 127; +/// The smallest and largest page boundary, in default user space units. +const MIN_PAGE: f64 = 3.0; +const MAX_PAGE: f64 = 14400.0; +/// How deep a direct object is followed (arrays and dictionaries inside one another). +const MAX_DEPTH: u8 = 32; +/// The page boundaries, of which only the media and crop boxes are inherited. +const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox"]; + +/// Which limits a value went past. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Exceeded { + pub(crate) int: bool, + pub(crate) string: bool, + pub(crate) name: bool, +} + +impl Exceeded { + fn all(self) -> bool { + self.int && self.string && self.name + } + + fn name(&mut self, n: &[u8]) { + self.name |= n.len() > MAX_NAME; + } + + /// One value and, within [`MAX_DEPTH`], what it holds: a dictionary's keys count as names. + fn value(&mut self, o: &Object, depth: u8) { + if self.all() || depth > MAX_DEPTH { + return; + } + match o { + Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, + Object::Name(n) => self.name(n), + Object::Array(a) => { + for v in a { + self.value(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => self.dict(d, depth), + Object::Stream(s) => self.dict(&s.dict, depth), + Object::Null | Object::Bool(_) | Object::Real(_) | Object::Ref(_) => {} + } + } + + fn dict(&mut self, d: &Dict, depth: u8) { + for (k, v) in d.iter() { + self.name(k); + self.value(v, depth.saturating_add(1)); + } + } +} + +/// The limits that the indirect objects `objs`, and the content streams among them (`content`: +/// the ones only a reference says hold content, besides form XObjects and appearance streams), go +/// past. +pub(crate) fn exceeded(objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { + let mut found = Exceeded::default(); + for (r, o) in objs { + if found.all() { + break; + } + found.value(o, 0); + if let Object::Stream(s) = &**o + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = s.decoded() + { + for op in pdfcraft_content::ops(&data) { + if found.all() { + break; + } + for v in &op.operands { + found.value(v, 0); + } + if let Some((image, _)) = &op.inline { + found.dict(image, 0); + } + } + } + } + found +} + +/// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or +/// lower than 3 units, or wider or higher than 14400. +pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { + BOXES.iter().filter_map(|k| page.get(k)).any(|b| { + let b = doc.resolve(b); + let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + let [x0, y0, x1, y1] = v.as_slice() else { return false }; + let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); + [w, h].iter().any(|s| s.is_finite() && !(MIN_PAGE..=MAX_PAGE).contains(s)) + }) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..5adf411714 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,43 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): +/// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name +/// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page +/// boundary under 3 or over 14400 units, read after inheritance, so a page that overrides its +/// parent's box is judged by its own. +#[test] +fn implementation_limits_are_reported() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let doc = |extra: &str, content: &str, parent_box: &str, page_box: &str| { + let objects = [ + format!("<< /Type /Catalog /Pages 2 0 R {extra} >>"), + format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {parent_box} >>"), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R {page_box} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + build(&refs, "") + }; + let ok = "[0 0 200 200]"; + assert!(limits(&doc("", "", ok, "")).is_empty(), "{:?}", limits(&doc("", "", ok, ""))); + assert_eq!(limits(&doc("/Big 2147483648", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&doc("/Small -2147483649", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert!(limits(&doc("/Edge -2147483648 /Top 2147483647", "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "2147483648 0 Td", ok, "")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&doc(&format!("/S ({long})"), "", ok, "")), ["A string is longer than 32767 bytes"]); + assert_eq!(limits(&doc("", &format!("BT ({long}) Tj ET"), ok, "")), ["A string is longer than 32767 bytes"]); + assert!(limits(&doc(&format!("/S ({})", "x".repeat(32767)), "", ok, "")).is_empty()); + let name = "n".repeat(128); + assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + // 200 bytes as written, 66 once the escapes are read. + let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); + assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "", "[0 0 2 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); +} From e001b9f4b0bffe1b45ad1f34495e86556c185d66 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:50:18 +0800 Subject: [PATCH 04/72] M11: PDF/A verify checks Separation and DeviceN colour spaces (6.2.4.4) Separation spaces of one colorant name must agree on their alternate space and tint transform, inside a DeviceN space's /Colorants too, and a DeviceN space must name each spot colorant it uses in its /Colorants (ISO 32000-1 8.6.6.5, Table 71). Process colorants (Cyan, Magenta, Yellow, Black and an NChannel space's /Process components) and None need no entry. Verify PDF/A now reports both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 11 +++ crates/preflight/src/spot.rs | 122 ++++++++++++++++++++++++++++++++++ crates/preflight/src/tests.rs | 54 +++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 crates/preflight/src/spot.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..93def2e7f5 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod spot; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -119,6 +120,8 @@ pub const WORDINGS: &[&str] = &[ "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", + "Separation colour spaces named {} differ in their alternate space or tint transform", + "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", "The document has additional actions (/AA)", "The document has document-level JavaScript", "The form asks viewers to make appearances (NeedAppearances)", @@ -515,6 +518,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } check_objects(doc, level, &objs, &page_of, &mut issue); + // 6.2.4.4: Separation and DeviceN colour spaces. + let spots = spot::check(doc, &objs); + for name in spots.differing { + issue("6.2.4.4", "Separation colour spaces named {} differ in their alternate space or tint transform", Some(name), None, false); + } + for name in spots.undefined { + issue("6.2.4.4", "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", Some(name), None, false); + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs new file mode 100644 index 0000000000..c0276398d9 --- /dev/null +++ b/crates/preflight/src/spot.rs @@ -0,0 +1,122 @@ +//! PDF/A-2 6.2.4.4, Separation and DeviceN colour spaces: +//! +//! - Separation spaces of the same colorant name agree on their alternate space and tint +//! transform, also where a DeviceN space's `/Colorants` dictionary holds them; +//! - a DeviceN space names each spot colorant it uses in the `/Colorants` dictionary of its +//! attributes (ISO 32000-1 8.6.6.5, Table 71). The process colorants (`Cyan`, `Magenta`, +//! `Yellow`, `Black`, and those an NChannel space's `/Process` lists) and `None` are not spot +//! colorants. +//! +//! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings +//! and other colour spaces, direct or by reference. + +use std::collections::{BTreeSet, HashMap}; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +/// How deep a direct object is followed (arrays and dictionaries inside one another). +const MAX_DEPTH: u8 = 32; +/// The colorants of DeviceCMYK, which a DeviceN space may name without defining them. +const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; + +/// What the colour spaces of a document break of 6.2.4.4. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Colorant names whose Separation spaces differ, in name order. + pub(crate) differing: BTreeSet, + /// Spot colorants a DeviceN space uses without a `/Colorants` entry, in name order. + pub(crate) undefined: BTreeSet, +} + +/// The 6.2.4.4 findings over the indirect objects `objs`. +pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { + let mut scan = Scan { doc, separations: HashMap::new(), findings: Findings::default() }; + for (_, o) in objs { + scan.walk(o, 0); + } + scan.findings +} + +struct Scan<'a> { + doc: &'a Document, + /// Each colorant name's first Separation space: its alternate space and tint transform. + separations: HashMap, (Object, Object)>, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Array(a) => { + match a.first().and_then(Object::as_name) { + Some(b"Separation") => self.separation(a), + Some(b"DeviceN") => self.devicen(a), + _ => {} + } + for v in a { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => { + for (_, v) in d.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Stream(s) => { + for (_, v) in s.dict.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + _ => {} + } + } + + /// `[/Separation name alternateSpace tintTransform]`: the same name must keep the same + /// alternate space and tint transform. `All` and `None` name no colorant of their own. + fn separation(&mut self, a: &[Object]) { + let [_, name, alternate, tint, ..] = a else { return }; + let Some(name) = name.as_name() else { return }; + if name == b"All" || name == b"None" { + return; + } + let this = ((*self.doc.resolve(alternate)).clone(), (*self.doc.resolve(tint)).clone()); + match self.separations.get(name) { + Some(first) if *first != this => { + self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); + } + Some(_) => {} + None => { + self.separations.insert(name.to_vec(), this); + } + } + } + + /// `[/DeviceN names alternateSpace tintTransform attributes]`: each spot colorant in `names` + /// needs an entry in the attributes' `/Colorants`. + fn devicen(&mut self, a: &[Object]) { + let Some(names) = a.get(1).map(|n| self.doc.resolve(n)) else { return }; + let Some(names) = names.as_array() else { return }; + let attributes = a.get(4).map(|d| self.doc.resolve(d)); + let attributes = attributes.as_deref().and_then(Object::as_dict); + let colorants = attributes.and_then(|d| d.get(b"Colorants")).map(|c| self.doc.resolve(c)); + let colorants = colorants.as_deref().and_then(Object::as_dict); + let process = attributes + .and_then(|d| d.get(b"Process")) + .map(|p| self.doc.resolve(p)) + .and_then(|p| p.as_dict().and_then(|p| p.get(b"Components")).map(|c| self.doc.resolve(c))); + let process: Vec> = process + .as_deref() + .and_then(Object::as_array) + .map(|c| c.iter().filter_map(|n| n.as_name().map(<[u8]>::to_vec)).collect()) + .unwrap_or_default(); + for n in names.iter().filter_map(Object::as_name) { + let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n); + if spot && !colorants.is_some_and(|c| c.get(n).is_some()) { + self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned()); + } + } + } +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..ff0f5ec802 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,57 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.2.4.4: Separation spaces of one colorant name agree on their alternate space and tint +/// transform, also inside a DeviceN space's `/Colorants`; and a DeviceN space names each spot +/// colorant it uses in its `/Colorants` (ISO 32000-1 8.6.6.5, Table 71). Process colorants (the +/// CMYK four, and an NChannel space's `/Process` components) and `None` need no entry. +#[test] +fn separation_and_devicen_spaces_are_checked() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let red = |alt: &str, f: u32| format!("[/Separation /Red /{alt} {f} 0 R]"); + // The same name, the same alternate and an identical tint transform (another object): fine. + assert!(spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 6))).is_empty()); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceGray", 4))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Inside two DeviceN spaces' Colorants. + let nchannel = |f: u32| format!("[/DeviceN [/Red] /DeviceRGB 4 0 R << /Subtype /NChannel /Colorants << /Red {} >> >>]", red("DeviceRGB", f)); + assert_eq!( + spots(&format!("/A {} /B {}", nchannel(4), nchannel(5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Spot colorants need a Colorants entry; process colorants and None don't. + assert_eq!( + spots("/A [/DeviceN [/Spot1 /Cyan /None] /DeviceCMYK 4 0 R]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert_eq!( + spots("/A [/DeviceN [/Spot1] /DeviceRGB 4 0 R << /Colorants << >> >>]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert!(spots(&format!("/A [/DeviceN [/Red /Black] /DeviceRGB 4 0 R << /Colorants << /Red {} >> >>]", red("DeviceRGB", 4))).is_empty()); + assert!( + spots("/A [/DeviceN [/PrCyan /Black] /DeviceCMYK 4 0 R << /Subtype /NChannel /Process << /ColorSpace /DeviceCMYK /Components [/PrCyan /M /Y /Black] >> >>]") + .is_empty() + ); +} From e21c600cdba36edfa417011371238573a059ae07 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:02:51 +0800 Subject: [PATCH 05/72] M11: 6.1.13 reads the trailer, joined page contents and deep values, within one budget From the review of #6202: - the trailer is scanned with the indirect objects; - a page's /Contents streams are read joined, as one content stream: operands may end one stream and their operator start the next (ISO 32000-1 7.8.2); - values are followed as deep as the parser nests them (128); - content is decoded within one 256 MiB allowance for the document; - a page box whose size overflows to infinity is reported; - the test covers a long name used as a content operand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 2 +- crates/preflight/src/limits.rs | 104 ++++++++++++++++++++++++--------- crates/preflight/src/tests.rs | 40 +++++++++++++ 3 files changed, 118 insertions(+), 28 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 63ceaf9aa1..059fff4a36 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,7 +521,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } check_objects(doc, level, &objs, &page_of, &mut issue); // 6.1.13: the implementation limits of ISO 32000-1 Annex C. - let over = limits::exceeded(&objs, &referenced_content(doc, &objs)); + let over = limits::exceeded(doc, &objs, &referenced_content(doc, &objs)); if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 0c86e78d46..7930cb9bc4 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -2,13 +2,16 @@ //! and the page size limits after it): integers that fit in 32 bits, strings of at most 32767 //! bytes, names of at most 127 bytes, and page boundaries between 3 and 14400 units wide and high. //! -//! The values are read from the indirect objects and from the content streams, as parsed: a name -//! written with `#xx` escapes counts its decoded bytes. Character identifiers above 65535 (in a -//! CMap) and the real-number range are not checked here. +//! The values are read from the trailer, the indirect objects and the content streams, as parsed: +//! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read +//! joined, as one content stream (an operator's operands may end one stream and the operator start +//! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. +//! Character identifiers above 65535 (in a CMap) and the real-number range are not checked here, +//! nor an integer token too long for 64 bits, which the parser reads as a real. use std::collections::HashSet; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; /// Table C.1: the smallest and largest integer values. const MIN_INT: i64 = i32::MIN as i64; @@ -20,8 +23,11 @@ const MAX_NAME: usize = 127; /// The smallest and largest page boundary, in default user space units. const MIN_PAGE: f64 = 3.0; const MAX_PAGE: f64 = 14400.0; -/// How deep a direct object is followed (arrays and dictionaries inside one another). -const MAX_DEPTH: u8 = 32; +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The decoded content, across all the document's content streams, the scan reads. +const MAX_CONTENT_BYTES: usize = 256 << 20; /// The page boundaries, of which only the media and crop boxes are inherited. const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox"]; @@ -62,6 +68,21 @@ impl Exceeded { } } + /// The operands of one content stream, inline images' dictionaries included. + fn content(&mut self, data: &[u8]) { + for op in pdfcraft_content::ops(data) { + if self.all() { + return; + } + for v in &op.operands { + self.value(v, 0); + } + if let Some((image, _)) = &op.inline { + self.dict(image, 0); + } + } + } + fn dict(&mut self, d: &Dict, depth: u8) { for (k, v) in d.iter() { self.name(k); @@ -70,36 +91,64 @@ impl Exceeded { } } -/// The limits that the indirect objects `objs`, and the content streams among them (`content`: -/// the ones only a reference says hold content, besides form XObjects and appearance streams), go -/// past. -pub(crate) fn exceeded(objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { +/// The limits that the trailer, the indirect objects `objs` and the content streams go past. +/// `content` holds the content streams only a reference identifies (a page's `/Contents`, Type 3 +/// glyph procedures), besides form XObjects and appearance streams, which say so themselves. +pub(crate) fn exceeded(doc: &Document, objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { let mut found = Exceeded::default(); - for (r, o) in objs { + found.dict(doc.trailer(), 0); + for (_, o) in objs { if found.all() { - break; + return found; } found.value(o, 0); - if let Object::Stream(s) = &**o - && (content.contains(r) || crate::is_content(&s.dict)) - && let Ok(data) = s.decoded() - { - for op in pdfcraft_content::ops(&data) { - if found.all() { - break; - } - for v in &op.operands { - found.value(v, 0); - } - if let Some((image, _)) = &op.inline { - found.dict(image, 0); - } + } + let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for page in pdfcraft_model::pages(doc) { + if found.all() || budget.remaining() == 0 { + return found; + } + let refs = contents(doc, &page.dict); + let mut joined = Vec::new(); + for r in &refs { + if let Object::Stream(s) = &*doc.get(*r) + && let Ok(data) = budget.decode(s) + { + joined.extend_from_slice(&data); + joined.push(b' '); } } + read.extend(refs); + found.content(&joined); + } + for (r, o) in objs { + if found.all() || budget.remaining() == 0 { + break; + } + if let Object::Stream(s) = &**o + && !read.contains(r) + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = budget.decode(s) + { + found.content(&data); + } } found } +/// A page's `/Contents` streams, in order. +fn contents(doc: &Document, page: &Dict) -> Vec { + match page.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => vec![*r], + }, + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + _ => Vec::new(), + } +} + /// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or /// lower than 3 units, or wider or higher than 14400. pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { @@ -108,6 +157,7 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); let [x0, y0, x1, y1] = v.as_slice() else { return false }; let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); - [w, h].iter().any(|s| s.is_finite() && !(MIN_PAGE..=MAX_PAGE).contains(s)) + // A size past `f64` (corners near ±1e308) is as much too large as any other. + [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 5adf411714..0255348361 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -521,6 +521,7 @@ fn implementation_limits_are_reported() { let name = "n".repeat(128); assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc("", &format!("/{name} Do"), ok, "")), ["A name is longer than 127 bytes"]); // 200 bytes as written, 66 once the escapes are read. let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); @@ -528,3 +529,42 @@ fn implementation_limits_are_reported() { assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); } + +/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one +/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the +/// parser allows, and a page box whose size overflows to infinity. +#[test] +fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |data: &str| { + format!( + "<< /Length {} >> +stream +{data} +endstream", + data.len() + ) + }; + let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| { + let (first, second) = contents.split_once('|').unwrap_or((contents, "")); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + &format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>", + &stream(first), + &stream(second), + ], + trailer, + ) + }; + let ok = "[0 0 200 200]"; + assert!(limits(&page("|", "", ok, "")).is_empty()); + assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); + let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&page("|", "", "[-1e308 0 1e308 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); +} From 72bc2548fb6cb5b1bfb1653c4fee071c3784efec Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:06:05 +0800 Subject: [PATCH 06/72] M11: 6.2.4.4 compares by content, resolves names, and has no All/None exemption From the review of #6203: - Separation definitions are compared as objects, references followed at any depth (to 32), so identical streams under different numbers agree; - All and None are compared like any other colorant name; - colorant names and /Process components given by reference are resolved; - /Process exempts its components only in an NChannel space; - a null /Colorants entry, or one referring to no object, defines nothing; - colour spaces are found as deep as the parser nests them (128). Corpus results are unchanged (15 of 23 6.2.4.4 files right, no new false failure). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/spot.rs | 69 ++++++++++++++++++++++++++--------- crates/preflight/src/tests.rs | 37 +++++++++++++++++++ 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs index c0276398d9..b7df8ddb30 100644 --- a/crates/preflight/src/spot.rs +++ b/crates/preflight/src/spot.rs @@ -1,11 +1,12 @@ //! PDF/A-2 6.2.4.4, Separation and DeviceN colour spaces: //! //! - Separation spaces of the same colorant name agree on their alternate space and tint -//! transform, also where a DeviceN space's `/Colorants` dictionary holds them; +//! transform, also where a DeviceN space's `/Colorants` dictionary holds them. They are compared +//! as objects, whether stored directly or by reference, at any depth (ISO 32000-1 7.3.10); //! - a DeviceN space names each spot colorant it uses in the `/Colorants` dictionary of its -//! attributes (ISO 32000-1 8.6.6.5, Table 71). The process colorants (`Cyan`, `Magenta`, -//! `Yellow`, `Black`, and those an NChannel space's `/Process` lists) and `None` are not spot -//! colorants. +//! attributes (ISO 32000-1 8.6.6.5, Table 71); a null or missing entry defines nothing. The +//! process colorants (`Cyan`, `Magenta`, `Yellow`, `Black`, and those an NChannel space's +//! `/Process` lists) and `None` are not spot colorants. //! //! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings //! and other colour spaces, direct or by reference. @@ -14,8 +15,11 @@ use std::collections::{BTreeSet, HashMap}; use pdfcraft_cos::{Document, ObjRef, Object}; -/// How deep a direct object is followed (arrays and dictionaries inside one another). -const MAX_DEPTH: u8 = 32; +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// How deep two definitions are compared, references followed. +const MAX_COMPARE_DEPTH: u8 = 32; /// The colorants of DeviceCMYK, which a DeviceN space may name without defining them. const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; @@ -75,19 +79,18 @@ impl Scan<'_> { } /// `[/Separation name alternateSpace tintTransform]`: the same name must keep the same - /// alternate space and tint transform. `All` and `None` name no colorant of their own. + /// alternate space and tint transform. fn separation(&mut self, a: &[Object]) { let [_, name, alternate, tint, ..] = a else { return }; + let name = self.doc.resolve(name); let Some(name) = name.as_name() else { return }; - if name == b"All" || name == b"None" { - return; - } - let this = ((*self.doc.resolve(alternate)).clone(), (*self.doc.resolve(tint)).clone()); + let this = (alternate.clone(), tint.clone()); match self.separations.get(name) { - Some(first) if *first != this => { - self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); + Some((alt, fun)) => { + if !same(self.doc, alt, &this.0, 0) || !same(self.doc, fun, &this.1, 0) { + self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); + } } - Some(_) => {} None => { self.separations.insert(name.to_vec(), this); } @@ -103,20 +106,52 @@ impl Scan<'_> { let attributes = attributes.as_deref().and_then(Object::as_dict); let colorants = attributes.and_then(|d| d.get(b"Colorants")).map(|c| self.doc.resolve(c)); let colorants = colorants.as_deref().and_then(Object::as_dict); + let nchannel = attributes.and_then(|d| d.get(b"Subtype")).map(|t| self.doc.resolve(t)).is_some_and(|t| t.as_name() == Some(b"NChannel")); let process = attributes + .filter(|_| nchannel) .and_then(|d| d.get(b"Process")) .map(|p| self.doc.resolve(p)) .and_then(|p| p.as_dict().and_then(|p| p.get(b"Components")).map(|c| self.doc.resolve(c))); let process: Vec> = process .as_deref() .and_then(Object::as_array) - .map(|c| c.iter().filter_map(|n| n.as_name().map(<[u8]>::to_vec)).collect()) + .map(|c| c.iter().filter_map(|n| self.doc.resolve(n).as_name().map(<[u8]>::to_vec)).collect()) .unwrap_or_default(); - for n in names.iter().filter_map(Object::as_name) { + for n in names.iter().map(|n| self.doc.resolve(n)) { + let Some(n) = n.as_name() else { continue }; let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n); - if spot && !colorants.is_some_and(|c| c.get(n).is_some()) { + // A null entry, or one referring to no object, defines nothing (ISO 32000-1 7.3.9). + let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| !matches!(*self.doc.resolve(e), Object::Null)); + if spot && !defined { self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned()); } } } } + +/// Whether `a` and `b` are the same object, wherever their parts are stored: references are +/// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object numbers +/// are the same. +fn same(doc: &Document, a: &Object, b: &Object, depth: u8) -> bool { + if let (Object::Ref(x), Object::Ref(y)) = (a, b) + && x == y + { + return true; + } + if depth > MAX_COMPARE_DEPTH { + return false; + } + let (a, b) = (doc.resolve(a), doc.resolve(b)); + let deeper = depth.saturating_add(1); + match (&*a, &*b) { + (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same(doc, p, q, deeper)), + (Object::Dict(x), Object::Dict(y)) => same_dict(doc, x, y, deeper), + (Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && same_dict(doc, &x.dict, &y.dict, deeper), + (x, y) => x == y, + } +} + +fn same_dict(doc: &Document, a: &pdfcraft_cos::Dict, b: &pdfcraft_cos::Dict, depth: u8) -> bool { + // `/Length` may be written directly in one and by reference in the other: `same` resolves it. + a.len() == b.len() && a.iter().all(|(k, v)| b.get(k).is_some_and(|w| same(doc, v, w, depth))) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index ff0f5ec802..eeaf376e6a 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -542,3 +542,40 @@ fn separation_and_devicen_spaces_are_checked() { .is_empty() ); } + +/// From the review of the 6.2.4.4 checks: nested parts compare by content, `All` and `None` get +/// no exemption, names may be given by reference, `/Process` exempts only in an NChannel space, a +/// null `/Colorants` entry defines nothing, and a colour space nested deep is still found. +#[test] +fn separation_and_devicen_checks_follow_references_depth_and_nchannel() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let icc = "<< /N 3 /Length 4 >>\nstream\nabcd\nendstream"; + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + icc, + icc, + "/Red", + "null", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = |n: &str| vec![format!("Separation colour spaces named {n} differ in their alternate space or tint transform")]; + let undefined = |n: &str| vec![format!("The spot colour {n} of a DeviceN colour space isn't in its Colorants dictionary")]; + // Identical ICC streams under different numbers are the same alternate space. + assert!(spots("/A [/Separation /Red [/ICCBased 6 0 R] 4 0 R] /B [/Separation /Red [/ICCBased 7 0 R] 4 0 R]").is_empty()); + assert_eq!(spots("/A [/Separation /All /DeviceRGB 4 0 R] /B [/Separation /All /DeviceRGB 5 0 R]"), differ("All")); + assert_eq!(spots("/A [/Separation 8 0 R /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 5 0 R]"), differ("Red")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Process << /Components [/Spot] >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot null >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot 9 0 R >> >>]"), undefined("Spot")); + let deep = format!("/A {}[/DeviceN [/Spot] /DeviceRGB 4 0 R]{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(spots(&deep), undefined("Spot")); +} From 841b4ab698e6b1d439302483285af2cc79aac1b4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:15:25 +0800 Subject: [PATCH 07/72] M11: parity: Verify PDF/A checks Separation and DeviceN spaces (6.2.4.4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..93b8eef721 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::separation_and_devicen_spaces_are_checked", "crates/preflight/src/tests.rs::separation_and_devicen_checks_follow_references_depth_and_nchannel"] +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, and Separation and DeviceN spaces (6.2.4.4: one definition per colorant name, spot colorants in /Colorants; a device alternate without a matching output intent is not yet reported). Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 8d948e1dd0e948ea7e35578d2e03e35194cbf229 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:16:15 +0800 Subject: [PATCH 08/72] =?UTF-8?q?M11:=206.1.13=20also=20reports=20reals=20?= =?UTF-8?q?past=20=C2=B13.403e38=20and=20CMaps=20mapping=20to=20CIDs=20abo?= =?UTF-8?q?ve=2065535?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the clause. Table C.1 of ISO 32000-1 Annex C bounds real values at about ±3.403 × 10^38 and character identifiers at 65535. A real past that range (an integer token too long even for a real reads as one) is reported, and so is an embedded CMap of a Type 0 font, or one it uses through /UseCMap, whose begincidrange entry reaches past CID 65535 or whose begincidchar entry names one (ISO 32000-1 9.7.5.4). A CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded within one 16 MiB allowance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 8 ++++ crates/preflight/src/limits.rs | 68 ++++++++++++++++++++++++++++++++-- crates/preflight/src/tests.rs | 42 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 059fff4a36..40f0bb78d1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,6 +117,8 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "An integer is outside the 32-bit range", + "A real number is outside ±3.403 × 10³⁸", + "A CMap maps a character code to a CID above 65535", "A string is longer than 32767 bytes", "A name is longer than 127 bytes", "Page {} has a boundary smaller than 3 or larger than 14400 units", @@ -525,9 +527,15 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } + if over.real { + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸", None, None, false); + } if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); } + if limits::cid_over_limit(doc, &objs) { + issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); + } if over.name { issue("6.1.13", "A name is longer than 127 bytes", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 7930cb9bc4..ec9886dcf5 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -6,8 +6,9 @@ //! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read //! joined, as one content stream (an operator's operands may end one stream and the operator start //! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. -//! Character identifiers above 65535 (in a CMap) and the real-number range are not checked here, -//! nor an integer token too long for 64 bits, which the parser reads as a real. +//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so is an embedded CMap of a Type 0 font +//! (or one it uses through `/UseCMap`) that maps a code to a CID above 65535. An integer token too +//! long even for a real reads as one past that range, and is reported as a real. use std::collections::HashSet; @@ -16,6 +17,14 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; /// Table C.1: the smallest and largest integer values. const MIN_INT: i64 = i32::MIN as i64; const MAX_INT: i64 = i32::MAX as i64; +/// Table C.1: the largest real value (approximate). +const MAX_REAL: f64 = 3.403e38; +/// Table C.1: the largest character identifier. +const MAX_CID: u64 = 65535; +/// The decoded CMap data, across all the document's embedded CMaps, the scan reads. +const MAX_CMAP_BYTES: usize = 16 << 20; +/// How many CMaps a `/UseCMap` chain is followed through. +const MAX_CMAP_CHAIN: usize = 8; /// Table C.1: the longest string, in bytes. const MAX_STRING: usize = 32767; /// Table C.1: the longest name, in bytes. @@ -35,13 +44,14 @@ const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"A #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Exceeded { pub(crate) int: bool, + pub(crate) real: bool, pub(crate) string: bool, pub(crate) name: bool, } impl Exceeded { fn all(self) -> bool { - self.int && self.string && self.name + self.int && self.real && self.string && self.name } fn name(&mut self, n: &[u8]) { @@ -55,6 +65,7 @@ impl Exceeded { } match o { Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL, Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, Object::Name(n) => self.name(n), Object::Array(a) => { @@ -64,7 +75,7 @@ impl Exceeded { } Object::Dict(d) => self.dict(d, depth), Object::Stream(s) => self.dict(&s.dict, depth), - Object::Null | Object::Bool(_) | Object::Real(_) | Object::Ref(_) => {} + Object::Null | Object::Bool(_) | Object::Ref(_) => {} } } @@ -161,3 +172,52 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } + +/// Whether an embedded CMap of a Type 0 font among `objs`, or one it uses through `/UseCMap`, maps +/// a code to a CID above 65535: a `begincidrange` entry ` cid` reaches +/// `cid + high − low`, a `begincidchar` entry ` cid` reaches `cid` (ISO 32000-1 9.7.5.4). A +/// CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded +/// within one allowance for the document. +pub(crate) fn cid_over_limit(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> bool { + let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for (_, o) in objs { + let Some(d) = o.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Type0") { + continue; + } + let mut next = d.get(b"Encoding").and_then(Object::as_ref); + for _ in 0..MAX_CMAP_CHAIN { + let Some(r) = next.filter(|r| read.insert(*r)) else { break }; + if budget.remaining() == 0 { + return false; + } + let Object::Stream(cmap) = &*doc.get(r) else { break }; + if budget.decode(cmap).is_ok_and(|data| maps_past_limit(&data)) { + return true; + } + next = cmap.dict.get(b"UseCMap").and_then(Object::as_ref); + } + } + false +} + +/// Whether the CMap program `data` maps a code to a CID above [`MAX_CID`]. The entries of a +/// `begincidrange` … `endcidrange` block are the operands of its `endcidrange`, read as content is. +fn maps_past_limit(data: &[u8]) -> bool { + let value = |o: &Object| -> Option { + match o { + Object::String(s) if s.bytes.len() <= 4 => Some(s.bytes.iter().fold(0u64, |v, b| (v << 8) | u64::from(*b))), + Object::Int(i) => u64::try_from(*i).ok(), + _ => None, + } + }; + pdfcraft_content::ops(data).any(|op| match op.op.as_slice() { + b"endcidrange" => op.operands.as_chunks::<3>().0.iter().any(|[low, high, cid]| match (value(low), value(high), value(cid)) { + (Some(low), Some(high), Some(cid)) => cid.saturating_add(high.saturating_sub(low)) > MAX_CID, + _ => false, + }), + b"endcidchar" => op.operands.as_chunks::<2>().0.iter().any(|[_, cid]| value(cid).is_some_and(|cid| cid > MAX_CID)), + _ => false, + }) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0255348361..6893a42e2f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -566,5 +566,45 @@ endstream", assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); - assert_eq!(limits(&page("|", "", "[-1e308 0 1e308 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. + assert_eq!( + limits(&page("|", "", "[-1e308 0 1e308 200]", "")), + ["A real number is outside ±3.403 × 10³⁸", "Page 1 has a boundary smaller than 3 or larger than 14400 units"] + ); +} + +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸, and +/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching +/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). +#[test] +fn implementation_limits_cover_reals_and_cids() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = |catalog_extra: &str, cmap: &str, used: &str| { + let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap); + let second = stream("/Type /CMap /CMapName /Test-B", used); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>", + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>", + &first, + &second, + ], + "", + ) + }; + let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange"); + let near = format!("34{}.0", "0".repeat(37)); + let far = format!("4{}.0", "0".repeat(38)); + assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + let cid = ["A CMap maps a character code to a CID above 65535"]; + assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); + assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); + assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid); + assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap"); } From 6ebfada74d246957764c359954122c4d8baf6457 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:16:15 +0800 Subject: [PATCH 09/72] M11: parity: Verify PDF/A checks the implementation limits (6.1.13) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..cb5b9f1ff9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::implementation_limits_are_reported", "crates/preflight/src/tests.rs::implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes", "crates/preflight/src/tests.rs::implementation_limits_cover_reals_and_cids"] +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, and the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C). Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 2c5e69a8e251fa1e24786c3ca6ed13da9b2e72e6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:33:21 +0800 Subject: [PATCH 10/72] M11: Verify PDF/A compares Separation spaces by decoded data and checks Colorants entries A /Colorants entry counts only as a Separation space of its own colorant (ISO 32000-1 8.6.6.5, Table 71). Tint-transform and other streams compare by decoded data, so the same function stored under another filter is the same. Equal reference pairs are remembered and comparisons are bounded, so a structure shared by many definitions is compared once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/spot.rs | 113 +++++++++++++++++++++++++--------- crates/preflight/src/tests.rs | 44 +++++++++++++ 2 files changed, 128 insertions(+), 29 deletions(-) diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs index b7df8ddb30..8367d8d28a 100644 --- a/crates/preflight/src/spot.rs +++ b/crates/preflight/src/spot.rs @@ -11,15 +11,22 @@ //! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings //! and other colour spaces, direct or by reference. -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; -use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; /// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as /// the parser nests them. const MAX_DEPTH: u8 = 128; /// How deep two definitions are compared, references followed. const MAX_COMPARE_DEPTH: u8 = 32; +/// How many object pairs the comparisons of one document visit in all. Past it, a comparison +/// claims no difference. +const MAX_COMPARE_STEPS: u32 = 1 << 20; +/// The decoded bytes the stream comparisons of one document may spend in all. +const MAX_COMPARE_BYTES: usize = 64 << 20; +/// The stream entries that say how the data is stored, not what it is. +const ENCODING: [&[u8]; 3] = [b"Length", b"Filter", b"DecodeParms"]; /// The colorants of DeviceCMYK, which a DeviceN space may name without defining them. const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; @@ -34,7 +41,8 @@ pub(crate) struct Findings { /// The 6.2.4.4 findings over the indirect objects `objs`. pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { - let mut scan = Scan { doc, separations: HashMap::new(), findings: Findings::default() }; + let compare = Compare { doc, equal: HashSet::new(), steps: MAX_COMPARE_STEPS, budget: DecodeBudget::new(MAX_COMPARE_BYTES, 0) }; + let mut scan = Scan { doc, compare, separations: HashMap::new(), findings: Findings::default() }; for (_, o) in objs { scan.walk(o, 0); } @@ -43,6 +51,7 @@ pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) - struct Scan<'a> { doc: &'a Document, + compare: Compare<'a>, /// Each colorant name's first Separation space: its alternate space and tint transform. separations: HashMap, (Object, Object)>, findings: Findings, @@ -87,7 +96,7 @@ impl Scan<'_> { let this = (alternate.clone(), tint.clone()); match self.separations.get(name) { Some((alt, fun)) => { - if !same(self.doc, alt, &this.0, 0) || !same(self.doc, fun, &this.1, 0) { + if !self.compare.same(alt, &this.0, 0) || !self.compare.same(fun, &this.1, 0) { self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); } } @@ -120,38 +129,84 @@ impl Scan<'_> { for n in names.iter().map(|n| self.doc.resolve(n)) { let Some(n) = n.as_name() else { continue }; let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n); - // A null entry, or one referring to no object, defines nothing (ISO 32000-1 7.3.9). - let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| !matches!(*self.doc.resolve(e), Object::Null)); + // Each entry is a Separation space for the colorant it is keyed by (ISO 32000-1 8.6.6.5, + // Table 71); a null entry, or one referring to no object, defines nothing (7.3.9). + let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| self.separation_named(e, n)); if spot && !defined { self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned()); } } } -} -/// Whether `a` and `b` are the same object, wherever their parts are stored: references are -/// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object numbers -/// are the same. -fn same(doc: &Document, a: &Object, b: &Object, depth: u8) -> bool { - if let (Object::Ref(x), Object::Ref(y)) = (a, b) - && x == y - { - return true; - } - if depth > MAX_COMPARE_DEPTH { - return false; - } - let (a, b) = (doc.resolve(a), doc.resolve(b)); - let deeper = depth.saturating_add(1); - match (&*a, &*b) { - (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same(doc, p, q, deeper)), - (Object::Dict(x), Object::Dict(y)) => same_dict(doc, x, y, deeper), - (Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && same_dict(doc, &x.dict, &y.dict, deeper), - (x, y) => x == y, + /// Whether `o` is `[/Separation name ...]` for the colorant `name`. + fn separation_named(&self, o: &Object, name: &[u8]) -> bool { + let o = self.doc.resolve(o); + let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false }; + kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name) } } -fn same_dict(doc: &Document, a: &pdfcraft_cos::Dict, b: &pdfcraft_cos::Dict, depth: u8) -> bool { - // `/Length` may be written directly in one and by reference in the other: `same` resolves it. - a.len() == b.len() && a.iter().all(|(k, v)| b.get(k).is_some_and(|w| same(doc, v, w, depth))) +/// Compares definitions, remembering which reference pairs it found equal, so a structure shared +/// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`]. +struct Compare<'a> { + doc: &'a Document, + /// Reference pairs compared equal. A depth cut-off only ever answers "different", so an + /// equal answer holds at any depth. + equal: HashSet<(ObjRef, ObjRef)>, + steps: u32, + budget: DecodeBudget, +} + +impl Compare<'_> { + /// Whether `a` and `b` are the same object, wherever their parts are stored: references are + /// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object + /// numbers are the same, and streams compare by their decoded data. Once the steps or the + /// decode allowance run out, nothing more is called different. + fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool { + let pair = match (a, b) { + (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true, + (Object::Ref(x), Object::Ref(y)) => Some((*x, *y)), + _ => None, + }; + let Some(steps) = self.steps.checked_sub(1) else { return true }; + self.steps = steps; + if depth > MAX_COMPARE_DEPTH { + return false; + } + let (a, b) = (self.doc.resolve(a), self.doc.resolve(b)); + let deeper = depth.saturating_add(1); + let same = match (&*a, &*b) { + (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)), + (Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper), + (Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper), + (x, y) => x == y, + }; + if same && let Some(pair) = pair { + self.equal.insert(pair); + } + same + } + + /// Whether two dictionaries hold the same entries, those keyed in `skip` aside. + fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool { + let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count(); + kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth))) + } + + /// Whether two streams hold the same data, however each is encoded. Data that can't be + /// decoded within the allowance is not called different. + fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool { + let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) { + (None, None) => true, + (Some(x), Some(y)) => c.same(x, y, depth), + _ => false, + }; + if a.raw == b.raw && stored_alike(self, b"Filter") && stored_alike(self, b"DecodeParms") { + return true; + } + match (self.budget.decode(a), self.budget.decode(b)) { + (Ok(x), Ok(y)) => x == y, + _ => true, + } + } } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index eeaf376e6a..6c10b7a31c 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -579,3 +579,47 @@ fn separation_and_devicen_checks_follow_references_depth_and_nchannel() { let deep = format!("/A {}[/DeviceN [/Spot] /DeviceRGB 4 0 R]{}", "[".repeat(40), "]".repeat(40)); assert_eq!(spots(&deep), undefined("Spot")); } + +/// From the second review of the 6.2.4.4 checks: a `/Colorants` entry is a Separation space of +/// its own colorant, tint-transform streams compare by decoded data, and a structure shared by +/// many definitions is compared once rather than once per path through it. +#[test] +fn separation_checks_match_colorant_entries_decode_streams_and_share_comparisons() { + // Two copies of a chain of arrays, each level naming the next twice: comparing them path by + // path takes about 2^19 steps. + const LEVELS: usize = 18; + let chain = |first: usize| -> Vec { + (0..LEVELS).map(|k| if k + 1 < LEVELS { format!("[{n} 0 R {n} 0 R]", n = first + k + 1) } else { "[1 2 3]".into() }).collect() + }; + let (a, b) = (5, 5 + LEVELS); + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>".into(), + ]; + objects.extend(chain(a)); + objects.extend(chain(b)); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter /ASCIIHexDecode /Length 31 >>\nstream\n7B206475702064757020706F70207D>\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup pop dup }\nendstream".into()); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let doc = build(&objects, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = vec!["Separation colour spaces named Red differ in their alternate space or tint transform".to_string()]; + let undefined = vec!["The spot colour Spot of a DeviceN colour space isn't in its Colorants dictionary".to_string()]; + // A Colorants entry that isn't a Separation space of the same colorant defines nothing. + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot /DeviceRGB >> >>]"), undefined); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Other /DeviceRGB 4 0 R] >> >>]"), undefined); + assert!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]").is_empty()); + // The same PostScript function, once plain and once hex-encoded, is the same tint transform. + let (plain, hex, other) = (5 + 2 * LEVELS, 6 + 2 * LEVELS, 7 + 2 * LEVELS); + assert!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {hex} 0 R]")).is_empty()); + assert_eq!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {other} 0 R]")), differ); + // Equal copies compared again and again still leave room to find the one that differs. + let sep = |f: usize| format!("[/Separation /Red /DeviceRGB {f} 0 R]"); + assert_eq!(spots(&format!("/A {} /B {} /C {} /D {} /E {}", sep(a), sep(b), sep(b), sep(b), sep(4))), differ); +} From d2776fa350dff6e6289b8d6a90cede25155cb36f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:54:30 +0800 Subject: [PATCH 11/72] M11: PDF/A verify checks the file header and Save as PDF/A rewrites it (6.1.2) The file must start with %PDF-1.0 to %PDF-1.7 and an end-of-line marker, followed by a comment of % and four bytes above 127. A document whose next save is a full rewrite is judged by the header that save writes. Save as PDF/A makes the next save full, at version 1.7 when the kept version isn't 1.0 to 1.7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/header.rs | 69 ++++++++++++++++++++++++++++++++++ crates/preflight/src/lib.rs | 13 +++++++ crates/preflight/src/tests.rs | 50 +++++++++++++++++++++++- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 crates/preflight/src/header.rs diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs new file mode 100644 index 0000000000..6bd01eaf3f --- /dev/null +++ b/crates/preflight/src/header.rs @@ -0,0 +1,69 @@ +//! PDF/A-2 6.1.2, the file header: the file starts at its first byte with `%PDF-1.n`, `n` from 0 +//! to 7, and an end-of-line marker; the next line is a comment, `%` and at least four bytes each +//! above 127, which marks the file as binary (ISO 32000-1 7.5.2). +//! +//! A document whose next save rewrites it in full is judged by the header that save writes: the +//! version it keeps, followed by the writer's own binary comment. + +use pdfcraft_cos::Document; + +/// What the header of `doc`, as it will be saved, breaks of 6.1.2. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Header { + /// The first line isn't `%PDF-1.0` to `%PDF-1.7` at the start of the file. + pub(crate) version: bool, + /// The second line isn't a comment of at least four bytes above 127. + pub(crate) comment: bool, +} + +pub(crate) fn check(doc: &Document) -> Header { + if doc.full_save_required() || doc.encryption_changed() { + let written = doc.version().get(..3).unwrap_or_default(); + return Header { version: !allowed(written.as_bytes()), comment: false }; + } + let data = doc.bytes(); + let Some(rest) = data.strip_prefix(b"%PDF-") else { return Header { version: true, comment: false } }; + let version = rest.get(..3).unwrap_or_default(); + let Some(rest) = rest.get(3..).and_then(eol) else { + return Header { version: true, comment: !binary_comment(after_line(rest)) }; + }; + Header { version: !allowed(version), comment: !binary_comment(rest) } +} + +/// Whether a version is `1.0` to `1.7`. +fn allowed(version: &[u8]) -> bool { + matches!(version, [b'1', b'.', b'0'..=b'7']) +} + +/// What follows an end-of-line marker (CR, LF or CR LF) at the start of `data`. +fn eol(data: &[u8]) -> Option<&[u8]> { + match data { + [b'\r', b'\n', rest @ ..] | [b'\r', rest @ ..] | [b'\n', rest @ ..] => Some(rest), + _ => None, + } +} + +/// What follows the first end-of-line marker in `data`, or nothing. +fn after_line(data: &[u8]) -> &[u8] { + let end = data.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(data.len()); + data.get(end..).and_then(eol).unwrap_or_default() +} + +/// Whether `data` starts with `%` and four bytes above 127. +fn binary_comment(data: &[u8]) -> bool { + matches!(data, [b'%', a, b, c, d, ..] if [a, b, c, d].iter().all(|&&x| x > 127)) +} + +/// Makes the next save of `doc` write a header 6.1.2 allows, when the current one breaks it: +/// a full save, at version 1.7 when the version kept isn't 1.0 to 1.7. What it fixed, if anything. +pub(crate) fn rewrite(doc: &mut Document) -> Option { + let header = check(doc); + if !header.version && !header.comment { + return None; + } + if !allowed(doc.version().as_bytes().get(..3).unwrap_or_default()) { + doc.set_version("1.7"); + } + doc.require_full_save(); + Some("Rewrote the file header".into()) +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..cae1b98df7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -21,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod header; pub mod icc; mod xmp; @@ -114,6 +115,8 @@ pub const WORDINGS: &[&str] = &[ "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", // The document as a whole. + "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", + "The file header isn't followed by a comment of four bytes above 127", "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", @@ -454,6 +457,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } } + // 6.1.2: the file header. + let header = header::check(doc); + if header.version { + issue("6.1.2", "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", None, None, true); + } + if header.comment { + issue("6.1.2", "The file header isn't followed by a comment of four bytes above 127", None, None, true); + } // 6.1.3: encryption. if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); @@ -798,6 +809,8 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Document { - let mut out = b"%PDF-1.7\n".to_vec(); + build_with_header(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", objs, trailer_extra) +} + +fn build_with_header(header: &[u8], objs: &[&str], trailer_extra: &str) -> Document { + let mut out = header.to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); @@ -488,3 +492,47 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.1.2: the file starts with `%PDF-1.0` to `%PDF-1.7` and an end-of-line marker, then a +/// comment of `%` and four bytes above 127; Save as PDF/A rewrites a header that doesn't. +#[test] +fn file_header_is_checked_and_rewritten() { + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let header = |h: &[u8]| -> Vec { + let doc = build_with_header(h, &objs, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.2").map(|i| i.message).collect() + }; + let version = || vec!["The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line".to_string()]; + let comment = || vec!["The file header isn't followed by a comment of four bytes above 127".to_string()]; + for fine in [&b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"[..], b"%PDF-1.4\r\n%\x80\x81\x82\x83af\r\n", b"%PDF-1.0\r%\xFF\xFF\xFF\xFF\r"] { + assert!(header(fine).is_empty(), "{}", String::from_utf8_lossy(fine)); + } + let wrong_version = [ + &b"%PDF-2.0\n%\xE2\xE3\xCF\xD3\n"[..], + b"%PDF-1.9\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.70\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7 \n%\xE2\xE3\xCF\xD3\n", + b"SomeData\n%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", + ]; + for wrong in wrong_version { + assert_eq!(header(wrong), version(), "{}", String::from_utf8_lossy(wrong)); + } + let wrong_comment = [ + &b"%PDF-1.7\n%\xE2\xE3\xCF\n"[..], + b"%PDF-1.7\ninvalidData\n", + b"%PDF-1.7\n%a\xE3\xCF\xD3\n", + b"%PDF-1.7\n\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7\n", + ]; + for wrong in wrong_comment { + assert_eq!(header(wrong), comment(), "{}", String::from_utf8_lossy(wrong)); + } + // Save as PDF/A rewrites the header: the version drops to 1.7 and the next save is full. + let mut doc = build_with_header(b"%PDF-2.0\n%ab\n", &objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert_eq!(doc.version(), "1.7"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2")); + let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")); +} From 307172fbd1d61640dff10f6e7894b14d54afb3a3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:55:43 +0800 Subject: [PATCH 12/72] M11: parity: PDF/A verify and Save as PDF/A cover the file header (6.1.2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/automation/src/tools.rs | 2 +- parity/acrobat-features.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f6afb7966a..b31e3218a5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1277,7 +1277,7 @@ pub fn tools() -> Vec { t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") + t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..5f007d170d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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::file_header_is_checked_and_rewritten", "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: the file header (6.1.2), 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." [[feature]] id = "optimize.pdfa-convert" @@ -7955,8 +7955,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, the file header (a full save at version 1.7 at most). Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] id = "optimize.pdfa-accessible-levels" From 9d0821ba8b4cddaf45ccdcf12666a577181cf5dd Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:06:35 +0800 Subject: [PATCH 13/72] M11: PDF/A verify reports transfer functions, halftones and rendering intents a graphics state may not use (6.2.5) A graphics state may not have /TR, may have /TR2 only as /Default, may use halftones of type 1 or 5 only and without /HalftoneName (also the colorant halftones of a type 5 one), and a rendering intent from ISO 32000-1 Table 70. Graphics states are read from every /ExtGState resource dictionary and from shading patterns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 136 +++++++++++++++++++++++++++++++++ crates/preflight/src/lib.rs | 23 ++++++ crates/preflight/src/tests.rs | 49 ++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 crates/preflight/src/gstate.rs diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs new file mode 100644 index 0000000000..e34b444079 --- /dev/null +++ b/crates/preflight/src/gstate.rs @@ -0,0 +1,136 @@ +//! PDF/A-2 6.2.5, the extended graphics state: device-dependent settings a conforming file may not +//! rely on. +//! +//! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4); +//! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones +//! a type 5 halftone holds for its colorants are checked the same way; +//! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. +//! +//! Graphics state dictionaries are found as the entries of every `/ExtGState` resource +//! dictionary, and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, Table 76), +//! direct or by reference. + +use std::collections::BTreeSet; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How deep a direct object is followed: as deep as the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The rendering intents of ISO 32000-1 Table 70. +const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; +/// The entries of a type 5 halftone that aren't colorant halftones. +const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; + +/// What the graphics states of a document break of 6.2.5. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// A graphics state has a `/TR` entry. + pub(crate) transfer: bool, + /// A graphics state has a `/TR2` other than `/Default`. + pub(crate) transfer2: bool, + /// Halftone types other than 1 and 5, in order. + pub(crate) halftone_types: BTreeSet, + /// A halftone has a `/HalftoneName`. + pub(crate) halftone_name: bool, + /// Rendering intents outside Table 70, in name order. + pub(crate) intents: BTreeSet, +} + +/// The 6.2.5 findings over the indirect objects `objs`. +pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { + let mut scan = Scan { doc, findings: Findings::default() }; + for (_, o) in objs { + scan.walk(o, 0); + } + scan.findings +} + +struct Scan<'a> { + doc: &'a Document, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + let entries: Box> = match o { + Object::Array(a) => Box::new(a.iter()), + Object::Dict(d) => { + self.dict(d); + Box::new(d.iter().map(|(_, v)| v)) + } + Object::Stream(s) => { + self.dict(&s.dict); + Box::new(s.dict.iter().map(|(_, v)| v)) + } + _ => return, + }; + for v in entries { + self.walk(v, depth.saturating_add(1)); + } + } + + /// A dictionary that holds graphics states: a shading pattern its one, resources by name. + fn dict(&mut self, d: &Dict) { + let Some(states) = d.get(b"ExtGState").map(|s| self.doc.resolve(s)) else { return }; + let Some(states) = states.as_dict() else { return }; + if d.get(b"PatternType").is_some() { + self.gstate(states); + return; + } + for (_, gs) in states.iter() { + if let Some(gs) = self.doc.resolve(gs).as_dict() { + self.gstate(gs); + } + } + } + + fn gstate(&mut self, gs: &Dict) { + let doc = self.doc; + let present = |k: &[u8]| gs.get(k).map(|v| doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)); + if present(b"TR").is_some() { + self.findings.transfer = true; + } + if present(b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + self.findings.transfer2 = true; + } + if let Some(ht) = present(b"HT") + && let Some(ht) = ht.as_dict() + { + self.halftone(ht, true); + } + if let Some(ri) = present(b"RI") + && let Some(ri) = ri.as_name() + && !INTENTS.contains(&ri) + { + self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + } + } + + /// A halftone dictionary or stream; `top` when it isn't one a type 5 halftone holds, whose + /// own entries are halftones in turn. + fn halftone(&mut self, ht: &Dict, top: bool) { + let kind = ht.get(b"HalftoneType").and_then(|t| self.doc.resolve(t).as_int()); + if let Some(kind) = kind + && kind != 1 + && kind != 5 + { + self.findings.halftone_types.insert(kind); + } + if ht.get(b"HalftoneName").is_some() { + self.findings.halftone_name = true; + } + if top && kind == Some(5) { + for (k, v) in ht.iter() { + if HALFTONE_KEYS.contains(&k.as_slice()) { + continue; + } + if let Some(component) = self.doc.resolve(v).as_dict() { + self.halftone(component, false); + } + } + } + } +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..10bbfca8a9 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -21,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod gstate; pub mod icc; mod xmp; @@ -121,6 +122,11 @@ pub const WORDINGS: &[&str] = &[ "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", "The document has additional actions (/AA)", "The document has document-level JavaScript", + "A graphics state has a transfer function (TR)", + "A graphics state has a transfer function (TR2) other than Default", + "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone has a HalftoneName", + "A graphics state has the rendering intent {}, which isn't a standard one", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -515,6 +521,23 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } check_objects(doc, level, &objs, &page_of, &mut issue); + // 6.2.5: the extended graphics state. + let gs = gstate::check(doc, &objs); + if gs.transfer { + issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); + } + if gs.transfer2 { + issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false); + } + for kind in gs.halftone_types { + issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); + } + if gs.halftone_name { + issue("6.2.5", "A halftone has a HalftoneName", None, None, false); + } + for intent in gs.intents { + issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false); + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..3285ebc35f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,52 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.2.5: a graphics state has no transfer function, halftones of type 1 or 5 without a +/// name, and a standard rendering intent, wherever it is kept. +#[test] +fn extended_graphics_states_are_checked() { + let gs = |states: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << {states} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + "<< /TR 4 0 R >>", + "<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").map(|i| i.message).collect() + }; + let tr = "A graphics state has a transfer function (TR)"; + let tr2 = "A graphics state has a transfer function (TR2) other than Default"; + let name = "A halftone has a HalftoneName"; + // What 6.2.5 allows. + assert!(gs("/A << /TR2 /Default /HT 5 0 R /RI /Perceptual >> /B << /HT /Default /RI /AbsoluteColorimetric >>").is_empty()); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R >> /RI /Saturation >> /B << /TR null >>").is_empty()); + // Transfer functions. + assert_eq!(gs("/A << /TR 4 0 R >>"), [tr]); + assert_eq!(gs("/A << /TR /Identity >>"), [tr]); + assert_eq!(gs("/A 6 0 R"), [tr]); + assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]); + assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]); + // Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name. + assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Magenta << /HalftoneType 10 >> >> >>"), + ["A halftone has type 10: only types 1 and 5 are allowed"] + ); + assert_eq!(gs("/A << /HT << /HalftoneType 1 /HalftoneName (Screen) >> >>"), [name]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]); + // Rendering intents. + assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); + // A shading pattern holds one graphics state, not a dictionary of them. + let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); + assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); + assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty()); +} From 77ffebf7bd59389b4577ece8e197847c59d5f2a6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:07:04 +0800 Subject: [PATCH 14/72] M11: parity: PDF/A verify checks graphics states (6.2.5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..0acb1cba0b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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::extended_graphics_states_are_checked", "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, graphics states (6.2.5: transfer functions, halftones, rendering intents), 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" From 5874e5205572cc3a6d54a1f25bac1ff2d48299ff Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:11:58 +0800 Subject: [PATCH 15/72] M11: PDF/A verify reports a halftone phase (HTP) in a graphics state (6.2.5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 6 ++++++ crates/preflight/src/lib.rs | 4 ++++ crates/preflight/src/tests.rs | 1 + 3 files changed, 11 insertions(+) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index e34b444079..ea0f9e569f 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -2,6 +2,7 @@ //! rely on. //! //! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4); +//! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines; //! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones //! a type 5 halftone holds for its colorants are checked the same way; //! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. @@ -28,6 +29,8 @@ pub(crate) struct Findings { pub(crate) transfer: bool, /// A graphics state has a `/TR2` other than `/Default`. pub(crate) transfer2: bool, + /// A graphics state has an `/HTP`. + pub(crate) phase: bool, /// Halftone types other than 1 and 5, in order. pub(crate) halftone_types: BTreeSet, /// A halftone has a `/HalftoneName`. @@ -96,6 +99,9 @@ impl Scan<'_> { if present(b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { self.findings.transfer2 = true; } + if present(b"HTP").is_some() { + self.findings.phase = true; + } if let Some(ht) = present(b"HT") && let Some(ht) = ht.as_dict() { diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 10bbfca8a9..3886a5181a 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -124,6 +124,7 @@ pub const WORDINGS: &[&str] = &[ "The document has document-level JavaScript", "A graphics state has a transfer function (TR)", "A graphics state has a transfer function (TR2) other than Default", + "A graphics state has a halftone phase (HTP)", "A halftone has type {}: only types 1 and 5 are allowed", "A halftone has a HalftoneName", "A graphics state has the rendering intent {}, which isn't a standard one", @@ -529,6 +530,9 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if gs.transfer2 { issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false); } + if gs.phase { + issue("6.2.5", "A graphics state has a halftone phase (HTP)", None, None, false); + } for kind in gs.halftone_types { issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 3285ebc35f..b35f490157 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -521,6 +521,7 @@ fn extended_graphics_states_are_checked() { assert_eq!(gs("/A 6 0 R"), [tr]); assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]); assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]); + assert_eq!(gs("/A << /HTP [0 1] >>"), ["A graphics state has a halftone phase (HTP)"]); // Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name. assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); From 869df9f2a3b4e98e05ed59e428131e6d28bfb5eb Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:12:11 +0800 Subject: [PATCH 16/72] M11: parity: PDF/A graphics state notes name what 6.2.5 covers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0acb1cba0b..c76ebeeae4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ 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::extended_graphics_states_are_checked", "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, graphics states (6.2.5: transfer functions, halftones, rendering intents), annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types and names, rendering intents; the TransferFunction of type 5 halftone components is not yet checked), 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" From 122a155126eabd126c385528e34be8b0cc0588af Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:16:02 +0800 Subject: [PATCH 17/72] M11: tests: Save as PDF/A saves a compliant header through the ordinary save (6.1.2) An engine test converts files without a binary comment and with a 2.0 header, saves them through Session::save_bytes and verifies the reopened bytes; the preflight test adds a good version with a short comment, and a good header that conversion leaves alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/engine/src/tests.rs | 19 +++++++++++++++++++ crates/preflight/src/header.rs | 2 ++ crates/preflight/src/tests.rs | 11 +++++++++++ 3 files changed, 32 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..745b505aa1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10480,3 +10480,22 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// PDF/A-2 6.1.2 through the ordinary save: Save as PDF/A on a file without a binary comment, or +/// with a 2.0 header, saves a file whose header verifies. +#[test] +fn save_as_pdfa_saves_a_compliant_file_header() { + let plain = fixture(1); + assert!(plain.starts_with(b"%PDF-1.7\n1 0 obj"), "the fixture has no binary comment"); + let mut newer = plain.clone(); + newer.splice(5..8, *b"2.0"); + for bytes in [plain, newer] { + let mut s = Session::new(); + let id = s.open("x.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"), "{:?}", String::from_utf8_lossy(&saved[..20])); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(pdfcraft_preflight::verify(&reopened, pdfcraft_preflight::Level::A2b).iter().all(|i| i.clause != "6.1.2")); + } +} diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs index 6bd01eaf3f..3e9263ef28 100644 --- a/crates/preflight/src/header.rs +++ b/crates/preflight/src/header.rs @@ -16,6 +16,8 @@ pub(crate) struct Header { pub(crate) comment: bool, } +/// What the header of `doc` breaks of 6.1.2: as stored, or, when the next save rewrites the +/// file, as that save will write it. pub(crate) fn check(doc: &Document) -> Header { if doc.full_save_required() || doc.encryption_changed() { let written = doc.version().get(..3).unwrap_or_default(); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e53e6f7a46..5d0b320068 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -535,4 +535,15 @@ fn file_header_is_checked_and_rewritten() { assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2")); let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")); + // A good version with a short comment is rewritten too, keeping its version. + let mut doc = build_with_header(b"%PDF-1.4\n%ab\n", &objs, ""); + assert!(!doc.full_save_required()); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert!(doc.full_save_required()); + assert_eq!(doc.version(), "1.4"); + // A good header is left as it is. + let mut doc = build(&objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.fixed.iter().any(|f| f == "Rewrote the file header")); } From 5f06cfaa55925498522c3504a901b1c00a368af3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:16:39 +0800 Subject: [PATCH 18/72] M11: parity: Save as PDF/A cites the engine header test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5f007d170d..a6a3614fd2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7955,7 +7955,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/engine/src/tests.rs::save_as_pdfa_saves_a_compliant_file_header", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, the file header (a full save at version 1.7 at most). Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] From cdc27e538c8e05b31bbc6749d591d6ed69f804b9 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:33:05 +0800 Subject: [PATCH 19/72] M11: PDF/A verify reads graphics states from resources only and checks halftone transfer functions (6.2.5) Graphics states come from /Resources and /DR entries and from shading patterns, never from private /PieceInfo data, and maps shared by reference are read once. A halftone without a /HalftoneType, a rendering intent that isn't a name, and a halftone /TransferFunction where it isn't allowed or is missing (non-primary colorants of a type 5 halftone need one; ISO 32000-1 Table 130) are reported. A null entry counts as absent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 158 +++++++++++++++++++++++---------- crates/preflight/src/lib.rs | 16 ++++ crates/preflight/src/tests.rs | 18 +++- 3 files changed, 144 insertions(+), 48 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index ea0f9e569f..fe26bbc830 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -5,13 +5,18 @@ //! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines; //! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones //! a type 5 halftone holds for its colorants are checked the same way; +//! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for +//! a non-primary colorant has one, and no other halftone does. The primary colorants are those +//! of DeviceCMYK; the `Default` component is left as it is (ISO 32000-1 10.5.5.6, Table 130); //! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. //! -//! Graphics state dictionaries are found as the entries of every `/ExtGState` resource -//! dictionary, and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, Table 76), -//! direct or by reference. +//! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries +//! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an +//! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, +//! Table 76). Private application data (`/PieceInfo`) isn't read. A map shared by reference is +//! read once. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -21,6 +26,10 @@ const MAX_DEPTH: u8 = 128; const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; /// The entries of a type 5 halftone that aren't colorant halftones. const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +/// The primary colorants, whose halftones take the graphics state's transfer function. +const PRIMARY: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; +/// The entries that hold resource dictionaries. +const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"]; /// What the graphics states of a document break of 6.2.5. #[derive(Debug, Default, PartialEq, Eq)] @@ -33,15 +42,23 @@ pub(crate) struct Findings { pub(crate) phase: bool, /// Halftone types other than 1 and 5, in order. pub(crate) halftone_types: BTreeSet, + /// A halftone has no `/HalftoneType` integer. + pub(crate) untyped: bool, /// A halftone has a `/HalftoneName`. pub(crate) halftone_name: bool, + /// A halftone that may not have a `/TransferFunction` has one. + pub(crate) halftone_transfer: bool, + /// Non-primary colorants whose halftone has no `/TransferFunction`, in name order. + pub(crate) missing_transfer: BTreeSet, /// Rendering intents outside Table 70, in name order. pub(crate) intents: BTreeSet, + /// A rendering intent isn't a name. + pub(crate) intent_not_name: bool, } /// The 6.2.5 findings over the indirect objects `objs`. pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { - let mut scan = Scan { doc, findings: Findings::default() }; + let mut scan = Scan { doc, read: HashSet::new(), findings: Findings::default() }; for (_, o) in objs { scan.walk(o, 0); } @@ -50,6 +67,8 @@ pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) - struct Scan<'a> { doc: &'a Document, + /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference. + read: HashSet, findings: Findings, } @@ -58,83 +77,128 @@ impl Scan<'_> { if depth > MAX_DEPTH { return; } - let entries: Box> = match o { - Object::Array(a) => Box::new(a.iter()), - Object::Dict(d) => { - self.dict(d); - Box::new(d.iter().map(|(_, v)| v)) - } - Object::Stream(s) => { - self.dict(&s.dict); - Box::new(s.dict.iter().map(|(_, v)| v)) + let d = match o { + Object::Array(a) => { + for v in a { + self.walk(v, depth.saturating_add(1)); + } + return; } + Object::Dict(d) => d, + Object::Stream(s) => &s.dict, _ => return, }; - for v in entries { - self.walk(v, depth.saturating_add(1)); + self.holder(d); + for (k, v) in d.iter() { + if k.as_slice() != b"PieceInfo" { + self.walk(v, depth.saturating_add(1)); + } } } - /// A dictionary that holds graphics states: a shading pattern its one, resources by name. - fn dict(&mut self, d: &Dict) { - let Some(states) = d.get(b"ExtGState").map(|s| self.doc.resolve(s)) else { return }; - let Some(states) = states.as_dict() else { return }; - if d.get(b"PatternType").is_some() { - self.gstate(states); + /// Whether `o` is a reference read before; marks it read. + fn read_before(&mut self, o: &Object) -> bool { + matches!(o, Object::Ref(r) if !self.read.insert(*r)) + } + + /// A dictionary that holds graphics states: through its resources, or as a shading pattern. + fn holder(&mut self, d: &Dict) { + for key in RESOURCES { + if let Some(resources) = d.get(key) { + self.resources(resources); + } + } + if d.get(b"PatternType").is_some() + && let Some(gs) = d.get(b"ExtGState") + && !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + + fn resources(&mut self, resources: &Object) { + if self.read_before(resources) { return; } + let resources = self.doc.resolve(resources); + let Some(states) = resources.as_dict().and_then(|r| r.get(b"ExtGState")) else { return }; + if self.read_before(states) { + return; + } + let states = self.doc.resolve(states); + let Some(states) = states.as_dict() else { return }; for (_, gs) in states.iter() { - if let Some(gs) = self.doc.resolve(gs).as_dict() { + if !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { self.gstate(gs); } } } + /// `key` of `d`, resolved, unless it is missing or null (ISO 32000-1 7.3.9). + fn present(&self, d: &Dict, key: &[u8]) -> Option> { + d.get(key).map(|v| self.doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)) + } + fn gstate(&mut self, gs: &Dict) { - let doc = self.doc; - let present = |k: &[u8]| gs.get(k).map(|v| doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)); - if present(b"TR").is_some() { + if self.present(gs, b"TR").is_some() { self.findings.transfer = true; } - if present(b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + if self.present(gs, b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { self.findings.transfer2 = true; } - if present(b"HTP").is_some() { + if self.present(gs, b"HTP").is_some() { self.findings.phase = true; } - if let Some(ht) = present(b"HT") + if let Some(ht) = self.present(gs, b"HT") && let Some(ht) = ht.as_dict() { - self.halftone(ht, true); + self.halftone(ht, None); } - if let Some(ri) = present(b"RI") - && let Some(ri) = ri.as_name() - && !INTENTS.contains(&ri) - { - self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + if let Some(ri) = self.present(gs, b"RI") { + match ri.as_name() { + Some(ri) if !INTENTS.contains(&ri) => { + self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + } + Some(_) => {} + None => self.findings.intent_not_name = true, + } } } - /// A halftone dictionary or stream; `top` when it isn't one a type 5 halftone holds, whose - /// own entries are halftones in turn. - fn halftone(&mut self, ht: &Dict, top: bool) { - let kind = ht.get(b"HalftoneType").and_then(|t| self.doc.resolve(t).as_int()); - if let Some(kind) = kind - && kind != 1 - && kind != 5 - { - self.findings.halftone_types.insert(kind); + /// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the + /// one a type 5 halftone holds for `colorant`. + fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) { + let kind = self.present(ht, b"HalftoneType").and_then(|t| t.as_int()); + match kind { + None => self.findings.untyped = true, + Some(1 | 5) => {} + Some(kind) => { + self.findings.halftone_types.insert(kind); + } } - if ht.get(b"HalftoneName").is_some() { + if self.present(ht, b"HalftoneName").is_some() { self.findings.halftone_name = true; } - if top && kind == Some(5) { + let transfer = self.present(ht, b"TransferFunction").is_some(); + match colorant { + Some(b"Default") => {} + Some(c) if !PRIMARY.contains(&c) => { + if !transfer { + self.findings.missing_transfer.insert(String::from_utf8_lossy(c).into_owned()); + } + } + _ => self.findings.halftone_transfer |= transfer, + } + if colorant.is_none() && kind == Some(5) { for (k, v) in ht.iter() { if HALFTONE_KEYS.contains(&k.as_slice()) { continue; } if let Some(component) = self.doc.resolve(v).as_dict() { - self.halftone(component, false); + self.halftone(component, Some(k)); } } } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 3886a5181a..667896c2e7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -126,8 +126,12 @@ pub const WORDINGS: &[&str] = &[ "A graphics state has a transfer function (TR2) other than Default", "A graphics state has a halftone phase (HTP)", "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone has no HalftoneType", "A halftone has a HalftoneName", + "A halftone has a transfer function, which only a non-primary colorant's halftone may have", + "The halftone for the colorant {} has no transfer function", "A graphics state has the rendering intent {}, which isn't a standard one", + "A graphics state has a rendering intent that isn't a name", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -536,12 +540,24 @@ pub fn verify(doc: &Document, level: Level) -> Vec { for kind in gs.halftone_types { issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); } + if gs.untyped { + issue("6.2.5", "A halftone has no HalftoneType", None, None, false); + } if gs.halftone_name { issue("6.2.5", "A halftone has a HalftoneName", None, None, false); } + if gs.halftone_transfer { + issue("6.2.5", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", None, None, false); + } + for colorant in gs.missing_transfer { + issue("6.2.5", "The halftone for the colorant {} has no transfer function", Some(colorant), None, false); + } for intent in gs.intents { issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false); } + if gs.intent_not_name { + issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false); + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b35f490157..9590054184 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,7 +507,8 @@ fn extended_graphics_states_are_checked() { ], "", ); - verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").map(|i| i.message).collect() + let issues = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5"); + issues.inspect(|i| assert!(!i.fixable, "{}", i.message)).map(|i| i.message).collect() }; let tr = "A graphics state has a transfer function (TR)"; let tr2 = "A graphics state has a transfer function (TR2) other than Default"; @@ -533,6 +534,21 @@ fn extended_graphics_states_are_checked() { assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]); // Rendering intents. assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); + assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]); + // A halftone's type is required; a null name is no name. + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), ["A halftone has no HalftoneType"]); + assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty()); + // Transfer functions in halftones: only, and always, for a non-primary colorant. + let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; + assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R >> >>"), + ["The halftone for the colorant Red has no transfer function", "The halftone for the colorant Spot has no transfer function"] + ); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); + // Private application data isn't graphics resources. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty()); // A shading pattern holds one graphics state, not a dictionary of them. let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); From ace9358b5876a7607425a00635bacb543b78bb80 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:34:14 +0800 Subject: [PATCH 20/72] M11: tests: private /PieceInfo data holding resources isn't read for 6.2.5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 9590054184..5ec513fac8 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -548,7 +548,9 @@ fn extended_graphics_states_are_checked() { ); assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); // Private application data isn't graphics resources. - assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty()); + assert!( + gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty() + ); // A shading pattern holds one graphics state, not a dictionary of them. let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); From 7e16d792841de79e67dbae1a102e39aa15264071 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:35:47 +0800 Subject: [PATCH 21/72] M11: parity: PDF/A graphics state notes cover halftone transfer functions (6.2.5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c76ebeeae4..a7ee1876de 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ 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::extended_graphics_states_are_checked", "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, graphics states (6.2.5: TR, TR2, HTP, halftone types and names, rendering intents; the TransferFunction of type 5 halftone components is not yet checked), annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions, rendering intents), 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" From bf20038b0bfe9da9864af6cf03476a05bcba6fd4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:46:57 +0800 Subject: [PATCH 22/72] =?UTF-8?q?M11:=20PDF/A=20verify=20reports=20nonzero?= =?UTF-8?q?=20reals=20nearer=20zero=20than=20=C2=B11.175=20=C3=97=2010?= =?UTF-8?q?=E2=81=BB=C2=B3=E2=81=B8=20(6.1.13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF Association TechNote 0010 A018: PDF/A-2 does not allow the nonzero reals that ISO 32000-1 Table C.1 lets a reader round to zero. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 4 ++-- crates/preflight/src/limits.rs | 8 ++++++-- crates/preflight/src/tests.rs | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 40f0bb78d1..6004ff7fa7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,7 +117,7 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "An integer is outside the 32-bit range", - "A real number is outside ±3.403 × 10³⁸", + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", "A CMap maps a character code to a CID above 65535", "A string is longer than 32767 bytes", "A name is longer than 127 bytes", @@ -528,7 +528,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } if over.real { - issue("6.1.13", "A real number is outside ±3.403 × 10³⁸", None, None, false); + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", None, None, false); } if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index ec9886dcf5..9d986d98aa 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -6,7 +6,9 @@ //! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read //! joined, as one content stream (an operator's operands may end one stream and the operator start //! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. -//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so is an embedded CMap of a Type 0 font +//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so are nonzero ones nearer zero than +//! ±1.175 × 10⁻³⁸ (ISO 32000-1 Table C.1 lets a reader round them to zero; PDF/A-2 does not allow +//! them, PDF Association TechNote 0010 A018), and so is an embedded CMap of a Type 0 font //! (or one it uses through `/UseCMap`) that maps a code to a CID above 65535. An integer token too //! long even for a real reads as one past that range, and is reported as a real. @@ -19,6 +21,8 @@ const MIN_INT: i64 = i32::MIN as i64; const MAX_INT: i64 = i32::MAX as i64; /// Table C.1: the largest real value (approximate). const MAX_REAL: f64 = 3.403e38; +/// Table C.1: the nonzero real value closest to zero (approximate). +const MIN_REAL: f64 = 1.175e-38; /// Table C.1: the largest character identifier. const MAX_CID: u64 = 65535; /// The decoded CMap data, across all the document's embedded CMaps, the scan reads. @@ -65,7 +69,7 @@ impl Exceeded { } match o { Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), - Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL, + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL || (*r != 0.0 && r.abs() < MIN_REAL), Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, Object::Name(n) => self.name(n), Object::Array(a) => { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6893a42e2f..bd861b3561 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -569,11 +569,15 @@ endstream", // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. assert_eq!( limits(&page("|", "", "[-1e308 0 1e308 200]", "")), - ["A real number is outside ±3.403 × 10³⁸", "Page 1 has a boundary smaller than 3 or larger than 14400 units"] + [ + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "Page 1 has a boundary smaller than 3 or larger than 14400 units" + ] ); } -/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸, and +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸ (or +/// nonzero and nearer zero than ±1.175 × 10⁻³⁸), and /// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching /// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). #[test] @@ -600,8 +604,15 @@ fn implementation_limits_cover_reals_and_cids() { let near = format!("34{}.0", "0".repeat(37)); let far = format!("4{}.0", "0".repeat(38)); assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); - assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); - assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + // A nonzero real nearer zero than ±1.175 × 10⁻³⁸ is not allowed either; zero and the + // smallest allowed magnitude are. + let tiny = format!("0.{}1", "0".repeat(39)); + let smallest = format!("0.{}1175", "0".repeat(37)); + assert!(limits(&doc(&format!("/Z 0.0 /M -0.0 /S {smallest} /T -{smallest}"), "", "")).is_empty(), "zero and the smallest real"); + assert_eq!(limits(&doc(&format!("/R {tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); let cid = ["A CMap maps a character code to a CID above 65535"]; assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); From c6b5e612707be28f8ae0f32559d2f22e2983cb99 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:46:59 +0800 Subject: [PATCH 23/72] M11: PDF/A verify reads graphics states from what the trailer reaches, and needs an integer HalftoneType (6.2.5) Objects are followed from the trailer through a queue of references, never into /PieceInfo, so an object only private data refers to is not read as a graphics state holder. /HalftoneType must be an integer (ISO 32000-1 Table 128); a real of the same value is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 35 ++++++++++++++++++++++++---------- crates/preflight/src/lib.rs | 6 +++--- crates/preflight/src/tests.rs | 8 +++++++- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index fe26bbc830..551b627db0 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -13,10 +13,11 @@ //! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries //! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an //! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, -//! Table 76). Private application data (`/PieceInfo`) isn't read. A map shared by reference is -//! read once. +//! Table 76). They are looked for in what the document reaches from its trailer, following +//! references, but not into private application data (`/PieceInfo`), so an object only that data +//! refers to isn't read. A map shared by reference is read once. -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashSet, VecDeque}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -42,7 +43,7 @@ pub(crate) struct Findings { pub(crate) phase: bool, /// Halftone types other than 1 and 5, in order. pub(crate) halftone_types: BTreeSet, - /// A halftone has no `/HalftoneType` integer. + /// A halftone's `/HalftoneType` is missing or isn't an integer. pub(crate) untyped: bool, /// A halftone has a `/HalftoneName`. pub(crate) halftone_name: bool, @@ -56,11 +57,15 @@ pub(crate) struct Findings { pub(crate) intent_not_name: bool, } -/// The 6.2.5 findings over the indirect objects `objs`. -pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { - let mut scan = Scan { doc, read: HashSet::new(), findings: Findings::default() }; - for (_, o) in objs { - scan.walk(o, 0); +/// The 6.2.5 findings over what `doc` reaches from its trailer. +pub(crate) fn check(doc: &Document) -> Findings { + let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() }; + scan.walk(&Object::Dict(doc.trailer().clone()), 0); + // References are followed from a queue, not by recursion: a chain of them can be as long as + // the file. + while let Some(r) = scan.queue.pop_front() { + let o = doc.get(r); + scan.walk(&o, 0); } scan.findings } @@ -69,6 +74,9 @@ struct Scan<'a> { doc: &'a Document, /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference. read: HashSet, + /// Objects reached so far, queued or walked. + reached: HashSet, + queue: VecDeque, findings: Findings, } @@ -78,6 +86,12 @@ impl Scan<'_> { return; } let d = match o { + Object::Ref(r) => { + if self.reached.insert(*r) { + self.queue.push_back(*r); + } + return; + } Object::Array(a) => { for v in a { self.walk(v, depth.saturating_add(1)); @@ -171,7 +185,8 @@ impl Scan<'_> { /// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the /// one a type 5 halftone holds for `colorant`. fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) { - let kind = self.present(ht, b"HalftoneType").and_then(|t| t.as_int()); + // An integer (ISO 32000-1 Table 128), not a real of the same value. + let kind = self.present(ht, b"HalftoneType").and_then(|t| if let Object::Int(t) = *t { Some(t) } else { None }); match kind { None => self.findings.untyped = true, Some(1 | 5) => {} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 667896c2e7..518cd2579d 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -126,7 +126,7 @@ pub const WORDINGS: &[&str] = &[ "A graphics state has a transfer function (TR2) other than Default", "A graphics state has a halftone phase (HTP)", "A halftone has type {}: only types 1 and 5 are allowed", - "A halftone has no HalftoneType", + "A halftone's HalftoneType is missing or isn't an integer", "A halftone has a HalftoneName", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", "The halftone for the colorant {} has no transfer function", @@ -527,7 +527,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } check_objects(doc, level, &objs, &page_of, &mut issue); // 6.2.5: the extended graphics state. - let gs = gstate::check(doc, &objs); + let gs = gstate::check(doc); if gs.transfer { issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); } @@ -541,7 +541,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); } if gs.untyped { - issue("6.2.5", "A halftone has no HalftoneType", None, None, false); + issue("6.2.5", "A halftone's HalftoneType is missing or isn't an integer", None, None, false); } if gs.halftone_name { issue("6.2.5", "A halftone has a HalftoneName", None, None, false); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 5ec513fac8..c5a6cc5816 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -504,6 +504,7 @@ fn extended_graphics_states_are_checked() { "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", "<< /TR 4 0 R >>", "<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream", + "<< /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >>", ], "", ); @@ -536,7 +537,9 @@ fn extended_graphics_states_are_checked() { assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]); // A halftone's type is required; a null name is no name. - assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), ["A halftone has no HalftoneType"]); + let untyped = ["A halftone's HalftoneType is missing or isn't an integer"]; + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), untyped); + assert_eq!(gs("/A << /HT << /HalftoneType 1.0 >> >>"), untyped); assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty()); // Transfer functions in halftones: only, and always, for a non-primary colorant. let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; @@ -551,6 +554,9 @@ fn extended_graphics_states_are_checked() { assert!( gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty() ); + // Nor is an object (8) only private data refers to; one the page refers to is read. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private 8 0 R >> >> /Z <<").is_empty()); + assert_eq!(gs("/A << /RI /Perceptual >> >> /Other 8 0 R /Z <<"), [tr]); // A shading pattern holds one graphics state, not a dictionary of them. let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); From 1928500350cea1dc45e974d4ed9198e76f0f04fe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:07:37 +0800 Subject: [PATCH 24/72] M11: PDF/A verify's graphics state scan reads each object once, whatever the generation (6.2.5) Document::get fetches by object number, so references that differ only in generation reach the same object; the reached and read sets now go by number, and each object is walked once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 25 ++++++++++++++++++------- crates/preflight/src/tests.rs | 12 ++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index 551b627db0..a84d303c04 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -59,6 +59,16 @@ pub(crate) struct Findings { /// The 6.2.5 findings over what `doc` reaches from its trailer. pub(crate) fn check(doc: &Document) -> Findings { + scan(doc).findings +} + +/// How many objects [`check`] reads. +#[cfg(test)] +pub(crate) fn reached(doc: &Document) -> usize { + scan(doc).reached.len() +} + +fn scan(doc: &Document) -> Scan<'_> { let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() }; scan.walk(&Object::Dict(doc.trailer().clone()), 0); // References are followed from a queue, not by recursion: a chain of them can be as long as @@ -67,15 +77,16 @@ pub(crate) fn check(doc: &Document) -> Findings { let o = doc.get(r); scan.walk(&o, 0); } - scan.findings + scan } struct Scan<'a> { doc: &'a Document, - /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference. - read: HashSet, - /// Objects reached so far, queued or walked. - reached: HashSet, + /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by object number. + read: HashSet, + /// Object numbers reached so far, queued or walked. Both sets go by number, as + /// [`Document::get`] does: references differing only in generation reach the same object. + reached: HashSet, queue: VecDeque, findings: Findings, } @@ -87,7 +98,7 @@ impl Scan<'_> { } let d = match o { Object::Ref(r) => { - if self.reached.insert(*r) { + if self.reached.insert(r.num) { self.queue.push_back(*r); } return; @@ -112,7 +123,7 @@ impl Scan<'_> { /// Whether `o` is a reference read before; marks it read. fn read_before(&mut self, o: &Object) -> bool { - matches!(o, Object::Ref(r) if !self.read.insert(*r)) + matches!(o, Object::Ref(r) if !self.read.insert(r.num)) } /// A dictionary that holds graphics states: through its resources, or as a shading pattern. diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index c5a6cc5816..3280ce8e51 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -562,3 +562,15 @@ fn extended_graphics_states_are_checked() { assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty()); } + +/// From the review of the 6.2.5 check: references that differ only in generation reach the same +/// object, which is read once. +#[test] +fn graphics_state_scan_reads_each_object_once_whatever_the_generation() { + let gens: String = (0..10).map(|g| format!("4 {g} R ")).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Same [{gens}] >>"); + let doc = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, "[1 2 3]"], ""); + // The catalog, the page tree, the page and object 4. + assert_eq!(gstate::reached(&doc), 4); +} From 4c5fd46af7b8f53c013bf84660af15c922742ac7 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:07:53 +0800 Subject: [PATCH 25/72] M11: parity: cite the 6.2.5 scan test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a7ee1876de..400f9be08b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,7 +7943,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::extended_graphics_states_are_checked", "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::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "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, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions, rendering intents), annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] From b4e99b6229e16a69027c13e0540d4e3cdf3bf74f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:21:14 +0800 Subject: [PATCH 26/72] M11: PDF/A verify takes the primary colour components of all three device spaces (6.2.5) Gray, Red, Green and Blue are primary colour components as well as Cyan, Magenta, Yellow and Black (ISO 32000-1 10.5.5.6, ISO 32000-2 10.6.5.6): their halftones need no transfer function and may not have one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 11 +++++++---- crates/preflight/src/tests.rs | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index a84d303c04..64c19de116 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -6,8 +6,10 @@ //! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones //! a type 5 halftone holds for its colorants are checked the same way; //! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for -//! a non-primary colorant has one, and no other halftone does. The primary colorants are those -//! of DeviceCMYK; the `Default` component is left as it is (ISO 32000-1 10.5.5.6, Table 130); +//! a non-primary colorant has one, and no other halftone does. The primary colour components +//! are those of the standard device spaces: Gray; Red, Green and Blue; Cyan, Magenta, Yellow +//! and Black (ISO 32000-1 10.5.5.6; ISO 32000-2 10.6.5.6). The `Default` component is left as +//! it is (ISO 32000-1 Table 130); //! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. //! //! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries @@ -27,8 +29,9 @@ const MAX_DEPTH: u8 = 128; const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; /// The entries of a type 5 halftone that aren't colorant halftones. const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; -/// The primary colorants, whose halftones take the graphics state's transfer function. -const PRIMARY: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; +/// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take +/// the graphics state's transfer function. +const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"]; /// The entries that hold resource dictionaries. const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"]; diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 3280ce8e51..94ec2c0055 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -545,9 +545,11 @@ fn extended_graphics_states_are_checked() { let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]); assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + // The primary components are those of DeviceGray, DeviceRGB and DeviceCMYK (ISO 32000-1 10.5.5.6). + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Gray << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); assert_eq!( - gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R >> >>"), - ["The halftone for the colorant Red has no transfer function", "The halftone for the colorant Spot has no transfer function"] + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R /Gray 5 0 R >> >>"), + ["The halftone for the colorant Spot has no transfer function"] ); assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); // Private application data isn't graphics resources. From 3d2134fa006afe3e426bad35c533b0a4e9d6d1f3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:22:40 +0800 Subject: [PATCH 27/72] M11: parity: 6.2.5 notes cite the primary colour components Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 400f9be08b..3906cfb5ae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ 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::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "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, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions, rendering intents), annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions with the primary components of ISO 32000-1 10.5.5.6, rendering intents), 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" From 1336bc88962da3eea1dcc8b6fecb2eb374b25d47 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:26:20 +0800 Subject: [PATCH 28/72] M11: PDF/A verify checks the file identifier and Save as PDF/A adds one (6.1.3) The trailer the last startxref points to (an update's, or a linearized file's first-page trailer) must have an /ID of two non-empty strings (ISO 32000-1 14.4). Save as PDF/A gives a document without a good one a new identifier, made by the writer's generator, now public in cos as new_file_id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/cos/src/lib.rs | 2 +- crates/cos/src/writer.rs | 12 ++++++--- crates/preflight/src/lib.rs | 13 +++++++++- crates/preflight/src/tests.rs | 43 +++++++++++++++++++++++++++++++++ crates/preflight/src/trailer.rs | 36 +++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 crates/preflight/src/trailer.rs diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 8e051e4578..8bb79d4aaa 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, }; -pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, new_file_id, pdf_date, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum CosError { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index dcfcb97997..b7a9d1c7ae 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -639,13 +639,19 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) { } } -/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4). +/// Keep an existing `/ID`, or create one with [`new_file_id`]. fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) { return; } + trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed)); +} + +/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of +/// two equal 16-byte strings, the same for the same content and `seed`. +pub fn new_file_id(content: &[u8], seed: u64) -> Object { // FNV-1a over a sample of the content and the seed: stable, dependency-free. - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed; + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed; for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) { h ^= *b as u64; h = h.wrapping_mul(0x0100_0000_01b3); @@ -654,7 +660,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { id.extend_from_slice(&h.to_be_bytes()); id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes()); let s = Object::String(PdfString { bytes: id, hex: true }); - trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); + Object::Array(vec![s.clone(), s]) } /// A PDF date string for "now" given seconds since the Unix epoch (UTC). diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..d7ccced02c 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod trailer; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -114,6 +115,8 @@ pub const WORDINGS: &[&str] = &[ "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", // The document as a whole. + "The trailer has no file identifier (ID)", + "The file identifier (ID) in the trailer isn't two non-empty strings", "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", @@ -454,7 +457,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } } - // 6.1.3: encryption. + // 6.1.3: the file identifier and encryption. + match trailer::check(doc) { + Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true), + Some(trailer::IdProblem::Malformed) => { + issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true) + } + None => {} + } if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); } @@ -798,6 +808,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let id = |extra: &str| -> Vec { + let doc = build(&objs, extra); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect() + }; + let missing = ["The trailer has no file identifier (ID)"]; + let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"]; + assert!(id("/ID [<0011> (ab)]").is_empty()); + assert_eq!(id(""), missing); + assert_eq!(id("/ID null"), missing); + for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] { + assert_eq!(id(wrong), malformed, "{wrong}"); + } + // An update without an /ID has none, whatever the trailer it replaces had. + let base = build(&objs, "/ID [<0011> <0011>]"); + let mut bytes = base.bytes().to_vec(); + let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap(); + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let updated = Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0])); + // Save as PDF/A adds one, which a save keeps. + for extra in ["", "/ID [<><>]"] { + let mut doc = build(&objs, extra); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap(); + let saved = Document::open(std::sync::Arc::new(saved)).unwrap(); + assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + } + // A good one is kept. + let mut doc = build(&objs, "/ID [<0011> <0022>]"); + assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier"))); +} diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs new file mode 100644 index 0000000000..8c75a54c75 --- /dev/null +++ b/crates/preflight/src/trailer.rs @@ -0,0 +1,36 @@ +//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4 +//! defines it, an array of two byte strings. +//! +//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a +//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't +//! stand in for it. + +use pdfcraft_cos::{Document, Object}; + +/// What is wrong with the file identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdProblem { + /// The trailer has no `/ID`, or a null one. + Missing, + /// The `/ID` isn't an array of two non-empty strings. + Malformed, +} + +/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything. +pub(crate) fn check(doc: &Document) -> Option { + let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null)); + let Some(id) = id else { return Some(IdProblem::Missing) }; + let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty())); + match id.as_array() { + Some(parts) if parts.len() == 2 && strings(parts) => None, + _ => Some(IdProblem::Malformed), + } +} + +/// Gives `doc` a new file identifier when it lacks a good one; what it fixed, if anything. +pub(crate) fn fix(doc: &mut Document) -> Option { + check(doc)?; + let id = pdfcraft_cos::new_file_id(doc.bytes(), 0); + doc.trailer_mut().set(b"ID".to_vec(), id); + Some("Added a file identifier (ID)".into()) +} From fd2c875702d12885d628894e1264659c150ff2e3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:35:15 +0800 Subject: [PATCH 29/72] M11: parity: PDF/A verify and Save as PDF/A cover the file identifier (6.1.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/automation/src/tools.rs | 2 +- parity/acrobat-features.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f6afb7966a..d6e5440971 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1277,7 +1277,7 @@ pub fn tools() -> Vec { t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") + t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..643f7b4923 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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::trailer_file_identifier_is_checked_and_added", "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 trailer file identifier (6.1.3), 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" @@ -7955,8 +7955,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, a missing or malformed file identifier. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] id = "optimize.pdfa-accessible-levels" From ea51204d5f2907fe9d94602b809ff67f156758dc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:36:59 +0800 Subject: [PATCH 30/72] M11: PDF/A verify reports SetState, NOP and Named actions beyond the four page moves (6.5.1) The PDF 1.2 SetState and NOP actions, which ISO 32000-1 no longer defines, are forbidden, and a Named action may only name NextPage, PrevPage, FirstPage or LastPage (ISO 32000-1 Table 212). Save as PDF/A removes the others as it does other forbidden actions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 44 +++++++++++++++++++++++++++++------ crates/preflight/src/tests.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..0c322ecba3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -10,7 +10,9 @@ //! - every font embedded; //! - annotations: allowed types, printable, not hidden, with appearance streams; //! - actions: none of the forbidden kinds (JavaScript, Launch, Sound, Movie, ResetForm, -//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView); no additional actions on the +//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView, and the PDF 1.2 SetState and +//! NOP that ISO 32000-1 no longer defines); Named actions only for NextPage, PrevPage, +//! FirstPage and LastPage (ISO 32000-1 12.6.4.11, Table 212); no additional actions on the //! catalog, pages, fields or widgets; `NeedAppearances` not true; //! - images: no `Interpolate`, no `Alternates`, no OPI; no PostScript XObjects; //! - embedded files: PDF/A-2 only allows embedded PDF/A files; PDF/A-3 needs `AFRelationship`. @@ -152,8 +154,23 @@ pub struct Declared { pub output_intents: Vec, } -const FORBIDDEN_ACTIONS: &[&[u8]] = - &[b"JavaScript", b"Launch", b"Sound", b"Movie", b"ResetForm", b"ImportData", b"Hide", b"SetOCGState", b"Rendition", b"Trans", b"GoTo3DView"]; +const FORBIDDEN_ACTIONS: &[&[u8]] = &[ + b"JavaScript", + b"Launch", + b"Sound", + b"Movie", + b"ResetForm", + b"ImportData", + b"Hide", + b"SetOCGState", + b"Rendition", + b"Trans", + b"GoTo3DView", + b"SetState", + b"NOP", +]; +/// The named actions of ISO 32000-1 Table 212, the only ones a Named action may name. +const NAMED_ACTIONS: &[&[u8]] = &[b"NextPage", b"PrevPage", b"FirstPage", b"LastPage"]; const FORBIDDEN_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"]; fn catalog(doc: &Document) -> Dict { @@ -753,10 +770,8 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec if *read > MAX_ACTION_CHAIN { return Some("too deeply chained".into()); } - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - { - return Some(String::from_utf8_lossy(s).into_owned()); + if let Some(s) = action_forbidden(d) { + return Some(s); } match d.get(b"Next") { Some(Object::Dict(next)) => stack.push(next), @@ -773,6 +788,21 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec None } +/// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named +/// action for anything but the four of [`NAMED_ACTIONS`]. +fn action_forbidden(d: &Dict) -> Option { + let s = d.name(b"S")?; + if FORBIDDEN_ACTIONS.contains(&s) { + return Some(String::from_utf8_lossy(s).into_owned()); + } + match d.name(b"N") { + _ if s != b"Named" => None, + Some(n) if NAMED_ACTIONS.contains(&n) => None, + Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), + None => Some("Named".into()), + } +} + /// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..17816823e2 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,39 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.5.1: the PDF 1.2 SetState and NOP actions aren't allowed, and a Named action may +/// only name NextPage, PrevPage, FirstPage or LastPage; Save as PDF/A removes the others. +#[test] +fn set_state_no_op_and_named_actions_are_checked() { + let doc_with = |action: &str| { + let outline = format!("<< /Title (x) /Parent 4 0 R /A {action} >>"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + &outline, + "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + ], + "", + ) + }; + let actions = |action: &str| -> Vec { + verify(&doc_with(action), Level::A2b).into_iter().filter(|i| i.clause == "6.5.1").map(|i| i.message).collect() + }; + assert_eq!(actions("<< /Type /Action /S /SetState /T [] >>"), ["A SetState action isn't allowed"]); + assert_eq!(actions("<< /S /NOP >>"), ["A NOP action isn't allowed"]); + assert_eq!(actions("<< /S /Named /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S /Named >>"), ["A Named action isn't allowed"]); + // Behind an allowed action, in its /Next. + assert_eq!(actions("<< /S /GoTo /D [0 /Fit] /Next << /S /NOP >> >>"), ["A NOP action isn't allowed"]); + for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { + assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); + } + // Save as PDF/A removes them. + for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}"); + } +} From 33cd765828139d8595964fda0a8e1ca3666e4538 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:37:22 +0800 Subject: [PATCH 31/72] M11: parity: PDF/A verify covers SetState, NOP and Named actions (6.5.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..e3d0ba80fa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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::set_state_no_op_and_named_actions_are_checked", "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 (with SetState, NOP and Named actions other than the four page moves), images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 86aad0304ce542278c166abe5065f7f55de3fd23 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 09:53:10 +0800 Subject: [PATCH 32/72] M11: Save as PDF/A keeps the permanent part of a malformed file identifier (6.1.3) The first /ID string is the file's permanent identifier (ISO 32000-1 14.4); when it is a non-empty string, conversion keeps it and makes only the second afresh. The test also checks that a good /ID is left as it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 13 ++++++++++++- crates/preflight/src/trailer.rs | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index a433703d02..e90b8c8b5d 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -527,7 +527,18 @@ fn trailer_file_identifier_is_checked_and_added() { let saved = Document::open(std::sync::Arc::new(saved)).unwrap(); assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); } - // A good one is kept. + // A good one is kept as it is. let mut doc = build(&objs, "/ID [<0011> <0022>]"); + let before = doc.trailer().get(b"ID").cloned(); assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier"))); + assert_eq!(doc.trailer().get(b"ID").cloned(), before); + // A malformed one keeps its non-empty first string, the permanent identifier (14.4). + let first = |doc: &Document| doc.trailer().get(b"ID").and_then(|id| id.as_array()?.first()?.as_string().map(|s| s.bytes.clone())); + let mut doc = build(&objs, "/ID [<0011> <>]"); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc), Some(vec![0x00, 0x11])); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3")); + let mut doc = build(&objs, "/ID [<> <0011>]"); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); } diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs index 8c75a54c75..c789e50a22 100644 --- a/crates/preflight/src/trailer.rs +++ b/crates/preflight/src/trailer.rs @@ -27,10 +27,21 @@ pub(crate) fn check(doc: &Document) -> Option { } } -/// Gives `doc` a new file identifier when it lacks a good one; what it fixed, if anything. +/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A +/// non-empty first string, the permanent identifier (ISO 32000-1 14.4), is kept, and only the +/// second is made afresh. pub(crate) fn fix(doc: &mut Document) -> Option { check(doc)?; - let id = pdfcraft_cos::new_file_id(doc.bytes(), 0); - doc.trailer_mut().set(b"ID".to_vec(), id); + let Object::Array(mut id) = pdfcraft_cos::new_file_id(doc.bytes(), 0) else { return None }; + let permanent = doc + .trailer() + .get(b"ID") + .map(|old| doc.resolve(old)) + .and_then(|old| old.as_array().and_then(|parts| parts.first().map(|p| doc.resolve(p)))) + .filter(|first| first.as_string().is_some_and(|s| !s.bytes.is_empty())); + if let (Some(permanent), Some(slot)) = (permanent, id.first_mut()) { + *slot = (*permanent).clone(); + } + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(id)); Some("Added a file identifier (ID)".into()) } From d9abc9b82ced7b2a931e461b5cdeaf62c2a6982a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:00:38 +0800 Subject: [PATCH 33/72] M11: PDF/A verify reads a Named action's name through a reference (6.5.1) A Named action whose /N refers to /NextPage (or another page move) is allowed and survives Save as PDF/A; the test checks that conversion keeps such an action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 10 ++++++---- crates/preflight/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 0c322ecba3..c615b7cb9f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -770,7 +770,7 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec if *read > MAX_ACTION_CHAIN { return Some("too deeply chained".into()); } - if let Some(s) = action_forbidden(d) { + if let Some(s) = action_forbidden(doc, d) { return Some(s); } match d.get(b"Next") { @@ -789,13 +789,15 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec } /// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named -/// action for anything but the four of [`NAMED_ACTIONS`]. -fn action_forbidden(d: &Dict) -> Option { +/// action for anything but the four of [`NAMED_ACTIONS`], its name given directly or by +/// reference. +fn action_forbidden(doc: &Document, d: &Dict) -> Option { let s = d.name(b"S")?; if FORBIDDEN_ACTIONS.contains(&s) { return Some(String::from_utf8_lossy(s).into_owned()); } - match d.name(b"N") { + let n = d.get(b"N").map(|n| doc.resolve(n)); + match n.as_deref().and_then(Object::as_name) { _ if s != b"Named" => None, Some(n) if NAMED_ACTIONS.contains(&n) => None, Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 17816823e2..dc5991d67f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -501,6 +501,7 @@ fn set_state_no_op_and_named_actions_are_checked() { "<< /Type /Pages /Kids [] /Count 0 >>", &outline, "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + "/NextPage", ], "", ) @@ -517,10 +518,26 @@ fn set_state_no_op_and_named_actions_are_checked() { for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); } + // The name given by reference (object 5 is /NextPage). + assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); // Save as PDF/A removes them. for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { let mut doc = doc_with(action); convert(&mut doc, Level::A2b).unwrap(); assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}"); } + // A permitted Named action survives it, also one naming its page move by reference. + for action in ["<< /S /Named /N /NextPage >>", "<< /S /Named /N 5 0 R >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + let outline = doc.get(ObjRef { num: 3, generation: 0 }); + let kept = outline + .as_dict() + .and_then(|d| d.get(b"A")) + .and_then(Object::as_dict) + .map(|a| (a.name(b"S").map(<[u8]>::to_vec), a.get(b"N").map(|n| doc.resolve(n)))); + let (s, n) = kept.unwrap_or_else(|| panic!("{action}: the action is gone")); + assert_eq!(s.as_deref(), Some(&b"Named"[..]), "{action}"); + assert_eq!(n.as_deref().and_then(Object::as_name), Some(&b"NextPage"[..]), "{action}"); + } } From fad9ee3f9f2503b95835d79adc0957572f1fb1b6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:10:41 +0800 Subject: [PATCH 34/72] M11: Save as PDF/A makes a new file identifier when the old first string is too long to keep (6.1.3) The first /ID string is kept only when a conforming file can hold it: non-empty and within 32767 bytes (ISO 32000-1 Annex C). The report says "Completed the file identifier" when it was kept and "Added a file identifier" when both strings are new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 13 ++++++++++++- crates/preflight/src/trailer.rs | 24 ++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e90b8c8b5d..39fa9b40b5 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -535,10 +535,21 @@ fn trailer_file_identifier_is_checked_and_added() { // A malformed one keeps its non-empty first string, the permanent identifier (14.4). let first = |doc: &Document| doc.trailer().get(b"ID").and_then(|id| id.as_array()?.first()?.as_string().map(|s| s.bytes.clone())); let mut doc = build(&objs, "/ID [<0011> <>]"); - convert(&mut doc, Level::A2b).unwrap(); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Completed the file identifier (ID)")); assert_eq!(first(&doc), Some(vec![0x00, 0x11])); assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3")); let mut doc = build(&objs, "/ID [<> <0011>]"); convert(&mut doc, Level::A2b).unwrap(); assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + // A first string longer than a conforming file may hold (Annex C) is made afresh too. + let long = format!("/ID [<{}> <>]", "ab".repeat(32768)); + let mut doc = build(&objs, &long); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)")); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + let longest = format!("/ID [<{}> <>]", "ab".repeat(32767)); + let mut doc = build(&objs, &longest); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(32767)); } diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs index c789e50a22..7c9d134bec 100644 --- a/crates/preflight/src/trailer.rs +++ b/crates/preflight/src/trailer.rs @@ -7,6 +7,9 @@ use pdfcraft_cos::{Document, Object}; +/// The longest string a conforming file may hold (ISO 32000-1 Annex C, Table C.1). +const MAX_STRING: usize = 32767; + /// What is wrong with the file identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum IdProblem { @@ -27,9 +30,10 @@ pub(crate) fn check(doc: &Document) -> Option { } } -/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A -/// non-empty first string, the permanent identifier (ISO 32000-1 14.4), is kept, and only the -/// second is made afresh. +/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A first +/// string a conforming file can keep (non-empty, within [`MAX_STRING`]) is the permanent +/// identifier (ISO 32000-1 14.4): it is kept, and only the second is made afresh. Otherwise both +/// are new, and the report says the identifier was added rather than completed. pub(crate) fn fix(doc: &mut Document) -> Option { check(doc)?; let Object::Array(mut id) = pdfcraft_cos::new_file_id(doc.bytes(), 0) else { return None }; @@ -38,10 +42,14 @@ pub(crate) fn fix(doc: &mut Document) -> Option { .get(b"ID") .map(|old| doc.resolve(old)) .and_then(|old| old.as_array().and_then(|parts| parts.first().map(|p| doc.resolve(p)))) - .filter(|first| first.as_string().is_some_and(|s| !s.bytes.is_empty())); - if let (Some(permanent), Some(slot)) = (permanent, id.first_mut()) { - *slot = (*permanent).clone(); - } + .filter(|first| first.as_string().is_some_and(|s| (1..=MAX_STRING).contains(&s.bytes.len()))); + let kept = match (permanent, id.first_mut()) { + (Some(permanent), Some(slot)) => { + *slot = (*permanent).clone(); + true + } + _ => false, + }; doc.trailer_mut().set(b"ID".to_vec(), Object::Array(id)); - Some("Added a file identifier (ID)".into()) + Some(if kept { "Completed the file identifier (ID)" } else { "Added a file identifier (ID)" }.into()) } From 75b82de0d59778de32f53abd9b48d3a421338335 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:14:44 +0800 Subject: [PATCH 35/72] M11: PDF/A verify reads an action's type through a reference (6.5.1) An action whose /S refers to /Named or /NOP is checked like one that names it directly, so verification reports it and conversion removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 7 ++++--- crates/preflight/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index c615b7cb9f..9e83218a70 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -789,10 +789,11 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec } /// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named -/// action for anything but the four of [`NAMED_ACTIONS`], its name given directly or by -/// reference. +/// action for anything but the four of [`NAMED_ACTIONS`]. The type and the name may each be given +/// directly or by reference. fn action_forbidden(doc: &Document, d: &Dict) -> Option { - let s = d.name(b"S")?; + let s = doc.resolve(d.get(b"S")?); + let s = s.as_name()?; if FORBIDDEN_ACTIONS.contains(&s) { return Some(String::from_utf8_lossy(s).into_owned()); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index dc5991d67f..b8f350b19d 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -502,6 +502,8 @@ fn set_state_no_op_and_named_actions_are_checked() { &outline, "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", "/NextPage", + "/Named", + "/NOP", ], "", ) @@ -520,6 +522,10 @@ fn set_state_no_op_and_named_actions_are_checked() { } // The name given by reference (object 5 is /NextPage). assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); + // The type given by reference (object 6 is /Named, 7 is /NOP). + assert_eq!(actions("<< /S 6 0 R /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S 7 0 R >>"), ["A NOP action isn't allowed"]); + assert!(actions("<< /S 6 0 R /N 5 0 R >>").is_empty()); // Save as PDF/A removes them. for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { let mut doc = doc_with(action); From 19da2752cf97bd40a94cda67e1b7028857ad6a18 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:52:22 +0800 Subject: [PATCH 36/72] M11: PDF/A verify reports a page with transparency and no blending colour space (6.2.10) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 17 +++ crates/preflight/src/tests.rs | 100 +++++++++++++++++ crates/preflight/src/transparency.rs | 159 +++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 crates/preflight/src/transparency.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..a506bcae0e 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod transparency; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -116,6 +117,7 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", + "Page {} uses transparency, but there is no PDF/A output intent and its group names no blending colour space", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", @@ -514,6 +516,21 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } + // 6.2.10: without a PDF/A output intent, a page with transparency names its blending colour + // space. + if !has_pdfa_intent(doc) { + for (i, p) in pages.iter().enumerate() { + if transparency::page_has_transparency(doc, &p.dict) && !transparency::page_names_blending_space(doc, &p.dict) { + issue( + "6.2.10", + "Page {} uses transparency, but there is no PDF/A output intent and its group names no blending colour space", + Some((i + 1).to_string()), + Some(i), + false, + ); + } + } + } 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 seen = std::collections::HashSet::new(); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..179c938baf 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,103 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// 6.2.10: without a PDF/A output intent, a page whose resources can paint transparently names its +/// blending colour space in its `/Group`. Transparency is found through graphics states, images, +/// transparency groups, nested form XObjects, Type 3 fonts, patterns and annotation appearances. +#[test] +fn a_page_with_transparency_and_no_output_intent_names_its_blending_space() { + // With `intent`, the catalog has a PDF/A output intent, its profile the last object. + let blending = |intent: bool, page_extra: &str, resources: &str, extra: &[&str]| -> Vec { + let profile = extra.len() + 4; + let intents = + format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) /DestOutputProfile {profile} 0 R >>]"); + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {} >>", if intent { intents.as_str() } else { "" }); + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + if intent { + objs.push( + "<< /N 3 /Length 0 >> +stream + +endstream", + ); + } + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let said = ["Page 1 uses transparency, but there is no PDF/A output intent and its group names no blending colour space"]; + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>"); + for (what, resources, extra) in [ + ("fill alpha", gs("/ca 0.5"), vec![]), + ("stroke alpha", gs("/CA 0.25"), vec![]), + ("blend mode", gs("/BM /Darken"), vec![]), + ("blend mode array", gs("/BM [/Multiply /Normal]"), vec![]), + ("soft mask", gs("/SMask << /S /Luminosity /G 4 0 R >>"), vec![form("")]), + ( + "image soft mask", + "/XObject << /Im 4 0 R >>".to_owned(), + vec![ + "<< /Subtype /Image /Width 1 /Height 1 /SMask 5 0 R /Length 0 >>\nstream\n\nendstream".to_owned(), + "<< /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 0 >>\nstream\n\nendstream".to_owned(), + ], + ), + ( + "SMaskInData", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ("transparency group", "/XObject << /Fm 4 0 R >>".to_owned(), vec![form("/Group << /S /Transparency >>")]), + ( + "nested form", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /In 5 0 R >> >>"), form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")], + ), + ( + "Type 3 font", + "/Font << /T3 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type3 /Resources << /ExtGState << /G << /CA 0.5 >> >> >> >>".to_owned()], + ), + ("tiling pattern", "/Pattern << /P 4 0 R >>".to_owned(), vec![form("/PatternType 1 /Resources << /ExtGState << /G << /BM /Screen >> >> >>")]), + ("shading pattern", "/Pattern << /P << /PatternType 2 /ExtGState << /ca 0.5 >> >> >>".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(blending(false, "", &resources, &extra), said, "{what}"); + // A page group with a blending colour space, or a PDF/A output intent, satisfies the rule. + assert!(blending(false, "/Group << /S /Transparency /CS /DeviceRGB >>", &resources, &extra).is_empty(), "{what}: page /CS"); + assert!(blending(true, "", &resources, &extra).is_empty(), "{what}: output intent"); + } + // Annotation appearances, as one stream or a dictionary of streams by state. + let annot = |ap: &str| format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 10 10] /AP << /N {ap} >> >>"); + assert_eq!(blending(false, "/Annots [4 0 R]", "", &[&annot("5 0 R"), &form("/Group << /S /Transparency >>")]), said, "appearance"); + assert_eq!( + blending(false, "/Annots [4 0 R]", "", &[&annot("<< /On 5 0 R /Off 6 0 R >>"), &form(""), &form("/Group << /S /Transparency >>")]), + said, + "appearance states" + ); + // Opaque painting needs no blending colour space: alpha 1, Normal or Compatible blending, no + // soft mask, an image without one, a form that is no transparency group. + for (what, resources, extra) in [ + ("opaque", gs("/CA 1 /ca 1.0 /BM /Normal /SMask /None"), vec![]), + ("compatible", gs("/BM [/Compatible /Darken]"), vec![]), + ( + "image", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 0 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ( + "plain form, its resources cycling back to itself", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /Me 4 0 R >> >>")], + ), + ( + "Type 1 font", + "/Font << /F 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Resources << /ExtGState << /G << /ca 0.5 >> >> >> >>".to_owned()], + ), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert!(blending(false, "", &resources, &extra).is_empty(), "{what}"); + } +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs new file mode 100644 index 0000000000..93353f310d --- /dev/null +++ b/crates/preflight/src/transparency.rs @@ -0,0 +1,159 @@ +//! PDF/A-2 6.2.10: a page that contains transparency, in a file without a PDF/A output intent, +//! names its blending colour space: its `/Group` dictionary has a `/CS` entry. +//! +//! A page contains transparency when something its resources reach can paint transparently (ISO +//! 32000-1 11.6.4.4 and 11.7.4): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, +//! a blend mode other than `Normal` or `Compatible`, or a soft mask; an image with a soft mask +//! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked +//! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they +//! hold, and those of the appearance streams of the page's annotations. Whether the content uses +//! a resource is not asked: a resource a page holds is one it may paint with. + +use std::collections::HashSet; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How many dictionaries one page's search reads, across its resources, XObjects, patterns, Type +/// 3 fonts and appearance streams. +const MAX_VISITS: usize = 4096; +/// How deep resources nest (a form XObject in a form XObject, ...). +const MAX_DEPTH: usize = 32; + +/// Whether the page `page` contains transparency. +pub(crate) fn page_has_transparency(doc: &Document, page: &Dict) -> bool { + let mut walk = Walk { doc, seen: HashSet::new(), visits: 0 }; + if page.get(b"Resources").is_some_and(|r| walk.resources(r, 0)) { + return true; + } + let Some(annots) = page.get(b"Annots").map(|a| doc.resolve(a)) else { return false }; + let Some(annots) = annots.as_array() else { return false }; + annots.iter().any(|a| { + let a = doc.resolve(a); + let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| doc.resolve(ap)) else { return false }; + let Some(ap) = ap.as_dict() else { return false }; + // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. + ap.iter().any(|(_, v)| match &*doc.resolve(v) { + Object::Stream(_) => walk.xobject(v, 0), + Object::Dict(states) => states.iter().any(|(_, s)| walk.xobject(s, 0)), + _ => false, + }) + }) +} + +/// Whether `page` names its blending colour space: a `/Group` dictionary with a `/CS` entry. +pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { + page.get(b"Group").map(|g| doc.resolve(g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.get(b"CS").is_some())) +} + +struct Walk<'a> { + doc: &'a Document, + /// The indirect objects already read: each is looked at once, and a cycle ends there. + seen: HashSet, + visits: usize, +} + +impl Walk<'_> { + /// Whether `o` may be read: within the budget, and an indirect object not read before. + fn enter(&mut self, o: &Object) -> bool { + if self.visits >= MAX_VISITS { + return false; + } + self.visits += 1; + match o { + Object::Ref(r) => self.seen.insert(*r), + _ => true, + } + } + + fn resources(&mut self, r: &Object, depth: usize) -> bool { + if depth > MAX_DEPTH || !self.enter(r) { + return false; + } + let doc = self.doc; + let Some(res) = doc.resolve(r).as_dict().cloned() else { return false }; + let entries = |key: &[u8]| -> Vec { + res.get(key).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().map(|d| d.iter().map(|(_, v)| v.clone()).collect())).unwrap_or_default() + }; + entries(b"ExtGState").iter().any(|g| self.ext_gstate(g)) + || entries(b"XObject").iter().any(|x| self.xobject(x, depth)) + || entries(b"Pattern").iter().any(|p| self.pattern(p, depth)) + || entries(b"Font").iter().any(|f| self.type3(f, depth)) + } + + /// A graphics state that paints transparently (ISO 32000-1 Table 58). + fn ext_gstate(&mut self, g: &Object) -> bool { + if !self.enter(g) { + return false; + } + let g = self.doc.resolve(g); + let Some(g) = g.as_dict() else { return false }; + let doc = self.doc; + let below_one = |key: &[u8]| g.get(key).and_then(|v| doc.resolve(v).as_f64()).is_some_and(|a| a < 1.0); + let blend = g.get(b"BM").map(|b| doc.resolve(b)).is_some_and(|b| blends(doc, &b)); + let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| !matches!(&*m, Object::Name(n) if n == b"None")); + below_one(b"CA") || below_one(b"ca") || blend || mask + } + + /// An image with a soft mask, or a form XObject that is a transparency group or holds + /// transparency in its resources. + fn xobject(&mut self, x: &Object, depth: usize) -> bool { + if !self.enter(x) { + return false; + } + let doc = self.doc; + let x = doc.resolve(x); + let Object::Stream(s) = &*x else { return false }; + let d = &s.dict; + if d.name(b"Subtype") == Some(b"Image") { + let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(_))); + let in_data = d.get(b"SMaskInData").and_then(|v| doc.resolve(v).as_int()).is_some_and(|v| v > 0); + return soft_mask || in_data; + } + let group = d.get(b"Group").map(|g| doc.resolve(g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + group || d.get(b"Resources").is_some_and(|r| self.resources(r, depth + 1)) + } + + /// A tiling pattern's resources, or a shading pattern's graphics state. + fn pattern(&mut self, p: &Object, depth: usize) -> bool { + if !self.enter(p) { + return false; + } + let doc = self.doc; + let p = doc.resolve(p); + let Some(d) = (match &*p { + Object::Stream(s) => Some(&s.dict), + Object::Dict(d) => Some(d), + _ => None, + }) else { + return false; + }; + let state = d.get(b"ExtGState").cloned(); + let resources = d.get(b"Resources").cloned(); + state.is_some_and(|g| self.ext_gstate(&g)) || resources.is_some_and(|r| self.resources(&r, depth + 1)) + } + + /// A Type 3 font's resources, which its glyph procedures paint with. + fn type3(&mut self, f: &Object, depth: usize) -> bool { + if !self.enter(f) { + return false; + } + let f = self.doc.resolve(f); + let Some(d) = f.as_dict() else { return false }; + if d.name(b"Subtype") != Some(b"Type3") { + return false; + } + let resources = d.get(b"Resources").cloned(); + resources.is_some_and(|r| self.resources(&r, depth + 1)) + } +} + +/// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose +/// first name is not one of those. +fn blends(doc: &Document, b: &Object) -> bool { + let normal = |n: &[u8]| n == b"Normal" || n == b"Compatible"; + match b { + Object::Name(n) => !normal(n), + Object::Array(a) => a.iter().find_map(|m| doc.resolve(m).as_name().map(|n| !normal(n))).unwrap_or(false), + _ => false, + } +} From c35d8441d91681d4dabde59b04842ec7cdf1f708 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:54:12 +0800 Subject: [PATCH 37/72] M11: PDF/A verify reports blend modes ISO 32000-1 doesn't define (6.2.10) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 18 +++---- crates/preflight/src/tests.rs | 22 ++++++++- crates/preflight/src/transparency.rs | 70 +++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index a506bcae0e..6667360fc2 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,7 +117,8 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", - "Page {} uses transparency, but there is no PDF/A output intent and its group names no blending colour space", + "Page {} has transparency but no blending colour space or PDF/A output intent", + "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", @@ -516,18 +517,19 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } + // 6.2.10: blend modes are those ISO 32000-1 defines. + for (_, o) in &objs { + for name in transparency::unknown_blend_modes(doc, o) { + issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), None, false); + } + } // 6.2.10: without a PDF/A output intent, a page with transparency names its blending colour // space. if !has_pdfa_intent(doc) { for (i, p) in pages.iter().enumerate() { if transparency::page_has_transparency(doc, &p.dict) && !transparency::page_names_blending_space(doc, &p.dict) { - issue( - "6.2.10", - "Page {} uses transparency, but there is no PDF/A output intent and its group names no blending colour space", - Some((i + 1).to_string()), - Some(i), - false, - ); + let n = Some((i + 1).to_string()); + issue("6.2.10", "Page {} has transparency but no blending colour space or PDF/A output intent", n, Some(i), false); } } } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 179c938baf..c16aec14e9 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -513,7 +513,7 @@ endstream", } verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() }; - let said = ["Page 1 uses transparency, but there is no PDF/A output intent and its group names no blending colour space"]; + let said = ["Page 1 has transparency but no blending colour space or PDF/A output intent"]; let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>"); for (what, resources, extra) in [ @@ -588,3 +588,23 @@ endstream", assert!(blending(false, "", &resources, &extra).is_empty(), "{what}"); } } + +/// 6.2.10: a graphics state's blend mode, or each name of a blend mode array, is one ISO 32000-1 +/// 11.3.5 defines, whether the graphics state is an object of its own or written in a page's +/// resources. +#[test] +fn blend_modes_are_the_ones_iso_32000_1_defines() { + let modes = |resources: &str, extra: &[&str]| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << {resources} >> >>"); + let mut objs: Vec<&str> = + vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let custom = ["A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define"]; + assert_eq!(modes("/ExtGState << /G 4 0 R >>", &["<< /BM /Custom >>"]), custom, "an object of its own"); + assert_eq!(modes("/ExtGState << /G << /BM /Custom >> >>", &[]), custom, "written in the resources"); + assert_eq!(modes("/ExtGState << /G << /BM [/Multiply /Custom] >> >>", &[]), custom, "in an array"); + let all = "/Normal /Compatible /Multiply /Screen /Overlay /Darken /Lighten /ColorDodge /ColorBurn /HardLight /SoftLight /Difference /Exclusion /Hue /Saturation /Color /Luminosity"; + assert!(modes(&format!("/ExtGState << /G << /BM [{all}] >> >>"), &[]).is_empty()); +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 93353f310d..37ccbc6223 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -1,5 +1,6 @@ -//! PDF/A-2 6.2.10: a page that contains transparency, in a file without a PDF/A output intent, -//! names its blending colour space: its `/Group` dictionary has a `/CS` entry. +//! PDF/A-2 6.2.10: a graphics state's blend mode is one ISO 32000-1 defines, and a page that +//! contains transparency, in a file without a PDF/A output intent, names its blending colour +//! space: its `/Group` dictionary has a `/CS` entry. //! //! A page contains transparency when something its resources reach can paint transparently (ISO //! 32000-1 11.6.4.4 and 11.7.4): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, @@ -147,6 +148,71 @@ impl Walk<'_> { } } +/// The blend modes of ISO 32000-1 11.3.5 (Tables 136 and 137), and `Compatible` (Table 58). +const BLEND_MODES: &[&[u8]] = &[ + b"Normal", + b"Compatible", + b"Multiply", + b"Screen", + b"Overlay", + b"Darken", + b"Lighten", + b"ColorDodge", + b"ColorBurn", + b"HardLight", + b"SoftLight", + b"Difference", + b"Exclusion", + b"Hue", + b"Saturation", + b"Color", + b"Luminosity", +]; + +/// The blend modes ISO 32000-1 doesn't define that the object `o` names in a `/BM` entry: its own, +/// or one of a dictionary written inside it (a page's resources hold their graphics states +/// directly), within [`MAX_DEPTH`]. +pub(crate) fn unknown_blend_modes(doc: &Document, o: &Object) -> Vec { + let mut out = Vec::new(); + blend_modes_within(doc, o, 0, &mut out); + out +} + +fn blend_modes_within(doc: &Document, o: &Object, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let d = match o { + Object::Dict(d) => d, + Object::Stream(s) => &s.dict, + Object::Array(a) => { + for v in a { + blend_modes_within(doc, v, depth + 1, out); + } + return; + } + _ => return, + }; + out.extend(unknown_in(doc, d)); + for (k, v) in d.iter() { + if k != b"BM" { + blend_modes_within(doc, v, depth + 1, out); + } + } +} + +/// The names in a graphics state's `/BM` (a name, or an array of them) that are not blend modes +/// ISO 32000-1 defines. +fn unknown_in(doc: &Document, gs: &Dict) -> Vec { + let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return Vec::new() }; + let names: Vec> = match &*bm { + Object::Name(n) => vec![n.clone()], + Object::Array(a) => a.iter().filter_map(|m| doc.resolve(m).as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }; + names.into_iter().filter(|n| !BLEND_MODES.contains(&n.as_slice())).map(|n| String::from_utf8_lossy(&n).into_owned()).collect() +} + /// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose /// first name is not one of those. fn blends(doc: &Document, b: &Object) -> bool { From be2c726935af332beef79aa74739b55771b6a6f2 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:55:36 +0800 Subject: [PATCH 38/72] M11: parity: Verify PDF/A checks transparency (6.2.10) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..c1d5654299 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ 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." +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, and transparency (6.2.10: blend modes ISO 32000-1 defines, and a blending colour space on pages with transparency when there is no PDF/A output intent). Not yet: content-stream operator checks, a transparency group blending colour space checked against the output intent, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" From 0b2c452bb151d57611114d68f8811ff3c8b2ce2d Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:56:01 +0800 Subject: [PATCH 39/72] M11: transparency: cite the ISO 32000-1 clauses that define transparent painting Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/transparency.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 37ccbc6223..a906396fb4 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -3,7 +3,7 @@ //! space: its `/Group` dictionary has a `/CS` entry. //! //! A page contains transparency when something its resources reach can paint transparently (ISO -//! 32000-1 11.6.4.4 and 11.7.4): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, +//! 32000-1 11.6.4.4 to 11.6.6): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, //! a blend mode other than `Normal` or `Compatible`, or a soft mask; an image with a soft mask //! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked //! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they From 903405072ef4161740aa6b4c6d700b09e2454984 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:03:28 +0800 Subject: [PATCH 40/72] M11: PDF/A verify checks the value types of the predefined XMP schemas (6.6.2.3.1) Properties of the Dublin Core, XMP Basic, Rights Management, Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and Camera Raw namespaces must have the types ISO 16684-1 clause 8 and XMP Specification Part 2 give them: simple values by their lexical form, arrays by kind and items, structures by their fields, in every RDF/XML form a value can take. xmp::parse is shared with the identification read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 7 + crates/preflight/src/tests.rs | 88 +++++ crates/preflight/src/xmp.rs | 13 +- crates/preflight/src/xmp_types.rs | 638 ++++++++++++++++++++++++++++++ 4 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 crates/preflight/src/xmp_types.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..8d6d1cd9c2 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}; @@ -113,6 +114,7 @@ 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 document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -447,6 +449,11 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } + if let Some(parsed) = xmp::parse(&x) { + for name in xmp_types::mistyped(&parsed) { + issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); + } + } if let Some(title) = info(doc, "Title") && xmp::value(&x, "dc:title").is_some_and(|t| t != title) { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..c187280667 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,91 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.6.2.3.1: the properties of the predefined XMP schemas have the value types XMP gives +/// them (ISO 16684-1 8.2 and XMP Part 2), in every RDF/XML form a value can take. +#[test] +fn xmp_properties_of_predefined_schemas_have_their_value_types() { + const 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/""# + ); + let mistyped = |body: &str| -> Vec { + let xmp = format!( + r#""# + ); + let parsed = xmp::parse(&xmp).expect("parses"); + xmp_types::mistyped(&parsed).into_iter().collect() + }; + let element = |p: &str, v: &str| mistyped(&format!("><{p}>{v}")); + // 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"] { + 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"]); + // 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"]); + // 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"]); + // 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#">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"]); + // Through verify: reported under 6.6.2.3.1, not fixable. + 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 issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it"); + assert!(!issues[0].fixable); +} 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..af7a703d07 --- /dev/null +++ b/crates/preflight/src/xmp_types.rs @@ -0,0 +1,638 @@ +//! 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`. +//! +//! 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::BTreeSet; + +use roxmltree::Node; + +/// How deep arrays and structures are followed. +const MAX_DEPTH: usize = 32; + +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, GUID, + /// Locale, MIMEType, ProperName, RenditionClass, URI, URL, XPath, and the like). + Text, + 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), + Struct(&'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", Text), + (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", Text), + (ST_REF, "filePath", Text), + (ST_REF, "fromPart", Text), + (ST_REF, "instanceID", Text), + (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(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)), (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)]); +/// Part 2 Table 16. +const MARKER: &[Field] = &[ + (XMP_DM, "comment", Text), + (XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct(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)), + (XMP_DM, "managed", Boolean), + (XMP_DM, "path", Text), + (XMP_DM, "startTime", Ty::Struct(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))), (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))), + ], + }, + // 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(RESOURCE_REF)), + ("DocumentID", Text), + ("History", Ty::Seq(&Ty::Struct(RESOURCE_EVENT))), + ("Ingredients", Ty::Bag(&Ty::Struct(RESOURCE_REF))), + ("InstanceID", Text), + ("ManagedFrom", Ty::Struct(RESOURCE_REF)), + ("Manager", Text), + ("ManageTo", Text), + ("ManageUI", Text), + ("ManagerVariant", Text), + ("OriginalDocumentID", Text), + ("Pantry", Ty::Bag(&Ty::AnyStruct)), + ("RenditionClass", Text), + ("RenditionParams", Text), + ("VersionID", Text), + ("Versions", Ty::Seq(&Ty::Struct(VERSION))), + ("LastURL", Text), + ("RenditionOf", Ty::Struct(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)))] }, + // 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)), + ("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)), + ("artist", Text), + ("audioModDate", Date), + ("audioChannelType", Text), + ("audioCompressor", Text), + ("audioSampleRate", Integer), + ("audioSampleType", Text), + ("beatSpliceParams", Ty::Struct(BEAT_SPLICE_STRETCH)), + ("cameraAngle", Text), + ("cameraLabel", Text), + ("cameraModel", Text), + ("cameraMove", Text), + ("client", Text), + ("comment", Text), + ("composer", Text), + ("contributedMedia", Ty::Bag(&Ty::Struct(MEDIA))), + ("copyright", Text), + ("director", Text), + ("directorPhotography", Text), + ("duration", Ty::Struct(TIME)), + ("engineer", Text), + ("fileDataRate", Rational), + ("genre", Text), + ("good", Boolean), + ("instrument", Text), + ("introTime", Ty::Struct(TIME)), + ("key", Text), + ("logComment", Text), + ("loop", Boolean), + ("numberOfBeats", Real), + ("markers", Ty::Seq(&Ty::Struct(MARKER))), + ("metadataModDate", Date), + ("outCue", Ty::Struct(TIME)), + ("projectName", Text), + ("projectRef", Ty::Struct(PROJECT_LINK)), + ("pullDown", Text), + ("relativePeakAudioFilePath", Text), + ("relativeTimestamp", Ty::Struct(TIME)), + ("releaseDate", Date), + ("resampleParams", Ty::Struct(RESAMPLE_STRETCH)), + ("scaleType", Text), + ("scene", Text), + ("shotDate", Date), + ("shotDay", Text), + ("shotLocation", Text), + ("shotName", Text), + ("shotNumber", Text), + ("shotSize", Text), + ("speakerPlacement", Text), + ("startTimecode", Ty::Struct(TIMECODE)), + ("stretchMode", Text), + ("takeNumber", Integer), + ("tapeName", Text), + ("tempo", Real), + ("timeScaleParams", Ty::Struct(TIME_SCALE_STRETCH)), + ("timeSignature", Text), + ("trackNumber", Integer), + ("Tracks", Ty::Bag(&Ty::Struct(TRACK))), + ("videoAlphaMode", Text), + ("videoAlphaPremultipleColor", Ty::Struct(COLORANT)), + ("videoAlphaUnityIsTransparent", Boolean), + ("videoColorSpace", Text), + ("videoCompressor", Text), + ("videoFieldOrder", Text), + ("videoFrameRate", Text), + ("videoFrameSize", Ty::Struct(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))), + ("Headline", Text), + ("History", Text), + ("ICCProfile", Text), + ("Instructions", Text), + ("Source", Text), + ("State", Text), + ("SupplementalCategories", Ty::Bag(&Text)), + ("TextLayers", Ty::Seq(&Ty::Struct(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), + ], + }, +]; + +/// The properties of predefined schemas in `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(); + 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())); + } + } + 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) + { + out.insert(format!("{}:{}", schema.prefix, name.name())); + } + } + } + out +} + +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 true; + } + 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"); + let description = match children.as_slice() { + [d] if is(d, RDF, "Description") => Some(*d), + _ => None, + }; + let fields_of = description.unwrap_or(*e); + if (resource || description.is_some()) + && let Some(v) = fields_of.children().find(|c| is(c, RDF, "value")) + { + return fits(ty, &v, deeper); + } + match ty { + Ty::Text | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + if let Some(uri) = e.attribute((RDF, "resource")) { + return simple(ty, uri); + } + children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, e.text().unwrap_or_default()) + } + 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 + } + Ty::AnyStruct => structure(e, &children, resource, description).is_some(), + Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)), + } +} + +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 { + [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() => { + Some(a.children().filter(Node::is_element).filter(|i| is(i, RDF, "li")).collect()) + } + _ => 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 { + return Some(*e); + } + if let Some(d) = description { + return Some(d); + } + // Fields written as attributes of the property element, with no content. + let text = e.text().unwrap_or_default().trim().is_empty(); + (children.is_empty() && text && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e) +} + +/// 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 => 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.get(1..).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 +} From 25e7024aae4e141aae1ce8fbd2fb89dc32a31cad Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:04:15 +0800 Subject: [PATCH 41/72] M11: parity: PDF/A verify checks predefined XMP value types (6.6.2.3.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..33be107925 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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_properties_of_predefined_schemas_have_their_value_types", "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" From 3c749e13df0c2ebd4e63c38549442397dbbf6a40 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:05:35 +0800 Subject: [PATCH 42/72] M11: tests: split the predefined XMP value type test by kind of value (6.6.2.3.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 60 ++++++++++++++++++++++++----------- parity/acrobat-features.toml | 2 +- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index c187280667..ab1686be55 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -489,25 +489,32 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } -/// PDF/A-2 6.6.2.3.1: the properties of the predefined XMP schemas have the value types XMP gives -/// them (ISO 16684-1 8.2 and XMP Part 2), in every RDF/XML form a value can take. -#[test] -fn xmp_properties_of_predefined_schemas_have_their_value_types() { - const 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/""# +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 mistyped = |body: &str| -> Vec { - let xmp = format!( - r#""# - ); - let parsed = xmp::parse(&xmp).expect("parses"); - xmp_types::mistyped(&parsed).into_iter().collect() - }; - let element = |p: &str, v: &str| mistyped(&format!("><{p}>{v}")); + let parsed = xmp::parse(&xmp).expect("parses"); + xmp_types::mistyped(&parsed).into_iter().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"]); @@ -529,6 +536,11 @@ fn xmp_properties_of_predefined_schemas_have_their_value_types() { } assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty()); assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]); +} + +/// 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"]); @@ -542,6 +554,11 @@ fn xmp_properties_of_predefined_schemas_have_their_value_types() { 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()); @@ -566,9 +583,14 @@ fn xmp_properties_of_predefined_schemas_have_their_value_types() { // A qualified value is checked by its rdf:value. assert!(mistyped(r#">5q"#).is_empty()); assert_eq!(mistyped(r#">five"#), ["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() { // Through verify: reported under 6.6.2.3.1, not fixable. let xmp = - format!(r#""#); + 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 issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 33be107925..309782be84 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,7 +7943,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_properties_of_predefined_schemas_have_their_value_types", "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/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]] From 52131ba093e5714457dd7d7078877163dec5a52b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:16:00 +0800 Subject: [PATCH 43/72] M11: 6.2.10 reads blend modes only from graphics states, wants a device-independent /CS and a profiled intent, rereads shallower Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 36 ++-- crates/preflight/src/tests.rs | 64 ++++++- crates/preflight/src/transparency.rs | 262 +++++++++++++++------------ 3 files changed, 232 insertions(+), 130 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 6667360fc2..5a71e86a9a 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,7 +117,7 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", - "Page {} has transparency but no blending colour space or PDF/A output intent", + "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", @@ -213,6 +213,19 @@ fn dict_of(o: &Object) -> Option<&Dict> { } } +/// Whether the document has a PDF/A output intent whose `/DestOutputProfile` is a stream: one with +/// a null or missing profile describes no colour, so it doesn't stand in for a blending colour +/// space (6.2.10). +fn pdfa_intent_has_profile(doc: &Document) -> bool { + let cat = catalog(doc); + let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().any(|oi| { + let oi = doc.resolve(oi); + let Some(d) = oi.as_dict() else { return false }; + d.name(b"S") == Some(b"GTS_PDFA1") && d.get(b"DestOutputProfile").is_some_and(|p| matches!(&*doc.resolve(p), Object::Stream(_))) + }) +} + fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } @@ -517,20 +530,17 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - // 6.2.10: blend modes are those ISO 32000-1 defines. - for (_, o) in &objs { - for name in transparency::unknown_blend_modes(doc, o) { + // 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with + // a profile) a page with transparency names a blending colour space. + let intent = pdfa_intent_has_profile(doc); + for (i, p) in pages.iter().enumerate() { + let painting = transparency::page_painting(doc, &p.dict); + for name in painting.unknown_blend_modes { issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), None, false); } - } - // 6.2.10: without a PDF/A output intent, a page with transparency names its blending colour - // space. - if !has_pdfa_intent(doc) { - for (i, p) in pages.iter().enumerate() { - if transparency::page_has_transparency(doc, &p.dict) && !transparency::page_names_blending_space(doc, &p.dict) { - let n = Some((i + 1).to_string()); - issue("6.2.10", "Page {} has transparency but no blending colour space or PDF/A output intent", n, Some(i), false); - } + if painting.transparency && !intent && !transparency::page_names_blending_space(doc, &p.dict) { + let n = Some((i + 1).to_string()); + issue("6.2.10", "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", n, Some(i), false); } } check_objects(doc, level, &objs, &page_of, &mut issue); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index c16aec14e9..07237c169e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -513,7 +513,7 @@ endstream", } verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() }; - let said = ["Page 1 has transparency but no blending colour space or PDF/A output intent"]; + let said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>"); for (what, resources, extra) in [ @@ -552,7 +552,11 @@ endstream", let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); assert_eq!(blending(false, "", &resources, &extra), said, "{what}"); // A page group with a blending colour space, or a PDF/A output intent, satisfies the rule. - assert!(blending(false, "/Group << /S /Transparency /CS /DeviceRGB >>", &resources, &extra).is_empty(), "{what}: page /CS"); + assert!( + blending(false, "/Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>", &resources, &extra).is_empty(), + "{what}: page /CS" + ); + assert_eq!(blending(false, "/Group << /S /Transparency >>", &resources, &extra), said, "{what}: page group without /CS"); assert!(blending(true, "", &resources, &extra).is_empty(), "{what}: output intent"); } // Annotation appearances, as one stream or a dictionary of streams by state. @@ -595,7 +599,9 @@ endstream", #[test] fn blend_modes_are_the_ones_iso_32000_1_defines() { let modes = |resources: &str, extra: &[&str]| -> Vec { - let page = format!("<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << {resources} >> >>"); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << {resources} >> >>" + ); let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; objs.extend_from_slice(extra); @@ -608,3 +614,55 @@ fn blend_modes_are_the_ones_iso_32000_1_defines() { let all = "/Normal /Compatible /Multiply /Screen /Overlay /Darken /Lighten /ColorDodge /ColorBurn /HardLight /SoftLight /Difference /Exclusion /Hue /Saturation /Color /Luminosity"; assert!(modes(&format!("/ExtGState << /G << /BM [{all}] >> >>"), &[]).is_empty()); } + +/// 6.2.10's blending colour space is one PDF/A accepts without an output intent: not null, and +/// not a device space unless the page gives it a default (6.2.4.3). An output intent stands in +/// for it only with a profile. +#[test] +fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { + let page_said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; + let check = |catalog_extra: &str, page_extra: &str, extra: &[&str]| -> Vec { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let alpha = "/ExtGState << /G << /ca 0.5 >> >>"; + let page = |group: &str, colour_spaces: &str| format!("/Group << /S /Transparency {group} >> /Resources << {alpha} {colour_spaces} >>"); + assert_eq!(check("", &page("/CS null", ""), &[]), page_said, "a null /CS"); + assert_eq!(check("", &page("/CS 9 0 R", ""), &[]), page_said, "a /CS naming no object"); + for (device, default) in [("DeviceGray", "DefaultGray"), ("DeviceRGB", "DefaultRGB"), ("DeviceCMYK", "DefaultCMYK")] { + assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[]), page_said, "{device}"); + let defaults = format!("/ColorSpace << /{default} [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>"); + assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[]).is_empty(), "{device} with {default}"); + } + let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]"); + let opaque_group = format!("/Resources << {alpha} >>"); + assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile"); + assert_eq!(check(&intent(""), &opaque_group, &[]), page_said, "no profile"); + assert!(check(&intent("/DestOutputProfile 4 0 R"), &opaque_group, &["<< /N 3 /Length 0 >>\nstream\n\nendstream"]).is_empty(), "a profile"); + // Private data is no graphics state: a /BM there is not a blend mode. + assert!(check("/PieceInfo << /App << /Private << /BM /Bookkeeping >> >> >>", "", &[]).is_empty(), "/PieceInfo"); +} + +/// A form XObject first reached at the depth limit, where its resources go unread, is read again +/// when a shallower place names it, so what it holds is not missed. +#[test] +fn transparency_reached_first_too_deep_is_found_from_a_shallower_place() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // A chain of 32 forms, objects 4 to 35, the last naming form 36, which holds a translucent + // graphics state; the page names the chain first and form 36 after it. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 36 0 R >> >> >>".to_owned(), + ]; + for n in 4..=35 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index a906396fb4..7b3e83784c 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -1,6 +1,7 @@ //! PDF/A-2 6.2.10: a graphics state's blend mode is one ISO 32000-1 defines, and a page that //! contains transparency, in a file without a PDF/A output intent, names its blending colour -//! space: its `/Group` dictionary has a `/CS` entry. +//! space: its `/Group` dictionary has a `/CS` entry, which as any colour space in such a file is +//! not a device space without a default (6.2.4.3). //! //! A page contains transparency when something its resources reach can paint transparently (ISO //! 32000-1 11.6.4.4 to 11.6.6): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, @@ -8,9 +9,10 @@ //! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked //! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they //! hold, and those of the appearance streams of the page's annotations. Whether the content uses -//! a resource is not asked: a resource a page holds is one it may paint with. +//! a resource is not asked: a resource a page holds is one it may paint with. Blend modes are read +//! from the same graphics states, so private data (`/PieceInfo`) is never taken for one. -use std::collections::HashSet; +use std::collections::HashMap; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -19,132 +21,197 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; const MAX_VISITS: usize = 4096; /// How deep resources nest (a form XObject in a form XObject, ...). const MAX_DEPTH: usize = 32; +/// The resource categories that hold what a page paints with, read in this order. +const CATEGORIES: [&[u8]; 4] = [b"ExtGState", b"XObject", b"Pattern", b"Font"]; -/// Whether the page `page` contains transparency. -pub(crate) fn page_has_transparency(doc: &Document, page: &Dict) -> bool { - let mut walk = Walk { doc, seen: HashSet::new(), visits: 0 }; - if page.get(b"Resources").is_some_and(|r| walk.resources(r, 0)) { - return true; - } - let Some(annots) = page.get(b"Annots").map(|a| doc.resolve(a)) else { return false }; - let Some(annots) = annots.as_array() else { return false }; - annots.iter().any(|a| { - let a = doc.resolve(a); - let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| doc.resolve(ap)) else { return false }; - let Some(ap) = ap.as_dict() else { return false }; - // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. - ap.iter().any(|(_, v)| match &*doc.resolve(v) { - Object::Stream(_) => walk.xobject(v, 0), - Object::Dict(states) => states.iter().any(|(_, s)| walk.xobject(s, 0)), - _ => false, - }) - }) +/// What the graphics a page's resources reach paint with. +#[derive(Debug, Default)] +pub(crate) struct Painting { + /// Something paints transparently. + pub(crate) transparency: bool, + /// The blend modes graphics states name that ISO 32000-1 doesn't define. + pub(crate) unknown_blend_modes: Vec, } -/// Whether `page` names its blending colour space: a `/Group` dictionary with a `/CS` entry. +/// What the page `page` paints with: its resources, and the appearance streams of its +/// annotations. +pub(crate) fn page_painting(doc: &Document, page: &Dict) -> Painting { + let mut walk = Walk { doc, seen: HashMap::new(), visits: 0, found: Painting::default() }; + if let Some(r) = page.get(b"Resources") { + walk.resources(r, 0); + } + let annots = page.get(b"Annots").map(|a| doc.resolve(a)); + for a in annots.as_deref().and_then(Object::as_array).into_iter().flatten() { + let a = doc.resolve(a); + let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| doc.resolve(ap)) else { continue }; + let Some(ap) = ap.as_dict() else { continue }; + // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Stream(_) => walk.xobject(v, 0), + Object::Dict(states) => { + for (_, s) in states.iter() { + walk.xobject(s, 0); + } + } + _ => {} + } + } + } + walk.found +} + +/// Whether `page` names a blending colour space PDF/A accepts without an output intent: its +/// `/Group` has a `/CS` that is not null and not a device colour space, unless the page's +/// resources give that device space a default (`DefaultGray`, `DefaultRGB`, `DefaultCMYK`), as +/// PDF/A-2 6.2.4.3 asks of any device colour. pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { - page.get(b"Group").map(|g| doc.resolve(g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.get(b"CS").is_some())) + let group = page.get(b"Group").map(|g| doc.resolve(g)); + let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; + let default: &[u8] = match &*cs { + Object::Null => return false, + Object::Name(n) if n == b"DeviceGray" => b"DefaultGray", + Object::Name(n) if n == b"DeviceRGB" => b"DefaultRGB", + Object::Name(n) if n == b"DeviceCMYK" => b"DefaultCMYK", + _ => return true, + }; + let resources = page.get(b"Resources").map(|r| doc.resolve(r)); + let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); + spaces.as_deref().and_then(Object::as_dict).is_some_and(|s| s.get(default).is_some()) } struct Walk<'a> { doc: &'a Document, - /// The indirect objects already read: each is looked at once, and a cycle ends there. - seen: HashSet, + /// The indirect objects already read, each with the shallowest depth it was read at: an + /// object is read again only from a shallower place, so a cycle ends and a deep first visit + /// doesn't hide what a shallower one would read. + seen: HashMap, visits: usize, + found: Painting, } impl Walk<'_> { - /// Whether `o` may be read: within the budget, and an indirect object not read before. - fn enter(&mut self, o: &Object) -> bool { - if self.visits >= MAX_VISITS { + /// Whether `o`, reached `depth` levels down, may be read: within the budget and the depth, and + /// an indirect object not read before from as shallow a place. + fn enter(&mut self, o: &Object, depth: usize) -> bool { + if self.visits >= MAX_VISITS || depth > MAX_DEPTH { return false; } + if let Object::Ref(r) = o { + if self.seen.get(r).is_some_and(|&d| d <= depth) { + return false; + } + self.seen.insert(*r, depth); + } self.visits += 1; - match o { - Object::Ref(r) => self.seen.insert(*r), - _ => true, + true + } + + fn resources(&mut self, r: &Object, depth: usize) { + if !self.enter(r, depth) { + return; + } + let doc = self.doc; + let Some(res) = doc.resolve(r).as_dict().cloned() else { return }; + for category in CATEGORIES { + // Once the budget is spent nothing more is read, so nothing more is gathered either. + if self.visits >= MAX_VISITS { + return; + } + let entries: Vec = res + .get(category) + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_dict().map(|d| d.iter().map(|(_, v)| v.clone()).collect())) + .unwrap_or_default(); + for v in &entries { + match category { + b"ExtGState" => self.ext_gstate(v, depth), + b"XObject" => self.xobject(v, depth), + b"Pattern" => self.pattern(v, depth), + _ => self.type3(v, depth), + } + } } } - fn resources(&mut self, r: &Object, depth: usize) -> bool { - if depth > MAX_DEPTH || !self.enter(r) { - return false; + /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend + /// modes it names that ISO 32000-1 doesn't define. + fn ext_gstate(&mut self, g: &Object, depth: usize) { + if !self.enter(g, depth) { + return; } let doc = self.doc; - let Some(res) = doc.resolve(r).as_dict().cloned() else { return false }; - let entries = |key: &[u8]| -> Vec { - res.get(key).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().map(|d| d.iter().map(|(_, v)| v.clone()).collect())).unwrap_or_default() - }; - entries(b"ExtGState").iter().any(|g| self.ext_gstate(g)) - || entries(b"XObject").iter().any(|x| self.xobject(x, depth)) - || entries(b"Pattern").iter().any(|p| self.pattern(p, depth)) - || entries(b"Font").iter().any(|f| self.type3(f, depth)) - } - - /// A graphics state that paints transparently (ISO 32000-1 Table 58). - fn ext_gstate(&mut self, g: &Object) -> bool { - if !self.enter(g) { - return false; - } - let g = self.doc.resolve(g); - let Some(g) = g.as_dict() else { return false }; - let doc = self.doc; + let g = doc.resolve(g); + let Some(g) = g.as_dict() else { return }; let below_one = |key: &[u8]| g.get(key).and_then(|v| doc.resolve(v).as_f64()).is_some_and(|a| a < 1.0); - let blend = g.get(b"BM").map(|b| doc.resolve(b)).is_some_and(|b| blends(doc, &b)); + let bm = g.get(b"BM").map(|b| doc.resolve(b)); + let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| !matches!(&*m, Object::Name(n) if n == b"None")); - below_one(b"CA") || below_one(b"ca") || blend || mask + self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; + for name in bm.as_deref().map(|b| unknown_blend_modes(doc, b)).unwrap_or_default() { + if !self.found.unknown_blend_modes.contains(&name) { + self.found.unknown_blend_modes.push(name); + } + } } /// An image with a soft mask, or a form XObject that is a transparency group or holds /// transparency in its resources. - fn xobject(&mut self, x: &Object, depth: usize) -> bool { - if !self.enter(x) { - return false; + fn xobject(&mut self, x: &Object, depth: usize) { + if !self.enter(x, depth) { + return; } let doc = self.doc; let x = doc.resolve(x); - let Object::Stream(s) = &*x else { return false }; + let Object::Stream(s) = &*x else { return }; let d = &s.dict; if d.name(b"Subtype") == Some(b"Image") { let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(_))); let in_data = d.get(b"SMaskInData").and_then(|v| doc.resolve(v).as_int()).is_some_and(|v| v > 0); - return soft_mask || in_data; + self.found.transparency |= soft_mask || in_data; + return; } let group = d.get(b"Group").map(|g| doc.resolve(g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); - group || d.get(b"Resources").is_some_and(|r| self.resources(r, depth + 1)) + self.found.transparency |= group; + if let Some(r) = d.get(b"Resources") { + self.resources(r, depth + 1); + } } /// A tiling pattern's resources, or a shading pattern's graphics state. - fn pattern(&mut self, p: &Object, depth: usize) -> bool { - if !self.enter(p) { - return false; + fn pattern(&mut self, p: &Object, depth: usize) { + if !self.enter(p, depth) { + return; } - let doc = self.doc; - let p = doc.resolve(p); + let p = self.doc.resolve(p); let Some(d) = (match &*p { Object::Stream(s) => Some(&s.dict), Object::Dict(d) => Some(d), _ => None, }) else { - return false; + return; }; - let state = d.get(b"ExtGState").cloned(); - let resources = d.get(b"Resources").cloned(); - state.is_some_and(|g| self.ext_gstate(&g)) || resources.is_some_and(|r| self.resources(&r, depth + 1)) + if let Some(g) = d.get(b"ExtGState") { + self.ext_gstate(g, depth + 1); + } + if let Some(r) = d.get(b"Resources") { + self.resources(r, depth + 1); + } } /// A Type 3 font's resources, which its glyph procedures paint with. - fn type3(&mut self, f: &Object, depth: usize) -> bool { - if !self.enter(f) { - return false; + fn type3(&mut self, f: &Object, depth: usize) { + if !self.enter(f, depth) { + return; } let f = self.doc.resolve(f); - let Some(d) = f.as_dict() else { return false }; + let Some(d) = f.as_dict() else { return }; if d.name(b"Subtype") != Some(b"Type3") { - return false; + return; + } + if let Some(r) = d.get(b"Resources") { + self.resources(r, depth + 1); } - let resources = d.get(b"Resources").cloned(); - resources.is_some_and(|r| self.resources(&r, depth + 1)) } } @@ -169,43 +236,10 @@ const BLEND_MODES: &[&[u8]] = &[ b"Luminosity", ]; -/// The blend modes ISO 32000-1 doesn't define that the object `o` names in a `/BM` entry: its own, -/// or one of a dictionary written inside it (a page's resources hold their graphics states -/// directly), within [`MAX_DEPTH`]. -pub(crate) fn unknown_blend_modes(doc: &Document, o: &Object) -> Vec { - let mut out = Vec::new(); - blend_modes_within(doc, o, 0, &mut out); - out -} - -fn blend_modes_within(doc: &Document, o: &Object, depth: usize, out: &mut Vec) { - if depth > MAX_DEPTH { - return; - } - let d = match o { - Object::Dict(d) => d, - Object::Stream(s) => &s.dict, - Object::Array(a) => { - for v in a { - blend_modes_within(doc, v, depth + 1, out); - } - return; - } - _ => return, - }; - out.extend(unknown_in(doc, d)); - for (k, v) in d.iter() { - if k != b"BM" { - blend_modes_within(doc, v, depth + 1, out); - } - } -} - -/// The names in a graphics state's `/BM` (a name, or an array of them) that are not blend modes -/// ISO 32000-1 defines. -fn unknown_in(doc: &Document, gs: &Dict) -> Vec { - let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return Vec::new() }; - let names: Vec> = match &*bm { +/// The names in a `/BM` value (a name, or an array of them) that are not blend modes ISO 32000-1 +/// defines. +fn unknown_blend_modes(doc: &Document, bm: &Object) -> Vec { + let names: Vec> = match bm { Object::Name(n) => vec![n.clone()], Object::Array(a) => a.iter().filter_map(|m| doc.resolve(m).as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), From ba4e86c5477e2cfc72de377f36588dab33b9d013 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:27:27 +0800 Subject: [PATCH 44/72] M11: PDF/A verify checks XMP value types in every metadata stream, more strictly (6.6.2.3.1) Every metadata stream is read, within one decode allowance, not only the catalog's. Predefined properties inside a Pantry item keep their types; rdf:value may be an attribute; rdf:resource makes a URI, which only a text type takes; a value's text is all its text nodes; an array item is rdf:li; a parseType="Resource" structure has no character data; a time zone is Z, +hh:mm or -hh:mm; and a value nested past every type's depth doesn't have its type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 9 ++-- crates/preflight/src/tests.rs | 87 +++++++++++++++++++++++++++---- crates/preflight/src/xmp_types.rs | 78 ++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 29 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 8d6d1cd9c2..234c6c10b3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -449,11 +449,6 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } - if let Some(parsed) = xmp::parse(&x) { - for name in xmp_types::mistyped(&parsed) { - issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); - } - } if let Some(title) = info(doc, "Title") && xmp::value(&x, "dc:title").is_some_and(|t| t != title) { @@ -467,6 +462,10 @@ 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 used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index ab1686be55..b18d033c9e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -531,11 +531,25 @@ fn xmp_simple_values_have_their_lexical_forms() { { 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"] { + 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"]); } /// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative. @@ -550,6 +564,7 @@ fn xmp_arrays_have_their_kind_and_item_types() { 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"]); // 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"]); @@ -564,6 +579,15 @@ fn xmp_structures_have_their_field_types() { 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"]); + // 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"] @@ -583,18 +607,61 @@ fn xmp_structures_have_their_field_types() { // 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"]); + // 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() { - // Through verify: reported under 6.6.2.3.1, not fixable. - 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 issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect(); - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].message, "The XMP property crs:Contrast doesn't have the value type the XMP Specification gives it"); - assert!(!issues[0].fixable); + 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, each once, in name order; 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 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" + ] + ); + assert!(found.iter().all(|i| !i.fixable)); + // 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, + ], + "", + ); + assert_eq!(issues(&doc).len(), 1); } diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index af7a703d07..d11c947926 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -7,7 +7,8 @@ //! 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`. +//! 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 @@ -17,10 +18,14 @@ use std::collections::BTreeSet; +use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; use roxmltree::Node; -/// How deep arrays and structures are followed. +/// 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"; @@ -442,8 +447,30 @@ const SCHEMAS: &[Schema] = &[ }, ]; -/// The properties of predefined schemas in `doc` whose values don't have their type, as -/// `prefix:name` in name order. +/// 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(); + let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); + for (_, 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 = String::from_utf8_lossy(&bytes); + if let Some(parsed) = crate::xmp::parse(&text) { + out.extend(mistyped(&parsed)); + } + } + out +} + +/// 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(); let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF"))); @@ -484,7 +511,7 @@ fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator, depth: usize) -> bool { if depth > MAX_DEPTH { - return true; + return false; } let deeper = depth + 1; let children: Vec> = e.children().filter(Node::is_element).collect(); @@ -500,12 +527,16 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { { return fits(ty, &v, deeper); } + if let Some(v) = description.and_then(|d| d.attribute((RDF, "value"))) { + return simple(ty, v); + } match ty { Ty::Text | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { - if let Some(uri) = e.attribute((RDF, "resource")) { - return simple(ty, uri); + // `rdf:resource` makes the value a URI, which only a text type takes. + if e.attribute((RDF, "resource")).is_some() { + return matches!(ty, Ty::Text); } - children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, e.text().unwrap_or_default()) + 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)), @@ -534,7 +565,18 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { }); attrs_ok && elements_ok } - Ty::AnyStruct => structure(e, &children, resource, description).is_some(), + // A structure of any fields, such as a Pantry item: predefined properties in it still have + // their types. + Ty::AnyStruct => { + let Some(holder) = structure(e, &children, resource, description) else { return false }; + 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, deeper)) + }); + attrs_ok && elements_ok + } Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)), } } @@ -546,8 +588,10 @@ fn field(fields: &[Field], ns: &str, name: &str) -> Option { /// 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 breaks the array. [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() => { - Some(a.children().filter(Node::is_element).filter(|i| is(i, RDF, "li")).collect()) + let items: Vec> = a.children().filter(Node::is_element).collect(); + items.iter().all(|i| is(i, RDF, "li")).then_some(items) } _ => None, } @@ -556,14 +600,20 @@ fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Opt /// 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 { - return Some(*e); + // 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 Some(d); } // Fields written as attributes of the property element, with no content. - let text = e.text().unwrap_or_default().trim().is_empty(); - (children.is_empty() && text && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e) + 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) +} + +/// 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`. @@ -624,7 +674,7 @@ fn date(v: &str) -> bool { }; let zone_ok = match zone { "" | "Z" => true, - z => z.get(1..).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)), + 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), From 798dfa44c1bb7e9f6df1e754fc4c107b652489d5 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:35:05 +0800 Subject: [PATCH 45/72] M11: 6.2.10 accepts only blending colour spaces and real soft masks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 21 +++++++++++++++ crates/preflight/src/transparency.rs | 39 +++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 07237c169e..0bfe747c4e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -637,6 +637,27 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { let defaults = format!("/ColorSpace << /{default} [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>"); assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[]).is_empty(), "{device} with {default}"); } + // Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4). + assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray"); + let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len()); + assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components"); + for (what, cs, extra) in [ + ("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]), + ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), + ( + "Separation", + "/CS [/Separation /Spot /DeviceCMYK 4 0 R]".to_owned(), + vec!["<< /FunctionType 2 /Domain [0 1] /C1 [0 0 0 1] /N 1 >>".to_owned()], + ), + ("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]), + ("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]), + ("a pattern space", "/CS /Pattern".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(check("", &page(&cs, ""), &extra), page_said, "{what}"); + } + // A null soft mask is none: an otherwise opaque graphics state is not transparency. + assert!(check("", "/Resources << /ExtGState << /G << /SMask null >> >> >>", &[]).is_empty(), "/SMask null"); let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]"); let opaque_group = format!("/Resources << {alpha} >>"); assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile"); diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 7b3e83784c..eccde37352 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -62,24 +62,50 @@ pub(crate) fn page_painting(doc: &Document, page: &Dict) -> Painting { } /// Whether `page` names a blending colour space PDF/A accepts without an output intent: its -/// `/Group` has a `/CS` that is not null and not a device colour space, unless the page's -/// resources give that device space a default (`DefaultGray`, `DefaultRGB`, `DefaultCMYK`), as -/// PDF/A-2 6.2.4.3 asks of any device colour. +/// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: +/// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes +/// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour +/// space unless the page's resources give that device space a default (`DefaultGray`, +/// `DefaultRGB`, `DefaultCMYK`), as PDF/A-2 6.2.4.3 asks of any device colour. pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { let group = page.get(b"Group").map(|g| doc.resolve(g)); let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; let default: &[u8] = match &*cs { - Object::Null => return false, Object::Name(n) if n == b"DeviceGray" => b"DefaultGray", Object::Name(n) if n == b"DeviceRGB" => b"DefaultRGB", Object::Name(n) if n == b"DeviceCMYK" => b"DefaultCMYK", - _ => return true, + Object::Array(a) => return independent_blending_space(doc, a), + _ => return false, }; let resources = page.get(b"Resources").map(|r| doc.resolve(r)); let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); spaces.as_deref().and_then(Object::as_dict).is_some_and(|s| s.get(default).is_some()) } +/// Whether the colour space array `a` is a device-independent blending colour space: `CalGray`, +/// `CalRGB`, or `ICCBased` with 1, 3 or 4 components and, where the profile header can be read +/// (within the output intent's profile cap), a gray, RGB or CMYK data colour space. +fn independent_blending_space(doc: &Document, a: &[Object]) -> bool { + let family = a.first().map(|f| doc.resolve(f)); + match family.as_deref().and_then(Object::as_name) { + Some(b"CalGray" | b"CalRGB") => true, + Some(b"ICCBased") => { + let profile = a.get(1).map(|p| doc.resolve(p)); + let Some(Object::Stream(s)) = profile.as_deref() else { return false }; + let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int()); + if !matches!(components, Some(1 | 3 | 4)) { + return false; + } + // ICC.1 header: the data colour space is bytes 16–19. + match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { + Some(space) => matches!(space.as_slice(), b"GRAY" | b"RGB " | b"CMYK"), + None => true, + } + } + _ => false, + } +} + struct Walk<'a> { doc: &'a Document, /// The indirect objects already read, each with the shallowest depth it was read at: an @@ -146,7 +172,8 @@ impl Walk<'_> { let below_one = |key: &[u8]| g.get(key).and_then(|v| doc.resolve(v).as_f64()).is_some_and(|a| a < 1.0); let bm = g.get(b"BM").map(|b| doc.resolve(b)); let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); - let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| !matches!(&*m, Object::Name(n) if n == b"None")); + // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). + let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; for name in bm.as_deref().map(|b| unknown_blend_modes(doc, b)).unwrap_or_default() { if !self.found.unknown_blend_modes.contains(&name) { From e3a3317d47dcc8b5eed1c436a90dd6e4004cabe8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:48:32 +0800 Subject: [PATCH 46/72] M11: PDF/A verify reads UTF-16 XMP packets and keeps GUIDs from being URIs (6.6.2.3.1) A metadata stream is decoded by its byte order mark (UTF-16 big- or little-endian, or UTF-8; ISO 16684-1 7.3) before it is parsed. A GUID is a simple value but not a URI (ISO 16684-1 8.2.2.3), so the document and instance IDs of xmpMM, stRef and stEvt don't take rdf:resource. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 19 +++++++++++++++ crates/preflight/src/xmp_types.rs | 40 ++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b18d033c9e..e2a160904f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -550,6 +550,13 @@ fn xmp_simple_values_have_their_lexical_forms() { 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. @@ -651,6 +658,18 @@ fn xmp_value_types_are_reported_by_verify() { ] ); assert!(found.iter().all(|i| !i.fixable)); + // 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( diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index d11c947926..55f542929c 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -33,9 +33,11 @@ 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, GUID, - /// Locale, MIMEType, ProperName, RenditionClass, URI, URL, XPath, and the like). + /// 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, @@ -113,7 +115,7 @@ const THUMBNAIL: &[Field] = &[(XMP_G_IMG, "format", Text), (XMP_G_IMG, "height", const RESOURCE_EVENT: &[Field] = &[ (ST_EVT, "action", Text), (ST_EVT, "changed", Text), - (ST_EVT, "instanceID", Text), + (ST_EVT, "instanceID", Ty::Guid), (ST_EVT, "parameters", Text), (ST_EVT, "softwareAgent", Text), (ST_EVT, "when", Date), @@ -121,10 +123,10 @@ const RESOURCE_EVENT: &[Field] = &[ /// ISO 16684-1 Table 3 and Part 2 Table 9. const RESOURCE_REF: &[Field] = &[ (ST_REF, "alternatePaths", Ty::Seq(&Text)), - (ST_REF, "documentID", Text), + (ST_REF, "documentID", Ty::Guid), (ST_REF, "filePath", Text), (ST_REF, "fromPart", Text), - (ST_REF, "instanceID", Text), + (ST_REF, "instanceID", Ty::Guid), (ST_REF, "lastModifyDate", Date), (ST_REF, "manager", Text), (ST_REF, "managerVariant", Text), @@ -249,16 +251,16 @@ const SCHEMAS: &[Schema] = &[ prefix: "xmpMM", properties: &[ ("DerivedFrom", Ty::Struct(RESOURCE_REF)), - ("DocumentID", Text), + ("DocumentID", Ty::Guid), ("History", Ty::Seq(&Ty::Struct(RESOURCE_EVENT))), ("Ingredients", Ty::Bag(&Ty::Struct(RESOURCE_REF))), - ("InstanceID", Text), + ("InstanceID", Ty::Guid), ("ManagedFrom", Ty::Struct(RESOURCE_REF)), ("Manager", Text), ("ManageTo", Text), ("ManageUI", Text), ("ManagerVariant", Text), - ("OriginalDocumentID", Text), + ("OriginalDocumentID", Ty::Guid), ("Pantry", Ty::Bag(&Ty::AnyStruct)), ("RenditionClass", Text), ("RenditionParams", Text), @@ -461,7 +463,7 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet break; } let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; - let text = String::from_utf8_lossy(&bytes); + let text = unicode(&bytes); if let Some(parsed) = crate::xmp::parse(&text) { out.extend(mistyped(&parsed)); } @@ -469,6 +471,22 @@ pub(crate) fn mistyped_in(objs: &[(ObjRef, std::sync::Arc)]) -> BTreeSet out } +/// 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, /// as `prefix:name` in name order. pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeSet { @@ -531,7 +549,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { return simple(ty, v); } match ty { - Ty::Text | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + 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. if e.attribute((RDF, "resource")).is_some() { return matches!(ty, Ty::Text); @@ -624,7 +642,7 @@ fn simple(ty: Ty, v: &str) -> bool { 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 => true, + Ty::Text | Ty::Guid => true, Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), _ => false, } From 75e9028e4035574260c1087b57456998b2b7476a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:53:54 +0800 Subject: [PATCH 47/72] M11: 6.2.10 checks a device space's default and an ICC profile's header against its components Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 24 ++++++++++-- crates/preflight/src/transparency.rs | 55 ++++++++++++++++------------ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0bfe747c4e..21e9e4748e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -632,11 +632,22 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { let page = |group: &str, colour_spaces: &str| format!("/Group << /S /Transparency {group} >> /Resources << {alpha} {colour_spaces} >>"); assert_eq!(check("", &page("/CS null", ""), &[]), page_said, "a null /CS"); assert_eq!(check("", &page("/CS 9 0 R", ""), &[]), page_said, "a /CS naming no object"); - for (device, default) in [("DeviceGray", "DefaultGray"), ("DeviceRGB", "DefaultRGB"), ("DeviceCMYK", "DefaultCMYK")] { - assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[]), page_said, "{device}"); - let defaults = format!("/ColorSpace << /{default} [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>"); - assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[]).is_empty(), "{device} with {default}"); + // A device space needs a default of as many components (ISO 32000-1 8.6.5.6). + let cmyk_profile = "<< /N 4 /Length 0 >>\nstream\n\nendstream"; + let cal = |family: &str| format!("[/{family} << /WhitePoint [0.9505 1 1.089] >>]"); + for (device, default, space) in [ + ("DeviceGray", "DefaultGray", cal("CalGray")), + ("DeviceRGB", "DefaultRGB", cal("CalRGB")), + ("DeviceCMYK", "DefaultCMYK", "[/ICCBased 4 0 R]".to_owned()), + ] { + assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[cmyk_profile]), page_said, "{device}"); + let defaults = format!("/ColorSpace << /{default} {space} >>"); + assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[cmyk_profile]).is_empty(), "{device} with {default}"); + let null = format!("/ColorSpace << /{default} null >>"); + assert_eq!(check("", &page(&format!("/CS /{device}"), &null), &[cmyk_profile]), page_said, "{device} with a null {default}"); } + let wrong = format!("/ColorSpace << /DefaultGray {} >>", cal("CalRGB")); + assert_eq!(check("", &page("/CS /DeviceGray", &wrong), &[]), page_said, "DeviceGray with a CalRGB default"); // Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4). assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray"); let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len()); @@ -651,6 +662,11 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { ), ("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]), ("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]), + ( + "an ICC profile of one component whose header says RGB", + "/CS [/ICCBased 4 0 R]".to_owned(), + vec![icc(1, &format!("{}RGB ", "\0".repeat(16)))], + ), ("a pattern space", "/CS /Pattern".to_owned(), vec![]), ] { let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index eccde37352..f58e8d59af 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -66,43 +66,50 @@ pub(crate) fn page_painting(doc: &Document, page: &Dict) -> Painting { /// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes /// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour /// space unless the page's resources give that device space a default (`DefaultGray`, -/// `DefaultRGB`, `DefaultCMYK`), as PDF/A-2 6.2.4.3 asks of any device colour. +/// `DefaultRGB`, `DefaultCMYK`) that is itself such a space with as many components (ISO +/// 32000-1 8.6.5.6), as PDF/A-2 6.2.4.3 asks of any device colour. pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { let group = page.get(b"Group").map(|g| doc.resolve(g)); let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; - let default: &[u8] = match &*cs { - Object::Name(n) if n == b"DeviceGray" => b"DefaultGray", - Object::Name(n) if n == b"DeviceRGB" => b"DefaultRGB", - Object::Name(n) if n == b"DeviceCMYK" => b"DefaultCMYK", - Object::Array(a) => return independent_blending_space(doc, a), + let (default, components): (&[u8], i64) = match &*cs { + Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1), + Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3), + Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4), + Object::Array(a) => return independent_blending_space(doc, a).is_some(), _ => return false, }; let resources = page.get(b"Resources").map(|r| doc.resolve(r)); let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); - spaces.as_deref().and_then(Object::as_dict).is_some_and(|s| s.get(default).is_some()) + let space = spaces.as_deref().and_then(Object::as_dict).and_then(|s| s.get(default)).map(|d| doc.resolve(d)); + space.as_deref().and_then(Object::as_array).and_then(|a| independent_blending_space(doc, a)) == Some(components) } -/// Whether the colour space array `a` is a device-independent blending colour space: `CalGray`, -/// `CalRGB`, or `ICCBased` with 1, 3 or 4 components and, where the profile header can be read -/// (within the output intent's profile cap), a gray, RGB or CMYK data colour space. -fn independent_blending_space(doc: &Document, a: &[Object]) -> bool { +/// The number of components of the colour space array `a` when it is a device-independent +/// blending colour space: `CalGray` (1), `CalRGB` (3), or `ICCBased` with 1, 3 or 4 components +/// whose profile header, where it can be read (within the output intent's profile cap), says +/// gray, RGB or CMYK to match. +fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { let family = a.first().map(|f| doc.resolve(f)); - match family.as_deref().and_then(Object::as_name) { - Some(b"CalGray" | b"CalRGB") => true, - Some(b"ICCBased") => { + match family.as_deref().and_then(Object::as_name)? { + b"CalGray" => Some(1), + b"CalRGB" => Some(3), + b"ICCBased" => { let profile = a.get(1).map(|p| doc.resolve(p)); - let Some(Object::Stream(s)) = profile.as_deref() else { return false }; - let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int()); - if !matches!(components, Some(1 | 3 | 4)) { - return false; - } + let Some(Object::Stream(s)) = profile.as_deref() else { return None }; + let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int())?; // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { - Some(space) => matches!(space.as_slice(), b"GRAY" | b"RGB " | b"CMYK"), - None => true, - } + let header: Option = match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { + Some(space) => match space.as_slice() { + b"GRAY" => Some(1), + b"RGB " => Some(3), + b"CMYK" => Some(4), + _ => return None, + }, + None => None, + }; + (matches!(components, 1 | 3 | 4) && header.is_none_or(|h| h == components)).then_some(components) } - _ => false, + _ => None, } } From ffa5c23f7ded4db4b8e67081240c5d381ad62ce8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:09:06 +0800 Subject: [PATCH 48/72] M11: 6.2.10 wants a CalGray or CalRGB blending space to carry its white point Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 3 +++ crates/preflight/src/transparency.rs | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 21e9e4748e..4d0ecabcb1 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -654,6 +654,9 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components"); for (what, cs, extra) in [ ("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]), + ("CalRGB without its dictionary", "/CS [/CalRGB]".to_owned(), vec![]), + ("CalGray without a white point", "/CS [/CalGray << >>]".to_owned(), vec![]), + ("CalRGB with a short white point", "/CS [/CalRGB << /WhitePoint [0.9505 1] >>]".to_owned(), vec![]), ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), ( "Separation", diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index f58e8d59af..335b65f95f 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -90,9 +90,16 @@ pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { /// gray, RGB or CMYK to match. fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { let family = a.first().map(|f| doc.resolve(f)); + // A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` of three + // numbers (ISO 32000-1 Tables 63 and 64). + let calibrated = || { + let params = a.get(1).map(|p| doc.resolve(p)); + let white = params.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"WhitePoint")).map(|w| doc.resolve(w)); + white.as_deref().and_then(Object::as_array).is_some_and(|w| w.len() == 3 && w.iter().all(|v| doc.resolve(v).as_f64().is_some())) + }; match family.as_deref().and_then(Object::as_name)? { - b"CalGray" => Some(1), - b"CalRGB" => Some(3), + b"CalGray" => calibrated().then_some(1), + b"CalRGB" => calibrated().then_some(3), b"ICCBased" => { let profile = a.get(1).map(|p| doc.resolve(p)); let Some(Object::Stream(s)) = profile.as_deref() else { return None }; From cdea50daaceafde4678510d3c0dcf26f1cd4bf8c Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:11:02 +0800 Subject: [PATCH 49/72] M11: PDF/A verify checks the value types of the TIFF, EXIF and aux XMP schemas (6.6.2.3.1) The TIFF, EXIF and Additional EXIF namespaces come from the September 2005 XMP Specification that PDF/A-2 cites ("EXIF Schemas", pages 58-70), with its EXIF value types (pages 83-87): Rational as numerator/ denominator, GPSCoordinate as DDD,MM,SSk or DDD,MM.mmk, and the Flash, OECF/SFR, CFAPattern and DeviceSettings structures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 58 ++++++++++- crates/preflight/src/xmp_types.rs | 158 +++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e2a160904f..026479c069 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -494,7 +494,8 @@ const XMP_NS: &str = concat!( 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/""# + r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" "#, + r#"xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:aux="http://ns.adobe.com/exif/1.0/aux/""# ); /// The mistyped predefined properties of a packet whose one description holds `body` (its @@ -684,3 +685,58 @@ fn xmp_value_types_are_reported_by_verify() { ); assert_eq!(issues(&doc).len(), 1); } + +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP +/// Specification (pages 58–70), with the EXIF value types of pages 83–87. +#[test] +fn xmp_exif_schemas_have_their_value_types() { + // TIFF: integers, closed choices of integer, rationals, sequences, language alternatives. + assert!(element("tiff:ImageWidth", "256").is_empty()); + assert_eq!(element("tiff:ImageWidth", "256 mm"), ["tiff:ImageWidth"]); + assert_eq!(element("tiff:Orientation", "4/1"), ["tiff:Orientation"]); + assert!(element("tiff:XResolution", "72/1").is_empty()); + assert_eq!(element("tiff:XResolution", "1/2"), ["tiff:XResolution"]); + assert!(element("tiff:BitsPerSample", "88").is_empty()); + assert_eq!(element("tiff:BitsPerSample", "88.0"), ["tiff:BitsPerSample"]); + assert_eq!(element("tiff:TransferFunction", "0"), ["tiff:TransferFunction"]); + assert_eq!(element("tiff:ImageDescription", "A picture"), ["tiff:ImageDescription"]); + // EXIF: GPS coordinates (page 85). + for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] { + assert!(element("exif:GPSLatitude", good).is_empty(), "{good}"); + } + for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N"] { + assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}"); + } + assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty()); + assert_eq!(element("exif:ISOSpeedRatings", "100"), ["exif:ISOSpeedRatings"]); + // EXIF structures: Flash, OECF/SFR, CFAPattern, DeviceSettings (pages 85–87). + assert!(mistyped(r#">True3"#).is_empty()); + assert_eq!(mistyped(r#">yes"#), ["exif:Flash"]); + assert_eq!(mistyped(r#">"#), ["exif:Flash"]); + assert_eq!(element("exif:Flash", "fired"), ["exif:Flash"]); + assert!( + mistyped(r#">1/2"#).is_empty() + ); + assert_eq!( + mistyped(r#">0.5"#), + ["exif:OECF"] + ); + assert_eq!( + mistyped( + r#">1/2"# + ), + ["exif:CFAPattern"] + ); + assert_eq!( + mistyped(r#">2.0"#), + ["exif:DeviceSettingDescription"] + ); + assert_eq!( + mistyped(r#">a"#), + ["exif:DeviceSettingDescription"] + ); + // Additional EXIF properties (page 69). + assert!(element("aux:Lens", "50mm").is_empty()); + assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); + assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 55f542929c..094bde8740 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -4,7 +4,10 @@ //! 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 +//! Camera Raw namespaces, with the structures they use. The TIFF, EXIF and Additional EXIF (`aux`) +//! namespaces, which the current Part 2 leaves to CIPA DC-010, come from the September 2005 XMP +//! Specification that PDF/A-2 cites: "EXIF Schemas" (pages 58–70) and "EXIF Schema Value Types" +//! (pages 83–87). 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 @@ -12,7 +15,8 @@ //! //! 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. +//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it and +//! the 2005 edition defines it (page 84); GPSCoordinate `DDD,MM,SSk` or `DDD,MM.mmk` (page 85). //! 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. @@ -43,6 +47,8 @@ enum Ty { Boolean, Date, Rational, + /// `DDD,MM,SSk` or `DDD,MM.mmk`, `k` one of N, S, E, W (2005 edition, page 85). + GpsCoordinate, /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). LangAlt, Seq(&'static Ty), @@ -198,6 +204,19 @@ const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; /// Part 2 Table 32. const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; +const EXIF: &str = "http://ns.adobe.com/exif/1.0/"; + +/// Flash (2005 edition, page 85). +const FLASH: &[Field] = + &[(EXIF, "Fired", Boolean), (EXIF, "Return", Integer), (EXIF, "Mode", Integer), (EXIF, "Function", Boolean), (EXIF, "RedEyeMode", Boolean)]; +/// OECF/SFR (2005 edition, page 86). +const OECF_SFR: &[Field] = + &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Names", Ty::Seq(&Text)), (EXIF, "Values", Ty::Seq(&Rational))]; +/// CFAPattern (2005 edition, page 87). +const CFA_PATTERN: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Values", Ty::Seq(&Integer))]; +/// DeviceSettings (2005 edition, page 87). +const DEVICE_SETTINGS: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Settings", Ty::Seq(&Text))]; + const SCHEMAS: &[Schema] = &[ // ISO 16684-1 Table 4. Schema { @@ -447,6 +466,126 @@ const SCHEMAS: &[Schema] = &[ ("WhiteBalance", Text), ], }, + // 2005 edition, "EXIF Schema for TIFF Properties", pages 58–60. A Closed Choice of Integer is an + // Integer. + Schema { + uri: "http://ns.adobe.com/tiff/1.0/", + prefix: "tiff", + properties: &[ + ("ImageWidth", Integer), + ("ImageLength", Integer), + ("BitsPerSample", Ty::Seq(&Integer)), + ("Compression", Integer), + ("PhotometricInterpretation", Integer), + ("Orientation", Integer), + ("SamplesPerPixel", Integer), + ("PlanarConfiguration", Integer), + ("YCbCrSubSampling", Ty::Seq(&Integer)), + ("YCbCrPositioning", Integer), + ("XResolution", Rational), + ("YResolution", Rational), + ("ResolutionUnit", Integer), + ("TransferFunction", Ty::Seq(&Integer)), + ("WhitePoint", Ty::Seq(&Rational)), + ("PrimaryChromaticities", Ty::Seq(&Rational)), + ("YCbCrCoefficients", Ty::Seq(&Rational)), + ("ReferenceBlackWhite", Ty::Seq(&Rational)), + ("DateTime", Date), + ("ImageDescription", LangAlt), + ("Make", Text), + ("Model", Text), + ("Software", Text), + ("Artist", Text), + ("Copyright", LangAlt), + ], + }, + // 2005 edition, "EXIF Schema for EXIF-specific Properties", pages 60–69. + Schema { + uri: EXIF, + prefix: "exif", + properties: &[ + ("ExifVersion", Text), + ("FlashpixVersion", Text), + ("ColorSpace", Integer), + ("ComponentsConfiguration", Ty::Seq(&Integer)), + ("CompressedBitsPerPixel", Rational), + ("PixelXDimension", Integer), + ("PixelYDimension", Integer), + ("UserComment", LangAlt), + ("RelatedSoundFile", Text), + ("DateTimeOriginal", Date), + ("DateTimeDigitized", Date), + ("ExposureTime", Rational), + ("FNumber", Rational), + ("ExposureProgram", Integer), + ("SpectralSensitivity", Text), + ("ISOSpeedRatings", Ty::Seq(&Integer)), + ("OECF", Ty::Struct(OECF_SFR)), + ("ShutterSpeedValue", Rational), + ("ApertureValue", Rational), + ("BrightnessValue", Rational), + ("ExposureBiasValue", Rational), + ("MaxApertureValue", Rational), + ("SubjectDistance", Rational), + ("MeteringMode", Integer), + ("LightSource", Integer), + ("Flash", Ty::Struct(FLASH)), + ("FocalLength", Rational), + ("SubjectArea", Ty::Seq(&Integer)), + ("FlashEnergy", Rational), + ("SpatialFrequencyResponse", Ty::Struct(OECF_SFR)), + ("FocalPlaneXResolution", Rational), + ("FocalPlaneYResolution", Rational), + ("FocalPlaneResolutionUnit", Integer), + ("SubjectLocation", Ty::Seq(&Integer)), + ("ExposureIndex", Rational), + ("SensingMethod", Integer), + ("FileSource", Integer), + ("SceneType", Integer), + ("CFAPattern", Ty::Struct(CFA_PATTERN)), + ("CustomRendered", Integer), + ("ExposureMode", Integer), + ("WhiteBalance", Integer), + ("DigitalZoomRatio", Rational), + ("FocalLengthIn35mmFilm", Integer), + ("SceneCaptureType", Integer), + ("GainControl", Integer), + ("Contrast", Integer), + ("Saturation", Integer), + ("Sharpness", Integer), + ("DeviceSettingDescription", Ty::Struct(DEVICE_SETTINGS)), + ("SubjectDistanceRange", Integer), + ("ImageUniqueID", Text), + ("GPSVersionID", Text), + ("GPSLatitude", Ty::GpsCoordinate), + ("GPSLongitude", Ty::GpsCoordinate), + ("GPSAltitudeRef", Integer), + ("GPSAltitude", Rational), + ("GPSTimeStamp", Date), + ("GPSSatellites", Text), + ("GPSStatus", Text), + ("GPSMeasureMode", Text), + ("GPSDOP", Rational), + ("GPSSpeedRef", Text), + ("GPSSpeed", Rational), + ("GPSTrackRef", Text), + ("GPSTrack", Rational), + ("GPSImgDirectionRef", Text), + ("GPSImgDirection", Rational), + ("GPSMapDatum", Text), + ("GPSDestLatitude", Ty::GpsCoordinate), + ("GPSDestLongitude", Ty::GpsCoordinate), + ("GPSDestBearingRef", Text), + ("GPSDestBearing", Rational), + ("GPSDestDistanceRef", Text), + ("GPSDestDistance", Rational), + ("GPSProcessingMethod", Text), + ("GPSAreaInformation", Text), + ("GPSDifferential", Integer), + ], + }, + // 2005 edition, "EXIF Schema for Additional EXIF Properties", page 69. + 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 @@ -549,7 +688,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { return simple(ty, v); } match ty { - Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational | Ty::GpsCoordinate => { // `rdf:resource` makes the value a URI, which only a text type takes. if e.attribute((RDF, "resource")).is_some() { return matches!(ty, Ty::Text); @@ -642,12 +781,25 @@ fn simple(ty: Ty, v: &str) -> bool { 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::GpsCoordinate => gps_coordinate(v), Ty::Text | Ty::Guid => true, Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), _ => false, } } +/// `DDD,MM,SSk` or `DDD,MM.mmk`: degrees, minutes and seconds or minutes with any number of +/// fractional digits, then N, S, E or W (2005 edition, page 85). +fn gps_coordinate(v: &str) -> bool { + let Some(v) = v.strip_suffix(['N', 'S', 'E', 'W']) else { return false }; + let Some((degrees, rest)) = v.split_once(',') else { return false }; + let minutes_ok = match rest.split_once(',') { + Some((minutes, seconds)) => digits(minutes) && digits(seconds), + None => rest.split_once('.').is_some_and(|(m, f)| digits(m) && digits(f)), + }; + digits(degrees) && minutes_ok +} + fn digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) } From a67a5c8d3541fa1740f30f97582e26fc9fd34b38 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:11:49 +0800 Subject: [PATCH 50/72] M11: tests: GPS coordinates need seconds or a minute fraction, and whole degrees (6.6.2.3.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 026479c069..bee328fe97 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -704,7 +704,7 @@ fn xmp_exif_schemas_have_their_value_types() { for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] { assert!(element("exif:GPSLatitude", good).is_empty(), "{good}"); } - for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N"] { + for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N", "51,30N", "51.5,30,26N"] { assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}"); } assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty()); From 6e7acce67accbb2ba8ec081166767ec041ba7673 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:12:05 +0800 Subject: [PATCH 51/72] M11: parity: PDF/A verify checks the TIFF, EXIF and aux XMP value types (6.6.2.3.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 309782be84..813510d257 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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/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"] -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." +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/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]] id = "optimize.pdfa-convert" From 14f8c2c64cb09a6841e4c21f8ff8d9bcfff16dc0 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:24:05 +0800 Subject: [PATCH 52/72] M11: 6.2.10 checks a calibrated blending space's white point values Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 3 +++ crates/preflight/src/transparency.rs | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 4d0ecabcb1..33dd65f1b3 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -657,6 +657,9 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { ("CalRGB without its dictionary", "/CS [/CalRGB]".to_owned(), vec![]), ("CalGray without a white point", "/CS [/CalGray << >>]".to_owned(), vec![]), ("CalRGB with a short white point", "/CS [/CalRGB << /WhitePoint [0.9505 1] >>]".to_owned(), vec![]), + ("CalRGB with a zero white point", "/CS [/CalRGB << /WhitePoint [0 0 0] >>]".to_owned(), vec![]), + ("CalGray with a white point whose Y isn't 1", "/CS [/CalGray << /WhitePoint [0.9505 0.5 1.089] >>]".to_owned(), vec![]), + ("CalRGB with a non-number in its white point", "/CS [/CalRGB << /WhitePoint [0.9505 1 /Z 1.089] >>]".to_owned(), vec![]), ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), ( "Separation", diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 335b65f95f..38b23389d4 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -90,12 +90,13 @@ pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { /// gray, RGB or CMYK to match. fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { let family = a.first().map(|f| doc.resolve(f)); - // A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` of three - // numbers (ISO 32000-1 Tables 63 and 64). + // A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` [X Y Z]: X + // and Z positive, Y 1 (ISO 32000-1 Tables 63 and 64). let calibrated = || { let params = a.get(1).map(|p| doc.resolve(p)); let white = params.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"WhitePoint")).map(|w| doc.resolve(w)); - white.as_deref().and_then(Object::as_array).is_some_and(|w| w.len() == 3 && w.iter().all(|v| doc.resolve(v).as_f64().is_some())) + let values: Option> = white.as_deref().and_then(Object::as_array).and_then(|w| w.iter().map(|v| doc.resolve(v).as_f64()).collect()); + matches!(values.as_deref(), Some(&[x, y, z]) if x > 0.0 && y == 1.0 && z > 0.0) }; match family.as_deref().and_then(Object::as_name)? { b"CalGray" => calibrated().then_some(1), From c86b161672570784879c88805ebdfd9e372a5dcc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:30:17 +0800 Subject: [PATCH 53/72] M11: PDF/A verify checks XMP qualifiers and empty value forms, and rejects stray text (6.6.2.3.1) Qualifiers beside an rdf:value that are predefined properties keep their types; rdf:value may be an attribute of an empty property element; an rdf:resource element is empty; and character data around array items or in an rdf:Description structure breaks the value, as it already did in a parseType="Resource" structure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 21 ++++++++++++ crates/preflight/src/xmp_types.rs | 55 ++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e2a160904f..07b92dee8a 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -572,6 +572,12 @@ fn xmp_arrays_have_their_kind_and_item_types() { 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"]); + 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"]); @@ -587,6 +593,11 @@ fn xmp_structures_have_their_field_types() { 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!( @@ -617,6 +628,16 @@ fn xmp_structures_have_their_field_types() { // 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); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 55f542929c..33b5d261d8 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -539,20 +539,27 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { [d] if is(d, RDF, "Description") => Some(*d), _ => None, }; - let fields_of = description.unwrap_or(*e); - if (resource || description.is_some()) - && let Some(v) = fields_of.children().find(|c| is(c, RDF, "value")) + // `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 + && let Some(v) = n.children().find(|c| is(c, RDF, "value")) { - return fits(ty, &v, deeper); + return blank(e) && blank(&n) && fits(ty, &v, deeper) && predefined_ok(&n, deeper); } - if let Some(v) = description.and_then(|d| d.attribute((RDF, "value"))) { - return simple(ty, v); + 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); + return matches!(ty, Ty::Text) && children.is_empty() && blank(e); } children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e)) } @@ -585,20 +592,22 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { } // A structure of any fields, such as a Pantry item: predefined properties in it still have // their types. - Ty::AnyStruct => { - let Some(holder) = structure(e, &children, resource, description) else { return false }; - 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, deeper)) - }); - attrs_ok && elements_ok - } + 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) } @@ -606,8 +615,9 @@ fn field(fields: &[Field], ns: &str, name: &str) -> Option { /// 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 breaks the array. - [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() => { + // Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element, or character data around + // them, breaks the array. + [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) => { let items: Vec> = a.children().filter(Node::is_element).collect(); items.iter().all(|i| is(i, RDF, "li")).then_some(items) } @@ -622,13 +632,18 @@ fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool return text(e).trim().is_empty().then_some(*e); } if let Some(d) = description { - return Some(d); + 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` 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() From 7bce37fefc5ba2c4340657fb9c8f702a5ccd01d3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:48:27 +0800 Subject: [PATCH 54/72] M11: PDF/A verify takes no array from a property whose RDF attributes make its value something else (6.6.2.3.1) An rdf:parseType or rdf:resource on a property element makes its value a resource or another literal, not the rdf:Seq, rdf:Bag or rdf:Alt it holds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 3 +++ crates/preflight/src/xmp_types.rs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 07b92dee8a..8eea6afeb1 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -575,6 +575,9 @@ fn xmp_arrays_have_their_kind_and_item_types() { // 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"]); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 33b5d261d8..7ef694ba2f 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -616,8 +616,9 @@ fn field(fields: &[Field], ns: &str, name: &str) -> Option { 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. - [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) => { + // 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) } @@ -639,6 +640,11 @@ fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool (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() From 1f3fc2ce926c25199830c5e0bd26843df313645b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:20:56 +0800 Subject: [PATCH 55/72] M11: PDF/A verify checks XMP extension schema descriptions (6.6.2.3.3) Every pdfaExtension:schemas description needs its entries: a schema its schema, namespaceURI and prefix; a property its name, valueType, category and description; a value type its type, namespaceURI, prefix, description and field; a field its name, valueType and description. Each description namespace is written with its own prefix. One pass over the metadata streams serves this check and the value types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 19 ++++- crates/preflight/src/tests.rs | 104 +++++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 114 ++++++++++++++++++++++++++ crates/preflight/src/xmp_types.rs | 10 +-- 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 crates/preflight/src/xmp_extension.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 234c6c10b3..d323606661 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -23,6 +23,7 @@ pub mod icc; mod xmp; +mod xmp_extension; mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -115,6 +116,8 @@ pub const WORDINGS: &[&str] = &[ "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", + "An XMP extension schema description has no {} entry", + "An XMP extension schema description writes the {} namespace with another prefix", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -462,10 +465,22 @@ 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) { + // 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in + // every metadata stream. + let (mut mistyped, mut extension) = (std::collections::BTreeSet::new(), xmp_extension::Findings::default()); + xmp_types::for_each_packet(&objs, |packet| { + mistyped.extend(xmp_types::mistyped(packet)); + extension.add(packet); + }); + for name in mistyped { issue("6.6.2.3.1", "The XMP property {} doesn't have the value type the XMP Specification gives it", Some(name), None, false); } + for entry in extension.missing { + issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false); + } + for prefix in extension.prefixes { + issue("6.6.2.3.3", "An XMP extension schema description writes the {} namespace with another prefix", Some(prefix.to_owned()), None, false); + } let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 51b1e3d85b..ddd2ce3502 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -764,3 +764,107 @@ fn xmp_exif_schemas_have_their_value_types() { assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); } + +/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five +/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default). +fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static str>) { + let [s, p, t, f] = prefixes; + let xmp = format!( + concat!( + r#""#, + r#""#, + r#"{schema}"#, + r#""# + ), + s = s, + p = p, + t = t, + f = f, + schema = schema + ); + let parsed = xmp::parse(&xmp).expect("parses"); + let mut found = xmp_extension::Findings::default(); + found.add(&parsed); + (found.missing.into_iter().collect(), found.prefixes.into_iter().collect()) +} + +const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; + +/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on). +fn schema_without(drop: &str) -> String { + let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}") }; + let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"] + .iter() + .map(|n| entry(n, "x")) + .collect::(); + let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::(); + let field = entry("pdfaType:field", &format!(r#"{field}"#)); + let value_type = + ["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::(); + [ + entry("pdfaSchema:schema", "Some schema"), + entry("pdfaSchema:namespaceURI", "http://example.com/ns/"), + entry("pdfaSchema:prefix", "ex"), + entry("pdfaSchema:property", &format!(r#"{property}"#)), + entry("pdfaSchema:valueType", &format!(r#"{value_type}{field}"#)), + ] + .concat() +} + +/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its +/// four kinds, and writes each description namespace with its own prefix. +#[test] +fn xmp_extension_schema_descriptions_are_complete() { + assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![])); + // The property and value type lists of a schema are optional. + assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![])); + assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![])); + // Every other entry is required. + for required in [ + "pdfaSchema:schema", + "pdfaSchema:namespaceURI", + "pdfaSchema:prefix", + "pdfaProperty:name", + "pdfaProperty:valueType", + "pdfaProperty:category", + "pdfaProperty:description", + "pdfaType:type", + "pdfaType:namespaceURI", + "pdfaType:prefix", + "pdfaType:description", + "pdfaType:field", + "pdfaField:name", + "pdfaField:valueType", + "pdfaField:description", + ] { + assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}"); + } + // An entry may be an attribute of an rdf:Description item. + let as_attribute = r#""#; + assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![])); + let missing_attribute = r#""#; + assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]); + // Each namespace with its own prefix: the right URI under another prefix breaks the rule. + for (i, own) in OWN.iter().enumerate() { + let mut prefixes = OWN; + prefixes[i] = "other"; + let schema = schema_without("").replace(&format!("{own}:"), "other:"); + assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); + } + // Through verify, under 6.6.2.3.3 and not fixable. + let xmp = concat!( + r#""#, + r#""#, + r#"Se"#, + 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 issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "An XMP extension schema description has no pdfaSchema:namespaceURI entry"); + assert!(!issues[0].fixable); +} diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs new file mode 100644 index 0000000000..4b683c6461 --- /dev/null +++ b/crates/preflight/src/xmp_extension.rs @@ -0,0 +1,114 @@ +//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it +//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description +//! is complete. +//! +//! The entries each description needs, and the prefixes it is written with, are those of the +//! requirement as stated with the conformance corpus: +//! - a schema (`pdfaSchema`) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional; +//! - a property (`pdfaProperty`) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`) has `type`, `namespaceURI`, `prefix`, `description` and `field`; +//! - a field (`pdfaField`) has `name`, `valueType` and `description`; +//! - each of the four namespaces is written with its own prefix (`pdfaSchema`, `pdfaProperty`, +//! `pdfaType`, `pdfaField`), as a PDF/A reader matches them by prefix. +//! +//! An entry counts as present written as an element or as an attribute of the description. + +use std::collections::BTreeSet; + +use roxmltree::{Document, Node}; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/"; +const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#"; +const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#"; +const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#"; +const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; + +/// Each description namespace with its prefix. +const PREFIXES: [(&str, &str); 4] = [(SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Entries a description lacks, as `prefix:name`, in name order. + pub(crate) missing: BTreeSet, + /// Description namespaces written with another prefix, by their own prefix, in name order. + pub(crate) prefixes: BTreeSet<&'static str>, +} + +impl Findings { + /// Adds what the packet `doc` breaks. + pub(crate) fn add(&mut self, doc: &Document<'_>) { + let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); + for container in containers { + self.prefixes_in(doc, &container); + for schema in items(&container) { + self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + for property in entry(&schema, SCHEMA, "property").iter().flat_map(items) { + self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + } + for value_type in entry(&schema, SCHEMA, "valueType").iter().flat_map(items) { + self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for field in entry(&value_type, TYPE, "field").iter().flat_map(items) { + self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); + } + } + } + } + } + + /// Notes the entries of `ns` in `names` that the description `d` lacks. + fn need(&mut self, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { + for name in names { + if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() { + self.missing.insert(format!("{prefix}:{name}")); + } + } + } + + /// Notes description namespaces written in `container` with another prefix than their own. + fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) { + let text = doc.input_text(); + for n in container.descendants().filter(Node::is_element) { + if let Some(own) = own_prefix(n.tag_name().namespace()) + && written_prefix(text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) != own + { + self.prefixes.insert(own); + } + for a in n.attributes() { + if let Some(own) = own_prefix(a.namespace()) + && written_prefix(text.get(a.range_qname()).unwrap_or_default()) != own + { + self.prefixes.insert(own); + } + } + } + } +} + +fn own_prefix(ns: Option<&str>) -> Option<&'static str> { + PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns).map(|(_, p)| *p) +} + +/// The prefix of the qualified name `qname` starts with, empty when it has none. +fn written_prefix(qname: &str) -> &str { + let name = qname.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default(); + name.split_once(':').map_or("", |(p, _)| p) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// The element entry `name` of `ns` in the description `d`. +fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option> { + d.children().find(|c| is(c, ns, name)) +} + +/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or +/// `rdf:Description`. +fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec> { + let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() }; + array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect() +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 3e77332972..dcfbf26625 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -588,10 +588,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), 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, each decoded once within one allowance. +pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc)], mut f: impl FnMut(&roxmltree::Document<'_>)) { let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); for (_, o) in objs { let Object::Stream(s) = &**o else { continue }; @@ -604,10 +603,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(&parsed); } } - out } /// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or From 509610ec4548864642c670f4239a00f30f8018fe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:21:34 +0800 Subject: [PATCH 56/72] M11: parity: PDF/A verify checks XMP extension schema descriptions (6.6.2.3.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7e47202493..d3ca9e78e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7943,8 +7943,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/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/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." +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_extension_schema_descriptions_are_complete", "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), XMP extension schema descriptions (6.6.2.3.3: required entries of the schema, property, value type and field descriptions, and their prefixes), 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" From 7afa1e8b2483b218b831d161258638782c7ca3b8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:29:20 +0800 Subject: [PATCH 57/72] M11: PDF/A verify requires the pdfaExtension prefix too, citing TechNote 0009 (6.6.2.3.3) All five extension schema container namespaces are written with their own prefixes, pdfaExtension included (PDF Association TechNote 0009, 4.1). The module comment cites TechNote 0009 section 4 for the schemas, entries and prefixes, and says where PDF/A-2 asks more than it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 12 +++++++++++ crates/preflight/src/xmp_extension.rs | 30 ++++++++++++++++----------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index ddd2ce3502..aa2e1d9af4 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -853,6 +853,18 @@ fn xmp_extension_schema_descriptions_are_complete() { let schema = schema_without("").replace(&format!("{own}:"), "other:"); assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); } + // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). + let xmp = concat!( + r#""#, + r#""#, + r#"Shttp://e/"#, + r#"e"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(&xmp::parse(xmp).expect("parses")); + assert!(found.missing.is_empty()); + assert_eq!(found.prefixes.into_iter().collect::>(), ["pdfaExtension"]); // Through verify, under 6.6.2.3.3 and not fixable. let xmp = concat!( r#""#, diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 4b683c6461..ddb596297d 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -2,15 +2,19 @@ //! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description //! is complete. //! -//! The entries each description needs, and the prefixes it is written with, are those of the -//! requirement as stated with the conformance corpus: -//! - a schema (`pdfaSchema`) has `schema`, `namespaceURI` and `prefix`; its `property` and -//! `valueType` lists are optional; -//! - a property (`pdfaProperty`) has `name`, `valueType`, `category` and `description`; -//! - a value type (`pdfaType`) has `type`, `namespaceURI`, `prefix`, `description` and `field`; -//! - a field (`pdfaField`) has `name`, `valueType` and `description`; -//! - each of the four namespaces is written with its own prefix (`pdfaSchema`, `pdfaProperty`, -//! `pdfaType`, `pdfaField`), as a PDF/A reader matches them by prefix. +//! The schemas, their entries and their prefixes are those of PDF Association TechNote 0009, "XMP +//! Extension Schemas in PDF/A-1" (2008), section 4, for PDF/A-2 6.6.2.3.3: +//! - a schema (`pdfaSchema`, 4.3) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional. TechNote 0009, written for PDF/A-1, calls `schema` an +//! optional description; PDF/A-2 requires it, as its conformance tests show; +//! - a property (`pdfaProperty`, 4.4) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`, 4.5) has `type`, `namespaceURI`, `prefix`, `description` and +//! `field`. TechNote 0009 calls `field` optional; PDF/A-2 requires it, as its conformance tests +//! show; +//! - a field (`pdfaField`, 4.6) has `name`, `valueType` and `description`; +//! - each of the five namespaces is written with its own prefix (`pdfaExtension`, `pdfaSchema`, +//! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace +//! prefixes are not only preferred, but required" (4.1). //! //! An entry counts as present written as an element or as an attribute of the description. @@ -25,8 +29,9 @@ const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#"; const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#"; const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; -/// Each description namespace with its prefix. -const PREFIXES: [(&str, &str); 4] = [(SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; +/// Each container namespace with its required prefix (TechNote 0009, 4.1). +const PREFIXES: [(&str, &str); 5] = + [(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; /// What the extension schema descriptions of a document break of 6.6.2.3.3. #[derive(Debug, Default, PartialEq, Eq)] @@ -67,7 +72,8 @@ impl Findings { } } - /// Notes description namespaces written in `container` with another prefix than their own. + /// Notes container namespaces written in `container` (itself included) with another prefix + /// than their own. fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) { let text = doc.input_text(); for n in container.descendants().filter(Node::is_element) { From 5d1f6b79aaae77a108e829d3c51985dca9b358d5 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:30:33 +0800 Subject: [PATCH 58/72] M11: parity: 6.6.2.3.3 notes cite TechNote 0009 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d3ca9e78e1..f77b233931 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7944,7 +7944,7 @@ 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_extension_schema_descriptions_are_complete", "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), XMP extension schema descriptions (6.6.2.3.3: required entries of the schema, property, value type and field descriptions, and their prefixes), 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." +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), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is 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" From abf0f94e787560c1bd0b8353df856aca2369e03e Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 16:56:37 +0800 Subject: [PATCH 59/72] M11: PDF/A verify allows only XMP's value forms: one rdf:value, parseType Resource, empty rdf:resource (6.6.2.3.1) A qualified value has exactly one rdf:value; a parse type other than Resource, which XMP doesn't use (ISO 16684-1 7.9.2.5), breaks the value; and an rdf:resource element holds no element content in any form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 18 ++++++++++++++++++ crates/preflight/src/xmp_types.rs | 16 ++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 8eea6afeb1..23677867a2 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -708,3 +708,21 @@ fn xmp_value_types_are_reported_by_verify() { ); assert_eq!(issues(&doc).len(), 1); } + +/// 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"] + ); + // 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_types.rs b/crates/preflight/src/xmp_types.rs index 7ef694ba2f..84f3ce6d9b 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -535,6 +535,11 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { 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, @@ -543,10 +548,13 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { // 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 - && let Some(v) = n.children().find(|c| is(c, RDF, "value")) - { - return blank(e) && blank(&n) && fits(ty, &v, deeper) && predefined_ok(&n, deeper); + if let Some(n) = node { + // A qualified value has one `rdf:value`. + match n.children().filter(|c| is(c, RDF, "value")).collect::>().as_slice() { + [] => {} + [v] => return 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 From 5241619b969b2f31d98841380c45713a0c52b622 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 17:04:40 +0800 Subject: [PATCH 60/72] M11: PDF/A verify reads every repeated extension schema list and wants a Bag container (6.6.2.3.3) A schema's property or value type list, or a value type's field list, written more than once is read each time; and a pdfaExtension:schemas that isn't one rdf:Bag (TechNote 0009, 4.2) is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 4 ++++ crates/preflight/src/tests.rs | 21 +++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 17 ++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d323606661..d32d01b9c4 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -118,6 +118,7 @@ pub const WORDINGS: &[&str] = &[ "The XMP property {} doesn't have the value type the XMP Specification gives it", "An XMP extension schema description has no {} entry", "An XMP extension schema description writes the {} namespace with another prefix", + "An XMP extension schema container isn't an rdf:Bag", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -478,6 +479,9 @@ pub fn verify(doc: &Document, level: Level) -> Vec { for entry in extension.missing { issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false); } + if extension.not_bag { + issue("6.6.2.3.3", "An XMP extension schema container isn't an rdf:Bag", None, None, false); + } for prefix in extension.prefixes { issue("6.6.2.3.3", "An XMP extension schema description writes the {} namespace with another prefix", Some(prefix.to_owned()), None, false); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0a7a0eec20..b49c135955 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -853,6 +853,27 @@ fn xmp_extension_schema_descriptions_are_complete() { let schema = schema_without("").replace(&format!("{own}:"), "other:"); assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); } + // A second property list is read as well as the first. + let complete = schema_without(""); + let second = complete.replacen( + "", + r#"p"#, + 1, + ); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); + // The container is an rdf:Bag (TechNote 0009, 4.2). + let container = |inner: &str| { + let xmp = format!( + r#"{inner}"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(&xmp::parse(&xmp).expect("parses")); + found.not_bag + }; + let schema = r#"Shttp://e/e"#; + assert!(!container(&format!("{schema}"))); + assert!(container(&format!("{schema}"))); + assert!(container(r#""#)); // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). let xmp = concat!( r#""#, diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index ddb596297d..5778cebc8d 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -40,6 +40,8 @@ pub(crate) struct Findings { pub(crate) missing: BTreeSet, /// Description namespaces written with another prefix, by their own prefix, in name order. pub(crate) prefixes: BTreeSet<&'static str>, + /// A `pdfaExtension:schemas` isn't one `rdf:Bag` (TechNote 0009, 4.2: "bag Schema"). + pub(crate) not_bag: bool, } impl Findings { @@ -48,14 +50,18 @@ impl Findings { let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); for container in containers { self.prefixes_in(doc, &container); + let children: Vec> = container.children().filter(Node::is_element).collect(); + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) { + self.not_bag = true; + } for schema in items(&container) { self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); - for property in entry(&schema, SCHEMA, "property").iter().flat_map(items) { + for property in entries(&schema, SCHEMA, "property").flat_map(|e| items(&e)) { self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); } - for value_type in entry(&schema, SCHEMA, "valueType").iter().flat_map(items) { + for value_type in entries(&schema, SCHEMA, "valueType").flat_map(|e| items(&e)) { self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); - for field in entry(&value_type, TYPE, "field").iter().flat_map(items) { + for field in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)) { self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); } } @@ -107,6 +113,11 @@ fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name } +/// Every element entry `name` of `ns` in the description `d`: a list written twice is read twice. +fn entries<'a, 'i>(d: &Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator> { + d.children().filter(move |c| is(c, ns, name)) +} + /// The element entry `name` of `ns` in the description `d`. fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option> { d.children().find(|c| is(c, ns, name)) From 97d054e14fb6b551f44271d418802ead0835eabd Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 17:05:05 +0800 Subject: [PATCH 61/72] M11: tests: the second extension property list is the incomplete one (6.6.2.3.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b49c135955..0e58de20b1 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -856,8 +856,8 @@ fn xmp_extension_schema_descriptions_are_complete() { // A second property list is read as well as the first. let complete = schema_without(""); let second = complete.replacen( - "", - r#"p"#, + "", + r#"p"#, 1, ); assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); From 7dad9e784a1c29652f8193499d071caf44da4969 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 17:21:39 +0800 Subject: [PATCH 62/72] M11: PDF/A verify takes an rdf:value attribute in place of the element, not beside it (6.6.2.3.1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 5 +++++ crates/preflight/src/xmp_types.rs | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 23677867a2..adc4ac57b4 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -718,6 +718,11 @@ fn xmp_value_forms_are_those_xmp_allows() { 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"]); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 84f3ce6d9b..333b39ab12 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -549,10 +549,10 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { // 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`. + // 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 blank(e) && blank(&n) && fits(ty, v, deeper) && predefined_ok(&n, deeper), + [v] => return n.attribute((RDF, "value")).is_none() && blank(e) && blank(&n) && fits(ty, v, deeper) && predefined_ok(&n, deeper), _ => return false, } } From 173cfa53902234fcb8642f11ee497dd2c1033bf1 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 06:46:03 +0800 Subject: [PATCH 63/72] M11: PDF/A verify names the metadata stream of a mistyped XMP property (optimize.pdfa-validate) A 6.6.2.3.1 issue now says which metadata stream holds the property ("in metadata stream 5 0 R") and carries its ObjRef in Issue::object, so a property mistyped in more than one stream is reported once per stream. Wordings that name more than one thing use named holes, such as {object}, filled in one pass with the {} value, so a translation can reorder them. Tests: the stream is named and located per issue; a non-preferred prefix bound to the TIFF, EXIF or aux namespace URI is checked, and the preferred prefixes bound to other URIs are not, proving the lookup is by URI. Co-Authored-By: Claude Opus 5.5 --- crates/preflight/src/lib.rs | 86 +++++++++++++++++++++++++------ crates/preflight/src/tests.rs | 63 ++++++++++++++++++---- crates/preflight/src/xmp_types.rs | 11 ++-- parity/acrobat-features.toml | 2 +- 4 files changed, 130 insertions(+), 32 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 590df20a14..8d8b1b0550 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,7 @@ 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} doesn't have the value type the XMP Specification gives it", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -429,15 +466,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 +509,15 @@ 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); + 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 used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { @@ -522,6 +575,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 fc176e987c..b4b9f8009d 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; @@ -660,7 +662,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( @@ -678,11 +680,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 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" ] ); - 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!(found[0].said(&reordered).starts_with("In 3 0 R, crs:Contrast doesn't"), "{}", found[0].said(&reordered)); // A UTF-16 packet, either byte order, is read. for big in [true, false] { let xmp = format!( @@ -707,7 +717,8 @@ 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))]); } /// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP @@ -765,6 +776,38 @@ fn xmp_exif_schemas_have_their_value_types() { assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); } +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its +/// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's +/// preferred prefix. +#[test] +fn xmp_exif_properties_are_known_by_namespace_uri() { + let tiff = r#"xmlns:foo="http://ns.adobe.com/tiff/1.0/""#; + assert_eq!(mistyped(&format!(r#">256 mm"#)), ["tiff:ImageWidth"]); + assert!(mistyped(&format!(r#">256"#)).is_empty()); + assert_eq!(mistyped(r#"xmlns:bar="http://ns.adobe.com/exif/1.0/" bar:GPSLatitude="51.5N">"#), ["exif:GPSLatitude"]); + assert_eq!( + mistyped(r#">50mm"#), + ["aux:Lens"] + ); + // The same local names in another namespace, even under the preferred prefixes, are not these + // properties. + let other = r#"xmlns:tiff="http://example.com/not-tiff/""#; + assert!(mistyped(&format!(r#">256 mm"#)).is_empty()); + assert!(mistyped(r#"xmlns:exif="http://example.com/not-exif/" exif:GPSLatitude="51.5N">"#).is_empty()); + assert!(mistyped(r#">50mm"#).is_empty()); +} + +/// 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, 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 afb1777d95..9f925741d0 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -589,11 +589,12 @@ 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(); +/// 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(); 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; @@ -604,7 +605,7 @@ 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)); + out.extend(mistyped(&parsed).into_iter().map(|name| (*r, name))); } } out diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f0f2178584..7371f9ff78 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/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_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]] From 1820ba241e197d5e2d3b57b70711e9c5da2e9126 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 06:53:21 +0800 Subject: [PATCH 64/72] M11: PDF/A verify says where each XMP extension schema finding is (optimize.pdfa-validate) 6.6.2.3.3 findings are now reported per description instead of once per entry name: each names the schema by its pdfaSchema:namespaceURI (or #n), the property, value type or field by its own name (a field as type/name), and the metadata stream, with Issue::object set. A misused prefix names the namespace URI and the prefix written (or that none was); a container that isn't one rdf:Bag names what it holds instead. Findings are capped at 1000 per document and repeated names at 200 characters. 6.6.2.3.1 issues now name the type the XMP Specification gives the property, as the specification writes it (Integer, seq Rational, Lang Alt, Flash); structure types carry their names for this. Co-Authored-By: Claude Opus 5.5 --- crates/preflight/src/lib.rs | 88 ++++++++++--- crates/preflight/src/tests.rs | 169 ++++++++++++++++++++++--- crates/preflight/src/xmp_extension.rs | 174 +++++++++++++++++++------- crates/preflight/src/xmp_types.rs | 126 +++++++++++-------- parity/acrobat-features.toml | 2 +- 5 files changed, 436 insertions(+), 123 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 9ad872446b..df4d5f4805 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -152,10 +152,13 @@ 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", - "An XMP extension schema description has no {} entry", - "An XMP extension schema description writes the {} namespace with another prefix", - "An XMP extension schema container isn't an rdf:Bag", + "The XMP property {property} in metadata stream {object} should have the value type {type}", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", // The document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -516,27 +519,82 @@ pub fn verify(doc: &Document, level: Level) -> Vec { // every metadata stream. let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default()); xmp_types::for_each_packet(&objs, |object, packet| { - mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|name| (object, name))); - extension.add(packet); + mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|(name, ty)| (object, name, ty))); + extension.add(object, packet); }); - for (object, name) in mistyped { - let values = vec![("{property}", name), ("{object}", object.to_string())]; + for (object, name, ty) in mistyped { + let values = vec![("{property}", name), ("{object}", object.to_string()), ("{type}", ty)]; 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", + "The XMP property {property} in metadata stream {object} should have the value type {type}", values, object, false, ); } - for entry in extension.missing { - issue("6.6.2.3.3", "An XMP extension schema description has no {} entry", Some(entry), None, false); + for m in extension.missing { + let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; + match m.item { + None => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + values, + m.object, + false, + ), + Some(item) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + values, + m.object, + false, + ); + } + } } - if extension.not_bag { - issue("6.6.2.3.3", "An XMP extension schema container isn't an rdf:Bag", None, None, false); + for c in extension.not_bag { + let object = c.object; + if c.found.is_empty() { + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + vec![("{object}", object.to_string())], + object, + false, + ); + } else { + let values = vec![("{object}", object.to_string()), ("{found}", c.found)]; + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + values, + object, + false, + ); + } } - for prefix in extension.prefixes { - issue("6.6.2.3.3", "An XMP extension schema description writes the {} namespace with another prefix", Some(prefix.to_owned()), None, false); + for p in extension.prefixes { + let mut values = vec![("{object}", p.object.to_string()), ("{namespace}", p.namespace.to_owned()), ("{prefix}", p.own.to_owned())]; + if p.written.is_empty() { + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + values, + p.object, + false, + ); + } else { + values.push(("{written}", p.written)); + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", + values, + p.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 66537dda09..b0b1414896 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().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!( @@ -779,6 +779,12 @@ fn xmp_exif_schemas_have_their_value_types() { /// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five /// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default). fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static str>) { + let found = extension_findings(schema, prefixes); + (found.missing.into_iter().map(|m| m.entry).collect(), found.prefixes.into_iter().map(|p| p.own).collect()) +} + +/// [`extension`]'s findings in full, as if from the metadata stream `3 0 R`. +fn extension_findings(schema: &str, prefixes: [&str; 4]) -> xmp_extension::Findings { let [s, p, t, f] = prefixes; let xmp = format!( concat!( @@ -797,8 +803,8 @@ fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static st ); let parsed = xmp::parse(&xmp).expect("parses"); let mut found = xmp_extension::Findings::default(); - found.add(&parsed); - (found.missing.into_iter().collect(), found.prefixes.into_iter().collect()) + found.add(ObjRef::new(3, 0), &parsed); + found } const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; @@ -871,20 +877,23 @@ fn xmp_extension_schema_descriptions_are_complete() { r#"p"#, 1, ); - assert_eq!(extension(&second, OWN).0, ["pdfaProperty:category", "pdfaProperty:description", "pdfaProperty:valueType"]); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]); // The container is an rdf:Bag (TechNote 0009, 4.2). let container = |inner: &str| { let xmp = format!( r#"{inner}"# ); let mut found = xmp_extension::Findings::default(); - found.add(&xmp::parse(&xmp).expect("parses")); - found.not_bag + found.add(ObjRef::new(3, 0), &xmp::parse(&xmp).expect("parses")); + found.not_bag.into_iter().map(|c| c.found).collect::>() }; let schema = r#"Shttp://e/e"#; - assert!(!container(&format!("{schema}"))); - assert!(container(&format!("{schema}"))); - assert!(container(r#""#)); + assert!(container(&format!("{schema}")).is_empty()); + // What it holds instead is named as the packet writes it. + assert_eq!(container(&format!("{schema}")), ["rdf:Seq"]); + assert_eq!(container(r#""#), ["rdf:Description"]); + assert_eq!(container(&format!("{schema}")), ["rdf:Bag, rdf:Bag"]); + assert_eq!(container(""), [""]); // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). let xmp = concat!( r#""#, @@ -894,9 +903,10 @@ fn xmp_extension_schema_descriptions_are_complete() { r#"e"# ); let mut found = xmp_extension::Findings::default(); - found.add(&xmp::parse(xmp).expect("parses")); + found.add(ObjRef::new(3, 0), &xmp::parse(xmp).expect("parses")); assert!(found.missing.is_empty()); - assert_eq!(found.prefixes.into_iter().collect::>(), ["pdfaExtension"]); + let written: Vec<(&str, &str, &str)> = found.prefixes.iter().map(|p| (p.namespace, p.own, p.written.as_str())).collect(); + assert_eq!(written, [("http://www.aiim.org/pdfa/ns/extension/", "pdfaExtension", "ext")]); // Through verify, under 6.6.2.3.3 and not fixable. let xmp = concat!( r#""#, @@ -909,10 +919,137 @@ fn xmp_extension_schema_descriptions_are_complete() { let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); let issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect(); assert_eq!(issues.len(), 1); - assert_eq!(issues[0].message, "An XMP extension schema description has no pdfaSchema:namespaceURI entry"); + assert_eq!(issues[0].message, "The XMP extension schema #1 in metadata stream 3 0 R has no pdfaSchema:namespaceURI entry"); + assert_eq!(issues[0].object, Some(ObjRef::new(3, 0))); assert!(!issues[0].fixable); } +/// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's +/// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. +#[test] +fn xmp_extension_findings_say_where_they_are() { + // Two schemas lacking the same entries are two findings, each with its schema. + let schema = |uri: &str, property: &str| { + format!( + concat!( + r#"S{uri}"#, + r#"e{property}"#, + r#""# + ), + uri = uri, + property = property + ) + }; + let named = "pText"; + let two = format!(r#"{}{}"#, schema("http://a.example/", named), schema("http://b.example/", "")); + let found = extension_findings(&two, OWN); + let said: Vec<(&str, Option<&str>, &str)> = found.missing.iter().map(|m| (m.schema.as_str(), m.item.as_deref(), m.entry.as_str())).collect(); + assert_eq!( + said, + [ + ("http://a.example/", Some("p"), "pdfaProperty:category"), + ("http://a.example/", Some("p"), "pdfaProperty:description"), + ("http://b.example/", Some("#1"), "pdfaProperty:name"), + ("http://b.example/", Some("#1"), "pdfaProperty:valueType"), + ("http://b.example/", Some("#1"), "pdfaProperty:category"), + ("http://b.example/", Some("#1"), "pdfaProperty:description"), + ] + ); + assert!(found.missing.iter().all(|m| m.object == ObjRef::new(3, 0))); + // A field is named by its value type and its own name. + let field = schema_without("pdfaField:description") + .replace(">x", ">Dim") + .replace(">x", ">w"); + let found = extension_findings(&field, OWN); + assert_eq!(found.missing.iter().map(|m| m.item.as_deref()).collect::>(), [Some("Dim/w")]); + // A name the packet gives is repeated only so far. + let long = format!("http://example.com/{}", "a".repeat(400)); + let found = extension_findings(&schema(&long, ""), OWN); + assert!(found.missing[0].schema.ends_with('…') && found.missing[0].schema.chars().count() == 201, "{}", found.missing[0].schema); + // So many findings are enough. + let many = vec![schema("http://m.example/", ""); 400].join(r#""#); + assert_eq!(extension_findings(&many, OWN).missing.len(), 1000); + + // Through verify: each wording, with the stream it is in. + let verify_packet = |description: &str| -> Vec { + let xmp = format!( + concat!( + r#""#, + r#""# + ), + description + ); + 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], ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect() + }; + let ns = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#""#; + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema("http://a.example/", named) + )), + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:category entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:description entry", + ] + ); + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema( + "http://a.example/", + &format!("{named}externald") + ) + )), + ["The XMP extension schema container in metadata stream 3 0 R holds rdf:Seq, not one rdf:Bag"] + ); + assert_eq!( + verify_packet(&format!(r#"{ns}>"#)), + ["The XMP extension schema container in metadata stream 3 0 R holds no rdf:Bag"] + ); + let other = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:s="http://www.aiim.org/pdfa/ns/schema#""#; + assert_eq!( + verify_packet(&format!( + r#"{other}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# with the prefix s, not pdfaSchema"] + ); + let default = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/""#; + assert_eq!( + verify_packet(&format!( + r#"{default}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# without a prefix, not with pdfaSchema"] + ); +} + +/// 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().collect() + }; + let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned()); + assert_eq!( + types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N" xmp:CreateDate="never">"#), + [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational"), t("xmp:CreateDate", "Date"),] + ); + assert_eq!( + types(">8T"), + [t("dc:title", "Lang Alt"), t("tiff:BitsPerSample", "seq Integer")] + ); + assert_eq!( + types(">firedh"), + [t("exif:Flash", "Flash"), t("xmpMM:History", "seq ResourceEvent")] + ); + assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); +} + /// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its /// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's /// preferred prefix. diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 5778cebc8d..248c73093c 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -18,8 +18,7 @@ //! //! An entry counts as present written as an element or as an attribute of the description. -use std::collections::BTreeSet; - +use pdfcraft_cos::ObjRef; use roxmltree::{Document, Node}; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; @@ -33,80 +32,171 @@ const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; const PREFIXES: [(&str, &str); 5] = [(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; -/// What the extension schema descriptions of a document break of 6.6.2.3.3. +/// How many findings a document's packets are read for. A document past this many has plainly +/// failed the clause, and listing every one of a hostile packet's descriptions helps nobody. +const MAX_FINDINGS: usize = 1000; +/// How much of a name the packet gives (a namespace URI, a property) a finding repeats. +const MAX_SHOWN_CHARS: usize = 200; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3, in the order the +/// metadata streams and their descriptions come. #[derive(Debug, Default, PartialEq, Eq)] pub(crate) struct Findings { - /// Entries a description lacks, as `prefix:name`, in name order. - pub(crate) missing: BTreeSet, - /// Description namespaces written with another prefix, by their own prefix, in name order. - pub(crate) prefixes: BTreeSet<&'static str>, - /// A `pdfaExtension:schemas` isn't one `rdf:Bag` (TechNote 0009, 4.2: "bag Schema"). - pub(crate) not_bag: bool, + /// Entries descriptions lack. + pub(crate) missing: Vec, + /// Description namespaces written with another prefix, each once per stream and prefix. + pub(crate) prefixes: Vec, + /// `pdfaExtension:schemas` containers that aren't one `rdf:Bag` (TechNote 0009, 4.2: "bag + /// Schema"). + pub(crate) not_bag: Vec, +} + +/// A description without an entry it needs. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Missing { + /// The metadata stream. + pub(crate) object: ObjRef, + /// The schema the description is in: its `pdfaSchema:namespaceURI`, or `#n` for the n-th + /// schema of its container when it has none. + pub(crate) schema: String, + /// The property, value type or field description, when it is one of those and not the schema + /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or + /// `#n` for the n-th of its list when it has no name. + pub(crate) item: Option, + /// The entry it lacks, as `prefix:name`. + pub(crate) entry: String, +} + +/// A description namespace written with another prefix than its own. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Prefix { + pub(crate) object: ObjRef, + pub(crate) namespace: &'static str, + /// The prefix it needs. + pub(crate) own: &'static str, + /// The prefix it is written with, empty when it is written as the default namespace. + pub(crate) written: String, +} + +/// A `pdfaExtension:schemas` container that isn't one `rdf:Bag`. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct NotBag { + pub(crate) object: ObjRef, + /// The elements it holds instead, by their names as the packet writes them, comma-separated; + /// empty when it holds none. + pub(crate) found: String, +} + +/// Where a description is: its stream, its schema and, below the schema, its own name. +struct Place { + object: ObjRef, + schema: String, + item: Option, } impl Findings { - /// Adds what the packet `doc` breaks. - pub(crate) fn add(&mut self, doc: &Document<'_>) { + /// Adds what the packet `doc`, from the metadata stream `object`, breaks. + pub(crate) fn add(&mut self, object: ObjRef, doc: &Document<'_>) { let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); for container in containers { - self.prefixes_in(doc, &container); + self.prefixes_in(object, doc, &container); let children: Vec> = container.children().filter(Node::is_element).collect(); - if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) { - self.not_bag = true; + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) && self.room() { + let found = children.iter().take(MAX_SHOWN_CHARS).map(|c| written_name(doc, c)).collect::>().join(", "); + self.not_bag.push(NotBag { object, found: shown(&found) }); } - for schema in items(&container) { - self.need(&schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); - for property in entries(&schema, SCHEMA, "property").flat_map(|e| items(&e)) { - self.need(&property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + for (n, schema) in items(&container).iter().enumerate() { + let id = value(schema, SCHEMA, "namespaceURI").unwrap_or_else(|| position(n)); + let at = |item: Option| Place { object, schema: id.clone(), item }; + self.need(&at(None), schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + let properties = entries(schema, SCHEMA, "property").flat_map(|e| items(&e)); + for (n, property) in properties.enumerate() { + let name = value(&property, PROPERTY, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(name)), &property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); } - for value_type in entries(&schema, SCHEMA, "valueType").flat_map(|e| items(&e)) { - self.need(&value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); - for field in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)) { - self.need(&field, FIELD, "pdfaField", &["name", "valueType", "description"]); + let value_types = entries(schema, SCHEMA, "valueType").flat_map(|e| items(&e)); + for (n, value_type) in value_types.enumerate() { + let name = value(&value_type, TYPE, "type").unwrap_or_else(|| position(n)); + self.need(&at(Some(name.clone())), &value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for (n, field) in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)).enumerate() { + let field_name = value(&field, FIELD, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(format!("{name}/{field_name}"))), &field, FIELD, "pdfaField", &["name", "valueType", "description"]); } } } } } - /// Notes the entries of `ns` in `names` that the description `d` lacks. - fn need(&mut self, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { + /// Whether another finding may be recorded (see [`MAX_FINDINGS`]). + fn room(&self) -> bool { + self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS + } + + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks. + fn need(&mut self, at: &Place, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { for name in names { - if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() { - self.missing.insert(format!("{prefix}:{name}")); + if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() && self.room() { + let Place { object, schema, item } = at; + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry: format!("{prefix}:{name}") }); } } } /// Notes container namespaces written in `container` (itself included) with another prefix /// than their own. - fn prefixes_in(&mut self, doc: &Document<'_>, container: &Node<'_, '_>) { + fn prefixes_in(&mut self, object: ObjRef, doc: &Document<'_>, container: &Node<'_, '_>) { let text = doc.input_text(); for n in container.descendants().filter(Node::is_element) { - if let Some(own) = own_prefix(n.tag_name().namespace()) - && written_prefix(text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) != own - { - self.prefixes.insert(own); - } - for a in n.attributes() { - if let Some(own) = own_prefix(a.namespace()) - && written_prefix(text.get(a.range_qname()).unwrap_or_default()) != own + let element = (n.tag_name().namespace(), text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()); + let attributes = n.attributes().map(|a| (a.namespace(), text.get(a.range_qname()).unwrap_or_default())); + for (ns, qname) in std::iter::once(element).chain(attributes) { + let Some(&(namespace, own)) = PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns) else { continue }; + let written = shown(written_prefix(qname)); + if written != own + && self.room() + && !self.prefixes.iter().any(|p| p.object == object && p.namespace == namespace && p.written == written) { - self.prefixes.insert(own); + self.prefixes.push(Prefix { object, namespace, own, written }); } } } } } -fn own_prefix(ns: Option<&str>) -> Option<&'static str> { - PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns).map(|(_, p)| *p) +/// `#n` for the 0-based position `n`, counted from 1. +fn position(n: usize) -> String { + format!("#{}", n.saturating_add(1)) } -/// The prefix of the qualified name `qname` starts with, empty when it has none. -fn written_prefix(qname: &str) -> &str { - let name = qname.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or_default(); - name.split_once(':').map_or("", |(p, _)| p) +/// At most [`MAX_SHOWN_CHARS`] of `s`, cut at a char boundary, with `…` when cut. +fn shown(s: &str) -> String { + let mut out: String = s.chars().take(MAX_SHOWN_CHARS).collect(); + if out.len() < s.len() { + out.push('…'); + } + out +} + +/// The text of the entry `name` of `ns` in the description `d`, written as an element or an +/// attribute, trimmed and [`shown`]; `None` when it is absent or blank. +fn value(d: &Node<'_, '_>, ns: &str, name: &str) -> Option { + let text = entry(d, ns, name).and_then(|e| e.text()).or_else(|| d.attribute((ns, name)))?.trim(); + (!text.is_empty()).then(|| shown(text)) +} + +/// The name of the element `n` as the packet writes it, prefix included. +fn written_name<'i>(doc: &Document<'i>, n: &Node<'_, '_>) -> &'i str { + qualified_name(doc.input_text().get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) +} + +/// The qualified name the text of a start tag (after its `<`) or of an attribute begins with. +fn qualified_name(text: &str) -> &str { + text.split(|c: char| c.is_whitespace() || c == '>' || c == '/' || c == '=').next().unwrap_or_default() +} + +/// The prefix of the qualified name `text` begins with, empty when it has none. +fn written_prefix(text: &str) -> &str { + qualified_name(text).split_once(':').map_or("", |(p, _)| p) } fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index f8658a3ad7..9e78be60f1 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), @@ -625,16 +630,16 @@ 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(); +/// as `prefix:name` in name order, each with the type it should have as [`named`] gives it. +pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeMap { + let mut out = BTreeMap::new(); 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())); + out.insert(format!("{}:{}", schema.prefix, a.name()), named(ty)); } } for p in d.children().filter(Node::is_element) { @@ -642,13 +647,36 @@ 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())); + out.insert(format!("{}:{}", schema.prefix, name.name()), named(ty)); } } } out } +/// `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)) @@ -720,7 +748,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 f3ace636ea..3d8d9891d5 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/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "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_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "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), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is 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]] From 8ca675b25d82cfb866bc4c02152025b06a424acc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:26:45 +0800 Subject: [PATCH 65/72] M11: PDF/A verify finds empty naming entries in XMP extension schema descriptions (6.6.2.3.3) A namespace URI, prefix, name, value type, type or category entry that is there but empty names nothing; it is now reported with its own wording. Descriptions stay free to be empty Text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 25 ++++++++-- crates/preflight/src/tests.rs | 70 +++++++++++++++++++++++++++ crates/preflight/src/xmp_extension.rs | 42 +++++++++++++--- parity/acrobat-features.toml | 2 +- 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index df4d5f4805..4168179fbc 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -155,6 +155,8 @@ pub const WORDINGS: &[&str] = &[ "The XMP property {property} in metadata stream {object} should have the value type {type}", "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", @@ -534,15 +536,22 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } for m in extension.missing { let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; - match m.item { - None => located_issue( + match (m.item, m.empty) { + (None, false) => located_issue( "6.6.2.3.3", "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", values, m.object, false, ), - Some(item) => { + (None, true) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + values, + m.object, + false, + ), + (Some(item), false) => { values.push(("{item}", item)); located_issue( "6.6.2.3.3", @@ -552,6 +561,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec { false, ); } + (Some(item), true) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + values, + m.object, + false, + ); + } } } for c in extension.not_bag { diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b0b1414896..916cbb5cdf 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -924,6 +924,76 @@ fn xmp_extension_schema_descriptions_are_complete() { assert!(!issues[0].fixable); } +/// PDF/A-2 6.6.2.3.3: an entry that names something is incomplete when it is empty, while the +/// human-readable descriptions may be empty (TechNote 0009, 4.3 to 4.6). +#[test] +fn xmp_extension_naming_entries_are_not_empty() { + let complete = schema_without(""); + let emptied = |name: &str, value: &str, with: &str| { + let element = format!("<{name}>{value}"); + assert_eq!(complete.matches(&element).count(), 1, "{name}"); + complete.replace(&element, &format!("<{name}>{with}")) + }; + let said = |schema: &str| -> Vec<(String, bool)> { extension_findings(schema, OWN).missing.into_iter().map(|m| (m.entry, m.empty)).collect() }; + for (name, value) in [ + ("pdfaSchema:namespaceURI", "http://example.com/ns/"), + ("pdfaSchema:prefix", "ex"), + ("pdfaProperty:name", "x"), + ("pdfaProperty:valueType", "x"), + ("pdfaProperty:category", "x"), + ("pdfaType:type", "x"), + ("pdfaType:namespaceURI", "x"), + ("pdfaType:prefix", "x"), + ("pdfaField:name", "x"), + ("pdfaField:valueType", "x"), + ] { + assert_eq!(said(&emptied(name, value, "")), [(name.to_owned(), true)], "{name}"); + assert_eq!(said(&emptied(name, value, " \n\t ")), [(name.to_owned(), true)], "{name} blank"); + } + for (name, value) in + [("pdfaSchema:schema", "Some schema"), ("pdfaProperty:description", "x"), ("pdfaType:description", "x"), ("pdfaField:description", "x")] + { + assert_eq!(said(&emptied(name, value, "")), [], "{name}"); + } + // An empty element written shut, and an empty attribute, are empty too. + let shut = complete.replace("x", ""); + assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]); + let attribute = r#""#; + assert_eq!(said(&format!("{complete}{attribute}")), [("pdfaSchema:prefix".to_owned(), true)]); + // A URI given as an rdf:resource, or a value given as rdf:value, is a value. + let resource = + complete.replace("x", r#""#); + assert_eq!(said(&resource), []); + let value = complete.replace("x", r#""#); + assert_eq!(said(&value), []); + // A comment is not a value. + assert_eq!(said(&emptied("pdfaType:prefix", "x", "")), [("pdfaType:prefix".to_owned(), true)]); + + // Through verify, with their own wordings. + let xmp = concat!( + r#""#, + r#""#, + r#""#, + r#"Shttp://a.example/"#, + r#""#, + r#"Thttp://t.example/"#, + r#" d"#, + r#""#, + 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.3").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R has an empty pdfaSchema:prefix entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes T with an empty pdfaType:prefix entry", + ] + ); +} + /// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's /// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. #[test] diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs index 248c73093c..92b3afa38e 100644 --- a/crates/preflight/src/xmp_extension.rs +++ b/crates/preflight/src/xmp_extension.rs @@ -16,7 +16,12 @@ //! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace //! prefixes are not only preferred, but required" (4.1). //! -//! An entry counts as present written as an element or as an attribute of the description. +//! An entry counts as present written as an element or as an attribute of the description. An +//! entry that names something is empty when it holds no value: a namespace URI or prefix, a +//! property, value type or field name, a value type reference, a property category. TechNote 0009 +//! asks for names that are valid XML names (4.4, 4.6) and a category that is internal or external +//! (4.4), and an empty prefix or namespace binds nothing. The human-readable descriptions +//! (`pdfaSchema:schema` and the `description` entries) are Text and may be empty. use pdfcraft_cos::ObjRef; use roxmltree::{Document, Node}; @@ -51,7 +56,7 @@ pub(crate) struct Findings { pub(crate) not_bag: Vec, } -/// A description without an entry it needs. +/// A description without an entry it needs, or with the entry empty. #[derive(Debug, PartialEq, Eq)] pub(crate) struct Missing { /// The metadata stream. @@ -63,8 +68,10 @@ pub(crate) struct Missing { /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or /// `#n` for the n-th of its list when it has no name. pub(crate) item: Option, - /// The entry it lacks, as `prefix:name`. + /// The entry it lacks or holds empty, as `prefix:name`. pub(crate) entry: String, + /// Whether the entry is there but empty, rather than absent. + pub(crate) empty: bool, } /// A description namespace written with another prefix than its own. @@ -132,12 +139,21 @@ impl Findings { self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS } - /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks. + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks, or holds + /// empty when they are entries that name something (see [`NAMING`]). fn need(&mut self, at: &Place, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { for name in names { - if entry(d, ns, name).is_none() && d.attribute((ns, *name)).is_none() && self.room() { + let element = entry(d, ns, name); + let attribute = d.attribute((ns, *name)); + let empty = match (element, attribute) { + (None, None) => false, + (Some(e), _) => NAMING.contains(name) && is_empty(&e), + (None, Some(a)) => NAMING.contains(name) && a.trim().is_empty(), + }; + if (empty || (element.is_none() && attribute.is_none())) && self.room() { let Place { object, schema, item } = at; - self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry: format!("{prefix}:{name}") }); + let entry = format!("{prefix}:{name}"); + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry, empty }); } } } @@ -163,6 +179,20 @@ impl Findings { } } +/// The entries that name something, which an empty value leaves without a name: namespace URIs +/// and prefixes, property, value type and field names, value type references and the property +/// category. +const NAMING: [&str; 6] = ["namespaceURI", "prefix", "name", "type", "valueType", "category"]; + +/// Whether the element entry `e` holds no value: no element, no text but white space, and no +/// `rdf:resource` or `rdf:value` attribute with a value. +fn is_empty(e: &Node<'_, '_>) -> bool { + let blank = |v: Option<&str>| v.is_none_or(|v| v.trim().is_empty()); + !e.children().any(|c| c.is_element() || (c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))) + && blank(e.attribute((RDF, "resource"))) + && blank(e.attribute((RDF, "value"))) +} + /// `#n` for the 0-based position `n`, counted from 1. fn position(n: usize) -> String { format!("#{}", n.saturating_add(1)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3d8d9891d5..3d50d71b31 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8004,7 +8004,7 @@ 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/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "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), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is 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." +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), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is 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" From 916bca01c8a17cd7d49b5250e5aeddae730d7fc8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:28:41 +0800 Subject: [PATCH 66/72] M11: 6.2.10 names the page of an unknown blend mode and reads shared resources once The unknown-blend-mode issue carries its page, as the transparency issue does. Pages keep what was found under the indirect objects they share (resources, forms, patterns, Type 3 fonts, graphics states) in a document-wide map, filled only from objects read to the end, so the next page takes them in one read. The walk reads objects in place instead of copying resources dictionaries and their entries, and gathers at most 32 unknown blend mode names. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 6 +- crates/preflight/src/tests.rs | 82 +++++++++ crates/preflight/src/transparency.rs | 260 ++++++++++++++++++--------- 3 files changed, 261 insertions(+), 87 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 76189f95f1..d1e22dd537 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -533,10 +533,12 @@ pub fn verify(doc: &Document, level: Level) -> Vec { // 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with // a profile) a page with transparency names a blending colour space. let intent = pdfa_intent_has_profile(doc); + // Pages share resources, forms and fonts: what one page found under them, the next takes as is. + let mut shared = transparency::Shared::default(); for (i, p) in pages.iter().enumerate() { - let painting = transparency::page_painting(doc, &p.dict); + let painting = transparency::page_painting(doc, &p.dict, &mut shared); for name in painting.unknown_blend_modes { - issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), None, false); + issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), Some(i), false); } if painting.transparency && !intent && !transparency::page_names_blending_space(doc, &p.dict) { let n = Some((i + 1).to_string()); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 33dd65f1b3..adf0eab39c 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -709,3 +709,85 @@ fn transparency_reached_first_too_deep_is_found_from_a_shallower_place() { let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); assert_eq!(said, ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]); } + +/// 6.2.10: an unknown blend mode is reported on the page whose resources reach it, as the +/// transparency issue is, each page once. +#[test] +fn an_unknown_blend_mode_names_its_page() { + let page = "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G 5 0 R >> >> >>"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R >>", + page, + "<< /BM /Custom >>", + ]; + let found: Vec<(String, Option)> = + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| (i.message, i.page)).collect(); + assert_eq!(found, [("A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define".to_owned(), Some(1))]); +} + +/// A `/BM` array of many unknown names gathers a bounded number of them. +#[test] +fn unknown_blend_mode_names_are_capped() { + let names: String = (0..1000).map(|k| format!("/Custom{k} ")).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G << /BM [/Multiply {names}] >> >> >> >>" + ); + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said.len(), 32); + assert_eq!(said.first().map(String::as_str), Some("A graphics state uses the blend mode Custom0, which ISO 32000-1 doesn't define")); +} + +/// Pages that share a form read it once: the next page takes what was found under it from +/// [`transparency::Shared`] in one read, and still reports it. +#[test] +fn pages_sharing_a_form_read_it_once() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let pages = 50; + let first_form = 3 + pages; + let kids: String = (0..pages).map(|k| format!("{} 0 R ", 3 + k)).collect(); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 200] >>")]; + for _ in 0..pages { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /F {first_form} 0 R >> >> >>")); + } + // A chain of ten forms, each with twenty graphics states, the last one translucent. + let gstates: String = (0..20).map(|k| format!("/G{k} << /CA 1 >> ")).collect(); + for n in 0..10 { + let next = if n < 9 { format!("/XObject << /X {} 0 R >>", first_form + n + 1) } else { "/ExtGState << /T << /ca 0.5 >> >>".to_owned() }; + objs.push(form(&format!("/Resources << /ExtGState << {gstates} >> {next} >>"))); + } + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&objs, ""); + let list = pdfcraft_model::pages(&doc); + let mut shared = transparency::Shared::default(); + let reads: Vec = list.iter().map(|p| transparency::page_reads(&doc, &p.dict, &mut shared)).collect(); + assert!(reads.first().is_some_and(|&n| n > 200), "the first page reads the chain: {reads:?}"); + assert!(reads.iter().skip(1).all(|&n| n == 2), "the others its resources and the form: {reads:?}"); + let said = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").count(); + assert_eq!(said, pages, "every page has transparency"); +} + +/// A form whose reading stopped at the depth limit on one page is not kept as read: a page that +/// names it from a shallower place reads what lies below it. +#[test] +fn a_form_cut_short_on_one_page_is_read_again_on_the_next() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // Page 1 (object 3) names a chain of 33 forms, objects 5 to 37, whose last holds a translucent + // graphics state beyond the depth limit; page 2 (object 4) names form 20, from which it is near. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 5 0 R >> >> >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 20 0 R >> >> >>".to_owned(), + ]; + for n in 5..=36 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 2 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 38b23389d4..605bf4dfae 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -13,19 +13,25 @@ //! from the same graphics states, so private data (`/PieceInfo`) is never taken for one. use std::collections::HashMap; +use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; /// How many dictionaries one page's search reads, across its resources, XObjects, patterns, Type -/// 3 fonts and appearance streams. +/// 3 fonts and appearance streams. An object [`Shared`] holds counts as one read. const MAX_VISITS: usize = 4096; /// How deep resources nest (a form XObject in a form XObject, ...). const MAX_DEPTH: usize = 32; /// The resource categories that hold what a page paints with, read in this order. const CATEGORIES: [&[u8]; 4] = [b"ExtGState", b"XObject", b"Pattern", b"Font"]; +/// How many unknown blend mode names one search gathers: enough to name them in the report, +/// without a `/BM` array of a million names comparing each with all the others. +const MAX_BLEND_NAMES: usize = 32; +/// How many objects [`Shared`] keeps what was found under. +const MAX_SHARED: usize = 1 << 16; /// What the graphics a page's resources reach paint with. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub(crate) struct Painting { /// Something paints transparently. pub(crate) transparency: bool, @@ -33,32 +39,96 @@ pub(crate) struct Painting { pub(crate) unknown_blend_modes: Vec, } -/// What the page `page` paints with: its resources, and the appearance streams of its -/// annotations. -pub(crate) fn page_painting(doc: &Document, page: &Dict) -> Painting { - let mut walk = Walk { doc, seen: HashMap::new(), visits: 0, found: Painting::default() }; - if let Some(r) = page.get(b"Resources") { - walk.resources(r, 0); +impl Painting { + fn add(&mut self, other: &Painting) { + self.transparency |= other.transparency; + for name in &other.unknown_blend_modes { + self.add_blend_mode(name); + } } - let annots = page.get(b"Annots").map(|a| doc.resolve(a)); + + /// Whether no more unknown blend mode names are gathered. + fn blend_modes_full(&self) -> bool { + self.unknown_blend_modes.len() >= MAX_BLEND_NAMES + } + + fn add_blend_mode(&mut self, name: &str) { + if !self.blend_modes_full() && !self.unknown_blend_modes.iter().any(|n| n == name) { + self.unknown_blend_modes.push(name.to_owned()); + } + } +} + +/// What was found under the indirect objects (resources, form XObjects, patterns, Type 3 fonts, +/// graphics states) that pages share, so the next page that names one takes it from here instead +/// of walking it again. Only an object read to the end goes in: none whose reading stopped below +/// it at the budget, at the depth limit, or at an object already read on that page (a cycle). +#[derive(Default)] +pub(crate) struct Shared(HashMap); + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied. +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} + +/// `o`, or the object it refers to, without copying either ([`Document::resolve`] copies a direct +/// object). +fn held<'o>(doc: &Document, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(doc.resolve(o)), + o => Held::Direct(o), + } +} + +/// What the page `page` paints with: its resources, and the appearance streams of its +/// annotations. `shared` carries what earlier pages found under the objects they share. +pub(crate) fn page_painting(doc: &Document, page: &Dict, shared: &mut Shared) -> Painting { + walk_page(doc, page, shared).found +} + +/// How many reads the search of `page` took, each object taken from `shared` counting as one. +#[cfg(test)] +pub(crate) fn page_reads(doc: &Document, page: &Dict, shared: &mut Shared) -> usize { + walk_page(doc, page, shared).visits +} + +fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk<'a> { + let mut walk = Walk { doc, shared, seen: HashMap::new(), visits: 0, cuts: 0, found: Painting::default() }; + if let Some(r) = page.get(b"Resources") { + walk.visit(r, 0, Walk::resources); + } + let annots = page.get(b"Annots").map(|a| held(doc, a)); for a in annots.as_deref().and_then(Object::as_array).into_iter().flatten() { - let a = doc.resolve(a); - let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| doc.resolve(ap)) else { continue }; + let a = held(doc, a); + let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| held(doc, ap)) else { continue }; let Some(ap) = ap.as_dict() else { continue }; // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. for (_, v) in ap.iter() { - match &*doc.resolve(v) { - Object::Stream(_) => walk.xobject(v, 0), + match &*held(doc, v) { + Object::Stream(_) => walk.visit(v, 0, Walk::xobject), Object::Dict(states) => { for (_, s) in states.iter() { - walk.xobject(s, 0); + walk.visit(s, 0, Walk::xobject); } } _ => {} } } } - walk.found + walk } /// Whether `page` names a blending colour space PDF/A accepts without an output intent: its @@ -123,53 +193,74 @@ fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { struct Walk<'a> { doc: &'a Document, - /// The indirect objects already read, each with the shallowest depth it was read at: an - /// object is read again only from a shallower place, so a cycle ends and a deep first visit - /// doesn't hide what a shallower one would read. + shared: &'a mut Shared, + /// The indirect objects already read on this page, each with the shallowest depth it was read + /// at: an object is read again only from a shallower place, so a cycle ends and a deep first + /// visit doesn't hide what a shallower one would read. seen: HashMap, visits: usize, + /// How many times reading stopped short (the budget, the depth limit, an object already read + /// on this page). An object with a stop below it doesn't go in [`Shared`]: what was found + /// under it may be incomplete. + cuts: usize, found: Painting, } impl Walk<'_> { - /// Whether `o`, reached `depth` levels down, may be read: within the budget and the depth, and - /// an indirect object not read before from as shallow a place. - fn enter(&mut self, o: &Object, depth: usize) -> bool { + /// Read `o`, reached `depth` levels down, with `read`: from [`Shared`] when an earlier page + /// read it to the end, else when it is within the budget and the depth, and not an indirect + /// object read before on this page from as shallow a place. + fn visit(&mut self, o: &Object, depth: usize, read: fn(&mut Self, &Object, usize)) { if self.visits >= MAX_VISITS || depth > MAX_DEPTH { - return false; + self.cuts = self.cuts.saturating_add(1); + return; } - if let Object::Ref(r) = o { - if self.seen.get(r).is_some_and(|&d| d <= depth) { - return false; - } - self.seen.insert(*r, depth); + let Object::Ref(r) = o else { + self.visits += 1; + read(self, o, depth); + return; + }; + if let Some(found) = self.shared.0.get(r) { + self.visits += 1; + self.found.add(found); + return; } + if self.seen.get(r).is_some_and(|&d| d <= depth) { + self.cuts = self.cuts.saturating_add(1); + return; + } + self.seen.insert(*r, depth); self.visits += 1; - true + // Gather what this object holds on its own, to keep for the next page, then add it to what + // the page holds. + let outer = std::mem::take(&mut self.found); + let cuts = self.cuts; + read(self, o, depth); + let below = std::mem::replace(&mut self.found, outer); + self.found.add(&below); + if self.cuts == cuts && self.shared.0.len() < MAX_SHARED { + self.shared.0.insert(*r, below); + } } fn resources(&mut self, r: &Object, depth: usize) { - if !self.enter(r, depth) { - return; - } let doc = self.doc; - let Some(res) = doc.resolve(r).as_dict().cloned() else { return }; + let res = held(doc, r); + let Some(res) = res.as_dict() else { return }; for category in CATEGORIES { - // Once the budget is spent nothing more is read, so nothing more is gathered either. - if self.visits >= MAX_VISITS { - return; - } - let entries: Vec = res - .get(category) - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_dict().map(|d| d.iter().map(|(_, v)| v.clone()).collect())) - .unwrap_or_default(); - for v in &entries { + let entries = res.get(category).map(|d| held(doc, d)); + let Some(entries) = entries.as_deref().and_then(Object::as_dict) else { continue }; + for (_, v) in entries.iter() { + // Once the budget is spent nothing more is read, so nothing more is gathered either. + if self.visits >= MAX_VISITS { + self.cuts = self.cuts.saturating_add(1); + return; + } match category { - b"ExtGState" => self.ext_gstate(v, depth), - b"XObject" => self.xobject(v, depth), - b"Pattern" => self.pattern(v, depth), - _ => self.type3(v, depth), + b"ExtGState" => self.visit(v, depth, Self::ext_gstate), + b"XObject" => self.visit(v, depth, Self::xobject), + b"Pattern" => self.visit(v, depth, Self::pattern), + _ => self.visit(v, depth, Self::type3), } } } @@ -177,55 +268,44 @@ impl Walk<'_> { /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend /// modes it names that ISO 32000-1 doesn't define. - fn ext_gstate(&mut self, g: &Object, depth: usize) { - if !self.enter(g, depth) { - return; - } + fn ext_gstate(&mut self, g: &Object, _depth: usize) { let doc = self.doc; - let g = doc.resolve(g); + let g = held(doc, g); let Some(g) = g.as_dict() else { return }; - let below_one = |key: &[u8]| g.get(key).and_then(|v| doc.resolve(v).as_f64()).is_some_and(|a| a < 1.0); - let bm = g.get(b"BM").map(|b| doc.resolve(b)); + let below_one = |key: &[u8]| g.get(key).and_then(|v| held(doc, v).as_f64()).is_some_and(|a| a < 1.0); + let bm = g.get(b"BM").map(|b| held(doc, b)); let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). - let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); + let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; - for name in bm.as_deref().map(|b| unknown_blend_modes(doc, b)).unwrap_or_default() { - if !self.found.unknown_blend_modes.contains(&name) { - self.found.unknown_blend_modes.push(name); - } + if let Some(bm) = bm.as_deref() { + unknown_blend_modes(doc, bm, &mut self.found); } } /// An image with a soft mask, or a form XObject that is a transparency group or holds /// transparency in its resources. fn xobject(&mut self, x: &Object, depth: usize) { - if !self.enter(x, depth) { - return; - } let doc = self.doc; - let x = doc.resolve(x); + let x = held(doc, x); let Object::Stream(s) = &*x else { return }; let d = &s.dict; if d.name(b"Subtype") == Some(b"Image") { - let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(_))); - let in_data = d.get(b"SMaskInData").and_then(|v| doc.resolve(v).as_int()).is_some_and(|v| v > 0); + let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_))); + let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0); self.found.transparency |= soft_mask || in_data; return; } - let group = d.get(b"Group").map(|g| doc.resolve(g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); self.found.transparency |= group; if let Some(r) = d.get(b"Resources") { - self.resources(r, depth + 1); + self.visit(r, depth + 1, Self::resources); } } /// A tiling pattern's resources, or a shading pattern's graphics state. fn pattern(&mut self, p: &Object, depth: usize) { - if !self.enter(p, depth) { - return; - } - let p = self.doc.resolve(p); + let p = held(self.doc, p); let Some(d) = (match &*p { Object::Stream(s) => Some(&s.dict), Object::Dict(d) => Some(d), @@ -234,25 +314,22 @@ impl Walk<'_> { return; }; if let Some(g) = d.get(b"ExtGState") { - self.ext_gstate(g, depth + 1); + self.visit(g, depth + 1, Self::ext_gstate); } if let Some(r) = d.get(b"Resources") { - self.resources(r, depth + 1); + self.visit(r, depth + 1, Self::resources); } } /// A Type 3 font's resources, which its glyph procedures paint with. fn type3(&mut self, f: &Object, depth: usize) { - if !self.enter(f, depth) { - return; - } - let f = self.doc.resolve(f); + let f = held(self.doc, f); let Some(d) = f.as_dict() else { return }; if d.name(b"Subtype") != Some(b"Type3") { return; } if let Some(r) = d.get(b"Resources") { - self.resources(r, depth + 1); + self.visit(r, depth + 1, Self::resources); } } } @@ -278,15 +355,28 @@ const BLEND_MODES: &[&[u8]] = &[ b"Luminosity", ]; -/// The names in a `/BM` value (a name, or an array of them) that are not blend modes ISO 32000-1 -/// defines. -fn unknown_blend_modes(doc: &Document, bm: &Object) -> Vec { - let names: Vec> = match bm { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|m| doc.resolve(m).as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), +/// Add to `found` the names in a `/BM` value (a name, or an array of them) that are not blend +/// modes ISO 32000-1 defines, up to [`MAX_BLEND_NAMES`] of them. +fn unknown_blend_modes(doc: &Document, bm: &Object, found: &mut Painting) { + let one = |found: &mut Painting, n: &[u8]| { + if !BLEND_MODES.contains(&n) { + found.add_blend_mode(&String::from_utf8_lossy(n)); + } }; - names.into_iter().filter(|n| !BLEND_MODES.contains(&n.as_slice())).map(|n| String::from_utf8_lossy(&n).into_owned()).collect() + match bm { + Object::Name(n) => one(found, n), + Object::Array(a) => { + for m in a { + if found.blend_modes_full() { + return; + } + if let Some(n) = held(doc, m).as_name() { + one(found, n); + } + } + } + _ => {} + } } /// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose From e6b5f2120c24fe384e6354c3409de61a54ad69b6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:32:05 +0800 Subject: [PATCH 67/72] 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]] From d6750ee660f7c666cf1ef369a639fd4249a630ab Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:58:19 +0800 Subject: [PATCH 68/72] M11: ensure_id's doc says it keeps any two-element /ID array, even a malformed one (optimize.pdfa-validate) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/cos/src/writer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 880bbd665d..c054293073 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -686,7 +686,9 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) { } } -/// Keep an existing `/ID`, or create one with [`new_file_id`]. +/// Keep any existing two-element `/ID` array as it is, even a malformed one such as `[<> <>]` or `[1 2]` (an ordinary +/// save preserves what it doesn't understand; Save as PDF/A replaces or completes it), or create one with +/// [`new_file_id`] when there is none or it isn't a two-element array. fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) { return; From ef0ae99b9ed1bdb8a96106afa496cb4366755bb4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 12:12:03 +0800 Subject: [PATCH 69/72] M11: rerun CI (a Fill & Sign UI test failed on a doc-only change) (optimize.pdfa-validate) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 3fe8ee4774aabd3c411249fde6d1fe5d41591720 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Mon, 9 Nov 2026 08:54:03 +0800 Subject: [PATCH 70/72] M11: PDF/A verify checks SetState, NOP and Named actions in open actions, standalone actions and links (6.5.1) The test now places each of them in the catalog's /OpenAction (direct and by reference), in an action of its own that a page's /AA refers to, and in a link's /A, and adds a Named action whose /N refers to /Print. Save as PDF/A removes each and keeps a Named page move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/preflight/src/tests.rs | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 6696a2ff51..c1fd49bc3b 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -550,6 +550,62 @@ fn set_state_no_op_and_named_actions_are_checked() { } } +/// The SetState, NOP and Named checks of 6.5.1 apply wherever an action can be: the catalog's +/// `/OpenAction`, an action standing alone as an object of its own (here one a page's `/AA` +/// refers to), and a link's `/A`, not only an outline item's. A Named action's `/N` given by +/// reference to a forbidden name is reported by that name. Save as PDF/A removes each one and +/// keeps a Named page move in the same places. +#[test] +fn set_state_no_op_and_named_actions_are_checked_wherever_actions_are() { + // Object 5 is the standalone action, 6 is /Print and 7 is /NextPage. + let doc_with = |open_action: &str, page_aa: &str, link_action: &str, standalone: &str| { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {open_action} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] {page_aa} >>"); + let link = format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 {link_action} >>"); + build(&[&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, &link, standalone, "/Print", "/NextPage"], "") + }; + let goto = "<< /S /GoTo /D [3 0 R /Fit] >>"; + // Where the action is, as (open action, page /AA, link /A, object 5). + let placed = |action: &str| { + [ + ("an open action", doc_with(&format!("/OpenAction {action}"), "", "", goto)), + ("an open action by reference", doc_with("/OpenAction 5 0 R", "", "", action)), + ("a page's /AA", doc_with("", "/AA << /O 5 0 R >>", "", action)), + ("a link's /A", doc_with("", "", &format!("/A {action}"), goto)), + ] + }; + let forbidden = [ + ("<< /S /SetState /T [] >>", "A SetState action isn't allowed"), + ("<< /S /NOP >>", "A NOP action isn't allowed"), + ("<< /S /Named /N /Print >>", "A Named Print action isn't allowed"), + ("<< /S /Named /N 6 0 R >>", "A Named Print action isn't allowed"), + ]; + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + for (action, message) in forbidden { + for (place, mut doc) in placed(action) { + let issues = verify(&doc, Level::A2b); + let found: Vec<&str> = issues.iter().filter(|i| i.clause == "6.5.1").map(|i| i.message.as_str()).collect(); + assert_eq!(found, [message], "{action} in {place}: {issues:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + let left: Vec<_> = r.remaining.iter().filter(|i| i.clause.starts_with("6.5")).collect(); + assert!(left.is_empty(), "{action} in {place}: {left:#?}"); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + for kind in [&b"/SetState"[..], b"/NOP", b"/Named"] { + assert!(!bytes.windows(kind.len()).any(|w| w == kind), "{action} in {place}: {}", String::from_utf8_lossy(&bytes)); + } + } + } + // A Named page move, with its name given by reference, is allowed and kept outside /AA. + for (place, mut doc) in placed("<< /S /Named /N 7 0 R >>") { + let issues = verify(&doc, Level::A2b); + assert!(!issues.iter().any(|i| i.clause == "6.5.1"), "{place}: {issues:#?}"); + convert(&mut doc, Level::A2b).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + let kept = bytes.windows(6).any(|w| w == b"/Named"); + assert_eq!(kept, place != "a page's /AA", "{place}: {}", String::from_utf8_lossy(&bytes)); + } +} + 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/" "#, From d9f55b0a692b0acfada1d1e68390bcab593a2e7a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 09:03:01 +0700 Subject: [PATCH 71/72] Split PDF/A verify into one helper per group of clause checks verify() went past clippy's cognitive complexity limit once every check was in it. The checks move as they are into helpers, run in the same order, so the issues and their order don't change. --- crates/preflight/src/lib.rs | 138 ++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 38 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 64ca00311e..d5a6d80b04 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -586,7 +586,48 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let message = fill(pattern, None, &values); push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable }); }; - let cat = catalog(doc); + check_identification(doc, level, &mut issue); + let objs = objects(doc); + check_xmp_packets(&objs, &located_issue); + check_output_intent(doc, &objs, &mut issue); + check_catalog_actions(doc, &mut issue); + // Pages: annotations and page actions. + let pages = pdfcraft_model::pages(doc); + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); + for (i, p) in pages.iter().enumerate() { + if p.dict.get(b"AA").is_some() { + issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); + } + } + check_transparency(doc, &pages, &mut issue); + let drawing = drawing_fonts(doc, &objs); + check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); + check_limits(doc, &objs, &pages, &mut issue); + check_spots(doc, &objs, &mut issue); + check_graphics_states(doc, &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 +} + +/// 6.1.2, 6.1.3 and 6.6: identification and metadata, the file header, the file identifier and +/// encryption. +fn check_identification(doc: &Document, level: Level, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6: identification and metadata. match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), @@ -622,12 +663,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); } - // 6.2.2: output intent when device colour is used. - let objs = objects(doc); - // 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in - // every metadata stream. +} + +/// 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in +/// every metadata stream. +fn check_xmp_packets( + objs: &[(ObjRef, std::sync::Arc)], + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default()); - xmp_types::for_each_packet(&objs, |object, packet| { + xmp_types::for_each_packet(objs, |object, packet| { mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty))); extension.add(object, packet); }); @@ -652,6 +697,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { ); } } + report_xmp_extension(extension, located_issue); +} + +/// 6.6.2.3.3: what the extension schema descriptions of the metadata streams lack. +fn report_xmp_extension( + extension: xmp_extension::Findings, + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { for m in extension.missing { let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; match (m.item, m.empty) { @@ -733,7 +786,15 @@ pub fn verify(doc: &Document, level: Level) -> Vec { ); } } - let used = uses_device_colour(doc, &objs); +} + +/// 6.2.3 and 6.2.4.3: the output intent device colour needs. +fn check_output_intent( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let used = uses_device_colour(doc, objs); match pdfa_intent_profile(doc) { None if used.any() => { issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk); @@ -752,7 +813,11 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } None => {} } +} + +fn check_catalog_actions(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6.1: catalog actions. + let cat = catalog(doc); if cat.get(b"AA").is_some() { issue("6.5.2", "The document has additional actions (/AA)", None, None, true); } @@ -766,27 +831,13 @@ pub fn verify(doc: &Document, level: Level) -> Vec { { issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true); } - // Pages: annotations and page actions. - let pages = pdfcraft_model::pages(doc); - // Each annotation's page: the first page that lists it. - let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) - && let Some(a) = a.as_array() - { - #[cfg(test)] - ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); - for r in a.iter().filter_map(Object::as_ref) { - annot_pages.entry(r).or_insert(i); - } - } - } - let page_of = |r: ObjRef| annot_pages.get(&r).copied(); - for (i, p) in pages.iter().enumerate() { - if p.dict.get(b"AA").is_some() { - issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); - } - } +} + +fn check_transparency( + doc: &Document, + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { // 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with // a profile) a page with transparency names a blending colour space. let intent = pdfa_intent_has_profile(doc); @@ -802,10 +853,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.2.10", "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", n, Some(i), false); } } - let drawing = drawing_fonts(doc, &objs); - check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); +} + +fn check_limits( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { // 6.1.13: the implementation limits of ISO 32000-1 Annex C. - let over = limits::exceeded(doc, &objs, &referenced_content(doc, &objs)); + let over = limits::exceeded(doc, objs, &referenced_content(doc, objs)); if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } @@ -815,7 +872,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); } - if limits::cid_over_limit(doc, &objs) { + if limits::cid_over_limit(doc, objs) { issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); } if over.name { @@ -826,14 +883,24 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.1.13", "Page {} has a boundary smaller than 3 or larger than 14400 units", Some((i + 1).to_string()), Some(i), false); } } +} + +fn check_spots( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { // 6.2.4.4: Separation and DeviceN colour spaces. - let spots = spot::check(doc, &objs); + let spots = spot::check(doc, objs); for name in spots.differing { issue("6.2.4.4", "Separation colour spaces named {} differ in their alternate space or tint transform", Some(name), None, false); } for name in spots.undefined { issue("6.2.4.4", "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", Some(name), None, false); } +} + +fn check_graphics_states(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.2.5: the extended graphics state. let gs = gstate::check(doc); if gs.transfer { @@ -866,11 +933,6 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if gs.intent_not_name { issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false); } - // 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 } fn check_objects( From 4e86cd337ded270a2250f9198b669a236b7590f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 09:56:47 +0700 Subject: [PATCH 72/72] Give the preflight test fixtures a PDF/A-2 file header The header check reports a file without the binary comment, and Save as PDF/A then makes the next save a full one. The known-answer documents and the generation-repair file predate it: the minimal document got a 6.1.2 issue, and an undamaged file needed a full save for its header instead of being saved incrementally. They now start with the comment, as the crate's unit test documents do. --- crates/preflight/tests/generation_repairs.rs | 4 +++- crates/preflight/tests/known_answers.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/preflight/tests/generation_repairs.rs b/crates/preflight/tests/generation_repairs.rs index c57ef141d2..68c2f2b663 100644 --- a/crates/preflight/tests/generation_repairs.rs +++ b/crates/preflight/tests/generation_repairs.rs @@ -4,7 +4,9 @@ use pdfcraft_preflight::{Level, convert}; use std::sync::Arc; fn image_file(damaged: bool) -> Arc> { - let mut bytes = b"%PDF-1.7\n".to_vec(); + // A PDF/A-2 header (6.1.2), so that Save as PDF/A has no header to rewrite and only the + // repaired references decide whether the next save is full. + let mut bytes = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let bodies = [ "<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs index deadf2630f..bb92e3dd83 100644 --- a/crates/preflight/tests/known_answers.rs +++ b/crates/preflight/tests/known_answers.rs @@ -22,7 +22,8 @@ use pdfcraft_preflight::{Level, convert, declared, icc, verify}; /// A document from hand-written objects and extra trailer entries (copied from the crate's unit /// tests). fn build(objs: &[&str], trailer_extra: &str) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); + // ISO 19005-2 6.1.2: the header line is followed by a comment of four bytes above 127. + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len());