Accessibility: identify structure objects in checker findings (#1207)

* Accessibility: identify structure objects in checker findings

* Include object generation in accessibility findings
This commit is contained in:
Earl Co 2026-10-12 01:24:30 +02:00 committed by GitHub
parent 0824f44daf
commit fe2eadb211
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 45 additions and 11 deletions

View File

@ -422,6 +422,8 @@ impl Status {
pub struct Finding {
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
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<usize>, 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<usize>, 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<usize>, 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.

View File

@ -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<ObjRef>,
/// The standard type after the role map, or the type as written when it maps to nothing.
ty: Vec<u8>,
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<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, v: &mut V) {
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, object: Option<ObjRef>, 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<usize> {
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.

View File

@ -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

View File

@ -31,7 +31,18 @@ pub(crate) fn report_json(r: &Report) -> Value {
.results
.iter()
.map(|x| {
let findings: Vec<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
let findings: Vec<Value> = 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();

View File

@ -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");

View File

@ -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"