mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
1 parent
d22641a875
commit
9d0821ba8b
3 files changed
+208
No files matched your search
@@ -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<i64>,
|
||||
/// A halftone has a `/HalftoneName`.
|
||||
pub(crate) halftone_name: bool,
|
||||
/// Rendering intents outside Table 70, in name order.
|
||||
pub(crate) intents: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// 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() };
|
||||
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<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))
|
||||
}
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Issue> {
|
||||
}
|
||||
}
|
||||
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)));
|
||||
|
||||
@@ -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<String> {
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user