Accessibility: show figure findings on the page (#1280)

* Highlight locatable accessibility figure findings

* M12: locate annotation and form accessibility findings

* M12: keep one contributor profile entry

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Earl CoandBrandon Thomas authored and GitHub committed 2026-10-12 01:29:25 -04:00
1 parent 1970935587
commit 05a862995d
11 files changed
+112 -14

No files matched your search

+2
View File
@@ -137,6 +137,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged.
- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged.
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
+21 -3
View File
@@ -218,6 +218,20 @@ fn subtype_name(d: &Dict) -> String {
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
}
/// An annotation's rectangle is already in page user space. Ignore missing, malformed and
/// degenerate rectangles rather than sending the checker to an arbitrary place on the page.
fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
let rect = annot.get(b"Rect").map(|r| doc.resolve(r))?;
let values = rect.as_array()?;
let [x0, y0, x1, y1] = values.as_slice() else { return None };
let [x0, y0, x1, y1] = [x0, y0, x1, y1].map(|v| doc.resolve(v).as_f64());
let (x0, y0, x1, y1) = (x0?, y0?, x1?, y1?);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) || x0 == x1 || y0 == y1 {
return None;
}
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
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)> {
@@ -260,7 +274,9 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
values.push(("{}", subtype_name(&a)));
"{} annotation on page {p} isn't tagged"
};
out.push(Finding::worded(Some(p), pattern, values));
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
@@ -313,11 +329,13 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
}
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
if text_of(doc, &fd, b"TU").is_none() {
out.push(Finding::worded(
let mut finding = Finding::worded(
Some(p),
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
));
);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
+25 -4
View File
@@ -8,7 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use pdfcraft_cos::Document;
@@ -426,6 +426,8 @@ pub struct Finding {
pub page: Option<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
/// Bounds in PDF user space when the affected content can be located safely.
pub bbox: Option<[f64; 4]>,
pub message: String,
/// The English sentence with a hole, such as `{p}` or `{what}`, where each value went.
pub pattern: &'static str,
@@ -436,13 +438,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, object: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
Finding { page, object: None, bbox: 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, object: None, message: say(pattern, &values), pattern, values }
Finding { page, object: None, bbox: None, message: say(pattern, &values), pattern, values }
}
/// A finding that says where the problem is and, in `{what}`, what is wrong, each worded
@@ -454,7 +456,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, object: None, message, pattern, values }
Finding { page, object: None, bbox: None, message, pattern, values }
}
/// This finding as one sentence, said in the language `words` translates into.
@@ -616,6 +618,7 @@ pub fn check(doc: &Document, options: &Options) -> Report {
let tagged = structure::has_struct_tree(doc);
let scan = content::Scan::run(doc, &pages, &page_list);
let mut tree_rules = structure::run(doc, &pages, &options.rules);
let mut figure_bounds: Option<HashMap<pdfcraft_cos::ObjRef, (usize, [f64; 4])>> = None;
let mut results = Vec::with_capacity(32);
for rule in Rule::ALL {
if !options.rules.contains(&rule) {
@@ -640,6 +643,24 @@ pub fn check(doc: &Document, options: &Options) -> Report {
}
_ => tree_rules.remove(&rule).unwrap_or_default(),
};
if rule.category() == Category::AlternateText && findings.iter().any(|f| f.object.is_some()) {
let bounds = figure_bounds.get_or_insert_with(|| {
figures(doc)
.into_iter()
.filter_map(|figure| {
let (page, bbox) = (figure.page?, figure.bbox?);
(bbox.iter().all(|v| v.is_finite()) && bbox[0] < bbox[2] && bbox[1] < bbox[3]).then_some((figure.obj, (page, bbox)))
})
.collect()
});
for finding in &mut findings {
if let Some((page, bbox)) = finding.object.and_then(|obj| bounds.get(&obj))
&& finding.page == Some(*page)
{
finding.bbox = Some(*bbox);
}
}
}
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);
+16
View File
@@ -342,6 +342,19 @@ fn page_content_and_annotation_problems_are_found() {
assert_eq!(got, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TabOrder, Rule::FieldDescriptions]);
assert_eq!(r.result(Rule::TaggedContent).unwrap().findings[0].message, "Page 1: 1 untagged content item");
assert!(r.result(Rule::FieldDescriptions).unwrap().findings[0].message.contains("\"name\""));
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, Some([20.0, 20.0, 80.0, 40.0]));
assert_eq!(r.result(Rule::FieldDescriptions).unwrap().findings[0].bbox, Some([20.0, 60.0, 120.0, 80.0]));
}
#[test]
fn annotation_findings_only_locate_valid_rectangles() {
let cases = [("[80 40 20 20]", Some([20.0, 20.0, 80.0, 40.0])), ("[20 20 20 40]", None), ("[20 20 80]", None), ("[20 20 80 (bad)]", None)];
for (rect, expected) in cases {
let mut objs = good();
objs[8] = format!("<< /Type /Annot /Subtype /Link /Rect {rect} >>");
let r = checked(&pdf(&objs, "/Info 8 0 R"), &[Rule::TaggedAnnotations]);
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, expected, "{rect}");
}
}
#[test]
@@ -430,6 +443,9 @@ fn figures_are_listed_with_their_alt_text_and_place() {
assert_eq!(f.len(), 2, "role-mapped Photo counts");
assert_eq!((f[0].page, f[0].alt.as_deref(), f[0].bbox), (Some(0), None, Some([10.0, 150.0, 50.0, 170.0])));
assert_eq!((f[1].alt.as_deref(), f[1].bbox), (Some("A bar"), Some([100.0, 100.0, 150.0, 130.0])));
let report = check(&doc, &Options { rules: [Rule::FiguresAltText].into(), pages: None });
let finding = &report.result(Rule::FiguresAltText).unwrap().findings[0];
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
}
#[test]
+1
View File
@@ -41,6 +41,7 @@ pub(crate) fn report_json(r: &Report) -> Value {
"page": f.page.map(|p| p + 1),
"object": f.object.map(|r| r.num),
"object_generation": f.object.map(|r| r.generation),
"bbox_user_space": f.bbox,
"message": f.message,
})
})
+1 -1
View File
@@ -842,7 +842,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
.with(schema(
+15
View File
@@ -72,6 +72,19 @@ fn form_pdf(name: &str) -> Vec<u8> {
bytes
}
#[test]
fn accessibility_check_locates_field_description_on_page() {
let dir = workdir("accessibility-field-location");
std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap();
let report = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["field-descriptions"] }));
let rule = report["results"].as_array().unwrap().iter().find(|r| r["rule"] == "field-descriptions").unwrap();
assert_eq!(rule["status"], "failed");
assert_eq!(rule["findings"][0]["page"], 1);
assert_eq!(rule["findings"][0]["bbox_user_space"], json!([60.0, 60.0, 200.0, 80.0]));
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
@@ -1705,8 +1718,10 @@ trailer << /Root 1 0 R >>
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"][0]["bbox_user_space"], json!([10.0, 150.0, 50.0, 170.0]));
assert_eq!(figures_rule["findings"][1]["object"], 8);
assert_eq!(figures_rule["findings"][1]["object_generation"], 0);
assert_eq!(figures_rule["findings"][1]["bbox_user_space"], json!([100.0, 100.0, 150.0, 130.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");
+9 -3
View File
@@ -59,7 +59,7 @@ pub(crate) enum PanelAction {
CheckAgain,
ShowReport,
Options,
GoTo(usize),
GoTo(usize, Option<[f64; 4]>),
}
/// Rules with an automatic (or guided) fix.
@@ -294,7 +294,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D
if let Some(p) = f.page
&& l.on_hover_text(tl!("Go to the page")).clicked()
{
action = Some(PanelAction::GoTo(p));
action = Some(PanelAction::GoTo(p, f.bbox));
}
}
});
@@ -372,7 +372,13 @@ impl PdfCraftApp {
PanelAction::CheckAgain => self.run_accessibility_check(),
PanelAction::Options => self.dialog = Some(Dialog::AccessibilityOptions),
PanelAction::ShowReport => self.show_accessibility_report(),
PanelAction::GoTo(p) => self.views[index].go_to_page(p),
PanelAction::GoTo(p, bbox) => {
let view = &mut self.views[index];
view.go_to_page(p);
if let Some(bbox) = bbox {
view.flash = Some((p, bbox.map(|v| v as f32), 0.0));
}
}
PanelAction::Skip(rule) => {
self.a11y_skipped.insert(rule);
if let Some((_, r)) = self.a11y.report.as_mut()
+19
View File
@@ -107,6 +107,25 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn checker_shows_a_figure_finding_on_its_page() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap();
app
});
h.run_steps(4);
h.state_mut().run_accessibility_check();
h.run_steps(3);
h.get_by_label("Figures alternate text - Failed").click();
h.run_steps(2);
h.get_by_label_contains("Figure element on page 1: no alternate text").click();
h.run_steps(1);
let flash = h.state().views[0].flash.map(|(page, rect, _)| (page, rect));
assert_eq!(flash, Some((0, [10.0, 150.0, 50.0, 170.0])));
}
#[test]
fn setting_alternate_text_figure_by_figure() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
+1 -1
View File
@@ -92,7 +92,7 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import, web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
+2 -2
View File
@@ -8390,8 +8390,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/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."
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/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::annotation_findings_only_locate_valid_rectangles", "crates/automation/tests/automation.rs::accessibility_check_locates_field_description_on_page", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/ui-egui/tests/accessibility.rs::checker_shows_a_figure_finding_on_its_page", "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. Locatable figure, annotation and form-widget findings flash their bounds on the page; accessibility_check exposes those bounds in PDF user space. Other findings navigate 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"