From 9d0821ba8b4cddaf45ccdcf12666a577181cf5dd Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:06:35 +0800 Subject: [PATCH] M11: PDF/A verify reports transfer functions, halftones and rendering intents a graphics state may not use (6.2.5) A graphics state may not have /TR, may have /TR2 only as /Default, may use halftones of type 1 or 5 only and without /HalftoneName (also the colorant halftones of a type 5 one), and a rendering intent from ISO 32000-1 Table 70. Graphics states are read from every /ExtGState resource dictionary and from shading patterns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 136 +++++++++++++++++++++++++++++++++ crates/preflight/src/lib.rs | 23 ++++++ crates/preflight/src/tests.rs | 49 ++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 crates/preflight/src/gstate.rs diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs new file mode 100644 index 0000000000..e34b444079 --- /dev/null +++ b/crates/preflight/src/gstate.rs @@ -0,0 +1,136 @@ +//! PDF/A-2 6.2.5, the extended graphics state: device-dependent settings a conforming file may not +//! rely on. +//! +//! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4); +//! - 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 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. + +use std::collections::BTreeSet; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How deep a direct object is followed: as deep as the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The rendering intents of ISO 32000-1 Table 70. +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"]; + +/// What the graphics states of a document break of 6.2.5. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// A graphics state has a `/TR` entry. + pub(crate) transfer: bool, + /// A graphics state has a `/TR2` other than `/Default`. + pub(crate) transfer2: bool, + /// Halftone types other than 1 and 5, in order. + pub(crate) halftone_types: BTreeSet, + /// A halftone has a `/HalftoneName`. + pub(crate) halftone_name: bool, + /// Rendering intents outside Table 70, in name order. + pub(crate) intents: BTreeSet, +} + +/// 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() }; + for (_, o) in objs { + scan.walk(o, 0); + } + scan.findings +} + +struct Scan<'a> { + doc: &'a Document, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + 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)) + } + _ => return, + }; + for v in entries { + 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); + return; + } + for (_, gs) in states.iter() { + if let Some(gs) = self.doc.resolve(gs).as_dict() { + self.gstate(gs); + } + } + } + + 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() { + self.findings.transfer = true; + } + if present(b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + self.findings.transfer2 = true; + } + if let Some(ht) = present(b"HT") + && let Some(ht) = ht.as_dict() + { + self.halftone(ht, true); + } + 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()); + } + } + + /// 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); + } + if ht.get(b"HalftoneName").is_some() { + self.findings.halftone_name = true; + } + if top && 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); + } + } + } + } +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..10bbfca8a9 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -21,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod gstate; pub mod icc; mod xmp; @@ -121,6 +122,11 @@ pub const WORDINGS: &[&str] = &[ "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", "The document has additional actions (/AA)", "The document has document-level JavaScript", + "A graphics state has a transfer function (TR)", + "A graphics state has a transfer function (TR2) other than Default", + "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone has a HalftoneName", + "A graphics state has the rendering intent {}, which isn't a standard one", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -515,6 +521,23 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } check_objects(doc, level, &objs, &page_of, &mut issue); + // 6.2.5: the extended graphics state. + let gs = gstate::check(doc, &objs); + if gs.transfer { + issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); + } + if gs.transfer2 { + issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false); + } + 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.halftone_name { + issue("6.2.5", "A halftone has a HalftoneName", None, 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); + } // 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 52e6903e60..3285ebc35f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -488,3 +488,52 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +/// PDF/A-2 6.2.5: a graphics state has no transfer function, halftones of type 1 or 5 without a +/// name, and a standard rendering intent, wherever it is kept. +#[test] +fn extended_graphics_states_are_checked() { + let gs = |states: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << {states} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + "<< /TR 4 0 R >>", + "<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").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"; + let name = "A halftone has a HalftoneName"; + // What 6.2.5 allows. + assert!(gs("/A << /TR2 /Default /HT 5 0 R /RI /Perceptual >> /B << /HT /Default /RI /AbsoluteColorimetric >>").is_empty()); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R >> /RI /Saturation >> /B << /TR null >>").is_empty()); + // Transfer functions. + assert_eq!(gs("/A << /TR 4 0 R >>"), [tr]); + assert_eq!(gs("/A << /TR /Identity >>"), [tr]); + assert_eq!(gs("/A 6 0 R"), [tr]); + assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]); + assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]); + // Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name. + assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Magenta << /HalftoneType 10 >> >> >>"), + ["A halftone has type 10: only types 1 and 5 are allowed"] + ); + assert_eq!(gs("/A << /HT << /HalftoneType 1 /HalftoneName (Screen) >> >>"), [name]); + 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"]); + // 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]); + assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty()); +}