From e001b9f4b0bffe1b45ad1f34495e86556c185d66 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:50:18 +0800 Subject: [PATCH 1/4] 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 72bc2548fb6cb5b1bfb1653c4fee071c3784efec Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:06:05 +0800 Subject: [PATCH 2/4] 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 3/4] 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 2c5e69a8e251fa1e24786c3ca6ed13da9b2e72e6 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:33:21 +0800 Subject: [PATCH 4/4] 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); +}