diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index 3203aca376..11dcbbd651 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -40,7 +40,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { 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 }; diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index d548c57f9d..f069d7b0cc 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -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 { 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)> { 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); } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 3024319ec3..18993cd983 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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"); +}