diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index ea0f9e569f..fe26bbc830 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -5,13 +5,18 @@ //! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines; //! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones //! a type 5 halftone holds for its colorants are checked the same way; +//! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for +//! a non-primary colorant has one, and no other halftone does. The primary colorants are those +//! of DeviceCMYK; the `Default` component is left as it is (ISO 32000-1 10.5.5.6, Table 130); //! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. //! -//! Graphics state dictionaries are found as the entries of every `/ExtGState` resource -//! dictionary, and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, Table 76), -//! direct or by reference. +//! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries +//! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an +//! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, +//! Table 76). Private application data (`/PieceInfo`) isn't read. A map shared by reference is +//! read once. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -21,6 +26,10 @@ const MAX_DEPTH: u8 = 128; const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; /// The entries of a type 5 halftone that aren't colorant halftones. const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +/// The primary colorants, whose halftones take the graphics state's transfer function. +const PRIMARY: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; +/// The entries that hold resource dictionaries. +const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"]; /// What the graphics states of a document break of 6.2.5. #[derive(Debug, Default, PartialEq, Eq)] @@ -33,15 +42,23 @@ pub(crate) struct Findings { pub(crate) phase: bool, /// Halftone types other than 1 and 5, in order. pub(crate) halftone_types: BTreeSet, + /// A halftone has no `/HalftoneType` integer. + pub(crate) untyped: bool, /// A halftone has a `/HalftoneName`. pub(crate) halftone_name: bool, + /// A halftone that may not have a `/TransferFunction` has one. + pub(crate) halftone_transfer: bool, + /// Non-primary colorants whose halftone has no `/TransferFunction`, in name order. + pub(crate) missing_transfer: BTreeSet, /// Rendering intents outside Table 70, in name order. pub(crate) intents: BTreeSet, + /// A rendering intent isn't a name. + pub(crate) intent_not_name: bool, } /// The 6.2.5 findings over the indirect objects `objs`. pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { - let mut scan = Scan { doc, findings: Findings::default() }; + let mut scan = Scan { doc, read: HashSet::new(), findings: Findings::default() }; for (_, o) in objs { scan.walk(o, 0); } @@ -50,6 +67,8 @@ pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) - struct Scan<'a> { doc: &'a Document, + /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference. + read: HashSet, findings: Findings, } @@ -58,83 +77,128 @@ impl Scan<'_> { if depth > MAX_DEPTH { return; } - let entries: Box> = match o { - Object::Array(a) => Box::new(a.iter()), - Object::Dict(d) => { - self.dict(d); - Box::new(d.iter().map(|(_, v)| v)) - } - Object::Stream(s) => { - self.dict(&s.dict); - Box::new(s.dict.iter().map(|(_, v)| v)) + let d = match o { + Object::Array(a) => { + for v in a { + self.walk(v, depth.saturating_add(1)); + } + return; } + Object::Dict(d) => d, + Object::Stream(s) => &s.dict, _ => return, }; - for v in entries { - self.walk(v, depth.saturating_add(1)); + self.holder(d); + for (k, v) in d.iter() { + if k.as_slice() != b"PieceInfo" { + self.walk(v, depth.saturating_add(1)); + } } } - /// A dictionary that holds graphics states: a shading pattern its one, resources by name. - fn dict(&mut self, d: &Dict) { - let Some(states) = d.get(b"ExtGState").map(|s| self.doc.resolve(s)) else { return }; - let Some(states) = states.as_dict() else { return }; - if d.get(b"PatternType").is_some() { - self.gstate(states); + /// Whether `o` is a reference read before; marks it read. + fn read_before(&mut self, o: &Object) -> bool { + matches!(o, Object::Ref(r) if !self.read.insert(*r)) + } + + /// A dictionary that holds graphics states: through its resources, or as a shading pattern. + fn holder(&mut self, d: &Dict) { + for key in RESOURCES { + if let Some(resources) = d.get(key) { + self.resources(resources); + } + } + if d.get(b"PatternType").is_some() + && let Some(gs) = d.get(b"ExtGState") + && !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + + fn resources(&mut self, resources: &Object) { + if self.read_before(resources) { return; } + let resources = self.doc.resolve(resources); + let Some(states) = resources.as_dict().and_then(|r| r.get(b"ExtGState")) else { return }; + if self.read_before(states) { + return; + } + let states = self.doc.resolve(states); + let Some(states) = states.as_dict() else { return }; for (_, gs) in states.iter() { - if let Some(gs) = self.doc.resolve(gs).as_dict() { + if !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { self.gstate(gs); } } } + /// `key` of `d`, resolved, unless it is missing or null (ISO 32000-1 7.3.9). + fn present(&self, d: &Dict, key: &[u8]) -> Option> { + d.get(key).map(|v| self.doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)) + } + fn gstate(&mut self, gs: &Dict) { - let doc = self.doc; - let present = |k: &[u8]| gs.get(k).map(|v| doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)); - if present(b"TR").is_some() { + if self.present(gs, b"TR").is_some() { self.findings.transfer = true; } - if present(b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + if self.present(gs, b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { self.findings.transfer2 = true; } - if present(b"HTP").is_some() { + if self.present(gs, b"HTP").is_some() { self.findings.phase = true; } - if let Some(ht) = present(b"HT") + if let Some(ht) = self.present(gs, b"HT") && let Some(ht) = ht.as_dict() { - self.halftone(ht, true); + self.halftone(ht, None); } - if let Some(ri) = present(b"RI") - && let Some(ri) = ri.as_name() - && !INTENTS.contains(&ri) - { - self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + if let Some(ri) = self.present(gs, b"RI") { + match ri.as_name() { + Some(ri) if !INTENTS.contains(&ri) => { + self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + } + Some(_) => {} + None => self.findings.intent_not_name = true, + } } } - /// A halftone dictionary or stream; `top` when it isn't one a type 5 halftone holds, whose - /// own entries are halftones in turn. - fn halftone(&mut self, ht: &Dict, top: bool) { - let kind = ht.get(b"HalftoneType").and_then(|t| self.doc.resolve(t).as_int()); - if let Some(kind) = kind - && kind != 1 - && kind != 5 - { - self.findings.halftone_types.insert(kind); + /// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the + /// one a type 5 halftone holds for `colorant`. + fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) { + let kind = self.present(ht, b"HalftoneType").and_then(|t| t.as_int()); + match kind { + None => self.findings.untyped = true, + Some(1 | 5) => {} + Some(kind) => { + self.findings.halftone_types.insert(kind); + } } - if ht.get(b"HalftoneName").is_some() { + if self.present(ht, b"HalftoneName").is_some() { self.findings.halftone_name = true; } - if top && kind == Some(5) { + let transfer = self.present(ht, b"TransferFunction").is_some(); + match colorant { + Some(b"Default") => {} + Some(c) if !PRIMARY.contains(&c) => { + if !transfer { + self.findings.missing_transfer.insert(String::from_utf8_lossy(c).into_owned()); + } + } + _ => self.findings.halftone_transfer |= transfer, + } + if colorant.is_none() && kind == Some(5) { for (k, v) in ht.iter() { if HALFTONE_KEYS.contains(&k.as_slice()) { continue; } if let Some(component) = self.doc.resolve(v).as_dict() { - self.halftone(component, false); + self.halftone(component, Some(k)); } } } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 3886a5181a..667896c2e7 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -126,8 +126,12 @@ pub const WORDINGS: &[&str] = &[ "A graphics state has a transfer function (TR2) other than Default", "A graphics state has a halftone phase (HTP)", "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone has no HalftoneType", "A halftone has a HalftoneName", + "A halftone has a transfer function, which only a non-primary colorant's halftone may have", + "The halftone for the colorant {} has no transfer function", "A graphics state has the rendering intent {}, which isn't a standard one", + "A graphics state has a rendering intent that isn't a name", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -536,12 +540,24 @@ pub fn verify(doc: &Document, level: Level) -> Vec { for kind in gs.halftone_types { issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); } + if gs.untyped { + issue("6.2.5", "A halftone has no HalftoneType", None, None, false); + } if gs.halftone_name { issue("6.2.5", "A halftone has a HalftoneName", None, None, false); } + if gs.halftone_transfer { + issue("6.2.5", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", None, None, false); + } + for colorant in gs.missing_transfer { + issue("6.2.5", "The halftone for the colorant {} has no transfer function", Some(colorant), None, false); + } for intent in gs.intents { issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false); } + if gs.intent_not_name { + issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false); + } // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index b35f490157..9590054184 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -507,7 +507,8 @@ fn extended_graphics_states_are_checked() { ], "", ); - verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").map(|i| i.message).collect() + let issues = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5"); + issues.inspect(|i| assert!(!i.fixable, "{}", i.message)).map(|i| i.message).collect() }; let tr = "A graphics state has a transfer function (TR)"; let tr2 = "A graphics state has a transfer function (TR2) other than Default"; @@ -533,6 +534,21 @@ fn extended_graphics_states_are_checked() { assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]); // Rendering intents. assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); + assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]); + // A halftone's type is required; a null name is no name. + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), ["A halftone has no HalftoneType"]); + assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty()); + // Transfer functions in halftones: only, and always, for a non-primary colorant. + let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; + assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R >> >>"), + ["The halftone for the colorant Red has no transfer function", "The halftone for the colorant Spot has no transfer function"] + ); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); + // Private application data isn't graphics resources. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty()); // A shading pattern holds one graphics state, not a dictionary of them. let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]);