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