From c6b5e612707be28f8ae0f32559d2f22e2983cb99 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 08:46:59 +0800 Subject: [PATCH] M11: PDF/A verify reads graphics states from what the trailer reaches, and needs an integer HalftoneType (6.2.5) Objects are followed from the trailer through a queue of references, never into /PieceInfo, so an object only private data refers to is not read as a graphics state holder. /HalftoneType must be an integer (ISO 32000-1 Table 128); a real of the same value is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/gstate.rs | 35 ++++++++++++++++++++++++---------- crates/preflight/src/lib.rs | 6 +++--- crates/preflight/src/tests.rs | 8 +++++++- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index fe26bbc830..551b627db0 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -13,10 +13,11 @@ //! 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. +//! Table 76). They are looked for in what the document reaches from its trailer, following +//! references, but not into private application data (`/PieceInfo`), so an object only that data +//! refers to isn't read. A map shared by reference is read once. -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashSet, VecDeque}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -42,7 +43,7 @@ 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. + /// A halftone's `/HalftoneType` is missing or isn't an integer. pub(crate) untyped: bool, /// A halftone has a `/HalftoneName`. pub(crate) halftone_name: bool, @@ -56,11 +57,15 @@ pub(crate) struct Findings { 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, read: HashSet::new(), findings: Findings::default() }; - for (_, o) in objs { - scan.walk(o, 0); +/// The 6.2.5 findings over what `doc` reaches from its trailer. +pub(crate) fn check(doc: &Document) -> Findings { + let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() }; + scan.walk(&Object::Dict(doc.trailer().clone()), 0); + // References are followed from a queue, not by recursion: a chain of them can be as long as + // the file. + while let Some(r) = scan.queue.pop_front() { + let o = doc.get(r); + scan.walk(&o, 0); } scan.findings } @@ -69,6 +74,9 @@ struct Scan<'a> { doc: &'a Document, /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference. read: HashSet, + /// Objects reached so far, queued or walked. + reached: HashSet, + queue: VecDeque, findings: Findings, } @@ -78,6 +86,12 @@ impl Scan<'_> { return; } let d = match o { + Object::Ref(r) => { + if self.reached.insert(*r) { + self.queue.push_back(*r); + } + return; + } Object::Array(a) => { for v in a { self.walk(v, depth.saturating_add(1)); @@ -171,7 +185,8 @@ impl Scan<'_> { /// 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()); + // An integer (ISO 32000-1 Table 128), not a real of the same value. + let kind = self.present(ht, b"HalftoneType").and_then(|t| if let Object::Int(t) = *t { Some(t) } else { None }); match kind { None => self.findings.untyped = true, Some(1 | 5) => {} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 667896c2e7..518cd2579d 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -126,7 +126,7 @@ 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's HalftoneType is missing or isn't an integer", "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", @@ -527,7 +527,7 @@ 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); + let gs = gstate::check(doc); if gs.transfer { issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); } @@ -541,7 +541,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { 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); + issue("6.2.5", "A halftone's HalftoneType is missing or isn't an integer", None, None, false); } if gs.halftone_name { issue("6.2.5", "A halftone has a HalftoneName", None, None, false); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 5ec513fac8..c5a6cc5816 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -504,6 +504,7 @@ fn extended_graphics_states_are_checked() { "<< /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", + "<< /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >>", ], "", ); @@ -536,7 +537,9 @@ fn extended_graphics_states_are_checked() { 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"]); + let untyped = ["A halftone's HalftoneType is missing or isn't an integer"]; + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), untyped); + assert_eq!(gs("/A << /HT << /HalftoneType 1.0 >> >>"), untyped); 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"; @@ -551,6 +554,9 @@ fn extended_graphics_states_are_checked() { assert!( gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty() ); + // Nor is an object (8) only private data refers to; one the page refers to is read. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private 8 0 R >> >> /Z <<").is_empty()); + assert_eq!(gs("/A << /RI /Perceptual >> >> /Other 8 0 R /Z <<"), [tr]); // 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]);