diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 60b26102..070f5cb4 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -422,6 +422,8 @@ impl Status { pub struct Finding { /// 0-based page, when the problem is on a page. pub page: Option, + /// Indirect structure element that caused the finding, when one is available. + pub object: Option, pub message: String, /// The English sentence with a hole, such as `{p}` or `{what}`, where each value went. pub pattern: &'static str, @@ -432,13 +434,13 @@ pub struct Finding { impl Finding { /// A finding whose sentence says the same thing however the document is put together. pub fn plain(page: Option, sentence: &'static str) -> Self { - Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() } + Finding { page, object: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() } } /// A finding built from a wording and what goes in its holes. The English `message` is filled /// in here, so it and `pattern` can never drift apart. pub fn worded(page: Option, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self { - Finding { page, message: say(pattern, &values), pattern, values } + Finding { page, object: None, message: say(pattern, &values), pattern, values } } /// A finding that says where the problem is and, in `{what}`, what is wrong, each worded @@ -450,7 +452,7 @@ impl Finding { pub fn nested(page: Option, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self { let message = say(&pattern.replace("{what}", what), &values); values.push(("{what}", what.to_owned())); - Finding { page, message, pattern, values } + Finding { page, object: None, message, pattern, values } } /// This finding as one sentence, said in the language `words` translates into. diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs index 2a481148..b5d5c1f9 100644 --- a/crates/a11y/src/structure.rs +++ b/crates/a11y/src/structure.rs @@ -154,6 +154,8 @@ fn kind_of(ty: &[u8]) -> Kind { /// is filled in as the walk goes on. #[derive(Debug)] pub(crate) struct Frame { + /// Indirect object holding this tag, if it is not an inline dictionary. + object: Option, /// The standard type after the role map, or the type as written when it maps to nothing. ty: Vec, kind: Kind, @@ -314,7 +316,7 @@ impl Walker<'_> { } Object::Int(_) => self.mark_content(), Object::Ref(r) => self.reference(*r, depth, page, v), - Object::Dict(_) => self.dict(loc, depth, page, v), + Object::Dict(_) => self.dict(loc, depth, page, None, v), _ => {} } } @@ -326,13 +328,13 @@ impl Walker<'_> { } let target = self.doc.get(r); if target.as_dict().is_some() { - self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, v); + self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, Some(r), v); } } /// A dictionary. Marked content and object references only mark the open element; a /// dictionary with /S is an element, and its /K kids are read inside it. - fn dict(&mut self, loc: &Loc, depth: usize, page: Option, v: &mut V) { + fn dict(&mut self, loc: &Loc, depth: usize, page: Option, object: Option, v: &mut V) { let Some(d) = loc.value().and_then(|o| o.as_dict()) else { return }; match d.name(b"Type") { Some(b"MCR") => return self.mark_content(), @@ -356,6 +358,7 @@ impl Walker<'_> { let kind = kind_of(&ty); let attrs = if matches!(kind, Kind::Table | Kind::Td | Kind::Th) { self.attr_dicts(d) } else { Vec::new() }; let frame = Frame { + object, ty, kind, index: self.opened, @@ -463,13 +466,15 @@ fn row_owner(path: &[Frame]) -> Option { fn worded(e: &Frame, what: &'static str, extra: Vec<(&'static str, String)>) -> Finding { let mut values = extra; values.push(("{ty}", String::from_utf8_lossy(&e.ty).into_owned())); - match e.page { + let mut finding = match e.page { Some(p) => { values.push(("{p}", p.saturating_add(1).to_string())); Finding::nested(e.page, "{ty} element on page {p}: {what}", what, values) } None => Finding::nested(None, "{ty} element: {what}", what, values), - } + }; + finding.object = e.object; + finding } /// A finding whose `{what}` wording has no holes of its own. diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 9aa3f57c..ba3a8118 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -216,6 +216,16 @@ fn cycles_shared_kids_and_missing_kids_are_read_once() { assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1); } +#[test] +fn structure_findings_identify_indirect_elements_without_guessing_inline_objects() { + let doc = structure_doc(&["<< /S /Document /K [4 0 R << /S /Figure >>] >>".into(), "<< /S /Figure >>".into()]); + let r = checked(&doc, &[Rule::FiguresAltText]); + let found = &r.result(Rule::FiguresAltText).unwrap().findings; + assert_eq!(found.len(), 2); + assert_eq!(found[0].object.map(|r| r.num), Some(4)); + assert_eq!(found[1].object, None); +} + #[test] fn nesting_is_read_to_the_depth_limit_and_no_further() { // Sections, each the kid of the one before, ending in a figure without alternate text. The diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 3e7a046a..5efb69b5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -31,7 +31,18 @@ pub(crate) fn report_json(r: &Report) -> Value { .results .iter() .map(|x| { - let findings: Vec = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect(); + let findings: Vec = x + .findings + .iter() + .map(|f| { + json!({ + "page": f.page.map(|p| p + 1), + "object": f.object.map(|r| r.num), + "object_generation": f.object.map(|r| r.generation), + "message": f.message, + }) + }) + .collect(); json!({ "rule": x.rule.id(), "name": x.rule.name(), "category": category_id(x.rule.category()), "status": status_id(x.status), "findings": findings }) }) .collect(); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c7611713..3e426d0d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1565,6 +1565,12 @@ trailer << /Root 1 0 R >> ) .unwrap(); let fd = ok(&mut a, "doc_open", json!({ "path": "figures.pdf" }))["doc"].as_u64().unwrap(); + let findings = ok(&mut a, "accessibility_check", json!({ "doc": fd, "rules": ["figures-alt-text"] })); + let figures_rule = findings["results"].as_array().unwrap().iter().find(|x| x["rule"] == "figures-alt-text").unwrap(); + assert_eq!(figures_rule["findings"][0]["object"], 7); + assert_eq!(figures_rule["findings"][0]["object_generation"], 0); + assert_eq!(figures_rule["findings"][1]["object"], 8); + assert_eq!(figures_rule["findings"][1]["object_generation"], 0); let figs = ok(&mut a, "accessibility_figures", json!({ "doc": fd })); assert_eq!(figs["count"], 2); assert_eq!(figs["figures"][0]["rect"], json!([10.0, 30.0, 50.0, 50.0]), "top-left-origin points"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 37308bf6..b49528a5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8374,8 +8374,8 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] -notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page" +evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page. Structure findings expose the indirect tag object number and generation through accessibility_check, when available, for precise follow-up in the Tags Panel." [[feature]] id = "a11y.checker-report"