1use std::collections::HashSet;
21
22use zpdf_core::{ObjectId, PdfDict, 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 check_forbidden_annotations(file, profile, &catalog, out);
397}
398
399const FORBIDDEN_ANNOT_SUBTYPES_BOTH: &[&str] = &["3D", "Sound", "Movie"];
402
403const FORBIDDEN_ANNOT_SUBTYPES_A1B: &[&str] = &["FileAttachment"];
406
407fn check_forbidden_annotations(
411 file: &PdfFile,
412 profile: Profile,
413 catalog: &PdfDict,
414 out: &mut Vec<Violation>,
415) {
416 let Some(pages) = catalog.get_ref("Pages").ok() else {
417 return;
418 };
419 let a1b = profile == Profile::A1b;
420 let mut stack = vec![(pages, 0usize)];
421 let mut visited: HashSet<ObjectId> = HashSet::new();
422 while let Some((node, depth)) = stack.pop() {
423 if depth > 64 || !visited.insert(node) {
424 continue;
425 }
426 let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
427 continue;
428 };
429 if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
430 for kid in kids {
431 if let PdfObject::Ref(r) = kid {
432 stack.push((*r, depth + 1));
433 }
434 }
435 }
436 let annots_obj = dict.get("Annots").map(|o| deref(file, o));
437 let Some(PdfObject::Array(annots)) = annots_obj.as_ref() else {
438 continue;
439 };
440 for a in annots {
441 let PdfObject::Dict(ad) = deref(file, a) else {
442 continue;
443 };
444 let subtype = ad.get_name("Subtype").unwrap_or("");
445 let forbidden = FORBIDDEN_ANNOT_SUBTYPES_BOTH.contains(&subtype)
446 || (a1b && FORBIDDEN_ANNOT_SUBTYPES_A1B.contains(&subtype));
447 if forbidden {
448 out.push(Violation {
449 rule: "annotation-subtype",
450 message: format!(
451 "/Annot /Subtype /{subtype} is forbidden in PDF/A{}",
452 if a1b && subtype == "FileAttachment" {
453 "-1 (carries an embedded file)"
454 } else {
455 ""
456 }
457 ),
458 });
459 continue;
460 }
461 if let Some(PdfObject::Dict(action)) = ad.get("A").map(|o| deref(file, o)).as_ref() {
462 let s = action.get_name("S").unwrap_or("");
463 if s == "JavaScript" || s == "Launch" {
464 out.push(Violation {
465 rule: "annotation-action",
466 message: format!("annotation /A /S /{s} action is forbidden in PDF/A"),
467 });
468 }
469 }
470 }
471 }
472}
473
474fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
475 match obj {
476 PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
477 other => other.clone(),
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn minimal_pdf() -> Vec<u8> {
486 let mut data = Vec::new();
487 data.extend_from_slice(b"%PDF-1.4\n");
488 data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
489 data.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
490 data.extend_from_slice(
491 b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
492 );
493 data.extend_from_slice(b"xref\n0 4\n");
494 data.extend_from_slice(b"0000000000 65535 f \n");
495 data.extend_from_slice(b"0000000009 00000 n \n");
496 data.extend_from_slice(b"0000000058 00000 n \n");
497 data.extend_from_slice(b"0000000117 00000 n \n");
498 data.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
499 data.extend_from_slice(b"startxref\n187\n%%EOF\n");
500 data
501 }
502
503 #[test]
504 fn bare_pdf_fails_with_specific_violations() {
505 let file = PdfFile::parse(minimal_pdf()).unwrap();
506 let report = validate(&file, Profile::A1b);
507 assert!(!report.conforms());
508 let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
509 assert!(rules.contains(&"file-id"), "missing /ID flagged: {rules:?}");
510 assert!(
511 rules.contains(&"xmp-missing"),
512 "missing XMP flagged: {rules:?}"
513 );
514 assert!(
515 rules.contains(&"output-intent"),
516 "missing output intent flagged: {rules:?}"
517 );
518 }
519
520 #[test]
521 fn claim_extraction_from_attribute_and_element_forms() {
522 assert_eq!(
523 extract_xmp_value(r#"<x pdfaid:part="2"/>"#, "pdfaid:part").as_deref(),
524 Some("2")
525 );
526 assert_eq!(
527 extract_xmp_value("<pdfaid:part>1</pdfaid:part>", "pdfaid:part").as_deref(),
528 Some("1")
529 );
530 assert_eq!(extract_xmp_value("<nothing/>", "pdfaid:part"), None);
531 }
532
533 fn pdf_with_annots(annots: &[&str]) -> Vec<u8> {
536 let n_objs = 3 + annots.len();
537 let mut data = Vec::new();
538 data.extend_from_slice(b"%PDF-1.4\n");
539 let mut offsets = Vec::new();
540 let annot_refs: Vec<String> = (0..annots.len())
542 .map(|i| format!("{} 0 R", 4 + i))
543 .collect();
544 let bodies = [
545 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
546 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
547 format!(
548 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [{}] >>",
549 annot_refs.join(" ")
550 ),
551 ];
552 for (i, body) in bodies.iter().enumerate() {
553 offsets.push(data.len());
554 data.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, body).as_bytes());
555 }
556 for (i, body) in annots.iter().enumerate() {
557 offsets.push(data.len());
558 data.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", 4 + i, body).as_bytes());
559 }
560 let xref = data.len();
561 data.extend_from_slice(format!("xref\n0 {}\n", n_objs + 1).as_bytes());
562 data.extend_from_slice(b"0000000000 65535 f \n");
563 for off in &offsets {
564 data.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
565 }
566 data.extend_from_slice(
567 format!(
568 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
569 n_objs + 1
570 )
571 .as_bytes(),
572 );
573 data
574 }
575
576 #[test]
577 fn forbidden_annotation_subtypes_are_flagged() {
578 let pdf = pdf_with_annots(&[
579 "<< /Type /Annot /Subtype /Sound /Rect [0 0 10 10] >>",
580 "<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] >>",
581 ]);
582 let file = PdfFile::parse(pdf).unwrap();
583 let report = validate(&file, Profile::A1b);
584 let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
585 assert!(
586 rules.contains(&"annotation-subtype"),
587 "Sound annotation must be flagged: {rules:?}"
588 );
589 }
590
591 #[test]
592 fn fileattachment_flagged_under_a1b_not_a2b() {
593 let pdf =
594 pdf_with_annots(&["<< /Type /Annot /Subtype /FileAttachment /Rect [0 0 10 10] >>"]);
595 let file = PdfFile::parse(pdf).unwrap();
596 let a1b = validate(&file, Profile::A1b);
597 let a2b = validate(&file, Profile::A2b);
598 let a1b_rules: Vec<&str> = a1b.violations.iter().map(|v| v.rule).collect();
599 let a2b_rules: Vec<&str> = a2b.violations.iter().map(|v| v.rule).collect();
600 assert!(
601 a1b_rules.contains(&"annotation-subtype"),
602 "A-1b must flag FileAttachment: {a1b_rules:?}"
603 );
604 assert!(
605 !a2b_rules.contains(&"annotation-subtype"),
606 "A-2b must not flag FileAttachment: {a2b_rules:?}"
607 );
608 }
609
610 #[test]
611 fn launch_action_annotation_is_flagged() {
612 let pdf = pdf_with_annots(&[
613 "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (x) >> >>",
614 ]);
615 let file = PdfFile::parse(pdf).unwrap();
616 let report = validate(&file, Profile::A1b);
617 let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
618 assert!(
619 rules.contains(&"annotation-action"),
620 "Launch-action annotation must be flagged: {rules:?}"
621 );
622 }
623}