Skip to main content

zpdf_document/
pdfua.rs

1//! PDF/UA-1 conformance validation (ISO 14289-1).
2//!
3//! A rule engine over the parsed document, mirroring [`crate::pdfa`]: each
4//! check inspects one aspect and yields zero or more [`Violation`]s. This
5//! covers the high-signal, machine-checkable clauses of PDF/UA-1 (the
6//! "Universal Accessibility" conformance):
7//!
8//! - the document is tagged (`/MarkInfo /Marked true`)
9//! - a `/StructTreeRoot` is present and non-empty
10//! - the catalog declares a `/Lang` (BCP 47)
11//! - every `/Figure` structure element carries `/Alt` or `/ActualText`
12//! - the structure tree contains at least one heading (`H` / `H1`…`H6`)
13//! - every structure role is a standard type or mapped onto one via `/RoleMap`
14//!   (no unresolved `Other` roles)
15//! - every page carries `/StructParents`
16//!
17//! Everything is best-effort and read-only over `ParseLimits`-bounded APIs.
18//! Annotation `/OBJR` coverage and table-structure consistency are out of
19//! scope (first version).
20
21use 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/// The validation profile.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Profile {
34    /// ISO 14289-1 (PDF/UA-1).
35    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/// One conformance violation.
47#[derive(Debug, Clone)]
48pub struct Violation {
49    /// Short rule identifier, e.g. `"tagged"`, `"figure-alt"`.
50    pub rule: &'static str,
51    /// Human-readable explanation.
52    pub message: String,
53}
54
55/// The outcome of a validation run.
56#[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
68/// Validate `file` against PDF/UA-1.
69pub 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
142/// Walk the structure tree; every `Figure` must carry `/Alt` or `/ActualText`.
143fn 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
158/// The tree must contain at least one heading.
159fn 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
174/// No element may carry an unresolved (non-standard, unmapped) role.
175fn 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
193/// Table-structure consistency (ISO 14289-1 §7.6): a `Table` element's
194/// element children should be `TR` rows, and a `TR`'s element children should
195/// be `TH`/`TD` cells. A `Table` with non-row element children, or a `TR` with
196/// non-cell element children, is flagged. Best-effort: only checks element
197/// kids (marked-content/OBJR kids inside a table are non-conformant but rare;
198/// nesting depth is bounded by the tree's parse-time guards).
199fn 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
238/// Annotation structure coverage (ISO 14289-1 §7.18): annotations that convey
239/// content (Widget form fields, Link, and content-bearing markup annotations)
240/// should participate in the structure tree via `/OBJR` references. This is a
241/// best-effort check: if the document has any such annotations but the
242/// structure tree contains no `OBJR` kids at all, flag it. A full per-annotation
243/// audit is out of scope (it needs annotation↔page↔OBJR cross-referencing).
244fn 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    // Count content-bearing annotations across all pages.
258    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            // Widget (form fields), Link, and the markup/content annotations
285            // are the ones PDF/UA requires to be structure-reachable.
286            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    // Count OBJR kids across the whole structure tree.
305    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
323/// Every page dict must carry `/StructParents`.
324fn 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        // A leaf page (has /MediaBox or no /Kids) must carry /StructParents.
345        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
365/// Depth-first visit over a structure-tree forest, calling `f` on every
366/// element (including nested ones). Bounded by the tree's own guards at parse
367/// time, so this walk cannot recurse without bound.
368fn 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
380/// Like [`visit`], but the callback can inspect each element's `kids` (the
381/// `/K` entries, including non-element kids such as `OBJR` references).
382fn 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        // StructTreeRoot → Document → [H1 (mcid 0), P (mcid 1)], /Lang set,
433        // /MarkInfo marked, page carries /StructParents.
434        let file = ua_pdf(
435            "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
436             /StructTreeRoot 4 0 R >>",
437            &[
438                // 4: StructTreeRoot
439                "<< /Type /StructTreeRoot /K 5 0 R /ParentTree 9 0 R /ParentTreeNextKey 1 >>",
440                // 5: Document
441                "<< /Type /StructElem /S /Document /P 4 0 R /K [6 0 R 7 0 R] >>",
442                // 6: H1
443                "<< /Type /StructElem /S /H1 /P 5 0 R /Pg 3 0 R /K 0 >>",
444                // 7: P
445                "<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K 1 >>",
446                // 8: parent-tree array (H1, P in MCID order)
447                "<< 6 0 R 7 0 R >>",
448                // 9: ParentTree number tree
449                "<< /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                // 4: StructTreeRoot with two top-level elements (H1, Figure).
467                "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 1 >>",
468                // 5: H1
469                "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
470                // 6: Figure without /Alt or /ActualText
471                "<< /Type /StructElem /S /Figure /P 4 0 R /Pg 3 0 R /K 1 >>",
472                // 7: ParentTree
473                "<< /Nums [0 8 0 R] >>",
474                // 8: array
475                "<< 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        // Table whose child is a P (not a TR) — must flag table-structure.
509        let file = ua_pdf(
510            "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> \
511             /StructTreeRoot 4 0 R >>",
512            &[
513                // 4: StructTreeRoot → [H1, Table]
514                "<< /Type /StructTreeRoot /K [5 0 R 6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 2 >>",
515                // 5: H1
516                "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
517                // 6: Table with a P child (non-TR) — non-conformant
518                "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
519                // 7: ParentTree
520                "<< /Nums [0 9 0 R] >>",
521                // 8: P inside the table
522                "<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 1 >>",
523                // 9: array
524                "<< 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        // Table → TR → P (non-cell) — flags the TR row check.
538        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                // 5: H1
544                "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
545                // 6: Table → TR → P
546                "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
547                // 7: ParentTree
548                "<< /Nums [0 9 0 R] >>",
549                // 8: TR with a P child (non-cell)
550                "<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R] >>",
551                // 9: array
552                "<< 5 0 R >>",
553                // 10: P inside the TR
554                "<< /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        // Table → TR → [TH, TD] — conformant table structure.
568        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                // 5: H1
574                "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>",
575                // 6: Table → TR → [TH, TD]
576                "<< /Type /StructElem /S /Table /P 4 0 R /K [8 0 R] >>",
577                // 7: ParentTree
578                "<< /Nums [0 9 0 R] >>",
579                // 8: TR → [TH, TD]
580                "<< /Type /StructElem /S /TR /P 6 0 R /K [10 0 R 11 0 R] >>",
581                // 9: array
582                "<< 5 0 R >>",
583                // 10: TH
584                "<< /Type /StructElem /S /TH /P 8 0 R /Pg 3 0 R /K 1 >>",
585                // 11: TD
586                "<< /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        // A page with a Link annotation but no /OBJR in the structure tree.
600        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            // 4: StructTreeRoot → H1 (no OBJR anywhere)
607            "<< /Type /StructTreeRoot /K 5 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>".to_string(),
608            // 5: H1
609            "<< /Type /StructElem /S /H1 /P 4 0 R /Pg 3 0 R /K 0 >>".to_string(),
610            // 6: ParentTree
611            "<< /Nums [0 7 0 R] >>".to_string(),
612            // 7: array
613            "<< 5 0 R >>".to_string(),
614            // 8,9 unused slots to keep 10 the annot
615            "null".to_string(),
616            "null".to_string(),
617            // 10: Link annotation
618            "<< /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}