use std::collections::HashSet;
use zpdf_core::{ObjectId, PdfObject};
use zpdf_parser::PdfFile;
use crate::catalog::Catalog;
use crate::structure::{
is_tagged, parse_struct_tree, StructElem, StructKid, StructRole, StructTree,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Profile {
Ua1,
}
impl Profile {
pub fn as_str(self) -> &'static str {
match self {
Profile::Ua1 => "PDF/UA-1",
}
}
}
#[derive(Debug, Clone)]
pub struct Violation {
pub rule: &'static str,
pub message: String,
}
#[derive(Debug)]
pub struct ValidationReport {
pub profile: Profile,
pub violations: Vec<Violation>,
}
impl ValidationReport {
pub fn conforms(&self) -> bool {
self.violations.is_empty()
}
}
pub fn validate(file: &PdfFile, profile: Profile) -> ValidationReport {
let mut v: Vec<Violation> = Vec::new();
check_tagged(file, &mut v);
let tree = check_struct_tree(file, &mut v);
check_lang(file, &mut v);
if let Some(tree) = &tree {
check_figures_have_alt(tree, &mut v);
check_has_heading(tree, &mut v);
check_roles_standard(tree, &mut v);
check_table_structure(tree, &mut v);
}
check_page_struct_parents(file, &mut v);
check_annotation_objr(file, tree.as_ref(), &mut v);
ValidationReport {
profile,
violations: v,
}
}
fn check_tagged(file: &PdfFile, out: &mut Vec<Violation>) {
if !is_tagged(file) {
out.push(Violation {
rule: "tagged",
message: "document is not tagged (/MarkInfo /Marked true absent); PDF/UA requires a tagged PDF".into(),
});
}
}
fn check_struct_tree(file: &PdfFile, out: &mut Vec<Violation>) -> Option<StructTree> {
let Ok(catalog) = Catalog::from_trailer(file) else {
out.push(Violation {
rule: "struct-tree",
message: "catalog cannot be built; no structure tree".into(),
});
return None;
};
match parse_struct_tree(file, &catalog) {
Some(tree) => {
if tree.element_count() == 0 {
out.push(Violation {
rule: "struct-tree",
message: "/StructTreeRoot is present but has no structure elements".into(),
});
return None;
}
Some(tree)
}
None => {
out.push(Violation {
rule: "struct-tree",
message: "no /StructTreeRoot; PDF/UA requires a structure tree".into(),
});
None
}
}
}
fn check_lang(file: &PdfFile, out: &mut Vec<Violation>) {
let Some(root) = crate::obj_util::catalog_dict(file) else {
out.push(Violation {
rule: "lang",
message: "catalog cannot be read; /Lang cannot be verified".into(),
});
return;
};
if root.get("Lang").is_none() {
out.push(Violation {
rule: "lang",
message: "catalog has no /Lang; PDF/UA requires a natural-language declaration".into(),
});
}
}
fn check_figures_have_alt(tree: &StructTree, out: &mut Vec<Violation>) {
let mut missing = 0usize;
visit(tree.children.iter(), &mut |elem| {
if elem.role == StructRole::Figure && elem.accessible_text().is_none() {
missing += 1;
}
});
if missing > 0 {
out.push(Violation {
rule: "figure-alt",
message: format!("{missing} Figure element(s) lack /Alt and /ActualText; PDF/UA requires alternative text for figures"),
});
}
}
fn check_has_heading(tree: &StructTree, out: &mut Vec<Violation>) {
let mut has_heading = false;
visit(tree.children.iter(), &mut |elem| {
if elem.role.is_heading() {
has_heading = true;
}
});
if !has_heading {
out.push(Violation {
rule: "headings",
message: "structure tree has no heading (H / H1–H6); PDF/UA requires heading-based document structure".into(),
});
}
}
fn check_roles_standard(tree: &StructTree, out: &mut Vec<Violation>) {
let mut bad: Vec<String> = Vec::new();
visit(tree.children.iter(), &mut |elem| {
if let StructRole::Other(name) = &elem.role {
bad.push(name.clone());
}
});
if !bad.is_empty() {
out.push(Violation {
rule: "role-unmapped",
message: format!(
"structure role(s) not mapped to a standard type via /RoleMap: {}",
bad.join(", ")
),
});
}
}
fn check_table_structure(tree: &StructTree, out: &mut Vec<Violation>) {
let mut table_bad = 0usize;
let mut row_bad = 0usize;
visit(tree.children.iter(), &mut |elem| {
if elem.role == StructRole::Table {
for kid in elem.child_elements() {
if kid.role != StructRole::Tr {
table_bad += 1;
break;
}
}
}
if elem.role == StructRole::Tr {
for kid in elem.child_elements() {
if !matches!(kid.role, StructRole::Th | StructRole::Td) {
row_bad += 1;
break;
}
}
}
});
if table_bad > 0 {
out.push(Violation {
rule: "table-structure",
message: format!(
"{table_bad} Table element(s) have non-TR element children; PDF/UA requires table rows"
),
});
}
if row_bad > 0 {
out.push(Violation {
rule: "table-structure",
message: format!(
"{row_bad} TR element(s) have non-TH/TD element children; PDF/UA requires table cells"
),
});
}
}
fn check_annotation_objr(file: &PdfFile, tree: Option<&StructTree>, out: &mut Vec<Violation>) {
let Some(tree) = tree else {
return;
};
let Ok(root) = file.trailer.get_ref("Root") else {
return;
};
let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
return;
};
let Ok(pages_root) = catalog.get_ref("Pages") else {
return;
};
let mut content_annots = 0usize;
let mut stack = vec![(pages_root, 0usize)];
let mut visited: HashSet<ObjectId> = HashSet::new();
while let Some((node, depth)) = stack.pop() {
if depth > 64 || !visited.insert(node) {
continue;
}
let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
continue;
};
if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
for kid in kids {
if let PdfObject::Ref(r) = kid {
stack.push((*r, depth + 1));
}
}
}
let annots_obj = dict.get("Annots").map(|o| deref(file, o));
let Some(PdfObject::Array(annots)) = annots_obj.as_ref() else {
continue;
};
for a in annots {
let Ok(ad) = deref(file, a).as_dict().cloned() else {
continue;
};
let subtype = ad.get_name("Subtype").unwrap_or("");
if matches!(
subtype,
"Widget"
| "Link"
| "FreeText"
| "Text"
| "Highlight"
| "Underline"
| "StrikeOut"
| "Squiggly"
) {
content_annots += 1;
}
}
}
if content_annots == 0 {
return;
}
let mut objr_count = 0usize;
visit_kids(tree.children.iter(), &mut |elem| {
for kid in &elem.kids {
if matches!(kid, StructKid::Object { .. }) {
objr_count += 1;
}
}
});
if objr_count == 0 {
out.push(Violation {
rule: "annotation-objr",
message: format!(
"document has {content_annots} content-bearing annotation(s) but the structure tree has no /OBJR references; PDF/UA requires annotations to be structure-reachable"
),
});
}
}
fn check_page_struct_parents(file: &PdfFile, out: &mut Vec<Violation>) {
let Ok(root) = file.trailer.get_ref("Root") else {
return;
};
let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
return;
};
let Ok(pages_root) = catalog.get_ref("Pages") else {
return;
};
let mut stack = vec![(pages_root, 0usize)];
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut missing = 0usize;
while let Some((node, depth)) = stack.pop() {
if depth > 64 || !visited.insert(node) {
continue;
}
let Ok(dict) = file.resolve(node).and_then(|o| o.as_dict().cloned()) else {
continue;
};
let is_leaf = dict.get("Kids").is_none();
if is_leaf && dict.get("StructParents").is_none() {
missing += 1;
}
if let Some(PdfObject::Array(kids)) = dict.get("Kids").map(|o| deref(file, o)).as_ref() {
for kid in kids {
if let PdfObject::Ref(r) = kid {
stack.push((*r, depth + 1));
}
}
}
}
if missing > 0 {
out.push(Violation {
rule: "page-struct-parents",
message: format!("{missing} page(s) lack /StructParents; PDF/UA requires every page to participate in the structure tree"),
});
}
}
fn visit<'a>(elems: impl Iterator<Item = &'a StructElem>, f: &mut dyn FnMut(&StructElem)) {
fn walk<'a>(elem: &'a StructElem, f: &mut dyn FnMut(&StructElem)) {
f(elem);
for child in elem.child_elements() {
walk(child, f);
}
}
for elem in elems {
walk(elem, f);
}
}
fn visit_kids<'a>(elems: impl Iterator<Item = &'a StructElem>, f: &mut dyn FnMut(&StructElem)) {
fn walk<'a>(elem: &'a StructElem, f: &mut dyn FnMut(&StructElem)) {
f(elem);
for child in elem.child_elements() {
walk(child, f);
}
}
for elem in elems {
walk(elem, f);
}
}
fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
match obj {
PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::build_pdf;
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /StructParents 0 >>";
fn open(objects: &[&str]) -> PdfFile {
PdfFile::parse(build_pdf(objects)).expect("parse pdf")
}
fn ua_pdf(catalog: &str, extra: &[&str]) -> PdfFile {
let mut objs = vec![catalog, PAGES, PAGE];
objs.extend_from_slice(extra);
open(&objs)
}
#[test]
fn untagged_pdf_fails() {
let file = ua_pdf("<< /Type /Catalog /Pages 2 0 R >>", &[]);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(rules.contains(&"tagged"));
assert!(rules.contains(&"struct-tree"));
assert!(rules.contains(&"lang"));
assert!(!r.conforms());
}
#[test]
fn compliant_tagged_pdf_passes() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K 5 0 R /ParentTree 9 0 R /ParentTreeNextKey 1 >>",
"<< /Type /StructElem /S /Document /P 4 0 R /K [6 0 R 7 0 R] >>",
"<< /Type /StructElem /S /H1 /P 5 0 R /Pg 3 0 R /K 0 >>",
"<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K 1 >>",
"<< 6 0 R 7 0 R >>",
"<< /Nums [0 8 0 R] >>",
],
);
let r = validate(&file, Profile::Ua1);
assert!(
r.conforms(),
"expected conformance, got: {:?}",
r.violations
);
}
#[test]
fn figure_without_alt_is_flagged() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 1 >>",
"<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
"<< /Type /StructElem /S /Figure /P 4 0 R /Pg 3 0 R /K 1 >>",
"<< /Nums [0 8 0 R] >>",
"<< 5 0 R 6 0 R >>",
],
);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
rules.contains(&"figure-alt"),
"figure-alt should fire: {rules:?}"
);
}
#[test]
fn no_heading_is_flagged() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K 5 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>",
"<< /Type /StructElem /S /P /P 4 0 R /Pg 3 0 R /K 0 >>",
"<< /Nums [0 7 0 R] >>",
"<< 5 0 R >>",
],
);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
rules.contains(&"headings"),
"headings should fire: {rules:?}"
);
}
#[test]
fn table_with_non_tr_children_is_flagged() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 2 >>",
"<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
"<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
"<< /Nums [0 9 0 R] >>",
"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 1 >>",
"<< 5 0 R 8 0 R >>",
],
);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
rules.contains(&"table-structure"),
"table-structure should fire for non-TR table child: {rules:?}"
);
}
#[test]
fn tr_with_non_cell_children_is_flagged() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 2 >>",
"<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
"<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
"<< /Nums [0 9 0 R] >>",
"<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R] >>",
"<< 5 0 R >>",
"<< /Type /StructElem /S /P /P 8 0 R /Pg 3 0 R /K 1 >>",
],
);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
rules.contains(&"table-structure"),
"table-structure should fire for non-cell TR child: {rules:?}"
);
}
#[test]
fn well_formed_table_passes() {
let file = ua_pdf(
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
/StructTreeRoot 4 0 R >>",
&[
"<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 3 >>",
"<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
"<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
"<< /Nums [0 9 0 R] >>",
"<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R 11 0 R] >>",
"<< 5 0 R >>",
"<< /Type /StructElem /S /TH /P 8 0 R /Pg 3 0 R /K 1 >>",
"<< /Type /StructElem /S /TD /P 8 0 R /Pg 3 0 R /K 2 >>",
],
);
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
!rules.contains(&"table-structure"),
"well-formed table should not flag: {rules:?}"
);
}
#[test]
fn content_annotation_without_objr_is_flagged() {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /StructParents 0 \
/Annots [10 0 R] >>";
let objs = vec![
"<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> /StructTreeRoot 4 0 R >>".to_string(),
PAGES.to_string(),
page.to_string(),
"<< /Type /StructTreeRoot /K 5 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>".to_string(),
"<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>".to_string(),
"<< /Nums [0 7 0 R] >>".to_string(),
"<< 5 0 R >>".to_string(),
"null".to_string(),
"null".to_string(),
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>".to_string(),
];
let file = PdfFile::parse(build_pdf(
&objs.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
))
.expect("parse");
let r = validate(&file, Profile::Ua1);
let rules: Vec<&str> = r.violations.iter().map(|v| v.rule).collect();
assert!(
rules.contains(&"annotation-objr"),
"annotation-objr should fire for a Link with no OBJR: {rules:?}"
);
}
}