fix(a11y): annotation checks and form tagging stop copying shared annotation data

This commit is contained in:
Dylan M. Taylor
2026-11-04 04:07:59 +00:00
parent 9ce6d89f46
commit 48b95e6494
3 changed files with 54 additions and 8 deletions
+8
View File
@@ -40,7 +40,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let mut widgets = Vec::new();
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
let mut seen = std::collections::HashSet::new();
// A shared indirect /Annots array is scanned for the first page that lists it: its widgets
// are tagged once anyway, so scanning it again for every page only repeats the work.
let mut arrays = std::collections::HashSet::new();
for page in &pages {
if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref)
&& !arrays.insert(array)
{
continue;
}
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
for annot in annots {
let Some(reference) = annot.as_ref() else { continue };
+12 -8
View File
@@ -261,11 +261,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
// The annotation objects are shared, never deep-copied: a reference repeated across a page's
// Annots array (or its pages) must not copy the dictionary each time.
let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc<Object>)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
.filter_map(|x| x.as_ref().map(|r| (r, doc.get(r))))
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup")))
.collect()
};
match rule {
@@ -283,7 +285,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (_, a) in annots(page) {
for (_, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
let st = a.name(b"Subtype").unwrap_or(b"");
let applies = match rule {
Rule::TaggedFormFields => st == b"Widget",
@@ -298,11 +301,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
values.push(("{}", subtype_name(a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
@@ -344,7 +347,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let mut seen = HashSet::new();
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (r, a) in annots(page) {
for (r, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
if a.name(b"Subtype") != Some(b"Widget") {
continue;
}
@@ -361,7 +365,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
+34
View File
@@ -1504,3 +1504,37 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
}
#[test]
fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
"[5 0 R 5 0 R]".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
}
#[test]
fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() {
let doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(),
"<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(),
"<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(),
],
"",
);
let pages = pdfcraft_model::pages(&doc);
let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false);
assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported");
}