mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 12:12:47 +00:00
fix(a11y): annotation checks and form tagging stop copying shared annotation data
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user