From 19da2752cf97bd40a94cda67e1b7028857ad6a18 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 10:52:22 +0800 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 52131ba093e5714457dd7d7078877163dec5a52b Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:16:00 +0800 Subject: [PATCH 05/10] 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 798dfa44c1bb7e9f6df1e754fc4c107b652489d5 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:35:05 +0800 Subject: [PATCH 06/10] 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 75e9028e4035574260c1087b57456998b2b7476a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 11:53:54 +0800 Subject: [PATCH 07/10] 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 08/10] 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 14f8c2c64cb09a6841e4c21f8ff8d9bcfff16dc0 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:24:05 +0800 Subject: [PATCH 09/10] 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 916bca01c8a17cd7d49b5250e5aeddae730d7fc8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:28:41 +0800 Subject: [PATCH 10/10] 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