pdfrum_doc/pdfa/report.rs
1//! The vocabulary a check speaks: the level asked for, the clause broken,
2//! the object that broke it, and the report holding the lot.
3
4use core::fmt;
5
6use pdfrum_object::ObjRef;
7
8/// Which PDF/A conformance level to check against.
9///
10/// An enum rather than a string or a pair of numbers, because the two levels
11/// differ in what they *forbid* and a caller who passes the wrong spelling of
12/// a string gets no answer at all. Only the two `b` (basic) levels are here;
13/// the module docs say why the `a` levels are absent.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum Level {
16 /// PDF/A-1b, ISO 19005-1:2005. Built on PDF 1.4: no transparency at all,
17 /// no optional content, no embedded files, no JPEG 2000.
18 A1b,
19 /// PDF/A-2b, ISO 19005-2:2011. Built on PDF 1.7: transparency is allowed
20 /// when a blending colour space is defined, JPEG 2000 is allowed, and
21 /// embedded files are allowed if they are themselves PDF/A.
22 A2b,
23}
24
25impl Level {
26 /// The level's name as ISO 19005 spells it.
27 #[must_use]
28 pub const fn name(self) -> &'static str {
29 match self {
30 Level::A1b => "PDF/A-1b",
31 Level::A2b => "PDF/A-2b",
32 }
33 }
34
35 /// The `pdfaid:part` an XMP identification schema must carry for this
36 /// level: 1 for A-1, 2 for A-2.
37 #[must_use]
38 pub const fn part(self) -> u8 {
39 match self {
40 Level::A1b => 1,
41 Level::A2b => 2,
42 }
43 }
44
45 /// Whether transparency is forbidden outright.
46 ///
47 /// True for A-1 only. A-2 permits transparency, so the check that walks
48 /// for it is skipped rather than run-and-ignored — a check that produces
49 /// findings nobody reads is how a report grows noise.
50 #[must_use]
51 pub const fn forbids_transparency(self) -> bool {
52 matches!(self, Level::A1b)
53 }
54}
55
56impl fmt::Display for Level {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(self.name())
59 }
60}
61
62/// The requirement a [`Violation`] breaks.
63///
64/// One variant per rule this checker enforces, named for what the rule *is*
65/// rather than for the section number, because the section numbers differ
66/// between ISO 19005-1 and -2 for the same requirement and a caller matching
67/// on `Clause::FontNotEmbedded` should not have to care which part it is
68/// reading. [`Clause::iso`] gives the citation for a report that wants to
69/// print one.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
71#[non_exhaustive]
72pub enum Clause {
73 /// A font used for rendering has no embedded font program.
74 FontNotEmbedded,
75 /// An embedded font program is a subset (its name carries the six-letter
76 /// tag) but does not declare the character set it covers.
77 FontSubsetIncomplete,
78 /// A symbolic TrueType font has no usable `cmap`, or a non-symbolic one
79 /// lacks the encoding PDF/A requires.
80 FontEncodingInvalid,
81 /// The file is encrypted. PDF/A forbids it outright: an archived file
82 /// must be readable without a key that may be lost.
83 Encrypted,
84 /// The document carries JavaScript, in the name tree or in an action.
85 JavaScript,
86 /// A forbidden action type: `/Launch`, `/Sound`, `/Movie`, `/ResetForm`,
87 /// `/ImportData` or `/JavaScript`.
88 ForbiddenAction,
89 /// An embedded sound or movie annotation, or a `/Sound` or `/Movie` entry
90 /// referencing multimedia content.
91 EmbeddedMultimedia,
92 /// The catalog carries no `/Metadata` XMP packet.
93 XmpMissing,
94 /// The XMP packet is present but is not well-formed enough to read the
95 /// identification schema from.
96 XmpMalformed,
97 /// The XMP packet carries no PDF/A identification schema (`pdfaid:part`).
98 XmpIdentificationMissing,
99 /// The XMP identification schema names a different part or conformance
100 /// letter than the level being checked.
101 XmpIdentificationMismatch,
102 /// A document information dictionary entry disagrees with the XMP
103 /// property that mirrors it.
104 XmpInfoMismatch,
105 /// The catalog has no `/OutputIntents`, or none with a
106 /// `GTS_PDFA1` subtype.
107 OutputIntentMissing,
108 /// An output intent is present but its `/DestOutputProfile` is missing or
109 /// is not an embeddable ICC stream.
110 OutputIntentProfileMissing,
111 /// An annotation's flag word is illegal: `/Hidden`, `/NoView` or
112 /// `/Invisible` set, or `/Print` clear.
113 AnnotationFlagsIllegal,
114 /// An annotation of a subtype PDF/A does not allow.
115 AnnotationSubtypeForbidden,
116 /// A non-widget annotation has no normal appearance stream.
117 AnnotationAppearanceMissing,
118 /// A stream, file specification or reference `XObject` points at content
119 /// outside the file.
120 ExternalContentReference,
121 /// Transparency: a soft mask, a non-`Normal` blend mode, a non-opaque
122 /// constant alpha, or a transparency group. A-1 only.
123 Transparency,
124 /// A device colour space is used with no output intent to define what it
125 /// means.
126 DeviceColorWithoutOutputIntent,
127 /// Optional content (`/OCProperties`, `/OCG`, `/OCMD`). A-1 only; A-2
128 /// permits it.
129 OptionalContent,
130 /// An embedded file. A-1 forbids embedded files outright.
131 EmbeddedFile,
132 /// A `/LZWDecode` filter, which both parts forbid for patent reasons.
133 ///
134 /// Separate from [`Clause::JpxFilter`] because they are separate
135 /// requirements with separate citations — folding them into one clause
136 /// made a JPEG 2000 stream cite the LZW rule, which the veraPDF oracle
137 /// caught.
138 LzwFilter,
139 /// A `/JPXDecode` filter under A-1, whose PDF 1.4 base does not define
140 /// JPEG 2000. A-2 permits it, so this never fires there.
141 JpxFilter,
142}
143
144impl Clause {
145 /// The ISO 19005 citation for this requirement, at the given level.
146 ///
147 /// The two parts number the same requirement differently, so the level is
148 /// a parameter rather than the clause carrying one number.
149 #[must_use]
150 // Several distinct clauses share a citation because ISO puts several
151 // requirements in one numbered paragraph: 6.6.1 covers JavaScript, the
152 // forbidden action list and multimedia together. Merging the arms would
153 // hide which requirement each clause *is*, which is the whole point of
154 // the enum, so the repetition is the readable form here.
155 #[allow(clippy::match_same_arms)]
156 pub const fn iso(self, level: Level) -> &'static str {
157 match (self, level) {
158 (Clause::FontNotEmbedded, Level::A1b) => "ISO 19005-1:2005, 6.3.4",
159 (Clause::FontNotEmbedded, Level::A2b) => "ISO 19005-2:2011, 6.2.11.4.1",
160 (Clause::FontSubsetIncomplete, Level::A1b) => "ISO 19005-1:2005, 6.3.5",
161 (Clause::FontSubsetIncomplete, Level::A2b) => "ISO 19005-2:2011, 6.2.11.4.2",
162 (Clause::FontEncodingInvalid, Level::A1b) => "ISO 19005-1:2005, 6.3.6",
163 (Clause::FontEncodingInvalid, Level::A2b) => "ISO 19005-2:2011, 6.2.11.5",
164 (Clause::Encrypted, Level::A1b) => "ISO 19005-1:2005, 6.1.3",
165 (Clause::Encrypted, Level::A2b) => "ISO 19005-2:2011, 6.1.3",
166 (Clause::JavaScript, Level::A1b) => "ISO 19005-1:2005, 6.6.1",
167 (Clause::JavaScript, Level::A2b) => "ISO 19005-2:2011, 6.5.1",
168 (Clause::ForbiddenAction, Level::A1b) => "ISO 19005-1:2005, 6.6.1",
169 (Clause::ForbiddenAction, Level::A2b) => "ISO 19005-2:2011, 6.5.1",
170 (Clause::EmbeddedMultimedia, Level::A1b) => "ISO 19005-1:2005, 6.6.1",
171 (Clause::EmbeddedMultimedia, Level::A2b) => "ISO 19005-2:2011, 6.5.1",
172 (Clause::XmpMissing, Level::A1b) => "ISO 19005-1:2005, 6.7.2",
173 (Clause::XmpMissing, Level::A2b) => "ISO 19005-2:2011, 6.6.2.1",
174 (Clause::XmpMalformed, Level::A1b) => "ISO 19005-1:2005, 6.7.2",
175 (Clause::XmpMalformed, Level::A2b) => "ISO 19005-2:2011, 6.6.2.1",
176 (Clause::XmpIdentificationMissing, Level::A1b) => "ISO 19005-1:2005, 6.7.11",
177 (Clause::XmpIdentificationMissing, Level::A2b) => "ISO 19005-2:2011, 6.6.4",
178 (Clause::XmpIdentificationMismatch, Level::A1b) => "ISO 19005-1:2005, 6.7.11",
179 (Clause::XmpIdentificationMismatch, Level::A2b) => "ISO 19005-2:2011, 6.6.4",
180 (Clause::XmpInfoMismatch, Level::A1b) => "ISO 19005-1:2005, 6.7.3",
181 (Clause::XmpInfoMismatch, Level::A2b) => "ISO 19005-2:2011, 6.6.2.3.1",
182 // A-1 folds the output intent into the uncalibrated-colour rule:
183 // both "no intent" and "device colour without one" are 6.2.3.3.
184 // A-2 separates them — 6.2.10 is the intent, 6.2.4.3 the colour.
185 (Clause::OutputIntentMissing, Level::A1b) => "ISO 19005-1:2005, 6.2.3.3",
186 (Clause::OutputIntentMissing, Level::A2b) => "ISO 19005-2:2011, 6.2.10",
187 (Clause::OutputIntentProfileMissing, Level::A1b) => "ISO 19005-1:2005, 6.2.3.3",
188 (Clause::OutputIntentProfileMissing, Level::A2b) => "ISO 19005-2:2011, 6.2.10",
189 (Clause::AnnotationFlagsIllegal, Level::A1b) => "ISO 19005-1:2005, 6.5.3",
190 (Clause::AnnotationFlagsIllegal, Level::A2b) => "ISO 19005-2:2011, 6.3.2",
191 (Clause::AnnotationSubtypeForbidden, Level::A1b) => "ISO 19005-1:2005, 6.5.2",
192 (Clause::AnnotationSubtypeForbidden, Level::A2b) => "ISO 19005-2:2011, 6.3.1",
193 (Clause::AnnotationAppearanceMissing, Level::A1b) => "ISO 19005-1:2005, 6.5.3",
194 (Clause::AnnotationAppearanceMissing, Level::A2b) => "ISO 19005-2:2011, 6.3.3",
195 (Clause::ExternalContentReference, Level::A1b) => "ISO 19005-1:2005, 6.1.6",
196 (Clause::ExternalContentReference, Level::A2b) => "ISO 19005-2:2011, 6.2.2",
197 (Clause::Transparency, Level::A1b) => "ISO 19005-1:2005, 6.4",
198 // A-2 permits transparency; the check never runs, so this arm is
199 // unreachable in practice and cites the clause that governs it.
200 (Clause::Transparency, Level::A2b) => "ISO 19005-2:2011, 6.2.4.3",
201 (Clause::DeviceColorWithoutOutputIntent, Level::A1b) => "ISO 19005-1:2005, 6.2.3.3",
202 (Clause::DeviceColorWithoutOutputIntent, Level::A2b) => "ISO 19005-2:2011, 6.2.4.3",
203 (Clause::OptionalContent, Level::A1b) => "ISO 19005-1:2005, 6.1.13",
204 (Clause::OptionalContent, Level::A2b) => "ISO 19005-2:2011, 6.1.13",
205 (Clause::EmbeddedFile, Level::A1b) => "ISO 19005-1:2005, 6.1.11",
206 (Clause::EmbeddedFile, Level::A2b) => "ISO 19005-2:2011, 6.8",
207 (Clause::LzwFilter, Level::A1b) => "ISO 19005-1:2005, 6.1.10",
208 (Clause::LzwFilter, Level::A2b) => "ISO 19005-2:2011, 6.1.7.2",
209 // A-1's JPEG 2000 prohibition is a consequence of its PDF 1.4
210 // base, which 6.1.3 fixes; A-2 permits JPX, so the check never
211 // runs there and this arm cites the same base clause.
212 (Clause::JpxFilter, Level::A1b) => "ISO 19005-1:2005, 6.1.3",
213 (Clause::JpxFilter, Level::A2b) => "ISO 19005-2:2011, 6.1.3",
214 }
215 }
216}
217
218/// What in the file broke a requirement.
219///
220/// Deliberately not a string. A converter that wants to repair a violation
221/// needs the `ObjRef` to reach the object; a caller that wants to tell a user
222/// which page to look at needs the index. Formatting either into a sentence
223/// throws that away and cannot be recovered.
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[non_exhaustive]
226pub enum Subject {
227 /// The document as a whole — the trailer, the header, or a property with
228 /// no single object behind it.
229 Document,
230 /// The document catalog.
231 Catalog,
232 /// A page, by zero-based index.
233 Page(u32),
234 /// A specific indirect object.
235 Object(ObjRef),
236 /// A named resource on a page: the page index and the resource's name.
237 Resource {
238 /// The page the resource is reached from.
239 page: u32,
240 /// The name it has in that page's resource dictionary.
241 name: String,
242 },
243}
244
245impl fmt::Display for Subject {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 match self {
248 Subject::Document => f.write_str("document"),
249 Subject::Catalog => f.write_str("catalog"),
250 Subject::Page(index) => write!(f, "page {}", index + 1),
251 Subject::Object(reference) => {
252 write!(f, "object {} {}", reference.num, reference.generation)
253 }
254 Subject::Resource { page, name } => write!(f, "page {} resource /{name}", page + 1),
255 }
256 }
257}
258
259/// One requirement the document fails.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct Violation {
262 /// The requirement broken.
263 pub clause: Clause,
264 /// What broke it.
265 pub subject: Subject,
266 /// A sentence naming the specific thing found, for a report a person
267 /// reads. The machine-readable answer is `clause` and `subject`; this is
268 /// the detail neither of them can carry, such as the font's base name.
269 pub detail: String,
270}
271
272impl fmt::Display for Violation {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 write!(f, "{}: {}", self.subject, self.detail)
275 }
276}
277
278/// What [`check`](super::check) found.
279///
280/// [`Report::conforms`] is the one-bit answer; the violations are the reason.
281/// A report is always complete for the checks this engine implements, which
282/// is not the same as complete for ISO 19005. A caller that needs the
283/// distinction should treat a passing report as "we found nothing", not as a
284/// certificate.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Report {
287 /// The level checked against.
288 pub level: Level,
289 /// Every requirement failed, in the order the checks ran: document-wide
290 /// properties first, then per-page walks in page order.
291 pub violations: Vec<Violation>,
292}
293
294impl Report {
295 /// Whether the document passed every check this engine runs.
296 #[must_use]
297 pub fn conforms(&self) -> bool {
298 self.violations.is_empty()
299 }
300
301 /// Every violation of one clause.
302 pub fn by_clause(&self, clause: Clause) -> impl Iterator<Item = &Violation> {
303 self.violations.iter().filter(move |v| v.clause == clause)
304 }
305
306 /// The distinct clauses failed, in first-seen order.
307 ///
308 /// The summary a caller usually wants: a file with two hundred
309 /// non-embedded fonts fails one requirement two hundred times, and the
310 /// interesting number is the one.
311 #[must_use]
312 pub fn clauses(&self) -> Vec<Clause> {
313 let mut out: Vec<Clause> = Vec::new();
314 for violation in &self.violations {
315 if !out.contains(&violation.clause) {
316 out.push(violation.clause);
317 }
318 }
319 out
320 }
321}