1use std::collections::HashSet;
21
22use zpdf_core::{ObjectId, PdfObject};
23use zpdf_parser::PdfFile;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Profile {
28 A1b,
30 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#[derive(Debug, Clone)]
45pub struct Violation {
46 pub rule: &'static str,
48 pub message: String,
50}
51
52#[derive(Debug)]
54pub struct ValidationReport {
55 pub profile: Profile,
56 pub violations: Vec<Violation>,
57 pub claimed: Option<(String, String)>,
60}
61
62impl ValidationReport {
63 pub fn conforms(&self) -> bool {
64 self.violations.is_empty()
65 }
66}
67
68pub 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
86fn check_structure(file: &PdfFile, profile: Profile, out: &mut Vec<Violation>) {
91 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 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 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
131fn 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
152fn 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
161fn 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
178fn 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
203fn check_fonts(file: &PdfFile, out: &mut Vec<Violation>) {
208 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 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
254fn 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 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
308fn 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 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 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 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 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}