From c35d8441d91681d4dabde59b04842ec7cdf1f708 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:54:12 +0800 Subject: [PATCH] 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 {