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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410
2026-11-05 07:06:05 +08:00
co-authored by Claude Opus 5.5
parent e001b9f4b0
commit 72bc2548fb
2 changed files with 89 additions and 17 deletions
+52 -17
View File
@@ -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<Vec<u8>> = 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)))
}
+37
View File
@@ -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<String> {
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"));
}