M11: PDF/A verify reads graphics states from resources only and checks halftone transfer functions (6.2.5)

Graphics states come from /Resources and /DR entries and from shading
patterns, never from private /PieceInfo data, and maps shared by
reference are read once. A halftone without a /HalftoneType, a
rendering intent that isn't a name, and a halftone /TransferFunction
where it isn't allowed or is missing (non-primary colorants of a type 5
halftone need one; ISO 32000-1 Table 130) are reported. A null entry
counts as absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 08:33:05 +08:00
1 parent 869df9f2a3
commit cdc27e538c
3 files changed
+144 -48

No files matched your search

+111 -47
View File
@@ -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<i64>,
/// 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<String>,
/// Rendering intents outside Table 70, in name order.
pub(crate) intents: BTreeSet<String>,
/// 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<Object>)]) -> 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<Object>)]) -
struct Scan<'a> {
doc: &'a Document,
/// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by reference.
read: HashSet<ObjRef>,
findings: Findings,
}
@@ -58,83 +77,128 @@ impl Scan<'_> {
if depth > MAX_DEPTH {
return;
}
let entries: Box<dyn Iterator<Item = &Object>> = 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<std::sync::Arc<Object>> {
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));
}
}
}
+16
View File
@@ -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<Issue> {
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)));
+17 -1
View File
@@ -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]);