Skip to main content

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}