Skip to main content

zpdf_document/
pdfa.rs

1//! PDF/A conformance validation (profiles A-1b and A-2b).
2//!
3//! A **rule engine** over the parsed document: each check inspects one aspect
4//! of the file and yields zero or more [`Violation`]s. This does not aim for
5//! veraPDF-level completeness — it covers the high-signal, machine-checkable
6//! clauses of ISO 19005-1 (PDF/A-1b) and 19005-2 (PDF/A-2b):
7//!
8//! - file structure: header version, no encryption, trailer /ID present
9//! - fonts: every used font embedded (except the standard 14 in no profile —
10//!   PDF/A requires embedding even for those)
11//! - XMP metadata: present, with a `pdfaid:part`/`conformance` claim
12//! - output intent: a PDF/A output intent with an embedded ICC profile
13//! - forbidden features: JavaScript/actions, embedded files (A-1),
14//!   transparency (A-1: soft masks / group /S /Transparency), LZW (A-1),
15//!   encryption of any kind
16//!
17//! Everything is best-effort and read-only over `ParseLimits`-bounded APIs;
18//! a check that cannot run (e.g. a malformed font dict) reports what it saw.
19
20use std::collections::HashSet;
21
22use zpdf_core::{ObjectId, PdfObject};
23use zpdf_parser::PdfFile;
24
25/// The validation profile.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Profile {
28    /// ISO 19005-1 Level B (PDF/A-1b): PDF 1.4 model, no transparency.
29    A1b,
30    /// ISO 19005-2 Level B (PDF/A-2b): PDF 1.7 model, transparency allowed.
31    A2b,
32}
33
34impl Profile {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Profile::A1b => "PDF/A-1b",
38            Profile::A2b => "PDF/A-2b",
39        }
40    }
41}
42
43/// One conformance violation.
44#[derive(Debug, Clone)]
45pub struct Violation {
46    /// Short rule identifier, e.g. `"encryption"`, `"font-not-embedded"`.
47    pub rule: &'static str,
48    /// Human-readable explanation with the offending object where known.
49    pub message: String,
50}
51
52/// The outcome of a validation run.
53#[derive(Debug)]
54pub struct ValidationReport {
55    pub profile: Profile,
56    pub violations: Vec<Violation>,
57    /// The `pdfaid:part`/`pdfaid:conformance` the document itself claims via
58    /// XMP, e.g. `Some(("1", "B"))` — independent of whether it conforms.
59    pub claimed: Option<(String, String)>,
60}
61
62impl ValidationReport {
63    pub fn conforms(&self) -> bool {
64        self.violations.is_empty()
65    }
66}
67
68/// Validate `file` against `profile`.
69pub fn validate(file: &PdfFile, profile: Profile) -> ValidationReport {
70    let mut v: Vec<Violation> = Vec::new();
71
72    check_structure(file, profile, &mut v);
73    check_xmp(file, &mut v);
74    let claimed = xmp_claim(file);
75    check_output_intent(file, &mut v);
76    check_fonts(file, &mut v);
77    check_forbidden_features(file, profile, &mut v);
78
79    ValidationReport {
80        profile,
81        violations: v,
82        claimed,
83    }
84}
85
86// ---------------------------------------------------------------------------
87// File structure
88// ---------------------------------------------------------------------------
89
90fn check_structure(file: &PdfFile, profile: Profile, out: &mut Vec<Violation>) {
91    // Encryption is forbidden in every PDF/A part.
92    if file.is_encrypted() {
93        out.push(Violation {
94            rule: "encryption",
95            message: "document is encrypted (/Encrypt present); PDF/A forbids encryption".into(),
96        });
97    }
98
99    // Trailer /ID is required.
100    match file.trailer.get("ID") {
101        Some(PdfObject::Array(a)) if a.len() == 2 => {}
102        _ => out.push(Violation {
103            rule: "file-id",
104            message: "trailer /ID missing or not a two-element array".into(),
105        }),
106    }
107
108    // Header version ceiling: 1.4 for A-1, 1.7 for A-2. The parser records
109    // the header; a higher version is only a violation for A-1 (A-2 is
110    // based on 1.7 which is the cap of what zpdf writes anyway).
111    if profile == Profile::A1b {
112        let data = file.data();
113        if let Some(line) = data.get(..16) {
114            let header = String::from_utf8_lossy(line);
115            if let Some(ver) = header.strip_prefix("%PDF-1.") {
116                if let Some(minor) = ver.chars().next().and_then(|c| c.to_digit(10)) {
117                    if minor > 4 {
118                        out.push(Violation {
119                            rule: "header-version",
120                            message: format!(
121                                "header declares PDF 1.{minor}; PDF/A-1 is based on PDF 1.4"
122                            ),
123                        });
124                    }
125                }
126            }
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// XMP metadata
133// ---------------------------------------------------------------------------
134
135fn check_xmp(file: &PdfFile, out: &mut Vec<Violation>) {
136    let Some(xml) = crate::xmp::metadata_bytes(file) else {
137        out.push(Violation {
138            rule: "xmp-missing",
139            message: "catalog has no /Metadata XMP stream; PDF/A requires XMP metadata".into(),
140        });
141        return;
142    };
143    let text = String::from_utf8_lossy(&xml);
144    if !text.contains("pdfaid:part") && !text.contains("http://www.aiim.org/pdfa/ns/id/") {
145        out.push(Violation {
146            rule: "xmp-pdfaid",
147            message: "XMP metadata carries no PDF/A identification (pdfaid:part)".into(),
148        });
149    }
150}
151
152/// The (part, conformance) the XMP claims, when parseable.
153fn xmp_claim(file: &PdfFile) -> Option<(String, String)> {
154    let xml = crate::xmp::metadata_bytes(file)?;
155    let text = String::from_utf8_lossy(&xml);
156    let part = extract_xmp_value(&text, "pdfaid:part")?;
157    let conf = extract_xmp_value(&text, "pdfaid:conformance").unwrap_or_default();
158    Some((part, conf))
159}
160
161/// Pull `name`'s value out of XMP in either element (`<name>v</name>`) or
162/// attribute (`name="v"`) form.
163fn extract_xmp_value(text: &str, name: &str) -> Option<String> {
164    if let Some(start) = text.find(&format!("<{name}>")) {
165        let vstart = start + name.len() + 2;
166        let vend = text[vstart..].find('<')? + vstart;
167        return Some(text[vstart..vend].trim().to_string());
168    }
169    let attr = format!("{name}=\"");
170    if let Some(start) = text.find(&attr) {
171        let vstart = start + attr.len();
172        let vend = text[vstart..].find('"')? + vstart;
173        return Some(text[vstart..vend].trim().to_string());
174    }
175    None
176}
177
178// ---------------------------------------------------------------------------
179// Output intent
180// ---------------------------------------------------------------------------
181
182fn check_output_intent(file: &PdfFile, out: &mut Vec<Violation>) {
183    let intents = crate::output_intents::parse_output_intents(file);
184    let pdfa_intent = intents.iter().find(|i| i.subtype == "GTS_PDFA1");
185    match pdfa_intent {
186        None => out.push(Violation {
187            rule: "output-intent",
188            message: "no GTS_PDFA1 output intent; PDF/A requires one for device-dependent color"
189                .into(),
190        }),
191        Some(intent) => {
192            if intent.dest_output_profile.is_none() {
193                out.push(Violation {
194                    rule: "output-intent-profile",
195                    message: "PDF/A output intent has no embedded /DestOutputProfile ICC stream"
196                        .into(),
197                });
198            }
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Fonts
205// ---------------------------------------------------------------------------
206
207fn check_fonts(file: &PdfFile, out: &mut Vec<Violation>) {
208    // Walk every page's resource /Font entries and require an embedded font
209    // file in the descriptor (FontFile / FontFile2 / FontFile3). Type0 fonts
210    // recurse into their descendant. Type3 fonts have no descriptor (their
211    // glyphs are content streams) and are exempt.
212    let mut reported: HashSet<String> = HashSet::new();
213    for dict in collect_font_dicts(file) {
214        let subtype = dict.get_name("Subtype").unwrap_or("");
215        if subtype == "Type3" {
216            continue;
217        }
218        let base = dict.get_name("BaseFont").unwrap_or("?").to_string();
219
220        // Type0: check the descendant CIDFont's descriptor.
221        let target = if subtype == "Type0" {
222            match dict.get("DescendantFonts").map(|o| deref(file, o)) {
223                Some(PdfObject::Array(a)) if !a.is_empty() => match deref(file, &a[0]) {
224                    PdfObject::Dict(d) => Some(d),
225                    _ => None,
226                },
227                _ => None,
228            }
229        } else {
230            Some(dict.clone())
231        };
232
233        let embedded = target
234            .as_ref()
235            .and_then(|d| d.get("FontDescriptor").map(|o| deref(file, o)))
236            .and_then(|fd| match fd {
237                PdfObject::Dict(d) => Some(d),
238                _ => None,
239            })
240            .is_some_and(|fd| {
241                fd.get("FontFile").is_some()
242                    || fd.get("FontFile2").is_some()
243                    || fd.get("FontFile3").is_some()
244            });
245        if !embedded && reported.insert(base.clone()) {
246            out.push(Violation {
247                rule: "font-not-embedded",
248                message: format!("font '{base}' is not embedded; PDF/A requires embedding"),
249            });
250        }
251    }
252}
253
254/// Every font dictionary referenced from any page's /Resources /Font.
255fn collect_font_dicts(file: &PdfFile) -> Vec<zpdf_core::PdfDict> {
256    let mut out = Vec::new();
257    let mut seen: HashSet<ObjectId> = HashSet::new();
258    let Ok(root) = file.trailer.get_ref("Root") else {
259        return out;
260    };
261    let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
262        return out;
263    };
264    let Ok(pages_root) = catalog.get_ref("Pages") else {
265        return out;
266    };
267    // Bounded page-tree walk collecting /Resources /Font values.
268    let mut stack = vec![(pages_root, 0usize)];
269    let mut visited: HashSet<ObjectId> = HashSet::new();
270    while let Some((node, depth)) = stack.pop() {
271        if depth > 64 || !visited.insert(node) {
272            continue;
273        }
274        let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
275            continue;
276        };
277        if dict.get("Resources").is_some() {
278            let res = match dict.get("Resources") {
279                Some(o) => deref(file, o),
280                None => PdfObject::Null,
281            };
282            if let PdfObject::Dict(res) = res {
283                if let Some(PdfObject::Dict(fonts)) = res.get("Font").map(|o| deref(file, o)) {
284                    for v in fonts.0.values() {
285                        if let PdfObject::Ref(r) = v {
286                            if !seen.insert(*r) {
287                                continue;
288                            }
289                        }
290                        if let PdfObject::Dict(f) = deref(file, v) {
291                            out.push(f);
292                        }
293                    }
294                }
295            }
296        }
297        if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)) {
298            for kid in kids {
299                if let PdfObject::Ref(r) = kid {
300                    stack.push((r, depth + 1));
301                }
302            }
303        }
304    }
305    out
306}
307
308// ---------------------------------------------------------------------------
309// Forbidden features
310// ---------------------------------------------------------------------------
311
312fn check_forbidden_features(file: &PdfFile, profile: Profile, out: &mut Vec<Violation>) {
313    let Ok(root) = file.trailer.get_ref("Root") else {
314        return;
315    };
316    let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
317        return;
318    };
319
320    // JavaScript / launch actions (all parts).
321    if let Some(PdfObject::Dict(names)) = catalog.get("Names").map(|o| deref(file, o)).as_ref() {
322        if names.get("JavaScript").is_some() {
323            out.push(Violation {
324                rule: "javascript",
325                message: "document-level JavaScript name tree present; forbidden in PDF/A".into(),
326            });
327        }
328    }
329    if catalog.get("OpenAction").is_some() {
330        // /OpenAction with a destination array is fine; an action dict with
331        // /S /JavaScript or /Launch is not. Flag only the risky forms.
332        if let Some(PdfObject::Dict(action)) =
333            catalog.get("OpenAction").map(|o| deref(file, o)).as_ref()
334        {
335            let s = action.get_name("S").unwrap_or("");
336            if s == "JavaScript" || s == "Launch" {
337                out.push(Violation {
338                    rule: "open-action",
339                    message: format!("/OpenAction /S /{s} is forbidden in PDF/A"),
340                });
341            }
342        }
343    }
344
345    // Embedded files: forbidden in A-1; allowed (with conditions) in A-2 — we
346    // flag A-1 only (A-2's "must itself be PDF/A" condition is out of scope).
347    if profile == Profile::A1b {
348        if let Some(PdfObject::Dict(names)) = catalog.get("Names").map(|o| deref(file, o)).as_ref()
349        {
350            if names.get("EmbeddedFiles").is_some() {
351                out.push(Violation {
352                    rule: "embedded-files",
353                    message: "embedded files are forbidden in PDF/A-1".into(),
354                });
355            }
356        }
357    }
358
359    // A-1: transparency is forbidden — detect page-level transparency groups.
360    if profile == Profile::A1b {
361        let mut stack = vec![(catalog.get_ref("Pages").ok(), 0usize)];
362        let mut visited: HashSet<ObjectId> = HashSet::new();
363        while let Some((Some(node), depth)) = stack.pop() {
364            if depth > 64 || !visited.insert(node) {
365                continue;
366            }
367            let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
368                continue;
369            };
370            if let Some(PdfObject::Dict(group)) = dict.get("Group").map(|o| deref(file, o)).as_ref()
371            {
372                if group.get_name("S").ok() == Some("Transparency") {
373                    out.push(Violation {
374                        rule: "transparency",
375                        message: "transparency group on a page; forbidden in PDF/A-1".into(),
376                    });
377                    break;
378                }
379            }
380            if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref()
381            {
382                for kid in kids {
383                    if let PdfObject::Ref(r) = kid {
384                        stack.push((Some(*r), depth + 1));
385                    }
386                }
387            }
388        }
389    }
390}
391
392fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
393    match obj {
394        PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
395        other => other.clone(),
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn minimal_pdf() -> Vec<u8> {
404        let mut data = Vec::new();
405        data.extend_from_slice(b"%PDF-1.4\n");
406        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
407        data.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
408        data.extend_from_slice(
409            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
410        );
411        data.extend_from_slice(b"xref\n0 4\n");
412        data.extend_from_slice(b"0000000000 65535 f \n");
413        data.extend_from_slice(b"0000000009 00000 n \n");
414        data.extend_from_slice(b"0000000058 00000 n \n");
415        data.extend_from_slice(b"0000000117 00000 n \n");
416        data.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
417        data.extend_from_slice(b"startxref\n187\n%%EOF\n");
418        data
419    }
420
421    #[test]
422    fn bare_pdf_fails_with_specific_violations() {
423        let file = PdfFile::parse(minimal_pdf()).unwrap();
424        let report = validate(&file, Profile::A1b);
425        assert!(!report.conforms());
426        let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
427        assert!(rules.contains(&"file-id"), "missing /ID flagged: {rules:?}");
428        assert!(
429            rules.contains(&"xmp-missing"),
430            "missing XMP flagged: {rules:?}"
431        );
432        assert!(
433            rules.contains(&"output-intent"),
434            "missing output intent flagged: {rules:?}"
435        );
436    }
437
438    #[test]
439    fn claim_extraction_from_attribute_and_element_forms() {
440        assert_eq!(
441            extract_xmp_value(r#"<x pdfaid:part="2"/>"#, "pdfaid:part").as_deref(),
442            Some("2")
443        );
444        assert_eq!(
445            extract_xmp_value("<pdfaid:part>1</pdfaid:part>", "pdfaid:part").as_deref(),
446            Some("1")
447        );
448        assert_eq!(extract_xmp_value("<nothing/>", "pdfaid:part"), None);
449    }
450}