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")); +}