1use std::collections::HashSet;
22
23use zpdf_core::{ObjectId, PdfObject};
24use zpdf_parser::PdfFile;
25
26use crate::catalog::Catalog;
27use crate::structure::{
28 is_tagged, parse_struct_tree, StructElem, StructKid, StructRole, StructTree,
29};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Profile {
34 Ua1,
36}
37
38impl Profile {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Profile::Ua1 => "PDF/UA-1",
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct Violation {
49 pub rule: &'static str,
51 pub message: String,
53}
54
55#[derive(Debug)]
57pub struct ValidationReport {
58 pub profile: Profile,
59 pub violations: Vec<Violation>,
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 check_tagged(file, &mut v);
72 let tree = check_struct_tree(file, &mut v);
73 check_lang(file, &mut v);
74 if let Some(tree) = &tree {
75 check_figures_have_alt(tree, &mut v);
76 check_has_heading(tree, &mut v);
77 check_roles_standard(tree, &mut v);
78 check_table_structure(tree, &mut v);
79 }
80 check_page_struct_parents(file, &mut v);
81 check_annotation_objr(file, tree.as_ref(), &mut v);
82 ValidationReport {
83 profile,
84 violations: v,
85 }
86}
87
88fn check_tagged(file: &PdfFile, out: &mut Vec<Violation>) {
89 if !is_tagged(file) {
90 out.push(Violation {
91 rule: "tagged",
92 message: "document is not tagged (/MarkInfo /Marked true absent); PDF/UA requires a tagged PDF".into(),
93 });
94 }
95}
96
97fn check_struct_tree(file: &PdfFile, out: &mut Vec<Violation>) -> Option<StructTree> {
98 let Ok(catalog) = Catalog::from_trailer(file) else {
99 out.push(Violation {
100 rule: "struct-tree",
101 message: "catalog cannot be built; no structure tree".into(),
102 });
103 return None;
104 };
105 match parse_struct_tree(file, &catalog) {
106 Some(tree) => {
107 if tree.element_count() == 0 {
108 out.push(Violation {
109 rule: "struct-tree",
110 message: "/StructTreeRoot is present but has no structure elements".into(),
111 });
112 return None;
113 }
114 Some(tree)
115 }
116 None => {
117 out.push(Violation {
118 rule: "struct-tree",
119 message: "no /StructTreeRoot; PDF/UA requires a structure tree".into(),
120 });
121 None
122 }
123 }
124}
125
126fn check_lang(file: &PdfFile, out: &mut Vec<Violation>) {
127 let Some(root) = crate::obj_util::catalog_dict(file) else {
128 out.push(Violation {
129 rule: "lang",
130 message: "catalog cannot be read; /Lang cannot be verified".into(),
131 });
132 return;
133 };
134 if root.get("Lang").is_none() {
135 out.push(Violation {
136 rule: "lang",
137 message: "catalog has no /Lang; PDF/UA requires a natural-language declaration".into(),
138 });
139 }
140}
141
142fn check_figures_have_alt(tree: &StructTree, out: &mut Vec<Violation>) {
144 let mut missing = 0usize;
145 visit(tree.children.iter(), &mut |elem| {
146 if elem.role == StructRole::Figure && elem.accessible_text().is_none() {
147 missing += 1;
148 }
149 });
150 if missing > 0 {
151 out.push(Violation {
152 rule: "figure-alt",
153 message: format!("{missing} Figure element(s) lack /Alt and /ActualText; PDF/UA requires alternative text for figures"),
154 });
155 }
156}
157
158fn check_has_heading(tree: &StructTree, out: &mut Vec<Violation>) {
160 let mut has_heading = false;
161 visit(tree.children.iter(), &mut |elem| {
162 if elem.role.is_heading() {
163 has_heading = true;
164 }
165 });
166 if !has_heading {
167 out.push(Violation {
168 rule: "headings",
169 message: "structure tree has no heading (H / H1–H6); PDF/UA requires heading-based document structure".into(),
170 });
171 }
172}
173
174fn check_roles_standard(tree: &StructTree, out: &mut Vec<Violation>) {
176 let mut bad: Vec<String> = Vec::new();
177 visit(tree.children.iter(), &mut |elem| {
178 if let StructRole::Other(name) = &elem.role {
179 bad.push(name.clone());
180 }
181 });
182 if !bad.is_empty() {
183 out.push(Violation {
184 rule: "role-unmapped",
185 message: format!(
186 "structure role(s) not mapped to a standard type via /RoleMap: {}",
187 bad.join(", ")
188 ),
189 });
190 }
191}
192
193fn check_table_structure(tree: &StructTree, out: &mut Vec<Violation>) {
200 let mut table_bad = 0usize;
201 let mut row_bad = 0usize;
202 visit(tree.children.iter(), &mut |elem| {
203 if elem.role == StructRole::Table {
204 for kid in elem.child_elements() {
205 if kid.role != StructRole::Tr {
206 table_bad += 1;
207 break;
208 }
209 }
210 }
211 if elem.role == StructRole::Tr {
212 for kid in elem.child_elements() {
213 if !matches!(kid.role, StructRole::Th | StructRole::Td) {
214 row_bad += 1;
215 break;
216 }
217 }
218 }
219 });
220 if table_bad > 0 {
221 out.push(Violation {
222 rule: "table-structure",
223 message: format!(
224 "{table_bad} Table element(s) have non-TR element children; PDF/UA requires table rows"
225 ),
226 });
227 }
228 if row_bad > 0 {
229 out.push(Violation {
230 rule: "table-structure",
231 message: format!(
232 "{row_bad} TR element(s) have non-TH/TD element children; PDF/UA requires table cells"
233 ),
234 });
235 }
236}
237
238fn check_annotation_objr(file: &PdfFile, tree: Option<&StructTree>, out: &mut Vec<Violation>) {
245 let Some(tree) = tree else {
246 return;
247 };
248 let Ok(root) = file.trailer.get_ref("Root") else {
249 return;
250 };
251 let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
252 return;
253 };
254 let Ok(pages_root) = catalog.get_ref("Pages") else {
255 return;
256 };
257 let mut content_annots = 0usize;
259 let mut stack = vec![(pages_root, 0usize)];
260 let mut visited: HashSet<ObjectId> = HashSet::new();
261 while let Some((node, depth)) = stack.pop() {
262 if depth > 64 || !visited.insert(node) {
263 continue;
264 }
265 let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
266 continue;
267 };
268 if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
269 for kid in kids {
270 if let PdfObject::Ref(r) = kid {
271 stack.push((*r, depth + 1));
272 }
273 }
274 }
275 let annots_obj = dict.get("Annots").map(|o| deref(file, o));
276 let Some(PdfObject::Array(annots)) = annots_obj.as_ref() else {
277 continue;
278 };
279 for a in annots {
280 let Ok(ad) = deref(file, a).as_dict().cloned() else {
281 continue;
282 };
283 let subtype = ad.get_name("Subtype").unwrap_or("");
284 if matches!(
287 subtype,
288 "Widget"
289 | "Link"
290 | "FreeText"
291 | "Text"
292 | "Highlight"
293 | "Underline"
294 | "StrikeOut"
295 | "Squiggly"
296 ) {
297 content_annots += 1;
298 }
299 }
300 }
301 if content_annots == 0 {
302 return;
303 }
304 let mut objr_count = 0usize;
306 visit_kids(tree.children.iter(), &mut |elem| {
307 for kid in &elem.kids {
308 if matches!(kid, StructKid::Object { .. }) {
309 objr_count += 1;
310 }
311 }
312 });
313 if objr_count == 0 {
314 out.push(Violation {
315 rule: "annotation-objr",
316 message: format!(
317 "document has {content_annots} content-bearing annotation(s) but the structure tree has no /OBJR references; PDF/UA requires annotations to be structure-reachable"
318 ),
319 });
320 }
321}
322
323fn check_page_struct_parents(file: &PdfFile, out: &mut Vec<Violation>) {
325 let Ok(root) = file.trailer.get_ref("Root") else {
326 return;
327 };
328 let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
329 return;
330 };
331 let Ok(pages_root) = catalog.get_ref("Pages") else {
332 return;
333 };
334 let mut stack = vec![(pages_root, 0usize)];
335 let mut visited: HashSet<ObjectId> = HashSet::new();
336 let mut missing = 0usize;
337 while let Some((node, depth)) = stack.pop() {
338 if depth > 64 || !visited.insert(node) {
339 continue;
340 }
341 let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
342 continue;
343 };
344 let is_leaf = dict.get("Kids").is_none();
346 if is_leaf && dict.get("StructParents").is_none() {
347 missing += 1;
348 }
349 if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
350 for kid in kids {
351 if let PdfObject::Ref(r) = kid {
352 stack.push((*r, depth + 1));
353 }
354 }
355 }
356 }
357 if missing > 0 {
358 out.push(Violation {
359 rule: "page-struct-parents",
360 message: format!("{missing} page(s) lack /StructParents; PDF/UA requires every page to participate in the structure tree"),
361 });
362 }
363}
364
365fn visit<'a>(elems: impl Iterator<Item = &'a StructElem>, f: &mut dyn FnMut(&StructElem)) {
369 fn walk<'a>(elem: &'a StructElem, f: &mut dyn FnMut(&StructElem)) {
370 f(elem);
371 for child in elem.child_elements() {
372 walk(child, f);
373 }
374 }
375 for elem in elems {
376 walk(elem, f);
377 }
378}
379
380fn visit_kids<'a>(elems: impl Iterator<Item = &'a StructElem>, f: &mut dyn FnMut(&StructElem)) {
383 fn walk<'a>(elem: &'a StructElem, f: &mut dyn FnMut(&StructElem)) {
384 f(elem);
385 for child in elem.child_elements() {
386 walk(child, f);
387 }
388 }
389 for elem in elems {
390 walk(elem, f);
391 }
392}
393
394fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
395 match obj {
396 PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
397 other => other.clone(),
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::test_util::build_pdf;
405
406 const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
407 const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /StructParents 0 >>";
408
409 fn open(objects: &[&str]) -> PdfFile {
410 PdfFile::parse(build_pdf(objects)).expect("parse pdf")
411 }
412
413 fn ua_pdf(catalog: &str, extra: &[&str]) -> PdfFile {
414 let mut objs = vec![catalog, PAGES, PAGE];
415 objs.extend_from_slice(extra);
416 open(&objs)
417 }
418
419 #[test]
420 fn untagged_pdf_fails() {
421 let file = ua_pdf("<< /Type /Catalog /Pages 2 0 R >>", &[]);
422 let r = validate(&file, Profile::Ua1);
423 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
424 assert!(rules.contains(&"tagged"));
425 assert!(rules.contains(&"struct-tree"));
426 assert!(rules.contains(&"lang"));
427 assert!(!r.conforms());
428 }
429
430 #[test]
431 fn compliant_tagged_pdf_passes() {
432 let file = ua_pdf(
435 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
436 /StructTreeRoot 4 0 R >>",
437 &[
438 "<< /Type /StructTreeRoot /K 5 0 R /ParentTree 9 0 R /ParentTreeNextKey 1 >>",
440 "<< /Type /StructElem /S /Document /P 4 0 R /K [6 0 R 7 0 R] >>",
442 "<< /Type /StructElem /S /H1 /P 5 0 R /Pg 3 0 R /K 0 >>",
444 "<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K 1 >>",
446 "<< 6 0 R 7 0 R >>",
448 "<< /Nums [0 8 0 R] >>",
450 ],
451 );
452 let r = validate(&file, Profile::Ua1);
453 assert!(
454 r.conforms(),
455 "expected conformance, got: {:?}",
456 r.violations
457 );
458 }
459
460 #[test]
461 fn figure_without_alt_is_flagged() {
462 let file = ua_pdf(
463 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
464 /StructTreeRoot 4 0 R >>",
465 &[
466 "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 1 >>",
468 "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
470 "<< /Type /StructElem /S /Figure /P 4 0 R /Pg 3 0 R /K 1 >>",
472 "<< /Nums [0 8 0 R] >>",
474 "<< 5 0 R 6 0 R >>",
476 ],
477 );
478 let r = validate(&file, Profile::Ua1);
479 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
480 assert!(
481 rules.contains(&"figure-alt"),
482 "figure-alt should fire: {rules:?}"
483 );
484 }
485
486 #[test]
487 fn no_heading_is_flagged() {
488 let file = ua_pdf(
489 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
490 /StructTreeRoot 4 0 R >>",
491 &[
492 "<< /Type /StructTreeRoot /K 5 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>",
493 "<< /Type /StructElem /S /P /P 4 0 R /Pg 3 0 R /K 0 >>",
494 "<< /Nums [0 7 0 R] >>",
495 "<< 5 0 R >>",
496 ],
497 );
498 let r = validate(&file, Profile::Ua1);
499 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
500 assert!(
501 rules.contains(&"headings"),
502 "headings should fire: {rules:?}"
503 );
504 }
505
506 #[test]
507 fn table_with_non_tr_children_is_flagged() {
508 let file = ua_pdf(
510 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
511 /StructTreeRoot 4 0 R >>",
512 &[
513 "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 2 >>",
515 "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
517 "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
519 "<< /Nums [0 9 0 R] >>",
521 "<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 1 >>",
523 "<< 5 0 R 8 0 R >>",
525 ],
526 );
527 let r = validate(&file, Profile::Ua1);
528 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
529 assert!(
530 rules.contains(&"table-structure"),
531 "table-structure should fire for non-TR table child: {rules:?}"
532 );
533 }
534
535 #[test]
536 fn tr_with_non_cell_children_is_flagged() {
537 let file = ua_pdf(
539 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
540 /StructTreeRoot 4 0 R >>",
541 &[
542 "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 2 >>",
543 "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
545 "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
547 "<< /Nums [0 9 0 R] >>",
549 "<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R] >>",
551 "<< 5 0 R >>",
553 "<< /Type /StructElem /S /P /P 8 0 R /Pg 3 0 R /K 1 >>",
555 ],
556 );
557 let r = validate(&file, Profile::Ua1);
558 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
559 assert!(
560 rules.contains(&"table-structure"),
561 "table-structure should fire for non-cell TR child: {rules:?}"
562 );
563 }
564
565 #[test]
566 fn well_formed_table_passes() {
567 let file = ua_pdf(
569 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
570 /StructTreeRoot 4 0 R >>",
571 &[
572 "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 3 >>",
573 "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
575 "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
577 "<< /Nums [0 9 0 R] >>",
579 "<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R 11 0 R] >>",
581 "<< 5 0 R >>",
583 "<< /Type /StructElem /S /TH /P 8 0 R /Pg 3 0 R /K 1 >>",
585 "<< /Type /StructElem /S /TD /P 8 0 R /Pg 3 0 R /K 2 >>",
587 ],
588 );
589 let r = validate(&file, Profile::Ua1);
590 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
591 assert!(
592 !rules.contains(&"table-structure"),
593 "well-formed table should not flag: {rules:?}"
594 );
595 }
596
597 #[test]
598 fn content_annotation_without_objr_is_flagged() {
599 let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /StructParents 0 \
601 /Annots [10 0 R] >>";
602 let objs = vec![
603 "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> /StructTreeRoot 4 0 R >>".to_string(),
604 PAGES.to_string(),
605 page.to_string(),
606 "<< /Type /StructTreeRoot /K 5 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>".to_string(),
608 "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>".to_string(),
610 "<< /Nums [0 7 0 R] >>".to_string(),
612 "<< 5 0 R >>".to_string(),
614 "null".to_string(),
616 "null".to_string(),
617 "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>".to_string(),
619 ];
620 let file = PdfFile::parse(build_pdf(
621 &objs.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
622 ))
623 .expect("parse");
624 let r = validate(&file, Profile::Ua1);
625 let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
626 assert!(
627 rules.contains(&"annotation-objr"),
628 "annotation-objr should fire for a Link with no OBJR: {rules:?}"
629 );
630 }
631}