From 0b628d55157b66e0153412913c6e3594889716f4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 05:05:56 -0400 Subject: [PATCH] M12: Accessibility Checker: new a11y crate with Acrobat's 32 rules in 7 categories, HTML report, fixes (language, title, tab order); options dialog and results panel (Fix, Skip Rule, Explain, Check Again, Show Report); accessibility_check / _report / _fix tools Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/a11y/Cargo.toml | 17 + crates/a11y/README.md | 24 ++ crates/a11y/src/content.rs | 348 ++++++++++++++++++ crates/a11y/src/lib.rs | 491 ++++++++++++++++++++++++++ crates/a11y/src/report.rs | 55 +++ crates/a11y/src/structure.rs | 447 +++++++++++++++++++++++ crates/a11y/src/tests.rs | 224 ++++++++++++ crates/automation/src/a11y.rs | 104 ++++++ crates/automation/src/lib.rs | 4 + crates/automation/src/tools.rs | 29 ++ crates/automation/tests/automation.rs | 31 ++ crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 8 +- crates/engine/src/commands.rs | 3 + crates/engine/src/lib.rs | 35 ++ crates/ui-egui/src/a11y_ui.rs | 382 ++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 5 + crates/ui-egui/src/commands.rs | 3 + crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/lib.rs | 14 + crates/ui-egui/src/panels.rs | 9 + crates/ui-egui/tests/accessibility.rs | 88 +++++ crates/ui-egui/tests/commands.rs | 2 +- parity/acrobat-features.toml | 350 +++++++++--------- 26 files changed, 2519 insertions(+), 179 deletions(-) create mode 100644 crates/a11y/Cargo.toml create mode 100644 crates/a11y/README.md create mode 100644 crates/a11y/src/content.rs create mode 100644 crates/a11y/src/lib.rs create mode 100644 crates/a11y/src/report.rs create mode 100644 crates/a11y/src/structure.rs create mode 100644 crates/a11y/src/tests.rs create mode 100644 crates/automation/src/a11y.rs create mode 100644 crates/ui-egui/src/a11y_ui.rs create mode 100644 crates/ui-egui/tests/accessibility.rs diff --git a/Cargo.lock b/Cargo.lock index 51fa735f..e991a91b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3334,6 +3334,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "printcraft-a11y" +version = "0.1.0" +dependencies = [ + "printcraft-content", + "printcraft-cos", + "printcraft-model", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-annot" version = "0.1.0" @@ -3397,6 +3407,7 @@ dependencies = [ "png 0.18.1", "printcraft-cos", "printcraft-fonts", + "printcraft-model", "thiserror 2.0.21", "tiff", ] @@ -3433,6 +3444,7 @@ dependencies = [ "chrono", "image", "png 0.18.1", + "printcraft-a11y", "printcraft-annot", "printcraft-cos", "printcraft-create", diff --git a/Cargo.toml b/Cargo.toml index 303ea7aa..05fee10b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ printcraft-print = { path = "crates/print", version = "0.1.0" } printcraft-xfdf = { path = "crates/xfdf", version = "0.1.0" } printcraft-sign = { path = "crates/sign", version = "0.1.0" } printcraft-optimize = { path = "crates/optimize", version = "0.1.0" } +printcraft-a11y = { path = "crates/a11y", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } diff --git a/crates/a11y/Cargo.toml b/crates/a11y/Cargo.toml new file mode 100644 index 00000000..47a958bd --- /dev/null +++ b/crates/a11y/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "printcraft-a11y" +description = "Accessibility: the Accessibility Checker (32 rules), its report and fixes" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } +printcraft-model = { workspace = true } +printcraft-content = { workspace = true } +thiserror.workspace = true diff --git a/crates/a11y/README.md b/crates/a11y/README.md new file mode 100644 index 00000000..be85efd6 --- /dev/null +++ b/crates/a11y/README.md @@ -0,0 +1,24 @@ +# printcraft-a11y + +Layer L4: accessibility (execution plan M12.3). Today: the Accessibility Checker's full check. + +```rust +let report = check(&doc, &Options::default()); // 31 of 32 rules (colour contrast off) +let html = report_html(&report, "file.pdf", "2026-10-02"); +``` + +- **32 rules in 7 categories**, as in Acrobat's full check: Document (permission flag, + image-only, tagged, reading order, language, title, bookmarks, colour contrast), Page Content + (tagged content, tagged annotations, tab order, character encoding, tagged multimedia, and + four manual checks), Forms (tagged fields, descriptions), Alternate Text (figures, nested, + associated with content, hides annotation, other elements), Tables (rows, cells, headers, + regularity with row/column spans, summary), Lists (items, Lbl/LBody) and Headings (nesting). +- Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped. +- **Content checks** walk the page content streams (and form XObjects) tracking marked + content: painting outside MCID-marked or `/Artifact` content is untagged. +- **Structure checks** read the structure tree through the role map; table regularity counts + `ColSpan`/`RowSpan` attributes. +- The report is a self-contained HTML page. Fixes that edit the document (language, title, tab + order) live in the engine (`Document::accessibility_fix`). + +Not yet: autotagging, the Tags/Order/Content panels, the Reading Order tool (M12.1–M12.2). diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs new file mode 100644 index 00000000..22373e88 --- /dev/null +++ b/crates/a11y/src/content.rs @@ -0,0 +1,348 @@ +//! The Document, Page Content and Forms rules: catalog settings, page content (tagged or +//! artifact, text, images, fonts) and annotations. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use printcraft_cos::{Dict, Document, ObjRef, Object}; +use printcraft_model::Page; + +use crate::structure::Tree; +use crate::{Finding, Rule}; + +/// What the page content holds, per checked page. +#[derive(Debug, Default)] +pub(crate) struct Scan { + pub has_text: bool, + pub has_images: bool, + /// Page → painting operations outside tagged or artifact content. + pub untagged: BTreeMap, + /// Page → fonts used. + pub fonts: BTreeMap>, +} + +const MAX_FORM_DEPTH: usize = 12; + +impl Scan { + pub fn run(doc: &Document, pages: &[Page], list: &[usize]) -> Scan { + let mut s = Scan::default(); + for &p in list { + let Some(page) = pages.get(p) else { continue }; + let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let data = page_content(doc, &page.dict); + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + w.content(&data, &res, false, 0); + } + s + } +} + +fn page_content(doc: &Document, page: &Dict) -> Vec { + let Some(c) = page.get(b"Contents") else { return Vec::new() }; + match &*doc.resolve(c) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + Object::Array(a) => a + .iter() + .flat_map(|x| match &*doc.resolve(x) { + Object::Stream(s) => { + let mut v = s.decoded().unwrap_or_default(); + v.push(b'\n'); + v + } + _ => Vec::new(), + }) + .collect(), + _ => Vec::new(), + } +} + +struct Walk<'a> { + doc: &'a Document, + scan: &'a mut Scan, + page: usize, + forms: HashSet, +} + +impl Walk<'_> { + fn sub(&self, res: &Dict, key: &[u8]) -> Dict { + res.get(key).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default() + } + + /// Walk a content stream; `covered`: already inside tagged or artifact content. + fn content(&mut self, data: &[u8], res: &Dict, covered: bool, depth: usize) { + let (fonts, xobjects, props) = (self.sub(res, b"Font"), self.sub(res, b"XObject"), self.sub(res, b"Properties")); + // Marked-content stack: whether each level is tagged or an artifact. + let mut stack: Vec = Vec::new(); + let mut untagged = 0; + for op in printcraft_content::parse(data).ops { + let inside = covered || stack.last().copied().unwrap_or(false); + match op.op.as_slice() { + b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), + b"BDC" => { + let marks = op.name(0) == Some(b"Artifact") + || match op.operands.get(1) { + Some(Object::Dict(d)) => d.contains(b"MCID"), + Some(Object::Name(n)) => { + props.get(n).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false) + } + _ => false, + }; + stack.push(inside || marks); + } + b"EMC" => { + stack.pop(); + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + self.scan.has_text = true; + untagged += usize::from(!inside); + } + b"Tf" => { + if let Some(r) = op.name(0).and_then(|n| fonts.get(n)).and_then(Object::as_ref) { + self.scan.fonts.entry(self.page).or_default().insert(r); + } + } + b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" => untagged += usize::from(!inside), + b"BI" => { + self.scan.has_images = true; + untagged += usize::from(!inside); + } + b"Do" => { + let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue }; + let obj = self.doc.get(r); + let Object::Stream(x) = &*obj else { continue }; + match x.dict.name(b"Subtype") { + Some(b"Image") => { + self.scan.has_images = true; + untagged += usize::from(!inside); + } + Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + let inner = x + .dict + .get(b"Resources") + .map(|o| self.doc.resolve(o)) + .and_then(|o| o.as_dict().cloned()) + .unwrap_or_else(|| res.clone()); + let data = x.decoded().unwrap_or_default(); + self.content(&data, &inner, inside, depth + 1); + self.forms.remove(&r); + } + _ => {} + } + } + _ => {} + } + } + if untagged > 0 { + *self.scan.untagged.entry(self.page).or_default() += untagged; + } + } +} + +fn catalog(doc: &Document) -> Dict { + doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default() +} + +fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()) +} + +fn doc_finding(message: impl Into) -> Vec { + vec![Finding { page: None, message: message.into() }] +} + +pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec { + let cat = catalog(doc); + match rule { + Rule::PermissionFlag => match doc.permissions() { + // The file's own setting, whichever password opened it. + Some(p) if (p.bits >> 9) & 1 == 0 => doc_finding("The security settings don't allow screen readers to read the text"), + _ => Vec::new(), + }, + Rule::ImageOnly => { + if !scan.has_text && scan.has_images { + doc_finding("No page has text: the document appears to be images of pages") + } else { + Vec::new() + } + } + Rule::TaggedPdf => { + let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned())); + let mut out = Vec::new(); + if !cat.contains(b"StructTreeRoot") { + out.push(Finding { page: None, message: "The document has no tags (structure tree)".into() }); + } + if !matches!(marked, Some(Object::Bool(true))) { + out.push(Finding { page: None, message: "The document isn't marked as tagged (MarkInfo Marked)".into() }); + } + out + } + Rule::PrimaryLanguage => { + if text_of(doc, &cat, b"Lang").is_none() { + doc_finding("No document language is set") + } else { + Vec::new() + } + } + Rule::Title => { + let mut out = Vec::new(); + let info = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().cloned()).unwrap_or_default(); + if text_of(doc, &info, b"Title").is_none() { + out.push(Finding { page: None, message: "The document has no title".into() }); + } + let shows = cat + .get(b"ViewerPreferences") + .map(|v| doc.resolve(v)) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"DisplayDocTitle").map(|x| matches!(&*doc.resolve(x), Object::Bool(true))))) + .unwrap_or(false); + if !shows { + out.push(Finding { page: None, message: "The window shows the file name, not the title (Initial View > Show)".into() }); + } + out + } + Rule::Bookmarks => { + let has = cat.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().map(|d| d.contains(b"First"))).unwrap_or(false); + if pages.len() > 20 && !has { doc_finding(format!("{} pages and no bookmarks", pages.len())) } else { Vec::new() } + } + _ => Vec::new(), + } +} + +const MULTIMEDIA: &[&[u8]] = &[b"Screen", b"Movie", b"Sound", b"RichMedia", b"3D"]; + +fn subtype_name(d: &Dict) -> String { + String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned() +} + +pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tree: &Tree) -> Vec { + let mut out = Vec::new(); + let tagged = tree.exists; + let annots = |p: &Page| -> Vec<(ObjRef, Dict)> { + 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")) + .collect() + }; + match rule { + Rule::TaggedContent => { + if !tagged && !scan.untagged.is_empty() { + return doc_finding("The document isn't tagged, so none of its content is"); + } + for (p, n) in &scan.untagged { + out.push(Finding { page: Some(*p), message: format!("Page {}: {n} untagged content item{}", p + 1, if *n == 1 { "" } else { "s" }) }); + } + } + Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => { + for &p in list { + let Some(page) = pages.get(p) else { continue }; + for (_, a) in annots(page) { + let st = a.name(b"Subtype").unwrap_or(b""); + let applies = match rule { + Rule::TaggedFormFields => st == b"Widget", + Rule::TaggedMultimedia => MULTIMEDIA.contains(&st), + _ => st != b"Widget" && !MULTIMEDIA.contains(&st) && st != b"PrinterMark", + }; + if applies && !(tagged && a.contains(b"StructParent")) { + let what = if st == b"Widget" { "Form field".to_string() } else { format!("{} annotation", subtype_name(&a)) }; + out.push(Finding { page: Some(p), message: format!("{what} on page {} isn't tagged", p + 1) }); + } + } + } + } + Rule::TabOrder => { + for &p in list { + let Some(page) = pages.get(p) else { continue }; + if !annots(page).is_empty() && page.dict.name(b"Tabs") != Some(b"S") { + out.push(Finding { page: Some(p), message: format!("Page {} doesn't tab in structure order", p + 1) }); + } + } + } + Rule::CharacterEncoding => { + let mut seen = HashSet::new(); + for (p, fonts) in &scan.fonts { + for r in fonts { + if seen.insert(*r) + && let Some(f) = doc.get(*r).as_dict() + && !maps_to_unicode(doc, f) + { + let name = f.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "(unnamed)".into()); + out.push(Finding { page: Some(*p), message: format!("Font {name} on page {} doesn't map its characters to Unicode", p + 1) }); + } + } + } + } + Rule::FieldDescriptions => { + let mut seen = HashSet::new(); + for &p in list { + let Some(page) = pages.get(p) else { continue }; + for (r, a) in annots(page) { + if a.name(b"Subtype") != Some(b"Widget") { + continue; + } + // The field is the widget itself when it has a name, else its parent. + let field = if a.contains(b"T") { Some(r) } else { a.get(b"Parent").and_then(Object::as_ref) }; + let Some(field) = field else { continue }; + if !seen.insert(field) { + continue; + } + let fd = doc.get(field).as_dict().cloned().unwrap_or_default(); + if text_of(doc, &fd, b"TU").is_none() { + out.push(Finding { + page: Some(p), + message: format!("Field \"{}\" on page {} has no description (tooltip)", full_name(doc, field), p + 1), + }); + } + } + } + } + _ => {} + } + out +} + +fn full_name(doc: &Document, field: ObjRef) -> String { + let mut parts = Vec::new(); + let mut node = Some(field); + for _ in 0..64 { + let Some(r) = node else { break }; + let Some(d) = doc.get(r).as_dict().cloned() else { break }; + if let Some(t) = text_of(doc, &d, b"T") { + parts.push(t); + } + node = d.get(b"Parent").and_then(Object::as_ref); + } + parts.reverse(); + parts.join(".") +} + +/// Whether text in this font can be mapped to Unicode (ToUnicode, a standard encoding, or a +/// predefined CJK character collection). +fn maps_to_unicode(doc: &Document, f: &Dict) -> bool { + if f.contains(b"ToUnicode") { + return true; + } + let enc = f.get(b"Encoding").map(|e| doc.resolve(e)); + match f.name(b"Subtype") { + Some(b"Type0") => { + let identity = matches!(enc.as_deref(), Some(Object::Name(n)) if n.starts_with(b"Identity")); + let ordering = f + .get(b"DescendantFonts") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().and_then(|a| a.first().cloned())) + .and_then(|d| doc.resolve(&d).as_dict().cloned()) + .and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned())) + .and_then(|c| c.get(b"Ordering").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone()))); + let known = matches!(ordering.as_deref(), Some(b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR")); + !identity && known + } + Some(b"Type3") => enc.is_some(), + _ => match enc.as_deref() { + Some(Object::Name(_) | Object::Dict(_)) => true, + _ => { + // No encoding: the font's built-in one, which is standard unless it is symbolic. + let flags = f.get(b"FontDescriptor").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.int(b"Flags"))).unwrap_or(32); + let base = f.name(b"BaseFont").unwrap_or(b""); + flags & 4 == 0 || matches!(base, b"Symbol" | b"ZapfDingbats") + } + }, + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs new file mode 100644 index 00000000..c539f076 --- /dev/null +++ b/crates/a11y/src/lib.rs @@ -0,0 +1,491 @@ +//! printcraft-a11y — accessibility (L4): the Accessibility Checker's full check. +//! +//! [`check`] runs the 32 rules in Acrobat's seven categories (Document, Page Content, Forms, +//! Alternate Text, Tables, Lists, Headings) and returns, per rule, Passed, Failed, Needs manual +//! check or Skipped, with the elements or pages at fault. [`report_html`] writes the +//! accessibility report. Rules read the document only; the fixes that need edits (language, +//! title, tab order) are applied by the engine. + +use std::collections::BTreeSet; + +use printcraft_cos::Document; + +mod content; +mod report; +mod structure; + +pub use report::report_html; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Category { + Document, + PageContent, + Forms, + AlternateText, + Tables, + Lists, + Headings, +} + +impl Category { + pub const ALL: [Category; 7] = + [Category::Document, Category::PageContent, Category::Forms, Category::AlternateText, Category::Tables, Category::Lists, Category::Headings]; + + pub fn label(self) -> &'static str { + match self { + Category::Document => "Document", + Category::PageContent => "Page Content", + Category::Forms => "Forms", + Category::AlternateText => "Alternate Text", + Category::Tables => "Tables", + Category::Lists => "Lists", + Category::Headings => "Headings", + } + } +} + +/// The 32 rules of the full check. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Rule { + PermissionFlag, + ImageOnly, + TaggedPdf, + LogicalReadingOrder, + PrimaryLanguage, + Title, + Bookmarks, + ColorContrast, + TaggedContent, + TaggedAnnotations, + TabOrder, + CharacterEncoding, + TaggedMultimedia, + ScreenFlicker, + Scripts, + TimedResponses, + NavigationLinks, + TaggedFormFields, + FieldDescriptions, + FiguresAltText, + NestedAltText, + AltTextAssociated, + AltTextHidesAnnotation, + OtherElementsAltText, + TableRows, + TableCells, + TableHeaders, + TableRegularity, + TableSummary, + ListItems, + LblLBody, + HeadingNesting, +} + +impl Rule { + pub const ALL: [Rule; 32] = [ + Rule::PermissionFlag, + Rule::ImageOnly, + Rule::TaggedPdf, + Rule::LogicalReadingOrder, + Rule::PrimaryLanguage, + Rule::Title, + Rule::Bookmarks, + Rule::ColorContrast, + Rule::TaggedContent, + Rule::TaggedAnnotations, + Rule::TabOrder, + Rule::CharacterEncoding, + Rule::TaggedMultimedia, + Rule::ScreenFlicker, + Rule::Scripts, + Rule::TimedResponses, + Rule::NavigationLinks, + Rule::TaggedFormFields, + Rule::FieldDescriptions, + Rule::FiguresAltText, + Rule::NestedAltText, + Rule::AltTextAssociated, + Rule::AltTextHidesAnnotation, + Rule::OtherElementsAltText, + Rule::TableRows, + Rule::TableCells, + Rule::TableHeaders, + Rule::TableRegularity, + Rule::TableSummary, + Rule::ListItems, + Rule::LblLBody, + Rule::HeadingNesting, + ]; + + /// The stable id used by automation (`permission-flag`, `tagged-content`, …). + pub fn id(self) -> &'static str { + self.info().0 + } + + pub fn category(self) -> Category { + self.info().1 + } + + /// The short name shown in the results. + pub fn name(self) -> &'static str { + self.info().2 + } + + /// What the rule checks, as the options dialog words it. + pub fn description(self) -> &'static str { + self.info().3 + } + + /// Whether only a person can judge it. + pub fn manual(self) -> bool { + matches!( + self, + Rule::LogicalReadingOrder | Rule::ColorContrast | Rule::ScreenFlicker | Rule::Scripts | Rule::TimedResponses | Rule::NavigationLinks + ) + } + + /// Checked unless deselected (Acrobat leaves colour contrast off). + pub fn on_by_default(self) -> bool { + self != Rule::ColorContrast + } + + /// A short explanation of why the rule matters and how to satisfy it ("Explain"). + pub fn explanation(self) -> &'static str { + self.info().4 + } + + pub fn from_id(id: &str) -> Option { + Rule::ALL.into_iter().find(|r| r.id() == id) + } + + fn info(self) -> (&'static str, Category, &'static str, &'static str, &'static str) { + use Category as C; + match self { + Rule::PermissionFlag => ( + "permission-flag", + C::Document, + "Accessibility permission flag", + "Accessibility permission flag is set", + "Security settings must allow assistive technology to read the content (\"Enable text access for screen reader devices\"). Change the document's security to allow it.", + ), + Rule::ImageOnly => ( + "image-only", + C::Document, + "Image-only PDF", + "Document is not image-only PDF", + "Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer.", + ), + Rule::TaggedPdf => ( + "tagged-pdf", + C::Document, + "Tagged PDF", + "Document is tagged PDF", + "Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF).", + ), + Rule::LogicalReadingOrder => ( + "logical-reading-order", + C::Document, + "Logical Reading Order", + "Document structure provides a logical reading order", + "Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it.", + ), + Rule::PrimaryLanguage => ( + "primary-language", + C::Document, + "Primary language", + "Text language is specified", + "Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language.", + ), + Rule::Title => ( + "title", + C::Document, + "Title", + "Document title is showing in title bar", + "The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title.", + ), + Rule::Bookmarks => ( + "bookmarks", + C::Document, + "Bookmarks", + "Bookmarks are present in large documents", + "Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections.", + ), + Rule::ColorContrast => ( + "color-contrast", + C::Document, + "Color contrast", + "Document has appropriate color contrast", + "Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool.", + ), + Rule::TaggedContent => ( + "tagged-content", + C::PageContent, + "Tagged content", + "All page content is tagged", + "Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content.", + ), + Rule::TaggedAnnotations => ( + "tagged-annotations", + C::PageContent, + "Tagged annotations", + "All annotations are tagged", + "Comments, links and fields must be in the tag tree to be announced. Tag the annotations.", + ), + Rule::TabOrder => ( + "tab-order", + C::PageContent, + "Tab order", + "Tab order is consistent with structure order", + "Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure.", + ), + Rule::CharacterEncoding => ( + "character-encoding", + C::PageContent, + "Character encoding", + "Reliable character encoding is provided", + "Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF.", + ), + Rule::TaggedMultimedia => ( + "tagged-multimedia", + C::PageContent, + "Tagged multimedia", + "All multimedia objects are tagged", + "Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations.", + ), + Rule::ScreenFlicker => ( + "screen-flicker", + C::PageContent, + "Screen flicker", + "Page will not cause screen flicker", + "Animation that flashes can cause seizures. Check scripts and media for flicker.", + ), + Rule::Scripts => ( + "scripts", + C::PageContent, + "Scripts", + "No inaccessible scripts", + "Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts.", + ), + Rule::TimedResponses => ( + "timed-responses", + C::PageContent, + "Timed responses", + "Page does not require timed responses", + "People may need more time to respond. Check that nothing has a time limit.", + ), + Rule::NavigationLinks => ( + "navigation-links", + C::PageContent, + "Navigation links", + "Navigation links are not repetitive", + "Repeated navigation links should be skippable. Check links that appear on every page.", + ), + Rule::TaggedFormFields => ( + "tagged-form-fields", + C::Forms, + "Tagged form fields", + "All form fields are tagged", + "Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields.", + ), + Rule::FieldDescriptions => ( + "field-descriptions", + C::Forms, + "Field descriptions", + "All form fields have description", + "Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip.", + ), + Rule::FiguresAltText => ( + "figures-alt-text", + C::AlternateText, + "Figures alternate text", + "Figures require alternate text", + "Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure.", + ), + Rule::NestedAltText => ( + "nested-alt-text", + C::AlternateText, + "Nested alternate text", + "Alternate text that will never be read", + "Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text.", + ), + Rule::AltTextAssociated => ( + "alt-text-associated", + C::AlternateText, + "Associated with content", + "Alternate text must be associated with some content", + "Alternate text on an element without page content is never reached. Remove it or attach the element to content.", + ), + Rule::AltTextHidesAnnotation => ( + "alt-text-hides-annotation", + C::AlternateText, + "Hides annotation", + "Alternate text should not hide annotation", + "Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text.", + ), + Rule::OtherElementsAltText => ( + "other-elements-alt-text", + C::AlternateText, + "Other elements alternate text", + "Other elements that require alternate text", + "Formulas and similar non-text elements need alternate text. Add alternate text to them.", + ), + Rule::TableRows => ( + "table-rows", + C::Tables, + "Rows", + "TR must be a child of Table, THead, TBody, or TFoot", + "Table rows belong directly in a table or its head, body or foot. Fix the table's tags.", + ), + Rule::TableCells => { + ("table-cells", C::Tables, "TH and TD", "TH and TD must be children of TR", "Table cells belong in a row. Fix the table's tags.") + } + Rule::TableHeaders => ( + "table-headers", + C::Tables, + "Headers", + "Tables should have headers", + "Header cells (TH) tell listeners what each column or row holds. Mark the header cells.", + ), + Rule::TableRegularity => ( + "table-regularity", + C::Tables, + "Regularity", + "Tables must contain the same number of columns in each row and rows in each column", + "Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans).", + ), + Rule::TableSummary => ( + "table-summary", + C::Tables, + "Summary", + "Tables must have a summary", + "A summary describes the table's purpose and layout. Add a Summary attribute to each table.", + ), + Rule::ListItems => ("list-items", C::Lists, "List items", "LI must be a child of L", "List items belong in a list. Fix the list's tags."), + Rule::LblLBody => ( + "lbl-lbody", + C::Lists, + "Lbl and LBody", + "Lbl and LBody must be children of LI", + "List labels and bodies belong in a list item. Fix the list's tags.", + ), + Rule::HeadingNesting => ( + "heading-nesting", + C::Headings, + "Appropriate nesting", + "Appropriate nesting", + "Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings.", + ), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Status { + Passed, + Failed, + /// Needs manual check. + Manual, + /// Not checked (deselected or skipped). + Skipped, +} + +impl Status { + pub fn label(self) -> &'static str { + match self { + Status::Passed => "Passed", + Status::Failed => "Failed", + Status::Manual => "Needs manual check", + Status::Skipped => "Skipped", + } + } +} + +/// One problem: where it is and what is wrong. +#[derive(Clone, Debug, PartialEq)] +pub struct Finding { + /// 0-based page, when the problem is on a page. + pub page: Option, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuleResult { + pub rule: Rule, + pub status: Status, + pub findings: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Options { + /// The rules to run; the others are reported as Skipped. + pub rules: BTreeSet, + /// 0-based pages for the page rules (`None`: all pages). + pub pages: Option>, +} + +impl Default for Options { + fn default() -> Self { + Self { rules: Rule::ALL.into_iter().filter(|r| r.on_by_default()).collect(), pages: None } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Report { + pub results: Vec, +} + +impl Report { + pub fn result(&self, rule: Rule) -> Option<&RuleResult> { + self.results.iter().find(|r| r.rule == rule) + } + + pub fn count(&self, status: Status) -> usize { + self.results.iter().filter(|r| r.status == status).count() + } + + /// Results of one category, in rule order. + pub fn in_category(&self, c: Category) -> impl Iterator { + self.results.iter().filter(move |r| r.rule.category() == c) + } +} + +/// Findings beyond this many per rule are summarised ("… and n more"). +const MAX_FINDINGS: usize = 200; + +/// Run the full check. +pub fn check(doc: &Document, options: &Options) -> Report { + let pages = printcraft_model::pages(doc); + let page_list: Vec = match &options.pages { + Some(p) => p.iter().copied().filter(|p| *p < pages.len()).collect(), + None => (0..pages.len()).collect(), + }; + let tree = structure::Tree::read(doc, &pages); + let scan = content::Scan::run(doc, &pages, &page_list); + let mut results = Vec::with_capacity(32); + for rule in Rule::ALL { + if !options.rules.contains(&rule) { + results.push(RuleResult { rule, status: Status::Skipped, findings: Vec::new() }); + continue; + } + if rule.manual() { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } + let mut findings = match rule.category() { + Category::Document => content::document_rule(doc, rule, &pages, &scan), + Category::PageContent | Category::Forms => content::page_rule(doc, rule, &pages, &page_list, &scan, &tree), + _ => tree.rule(rule), + }; + if findings.len() > MAX_FINDINGS { + let more = findings.len() - MAX_FINDINGS; + findings.truncate(MAX_FINDINGS); + findings.push(Finding { page: None, message: format!("… and {more} more") }); + } + let status = if findings.is_empty() { Status::Passed } else { Status::Failed }; + results.push(RuleResult { rule, status, findings }); + } + Report { results } +} + +#[cfg(test)] +mod tests; diff --git a/crates/a11y/src/report.rs b/crates/a11y/src/report.rs new file mode 100644 index 00000000..7b042ba9 --- /dev/null +++ b/crates/a11y/src/report.rs @@ -0,0 +1,55 @@ +//! The accessibility report: a self-contained HTML page with the summary and, per category, +//! each rule's status, description and problems. + +use crate::{Category, Report, Status}; + +fn esc(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// The report for `file_name`, dated `date` (as the caller formats it). +pub fn report_html(report: &Report, file_name: &str, date: &str) -> String { + let mut h = String::new(); + h.push_str("\nAccessibility Report\n\n

Accessibility Report

\n"); + h.push_str(&format!("

Filename: {}
Report created: {}

\n", esc(file_name), esc(date))); + h.push_str("

Summary

\n

"); + h.push_str(if report.count(Status::Failed) > 0 { + "The checker found problems which may prevent the document from being fully accessible." + } else { + "The checker found no problems in this document." + }); + h.push_str("

\n
    \n"); + for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] { + h.push_str(&format!("
  • {}: {}
  • \n", s.label(), report.count(s))); + } + h.push_str("
\n

Detailed Report

\n"); + for c in Category::ALL { + h.push_str(&format!("

{}

\n\n", c.label())); + for r in report.in_category(c) { + h.push_str(&format!( + "\n"); + } + h.push_str("
Rule NameStatusDescription
{}{}{}", + esc(r.rule.name()), + r.status, + r.status.label(), + esc(r.rule.description()) + )); + if !r.findings.is_empty() { + h.push_str("
    "); + for f in &r.findings { + h.push_str(&format!("
  • {}
  • ", esc(&f.message))); + } + h.push_str("
"); + } + h.push_str("
\n"); + } + h.push_str("\n"); + h +} diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs new file mode 100644 index 00000000..4081dacd --- /dev/null +++ b/crates/a11y/src/structure.rs @@ -0,0 +1,447 @@ +//! The structure tree (tags) as the checker needs it, and the rules that read it: alternate +//! text, tables, lists and headings (ISO 32000-2 §14.7–14.8). + +use std::collections::{HashMap, HashSet}; + +use printcraft_cos::{Dict, Document, ObjRef, Object}; +use printcraft_model::Page; + +use crate::{Finding, Rule}; + +/// Standard structure types (PDF 1.7 and 2.0); anything else goes through the role map. +const STANDARD: &[&[u8]] = &[ + b"Document", + b"DocumentFragment", + b"Part", + b"Art", + b"Sect", + b"Div", + b"BlockQuote", + b"Caption", + b"TOC", + b"TOCI", + b"Index", + b"NonStruct", + b"Private", + b"Aside", + b"Title", + b"FENote", + b"P", + b"H", + b"H1", + b"H2", + b"H3", + b"H4", + b"H5", + b"H6", + b"L", + b"LI", + b"Lbl", + b"LBody", + b"Table", + b"TR", + b"TH", + b"TD", + b"THead", + b"TBody", + b"TFoot", + b"Span", + b"Quote", + b"Note", + b"Reference", + b"BibEntry", + b"Code", + b"Link", + b"Annot", + b"Ruby", + b"RB", + b"RT", + b"RP", + b"Warichu", + b"WT", + b"WP", + b"Figure", + b"Formula", + b"Form", + b"Sub", + b"Em", + b"Strong", + b"Artifact", +]; + +/// Elements beyond this are not read (a guard against absurd trees). +const MAX_ELEMENTS: usize = 2_000_000; + +#[derive(Clone, Debug, Default)] +pub(crate) struct Elem { + /// The standard type after the role map (or the type as written if it maps to nothing). + pub ty: Vec, + pub parent: Option, + pub children: Vec, + /// Has /Alt (or /ActualText, which also replaces the content). + pub alt: bool, + /// Marked content or an object reference directly under it. + pub content: bool, + /// Annotations it references (OBJR). + pub annots: Vec, + pub page: Option, + pub attrs: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Tree { + /// A /StructTreeRoot exists. + pub exists: bool, + pub elems: Vec, + /// Top-level elements in order. + pub roots: Vec, +} + +impl Tree { + pub fn read(doc: &Document, pages: &[Page]) -> Tree { + let mut t = Tree::default(); + let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return t }; + let Some(root) = cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else { return t }; + t.exists = true; + let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let page_index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut seen = HashSet::new(); + let mut reader = Reader { doc, role_map: &role_map, page_index: &page_index, seen: &mut seen }; + if let Some(k) = root.get(b"K") { + let mut roots = Vec::new(); + reader.kids(&mut t, k, None, None, &mut roots, 0); + t.roots = roots; + } + t + } + + /// Elements in document (pre-)order. + fn preorder(&self) -> Vec { + let mut out = Vec::with_capacity(self.elems.len()); + let mut stack: Vec = self.roots.iter().rev().copied().collect(); + while let Some(i) = stack.pop() { + out.push(i); + stack.extend(self.elems[i].children.iter().rev().copied()); + } + out + } + + fn ancestors(&self, i: usize) -> impl Iterator + '_ { + std::iter::successors(self.elems[i].parent, move |p| self.elems[*p].parent) + } + + /// `i` and everything under it. + fn subtree(&self, i: usize) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![i]; + while let Some(j) = stack.pop() { + out.push(j); + stack.extend(self.elems[j].children.iter().copied()); + } + out + } + + fn is(&self, i: usize, ty: &[u8]) -> bool { + self.elems[i].ty == ty + } + + fn parent_is(&self, i: usize, types: &[&[u8]]) -> bool { + self.elems[i].parent.is_some_and(|p| types.contains(&self.elems[p].ty.as_slice())) + } + + fn finding(&self, i: usize, what: &str) -> Finding { + let e = &self.elems[i]; + let ty = String::from_utf8_lossy(&e.ty); + let at = e.page.map(|p| format!(" on page {}", p + 1)).unwrap_or_default(); + Finding { page: e.page, message: format!("{ty} element{at}: {what}") } + } + + pub fn rule(&self, rule: Rule) -> Vec { + let order = self.preorder(); + let mut out = Vec::new(); + match rule { + Rule::FiguresAltText => { + for &i in &order { + if self.is(i, b"Figure") && !self.elems[i].alt && !self.ancestors(i).any(|a| self.elems[a].alt) { + out.push(self.finding(i, "no alternate text")); + } + } + } + Rule::OtherElementsAltText => { + for &i in &order { + if self.is(i, b"Formula") && !self.elems[i].alt && !self.ancestors(i).any(|a| self.elems[a].alt) { + out.push(self.finding(i, "no alternate text")); + } + } + } + Rule::NestedAltText => { + for &i in &order { + if self.elems[i].alt && self.ancestors(i).any(|a| self.elems[a].alt) { + out.push(self.finding(i, "its alternate text is inside another element's and will never be read")); + } + } + } + Rule::AltTextAssociated => { + for &i in &order { + if self.elems[i].alt && !self.subtree(i).iter().any(|j| self.elems[*j].content) { + out.push(self.finding(i, "alternate text on an element with no page content")); + } + } + } + Rule::AltTextHidesAnnotation => { + for &i in &order { + if self.elems[i].alt && self.subtree(i).iter().any(|j| !self.elems[*j].annots.is_empty()) { + out.push(self.finding(i, "alternate text hides an annotation inside it")); + } + } + } + Rule::TableRows => { + for &i in &order { + if self.is(i, b"TR") && !self.parent_is(i, &[b"Table", b"THead", b"TBody", b"TFoot"]) { + out.push(self.finding(i, "not in a Table, THead, TBody or TFoot")); + } + } + } + Rule::TableCells => { + for &i in &order { + if (self.is(i, b"TH") || self.is(i, b"TD")) && !self.parent_is(i, &[b"TR"]) { + out.push(self.finding(i, "not in a TR")); + } + } + } + Rule::TableHeaders => { + for &i in &order { + if self.is(i, b"Table") && !self.table_cells(i).iter().any(|(_, c)| self.is(*c, b"TH")) { + out.push(self.finding(i, "no header cells (TH)")); + } + } + } + Rule::TableRegularity => { + for &i in &order { + if self.is(i, b"Table") + && let Some(why) = self.irregular(i) + { + out.push(self.finding(i, &why)); + } + } + } + Rule::TableSummary => { + for &i in &order { + if self.is(i, b"Table") && self.attr(i, b"Summary").is_none() { + out.push(self.finding(i, "no summary")); + } + } + } + Rule::ListItems => { + for &i in &order { + if self.is(i, b"LI") && !self.parent_is(i, &[b"L"]) { + out.push(self.finding(i, "not in an L (list)")); + } + } + } + Rule::LblLBody => { + for &i in &order { + if (self.is(i, b"Lbl") || self.is(i, b"LBody")) && !self.parent_is(i, &[b"LI"]) && !self.inside_toc_or_note(i) { + out.push(self.finding(i, "not in an LI (list item)")); + } + } + } + Rule::HeadingNesting => { + let mut prev = 0u8; + for &i in &order { + let level = match self.elems[i].ty.as_slice() { + [b'H', d @ b'1'..=b'6'] => d - b'0', + _ => continue, + }; + if level > prev + 1 { + let after = if prev == 0 { "the first heading".to_string() } else { format!("follows an H{prev}") }; + out.push(self.finding(i, &format!("H{level} is {after}; headings should not skip levels"))); + } + prev = level; + } + } + _ => {} + } + out + } + + /// Lbl is also allowed in TOCI and Note-like contexts (PDF 2.0); only list misuse is flagged. + fn inside_toc_or_note(&self, i: usize) -> bool { + self.parent_is(i, &[b"TOCI", b"BibEntry", b"Note", b"FENote"]) + } + + /// An attribute from the element's attribute objects (/A), any owner. + fn attr(&self, i: usize, key: &[u8]) -> Option<&Object> { + self.elems[i].attrs.iter().find_map(|d| d.get(key)) + } + + fn span(&self, i: usize, key: &[u8]) -> usize { + self.attr(i, key).and_then(Object::as_int).filter(|n| *n >= 1).map_or(1, |n| n.min(10_000) as usize) + } + + /// The rows of a table (through THead/TBody/TFoot, not into nested tables) and their cells. + fn table_cells(&self, table: usize) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + for (r, row) in self.table_rows(table).into_iter().enumerate() { + for &c in &self.elems[row].children { + if self.is(c, b"TH") || self.is(c, b"TD") { + out.push((r, c)); + } + } + } + out + } + + fn table_rows(&self, table: usize) -> Vec { + let mut rows = Vec::new(); + for &k in &self.elems[table].children { + match self.elems[k].ty.as_slice() { + b"TR" => rows.push(k), + b"THead" | b"TBody" | b"TFoot" => rows.extend(self.elems[k].children.iter().copied().filter(|r| self.is(*r, b"TR"))), + _ => {} + } + } + rows + } + + /// Why the table's rows don't all have the same width, counting row and column spans. + fn irregular(&self, table: usize) -> Option { + let rows = self.table_rows(table); + if rows.len() < 2 { + return None; + } + // carry[c] = rows still covered at column c by a cell above. + let mut carry: Vec = Vec::new(); + let mut widths = Vec::with_capacity(rows.len()); + for &row in &rows { + let mut col = 0; + let mut next = carry.iter().map(|n| n.saturating_sub(1)).collect::>(); + for &c in self.elems[row].children.iter().filter(|c| self.is(**c, b"TH") || self.is(**c, b"TD")) { + while col < carry.len() && carry[col] > 0 { + col += 1; + } + let (cs, rs) = (self.span(c, b"ColSpan"), self.span(c, b"RowSpan")); + if next.len() < col + cs { + next.resize(col + cs, 0); + } + for n in next.iter_mut().skip(col).take(cs) { + *n = (*n).max(rs - 1); + } + col += cs; + } + while col < carry.len() && carry[col] > 0 { + col += 1; + } + widths.push(col); + carry = next; + } + let (min, max) = (widths.iter().min()?, widths.iter().max()?); + (min != max).then(|| format!("rows have between {min} and {max} columns")) + } +} + +struct Reader<'a> { + doc: &'a Document, + role_map: &'a Dict, + page_index: &'a HashMap, + seen: &'a mut HashSet, +} + +impl Reader<'_> { + fn standard(&self, ty: &[u8]) -> Vec { + let mut t = ty.to_vec(); + for _ in 0..12 { + if STANDARD.contains(&t.as_slice()) { + return t; + } + match self.role_map.get(&t).map(|o| self.doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)) { + Some(n) if n != t => t = n, + _ => break, + } + } + t + } + + fn page_of(&self, d: &Dict) -> Option { + d.get(b"Pg").and_then(Object::as_ref).and_then(|r| self.page_index.get(&r).copied()) + } + + /// Read the kids `k` of `parent` (or of the root), appending element indexes to `out`. + fn kids(&mut self, t: &mut Tree, k: &Object, parent: Option, page: Option, out: &mut Vec, depth: usize) { + if depth > 256 || t.elems.len() >= MAX_ELEMENTS { + return; + } + match k { + Object::Array(a) => { + for x in a { + self.kids(t, x, parent, page, out, depth + 1); + } + } + Object::Int(_) => { + if let Some(p) = parent { + t.elems[p].content = true; + } + } + Object::Ref(r) => { + if !self.seen.insert(*r) { + return; + } + let obj = self.doc.get(*r); + if let Some(d) = obj.as_dict() { + self.dict(t, d, parent, page, out, depth); + } + } + Object::Dict(d) => self.dict(t, d, parent, page, out, depth), + _ => {} + } + } + + fn dict(&mut self, t: &mut Tree, d: &Dict, parent: Option, page: Option, out: &mut Vec, depth: usize) { + match d.name(b"Type") { + Some(b"MCR") => { + if let Some(p) = parent { + t.elems[p].content = true; + } + return; + } + Some(b"OBJR") => { + if let Some(p) = parent { + t.elems[p].content = true; + if let Some(o) = d.get(b"Obj").and_then(Object::as_ref) + && self.doc.get(o).as_dict().is_some_and(|a| a.contains(b"Subtype") && a.contains(b"Rect")) + { + t.elems[p].annots.push(o); + } + } + return; + } + _ => {} + } + let Some(s) = d.name(b"S") else { return }; + let page = self.page_of(d).or(page); + let text = + |k: &[u8]| d.get(k).map(|o| self.doc.resolve(o)).and_then(|o| o.as_string().map(|s| !s.to_text().trim().is_empty())).unwrap_or(false); + let mut attrs = Vec::new(); + if let Some(a) = d.get(b"A").map(|o| self.doc.resolve(o)) { + let list = match &*a { + Object::Array(v) => v.clone(), + other => vec![other.clone()], + }; + for x in list { + if let Some(ad) = self.doc.resolve(&x).as_dict() { + attrs.push(ad.clone()); + } + } + } + let i = t.elems.len(); + t.elems.push(Elem { ty: self.standard(s), parent, alt: text(b"Alt") || text(b"ActualText"), page, attrs, ..Elem::default() }); + if let Some(p) = parent { + t.elems[p].children.push(i); + } + out.push(i); + if let Some(k) = d.get(b"K") { + let mut ignored = Vec::new(); + self.kids(t, k, Some(i), page, &mut ignored, depth + 1); + } + } +} diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs new file mode 100644 index 00000000..f0ec367a --- /dev/null +++ b/crates/a11y/src/tests.rs @@ -0,0 +1,224 @@ +use std::sync::Arc; + +use printcraft_cos::Document; + +use super::*; + +/// A PDF from object bodies (object n = bodies[n - 1]) and extra trailer entries. +fn pdf(bodies: &[String], trailer: &str) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(data: &str) -> String { + format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +fn status(r: &Report, rule: Rule) -> Status { + r.result(rule).unwrap().status +} + +fn failed(r: &Report) -> Vec { + r.results.iter().filter(|x| x.status == Status::Failed).map(|x| x.rule).collect() +} + +const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + +#[test] +fn an_untagged_document_fails_the_document_rules() { + 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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"), + FONT.into(), + ], + "", + ); + let r = check(&doc, &Options::default()); + assert_eq!(r.results.len(), 32); + assert_eq!(failed(&r), [Rule::TaggedPdf, Rule::PrimaryLanguage, Rule::Title, Rule::TaggedContent]); + assert_eq!(status(&r, Rule::ColorContrast), Status::Skipped, "off by default"); + assert_eq!(status(&r, Rule::LogicalReadingOrder), Status::Manual); + assert_eq!(status(&r, Rule::ImageOnly), Status::Passed); + assert_eq!(r.result(Rule::TaggedPdf).unwrap().findings.len(), 2); + // Only the chosen rules run. + let only = check(&doc, &Options { rules: [Rule::Title].into(), pages: None }); + assert_eq!((only.count(Status::Failed), only.count(Status::Skipped)), (1, 31)); +} + +/// A tagged document that passes every automatic rule. +fn good() -> Vec { + vec![ + // 1 catalog, 2 pages, 3 page, 4 content, 5 font + "<< /Type /Catalog /Pages 2 0 R /Lang (en-US) /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R /ViewerPreferences << /DisplayDocTitle true >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Tabs /S /Annots [9 0 R 10 0 R] /StructParents 0 \ + /Resources << /Font << /F1 5 0 R >> /XObject << /Im 11 0 R >> >> >>" + .into(), + stream( + "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 20 180 Td (Title) Tj ET EMC \ + /Figure << /MCID 1 >> BDC q 10 0 0 10 20 120 cm /Im Do Q EMC \ + /Artifact BMC 0 0 200 2 re f EMC \ + /TH << /MCID 2 >> BDC BT (A) Tj ET EMC /TH << /MCID 3 >> BDC BT (B) Tj ET EMC \ + /TD << /MCID 4 >> BDC BT (1) Tj ET EMC /TD << /MCID 5 >> BDC BT (2) Tj ET EMC \ + /Lbl << /MCID 6 >> BDC BT (1.) Tj ET EMC /LBody << /MCID 7 >> BDC BT (Item) Tj ET EMC", + ), + FONT.into(), + // 6 struct tree root, 7 Document element, 8 info + "<< /Type /StructTreeRoot /K 7 0 R /RoleMap << /Heading1 /H1 >> >>".into(), + "<< /S /Document /P 6 0 R /Pg 3 0 R /K [\ + << /S /Heading1 /Pg 3 0 R /K 0 >> \ + << /S /Figure /Pg 3 0 R /Alt (A red square) /K 1 >> \ + << /S /Table /Pg 3 0 R /A << /O /Table /Summary (Two columns) >> /K [\ + << /S /TR /K [ << /S /TH /K 2 >> << /S /TH /K 3 >> ] >> \ + << /S /TR /K [ << /S /TD /K 4 >> << /S /TD /K 5 >> ] >> ] >> \ + << /S /L /K [ << /S /LI /K [ << /S /Lbl /K 6 >> << /S /LBody /K 7 >> ] >> ] >> \ + << /S /Link /K << /Type /OBJR /Obj 9 0 R >> >> \ + << /S /Form /K << /Type /OBJR /Obj 10 0 R >> >> ] >>" + .into(), + "<< /Title (Quarterly report) >>".into(), + // 9 link, 10 field, 11 image + "<< /Type /Annot /Subtype /Link /Rect [20 20 80 40] /StructParent 1 >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [20 60 120 80] /StructParent 2 >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + ] +} + +#[test] +fn a_well_tagged_document_passes() { + let doc = pdf(&good(), "/Info 8 0 R"); + let r = check(&doc, &Options::default()); + assert!(failed(&r).is_empty(), "{:?}", r.results.iter().filter(|x| x.status == Status::Failed).collect::>()); + assert_eq!((r.count(Status::Manual), r.count(Status::Skipped), r.count(Status::Passed)), (5, 1, 26)); +} + +#[test] +fn structure_problems_are_found() { + let mut objs = good(); + objs[6] = "<< /S /Document /P 6 0 R /Pg 3 0 R /K [\ + << /S /H1 /K 0 >> << /S /H3 /K 2 >> \ + << /S /Figure /K 1 >> \ + << /S /Sect /Alt (Outer) /K [ << /S /P /Alt (Inner) /K 3 >> ] >> \ + << /S /Div /Alt (Nothing) >> \ + << /S /P /Alt (Over a link) /K << /Type /OBJR /Obj 9 0 R >> >> \ + << /S /Formula /K 4 >> \ + << /S /Table /K [ << /S /TR /K [ << /S /TD /K 5 >> << /S /TD >> ] >> << /S /TR /K [ << /S /TD /K 6 >> ] >> ] >> \ + << /S /TD /K 7 >> << /S /LI >> << /S /LBody >> << /S /TR >> ] >>" + .into(); + let doc = pdf(&objs, "/Info 8 0 R"); + let r = check(&doc, &Options::default()); + let mut want = vec![ + Rule::FiguresAltText, + Rule::NestedAltText, + Rule::AltTextAssociated, + Rule::AltTextHidesAnnotation, + Rule::OtherElementsAltText, + Rule::TableRows, + Rule::TableCells, + Rule::TableHeaders, + Rule::TableRegularity, + Rule::TableSummary, + Rule::ListItems, + Rule::LblLBody, + Rule::HeadingNesting, + ]; + want.sort(); + let mut got = failed(&r); + got.sort(); + assert_eq!(got, want); + let h = &r.result(Rule::HeadingNesting).unwrap().findings[0].message; + assert!(h.contains("H3") && h.contains("H1"), "{h}"); + assert!(r.result(Rule::TableRegularity).unwrap().findings[0].message.contains("between 1 and 2")); +} + +#[test] +fn row_and_column_spans_keep_a_table_regular() { + let mut objs = good(); + // Row 1: a cell spanning two rows and one spanning two columns; row 2 then has one cell. + objs[6] = "<< /S /Document /P 6 0 R /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [\ + << /S /TR /K [ << /S /TH /A << /O /Table /RowSpan 2 >> /K 0 >> << /S /TH /A [ << /O /Table /ColSpan 2 >> ] /K 1 >> ] >> \ + << /S /TR /K [ << /S /TD /K 2 >> << /S /TD /K 3 >> ] >> ] >> ] >>" + .into(); + let r = check(&pdf(&objs, "/Info 8 0 R"), &Options { rules: [Rule::TableRegularity].into(), pages: None }); + assert_eq!(status(&r, Rule::TableRegularity), Status::Passed, "{:?}", r.result(Rule::TableRegularity)); +} + +#[test] +fn page_content_and_annotation_problems_are_found() { + let mut objs = good(); + // Untagged text, no tab order, an untagged link and a field without a tooltip. + objs[2] = objs[2].replace("/Tabs /S ", ""); + objs[3] = stream("BT /F1 12 Tf (stray) Tj ET /P << /MCID 0 >> BDC BT (ok) Tj ET EMC"); + objs[8] = "<< /Type /Annot /Subtype /Link /Rect [20 20 80 40] >>".into(); + objs[9] = "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] /StructParent 2 >>".into(); + let r = check(&pdf(&objs, "/Info 8 0 R"), &Options::default()); + let mut got = failed(&r); + got.sort(); + 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\"")); +} + +#[test] +fn scans_identity_fonts_and_long_documents() { + // An image-only page. + 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] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(), + stream("q 200 0 0 200 0 0 cm /Im Do Q"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + ], + "", + ); + assert_eq!(status(&check(&doc, &Options::default()), Rule::ImageOnly), Status::Failed); + // An Identity-H font without ToUnicode; 21 pages without bookmarks. + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count 21 >>", (0..21).map(|i| format!("{} 0 R", 6 + i)).collect::>().join(" ")), + stream("BT /F1 12 Tf <0001> Tj ET"), + "<< /Type /Font /Subtype /Type0 /BaseFont /Custom /Encoding /Identity-H /DescendantFonts [5 0 R] >>".into(), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Custom /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> >>".into(), + ]; + for _ in 0..21 { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 3 0 R /Resources << /Font << /F1 4 0 R >> >> >>".into()); + } + let r = check(&pdf(&objs, ""), &Options::default()); + assert_eq!(status(&r, Rule::Bookmarks), Status::Failed); + let enc = r.result(Rule::CharacterEncoding).unwrap(); + assert_eq!((enc.status, enc.findings.len()), (Status::Failed, 1), "each font once"); + assert!(enc.findings[0].message.contains("Custom")); + // A page range limits the page rules. + let some = check(&pdf(&objs, ""), &Options { pages: Some(vec![3]), ..Options::default() }); + assert_eq!(some.result(Rule::CharacterEncoding).unwrap().findings[0].page, Some(3)); +} + +#[test] +fn rules_have_ids_and_the_report_lists_them() { + assert_eq!(Rule::ALL.len(), 32); + for r in Rule::ALL { + assert_eq!(Rule::from_id(r.id()), Some(r)); + } + let per: Vec = Category::ALL.iter().map(|c| Rule::ALL.iter().filter(|r| r.category() == *c).count()).collect(); + assert_eq!(per, [8, 9, 2, 5, 5, 2, 1]); + let doc = pdf(&good(), "/Info 8 0 R"); + let html = report_html(&check(&doc, &Options::default()), "a.pdf", "2026-10-02"); + assert!(html.contains("a<b>.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5")); + assert!(html.contains("

Headings

")); +} diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs new file mode 100644 index 00000000..2a7e5804 --- /dev/null +++ b/crates/automation/src/a11y.rs @@ -0,0 +1,104 @@ +//! Accessibility tools: the full check, the accessibility report, and the automatic fixes. + +use printcraft_engine::a11y::{Category, Options, Report, Rule, Status}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; + +fn status_id(s: Status) -> &'static str { + match s { + Status::Passed => "passed", + Status::Failed => "failed", + Status::Manual => "manual", + Status::Skipped => "skipped", + } +} + +fn category_id(c: Category) -> &'static str { + match c { + Category::Document => "document", + Category::PageContent => "page_content", + Category::Forms => "forms", + Category::AlternateText => "alternate_text", + Category::Tables => "tables", + Category::Lists => "lists", + Category::Headings => "headings", + } +} + +pub(crate) fn report_json(r: &Report) -> Value { + let results: Vec = r + .results + .iter() + .map(|x| { + let findings: Vec = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "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(); + json!({ + "passed": r.count(Status::Passed), + "failed": r.count(Status::Failed), + "manual": r.count(Status::Manual), + "skipped": r.count(Status::Skipped), + "results": results, + }) +} + +impl Automation { + fn a11y_options(&self, a: &Args) -> Result { + let mut o = Options::default(); + if a.opt_bool("all")?.unwrap_or(false) { + o.rules = Rule::ALL.into_iter().collect(); + } + if a.get("rules").is_some() { + o.rules = a + .strs("rules")? + .iter() + .map(|id| Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))) + .collect::>()?; + } + if a.get("categories").is_some() { + let cats: Vec<&str> = a.strs("categories")?; + for c in &cats { + if !Category::ALL.iter().any(|k| category_id(*k) == *c) { + return Err(ToolError::InvalidArgs(format!("unknown category {c:?}"))); + } + } + o.rules.retain(|r| cats.contains(&category_id(r.category()))); + } + if a.opt_ints("pages")?.is_some() { + o.pages = Some(self.pages(a, "pages")?); + } + Ok(o) + } + + pub(crate) fn a11y_check(&self, a: &Args) -> Result { + let o = self.a11y_options(a)?; + let r = self.doc(a)?.accessibility_check(&o).ok_or_else(|| failed("the document can't be read for checking"))?; + Ok(report_json(&r)) + } + + pub(crate) fn a11y_report(&self, a: &Args) -> Result { + let o = self.a11y_options(a)?; + let doc = self.doc(a)?; + let r = doc.accessibility_check(&o).ok_or_else(|| failed("the document can't be read for checking"))?; + let path = self.resolve(a.str("path")?, true)?; + let (y, m, d) = self.session.today(); + let html = printcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}")); + write_atomic(&path, html.as_bytes())?; + let mut out = report_json(&r); + out["path"] = json!(path.to_string_lossy()); + Ok(out) + } + + pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result { + let id = a.str("rule")?; + let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?; + let edit = self.doc(a)?.accessibility_fix(rule, a.opt_str("value")?).map_err(failed)?; + let doc = self.doc(a)?.id; + self.session.apply(doc, edit).map_err(failed)?; + let r = + self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?; + Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 008a18ad..c06397f4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -9,6 +9,7 @@ //! with the origin at the top-left of the displayed page. //! - An optional root directory confines every path a tool reads or writes. +mod a11y; mod comments; mod content; mod forms; @@ -320,6 +321,9 @@ impl Automation { "comment_reply" => self.comment_reply(&a)?, "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, + "accessibility_check" => self.a11y_check(&a)?, + "accessibility_report" => self.a11y_report(&a)?, + "accessibility_fix" => self.a11y_fix(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, "sign_trust" => self.sign_trust(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b81569dc..b4c319ed 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -673,6 +673,35 @@ pub fn tools() -> Vec { }), &["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.") + .ro() + .cmd("a11y.check") + .with(schema( + json!({ + "doc": doc(), + "rules": { "type": "array", "items": { "type": "string" } }, + "categories": { "type": "array", "items": { "type": "string", "enum": ["document", "page_content", "forms", "alternate_text", "tables", "lists", "headings"] } }, + "all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." }, + "pages": pages("for the page rules (default: all)"), + }), + &["doc"], + )), + t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.") + .ro() + .cmd("a11y.report") + .with(schema( + json!({ + "doc": doc(), + "path": { "type": "string" }, + "rules": { "type": "array", "items": { "type": "string" } }, + "categories": { "type": "array", "items": { "type": "string" } }, + "all": { "type": "boolean" }, + "pages": pages("for the page rules (default: all)"), + }), + &["doc", "path"], + )), + t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.") + .with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])), t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 9f8620bf..d7ed9933 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -566,6 +566,37 @@ fn exporting_images_and_text_through_tools() { assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root"); } +#[test] +fn accessibility_check_report_and_fixes_through_tools() { + let dir = workdir("a11y"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "accessibility_check", json!({ "doc": doc })); + assert_eq!(r["results"].as_array().unwrap().len(), 32); + let status = + |r: &Value, id: &str| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned(); + for id in ["tagged-pdf", "primary-language", "title", "tagged-content"] { + assert_eq!(status(&r, id), "failed", "{id}"); + } + assert_eq!((status(&r, "color-contrast"), status(&r, "scripts")), ("skipped".into(), "manual".into())); + assert_eq!(status(&ok(&mut a, "accessibility_check", json!({ "doc": doc, "all": true })), "color-contrast"), "manual"); + let docs = ok(&mut a, "accessibility_check", json!({ "doc": doc, "categories": ["document"] })); + assert_eq!(docs["skipped"], 25, "24 other rules and colour contrast"); + // Fixes. + assert!(matches!(a.call("accessibility_fix", &json!({ "doc": doc, "rule": "primary-language" })), Err(ToolError::Failed(_))), "needs a language"); + assert_eq!(ok(&mut a, "accessibility_fix", json!({ "doc": doc, "rule": "primary-language", "value": "en-GB" }))["status"], "passed"); + assert_eq!(ok(&mut a, "accessibility_fix", json!({ "doc": doc, "rule": "title" }))["status"], "passed"); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["title"], "a"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Set document title"); + assert!(matches!(a.call("accessibility_fix", &json!({ "doc": doc, "rule": "tagged-pdf" })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("accessibility_check", &json!({ "doc": doc, "rules": ["nope"] })), Err(ToolError::InvalidArgs(_)))); + // The report. + let r = ok(&mut a, "accessibility_report", json!({ "doc": doc, "path": "report.html" })); + assert_eq!(r["failed"].as_u64(), Some(3), "tagging, tagged content and (undone) title"); + let html = std::fs::read_to_string(dir.join("report.html")).unwrap(); + assert!(html.contains("Accessibility Report") && html.contains("a.pdf")); +} + #[test] fn exporting_all_images_through_tools() { let dir = workdir("export-all-images"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 5d29f1c6..1e888d75 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,6 +21,7 @@ printcraft-print = { workspace = true } printcraft-xfdf = { workspace = true } printcraft-sign = { workspace = true } printcraft-optimize = { workspace = true } +printcraft-a11y = { workspace = true } printcraft-create = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index ff78dbb2..8de6a848 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -436,14 +436,16 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "accessibility", hue: PURPLE, badge: None, - availability: Planned("M12"), + availability: Ready, sections: &[ToolSection { title: "Accessibility", items: &[ item("Automatically tag PDF", "tag", "a11y.autotag", Planned("M12")), - item("Check for accessibility", "accessibility", "a11y.check", Planned("M12")), - item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")), + item("Change reading options", "book-open", "a11y.reading_options", Ready), + item("Check for accessibility", "accessibility", "a11y.check", Ready), + item("Open accessibility report", "file-text", "a11y.report", Ready), item("Add alternate text", "image", "a11y.alt_text", Planned("M12")), + item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6898180a..da710850 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -244,6 +244,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Document, "type"), + c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), + c("a11y.report", "Open accessibility report", None, None, Document, "file-text"), + c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"), c("export.all_images", "Export all images…", FILE, None, Document, "image"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 04fdf89f..f7ab326e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ pub use printcraft_forms::{ Widget as FormWidget, af as form_scripts, flags as field_flags, }; +pub use printcraft_a11y as a11y; /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; pub use printcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; @@ -154,6 +155,40 @@ pub struct Document { impl Document { /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + pub fn initial_view(&self) -> InitialView { self.editor.as_ref().map(|e| printcraft_organize::initial_view(&e.cos)).unwrap_or_default() } diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs new file mode 100644 index 00000000..29e275ad --- /dev/null +++ b/crates/ui-egui/src/a11y_ui.rs @@ -0,0 +1,382 @@ +//! Prepare for accessibility ▸ Check for accessibility: the Accessibility Checker Options dialog +//! and the Accessibility Checker panel (results by category, with Fix, Skip rule, Explain, +//! Check again and Show report on each rule). + +use std::collections::BTreeSet; + +use egui::{Align, Layout}; +use printcraft_engine::DocId; +use printcraft_engine::a11y::{Category, Options, Report, Rule, Status}; + +use crate::theme::{self, Tokens}; +use crate::{Dialog, PrintCraftApp, PropsTab, RightPanel, icons, widgets}; + +/// The options dialog's settings (kept for the session, like Acrobat's). +#[derive(Clone, Debug, PartialEq)] +pub struct A11yOptions { + pub rules: BTreeSet, + pub all_pages: bool, + /// 1-based page range when not all pages. + pub from: usize, + pub to: usize, + /// Save the accessibility report when checking finishes. + pub create_report: bool, + /// Show the options when the checker starts. + pub show_dialog: bool, + /// The category whose rules the dialog lists. + pub category: Category, +} + +impl Default for A11yOptions { + fn default() -> Self { + Self { + rules: Options::default().rules, + all_pages: true, + from: 1, + to: 1, + create_report: false, + show_dialog: true, + category: Category::Document, + } + } +} + +/// The last check of a document, as the panel shows it. +#[derive(Clone, Debug, Default)] +pub struct A11yState { + pub report: Option<(DocId, Report)>, + /// Collapsed categories and expanded rules. + pub collapsed: BTreeSet, + pub expanded: BTreeSet, + /// The rule whose explanation is showing. + pub explain: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PanelAction { + Fix(Rule), + Skip(Rule), + CheckAgain, + ShowReport, + Options, + GoTo(usize), +} + +/// Rules with an automatic (or guided) fix. +pub(crate) fn fixable(rule: Rule) -> bool { + matches!(rule, Rule::PrimaryLanguage | Rule::Title | Rule::TabOrder) +} + +pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len().max(1)); + let o = &mut app.a11y_options; + o.to = o.to.clamp(1, pages); + o.from = o.from.clamp(1, o.to); + ui.label(egui::RichText::new("Accessibility Checker Options").font(theme::semibold(18.0))); + ui.add_space(8.0); + let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { + ui.label(egui::RichText::new(title).font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); + ui.add_space(8.0); + }; + group(ui, "Report Options", &mut |ui| { + ui.checkbox(&mut o.create_report, "Create accessibility report"); + }); + group(ui, "Page Range", &mut |ui| { + ui.horizontal(|ui| { + ui.radio_value(&mut o.all_pages, true, "All pages in document"); + ui.radio_value(&mut o.all_pages, false, "Pages from"); + ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.from).range(1..=pages)); + ui.label("to"); + ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.to).range(1..=pages)); + }); + }); + let on = o.rules.len(); + group(ui, &format!("Checking Options ({on} of 32 in all categories)"), &mut |ui| { + ui.horizontal(|ui| { + ui.label("Category:"); + egui::ComboBox::from_id_salt("a11y-category").selected_text(o.category.label()).width(260.0).show_ui(ui, |ui| { + for c in Category::ALL { + ui.selectable_value(&mut o.category, c, c.label()); + } + }); + }); + ui.add_space(4.0); + for r in Rule::ALL.into_iter().filter(|r| r.category() == o.category) { + let mut checked = o.rules.contains(&r); + if ui.checkbox(&mut checked, r.description()).changed() { + if checked { + o.rules.insert(r); + } else { + o.rules.remove(&r); + } + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Select All").clicked() { + o.rules.extend(Rule::ALL.into_iter().filter(|r| r.category() == o.category)); + } + if ui.button("Clear All").clicked() { + let c = o.category; + o.rules.retain(|r| r.category() != c); + } + }); + }); + ui.checkbox(&mut o.show_dialog, "Show this dialog when the Checker starts"); + ui.add_space(10.0); + let (mut start, mut cancel) = (false, false); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Start Checking", true).clicked() { + start = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (start, cancel) +} + +/// The red of failed checks (as for invalid signatures). +const FAILED: egui::Color32 = egui::Color32::from_rgb(0xD7, 0x37, 0x3F); + +fn status_icon(t: &Tokens, s: Status) -> (&'static str, egui::Color32) { + match s { + Status::Passed => ("circle-check", egui::Color32::from_rgb(0x2D, 0x9D, 0x4F)), + Status::Failed => ("circle-x", FAILED), + Status::Manual => ("circle-help", egui::Color32::from_rgb(0xD9, 0x8E, 0x04)), + Status::Skipped => ("circle-dashed", t.text_faint), + } +} + +/// The Accessibility Checker panel. +pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: DocId) -> Option { + let mut action = None; + let Some((_, report)) = state.report.as_ref().filter(|(d, _)| *d == doc) else { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.add(icons::image("accessibility", 32.0, t.text_faint)); + ui.add_space(6.0); + ui.label(egui::RichText::new("This document hasn't been checked yet.").color(t.text_muted)); + ui.add_space(6.0); + if widgets::pill_button(ui, "Check for accessibility", true).clicked() { + action = Some(PanelAction::Options); + } + }); + return action; + }; + let failed = report.count(Status::Failed); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(if failed == 0 { + "No issues found".to_string() + } else { + format!("{failed} issue{}", if failed == 1 { "" } else { "s" }) + }) + .color(t.text_muted), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Check again", false).clicked() { + action = Some(PanelAction::CheckAgain); + } + if widgets::pill_button(ui, "Report", false).on_hover_text("Show the accessibility report").clicked() { + action = Some(PanelAction::ShowReport); + } + }); + }); + ui.add_space(6.0); + for c in Category::ALL { + let rules: Vec<_> = report.in_category(c).collect(); + let issues = rules.iter().filter(|r| r.status == Status::Failed).count(); + let open = !state.collapsed.contains(&c); + let head = if issues > 0 { format!("{} ({issues} issue{})", c.label(), if issues == 1 { "" } else { "s" }) } else { c.label().to_string() }; + let clicked = ui + .horizontal(|ui| { + let toggle = + icons::button(ui, if open { "chevron-down" } else { "chevron-right" }, 20.0, false, if open { "Collapse" } else { "Expand" }) + .clicked(); + let color = if issues > 0 { FAILED } else { t.text }; + let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(13.0)).color(color)).sense(egui::Sense::click())); + toggle || l.clicked() + }) + .inner; + if clicked { + if open { + state.collapsed.insert(c); + } else { + state.collapsed.remove(&c); + } + } + if !open { + continue; + } + for r in rules { + let (icon, color) = status_icon(t, r.status); + let row = ui + .horizontal(|ui| { + ui.add_space(24.0); + ui.add(icons::image(icon, 15.0, color)); + ui.add( + egui::Label::new( + egui::RichText::new(format!("{} - {}", r.rule.name(), r.status.label())).font(theme::regular(12.5)).color(t.text), + ) + .sense(egui::Sense::click()), + ) + }) + .inner; + if row.clicked() && !r.findings.is_empty() && !state.expanded.remove(&r.rule) { + state.expanded.insert(r.rule); + } + row.context_menu(|ui| { + ui.set_min_width(170.0); + if ui.add_enabled(fixable(r.rule) && r.status == Status::Failed, egui::Button::new("Fix")).clicked() { + action = Some(PanelAction::Fix(r.rule)); + ui.close(); + } + if ui.add_enabled(r.status != Status::Skipped, egui::Button::new("Skip Rule")).clicked() { + action = Some(PanelAction::Skip(r.rule)); + ui.close(); + } + if ui.button("Explain").clicked() { + state.explain = if state.explain == Some(r.rule) { None } else { Some(r.rule) }; + ui.close(); + } + ui.separator(); + if ui.button("Check Again").clicked() { + action = Some(PanelAction::CheckAgain); + ui.close(); + } + if ui.button("Show Report").clicked() { + action = Some(PanelAction::ShowReport); + ui.close(); + } + if ui.button("Options…").clicked() { + action = Some(PanelAction::Options); + ui.close(); + } + }); + if state.explain == Some(r.rule) { + egui::Frame::NONE.inner_margin(egui::Margin { left: 46, right: 4, top: 0, bottom: 6 }).show(ui, |ui| { + ui.label(egui::RichText::new(r.rule.explanation()).small().color(t.text_muted)); + }); + } + if state.expanded.contains(&r.rule) { + egui::Frame::NONE.inner_margin(egui::Margin { left: 46, right: 4, top: 0, bottom: 6 }).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for f in &r.findings { + let l = ui.add(egui::Label::new(egui::RichText::new(&f.message).small().color(t.text_muted)).sense(egui::Sense::click())); + if let Some(p) = f.page + && l.on_hover_text("Go to the page").clicked() + { + action = Some(PanelAction::GoTo(p)); + } + } + }); + } + } + } + action +} + +impl PrintCraftApp { + /// Check for accessibility: the options first, unless they are turned off. + pub(crate) fn start_accessibility_check(&mut self) { + if self.a11y_options.show_dialog { + self.dialog = Some(Dialog::AccessibilityOptions); + } else { + self.run_accessibility_check(); + } + } + + /// Run the full check with the current options and show the results. + pub fn run_accessibility_check(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let o = &self.a11y_options; + let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (o.from.max(1) - 1..o.to.max(o.from)).collect()) }; + let Some(report) = doc.accessibility_check(&options) else { + self.notify("This document can't be checked: it couldn't be read for editing"); + return; + }; + // Rules skipped by hand stay skipped when checking again. + let mut report = report; + for r in report.results.iter_mut().filter(|r| self.a11y_skipped.contains(&r.rule)) { + r.status = Status::Skipped; + r.findings.clear(); + } + let failed = report.count(Status::Failed); + self.a11y.report = Some((id, report)); + self.right = Some(RightPanel::Accessibility); + self.notify(if failed == 0 { + "The checker found no problems in this document.".to_string() + } else { + format!( + "The checker found {failed} problem{} which may prevent the document from being fully accessible.", + if failed == 1 { "" } else { "s" } + ) + }); + if self.a11y_options.create_report { + self.show_accessibility_report(); + } + } + + /// Save the accessibility report (HTML) for the last check, checking first if needed. + pub fn show_accessibility_report(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + if !self.a11y.report.as_ref().is_some_and(|(d, _)| *d == id) { + let create = std::mem::replace(&mut self.a11y_options.create_report, false); + self.run_accessibility_check(); + self.a11y_options.create_report = create; + } + let Some((_, report)) = self.a11y.report.as_ref().filter(|(d, _)| *d == id) else { return }; + let Some(doc) = self.session.get(id) else { return }; + let (y, m, d) = self.session.today(); + let html = printcraft_engine::a11y::report_html(report, &doc.name, &format!("{y}-{m:02}-{d:02}")); + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + self.write_files(&[(format!("{stem} Accessibility Report.html"), std::sync::Arc::new(html.into_bytes()))], "Save the accessibility report"); + } + + pub(crate) fn a11y_action(&mut self, index: usize, action: PanelAction) { + match action { + 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::Skip(rule) => { + self.a11y_skipped.insert(rule); + if let Some((_, r)) = self.a11y.report.as_mut() + && let Some(x) = r.results.iter_mut().find(|x| x.rule == rule) + { + x.status = Status::Skipped; + x.findings.clear(); + } + } + PanelAction::Fix(rule) => { + let Some((_, id)) = self.active_ids() else { return }; + match rule { + // The language is chosen in Document Properties ▸ Advanced. + Rule::PrimaryLanguage => self.dialog = Some(Dialog::Properties(PropsTab::Advanced)), + _ => { + let edit = self.session.get(id).map(|d| d.accessibility_fix(rule, None)); + match edit { + Some(Ok(e)) => { + if self.apply_edit(e) { + self.run_accessibility_check(); + // The title can then be edited. + if rule == Rule::Title { + self.dialog = Some(Dialog::Properties(PropsTab::Description)); + } + } + } + Some(Err(e)) => self.notify(e), + None => {} + } + } + } + } + } + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c95a66b9..40d55818 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -241,6 +241,7 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) { (RightPanel::Layers, "Layers"), (RightPanel::Attachments, "Attachments"), (RightPanel::Signatures, "Signatures"), + (RightPanel::Accessibility, "Accessibility Checker"), ] { if ui.radio(app.right == Some(p), label).clicked() { app.right = Some(p); @@ -257,6 +258,7 @@ pub fn right_rail(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let Some((index, id)) = app.active_ids() else { return }; let Some(doc) = app.session.get(id) else { return }; let has_signatures = doc.is_signed(); + let has_check = app.a11y.report.as_ref().is_some_and(|(d, _)| *d == id); let (has_comments, has_outline, has_fields, has_layers, has_files) = ( !doc.info.annotations.is_empty(), !doc.info.outline.is_empty(), @@ -290,6 +292,9 @@ pub fn right_rail(app: &mut PrintCraftApp, ui: &mut egui::Ui) { rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); + if has_check { + rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); + } // Page navigation cluster at the bottom (as in Acrobat's rail). let view = &mut app.views[index]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 314b130a..e7f92b9f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -222,6 +222,9 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + "a11y.check" => self.start_accessibility_check(), + "a11y.report" => self.show_accessibility_report(), + "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), "edit.text" => { self.quick_tool = crate::QuickTool::AddText; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e20f023..12bb837a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -49,6 +49,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut duplicate_now: Option = None; let mut replace_now = false; let mut open_revision: Option = None; + let mut a11y_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; @@ -756,6 +757,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = apply || cancel; return; } + Dialog::AccessibilityOptions => { + let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); + a11y_now = start; + close = start || cancel; + return; + } Dialog::Export(kind) => { let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind); export_now = apply; @@ -1105,6 +1112,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { if let Some(n) = open_revision { app.open_revision(n); } + if a11y_now { + app.a11y_skipped.clear(); + app.run_accessibility_check(); + } } /// Info edits needed to make the document match the Description draft. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d5ece1ac..52c2a5f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5,6 +5,7 @@ //! Everything here is presentation: documents, rendering and the tool catalogue live in //! `printcraft-engine`. +mod a11y_ui; pub mod canvas; mod chrome; mod commands; @@ -85,6 +86,8 @@ pub enum RightPanel { Layers, Attachments, Signatures, + /// Accessibility Checker results. + Accessibility, } /// Quick-action bar tools (the vertical floating strip). @@ -169,6 +172,8 @@ pub enum Dialog { Optimize, /// Prepare a form ▸ right-click a field ▸ Duplicate. DuplicateField, + /// Check for accessibility ▸ Accessibility Checker Options. + AccessibilityOptions, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -267,6 +272,10 @@ pub struct PrintCraftApp { pub digital_ids: Vec, /// Signatures panel: expanded entries (field names). pub sig_expanded: Vec, + /// Accessibility Checker: options, the last check, and rules skipped by hand. + pub a11y_options: a11y_ui::A11yOptions, + pub a11y: a11y_ui::A11yState, + pub a11y_skipped: std::collections::BTreeSet, /// PDF Optimizer choices. pub optimize_draft: OptimizeDraft, /// Pages copied or cut in Organize Pages, ready to paste (into any document). @@ -386,6 +395,9 @@ impl PrintCraftApp { sign_draft: None, digital_ids: Vec::new(), sig_expanded: Vec::new(), + a11y_options: a11y_ui::A11yOptions::default(), + a11y: a11y_ui::A11yState::default(), + a11y_skipped: Default::default(), optimize_draft: OptimizeDraft::default(), page_clipboard: None, last_snapshot: None, @@ -755,6 +767,7 @@ impl PrintCraftApp { "layers" => Some(RightPanel::Layers), "attachments" => Some(RightPanel::Attachments), "signatures" => Some(RightPanel::Signatures), + "accessibility" => Some(RightPanel::Accessibility), "none" => None, other => return Err(format!("unknown panel {other}")), } @@ -791,6 +804,7 @@ impl PrintCraftApp { "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), + "accessibility-options" => Some(Dialog::AccessibilityOptions), "signature" => Some(Dialog::Signature), "optimize" => Some(Dialog::Optimize), "sign" | "certify" => { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a9d88a84..31369405 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -340,6 +340,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut panel_edit: Option = None; let mut panel_command: Option<&'static str> = None; let mut sig_action: Option = None; + let mut a11y_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); // Prepare a form is open: the Fields panel orders tabs. @@ -350,6 +351,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; let prefs = &app.comment_prefs; let sig_expanded = &mut app.sig_expanded; + let a11y = &mut app.a11y; let comment_allowed = doc.allows_annotation(); egui::Panel::right("right_panel") .resizable(true) @@ -370,6 +372,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { RightPanel::Layers => ("Layers", Some(info.layers.len())), RightPanel::Attachments => ("Attachments", Some(info.attachments.len())), RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())), + RightPanel::Accessibility => ("Accessibility Checker", None), }; ui.horizontal(|ui| { ui.label(egui::RichText::new(title).font(theme::semibold(15.5))); @@ -467,6 +470,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { RightPanel::Signatures => { sig_action = crate::sign_ui::panel(ui, &t, &doc.signatures, sig_expanded); } + RightPanel::Accessibility => { + a11y_action = crate::a11y_ui::panel(ui, &t, a11y, id); + } RightPanel::Attachments => { if info.attachments.is_empty() { empty(ui, &t, "paperclip", "This document has no attachments."); @@ -513,6 +519,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if let Some(c) = panel_command { app.run_command(c); } + if let Some(a) = a11y_action { + app.a11y_action(index, a); + } match sig_action { Some(crate::sign_ui::PanelAction::Validate) => app.run_command("sign.validate"), Some(crate::sign_ui::PanelAction::GoTo(p)) => app.views[index].go_to_page(p), diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs new file mode 100644 index 00000000..aac4c3a5 --- /dev/null +++ b/crates/ui-egui/tests/accessibility.rs @@ -0,0 +1,88 @@ +//! Prepare for accessibility ▸ Check for accessibility in the real shell (egui_kittest): the +//! options dialog, the results panel, Fix, Skip Rule and the report. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_engine::a11y::{Rule, Status}; +use printcraft_ui_egui::{Dialog, PrintCraftApp, RightPanel}; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length 37 >> stream +BT /F1 12 Tf 20 50 Td (Hello) Tj ET +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn status(h: &Harness<'static, PrintCraftApp>, rule: Rule) -> Status { + h.state().a11y.report.as_ref().unwrap().1.result(rule).unwrap().status +} + +fn right_click(h: &mut Harness<'static, PrintCraftApp>, label: &str) { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn checking_fixing_skipping_and_reporting() { + let dir = std::env::temp_dir().join(format!("printcraft-a11y-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let d = dir.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("notes.pdf", None, FIXTURE.to_vec()).unwrap(); + app.export_dir_override = Some(d.to_string_lossy().into_owned()); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("a11y.check")); + h.run_steps(2); + h.get_by_label("Accessibility Checker Options"); + h.get_by_label_contains("31 of 32"); + h.get_by_label("Document title is showing in title bar"); + h.get_by_label("Start Checking").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!(h.state().right, Some(RightPanel::Accessibility)); + h.get_by_label("Document (3 issues)"); + h.get_by_label("Title - Failed"); + h.get_by_label("Color contrast - Skipped"); + // Fix the title: the file name becomes the title and it shows; the description opens to edit it. + right_click(&mut h, "Title - Failed"); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(status(&h, Rule::Title), Status::Passed); + assert!(matches!(h.state().dialog, Some(Dialog::Properties(_)))); + assert_eq!(h.state().window_title, "notes — PrintCraft"); + h.state_mut().dialog = None; + h.run_steps(2); + // Skip a rule; it stays skipped when checking again. + right_click(&mut h, "Tagged PDF - Failed"); + h.get_by_label("Skip Rule").click(); + h.run_steps(2); + h.get_by_label("Check again").click(); + h.run_steps(3); + assert_eq!(status(&h, Rule::TaggedPdf), Status::Skipped); + h.get_by_label("Document (1 issue)"); + // Explain shows why the rule matters; findings list the problems. + right_click(&mut h, "Primary language - Failed"); + h.get_by_label("Explain").click(); + h.run_steps(2); + h.get_by_label_contains("Screen readers choose their voice"); + h.get_by_label("Primary language - Failed").click(); + h.run_steps(2); + h.get_by_label("No document language is set"); + // The report. + h.get_by_label("Report").click(); + h.run_steps(2); + let html = std::fs::read_to_string(dir.join("notes Accessibility Report.html")).unwrap(); + assert!(html.contains("Accessibility Report") && html.contains("Skipped")); +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 66cf2473..b6f3c191 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -45,7 +45,7 @@ fn harness() -> Harness<'static, PrintCraftApp> { /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) const PICKERS: &[&str] = - &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace", "create.clipboard"]; + &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace", "create.clipboard", "a11y.report"]; #[test] fn every_registered_command_is_implemented() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6ee8a3b9..3b035c70 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8263,11 +8263,11 @@ title = "Accessibility checker (full check)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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" [[feature]] id = "a11y.checker-report" @@ -8275,11 +8275,11 @@ title = "Accessibility report" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.report"] +tools = ["accessibility_report"] +evidence = ["crates/a11y/src/tests.rs::rules_have_ids_and_the_report_lists_them", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "HTML report: summary counts and a table per category with descriptions and findings" [[feature]] id = "a11y.checker-fix" @@ -8287,11 +8287,11 @@ title = "Fix, skip, explain and re-check rules" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_fix"] +evidence = ["crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "right-click a rule: Fix (language via Document Properties, title, tab order), Skip Rule (kept on Check Again), Explain, Check Again, Show Report, Options" [[feature]] id = "a11y.rule-permission-flag" @@ -8299,11 +8299,11 @@ title = "Rule: accessibility permission flag" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"] +notes = "rule permission-flag: the file's /P bit 10 whichever password opened it" [[feature]] id = "a11y.rule-image-only" @@ -8311,11 +8311,11 @@ title = "Rule: image-only PDF" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"] +notes = "rule image-only: images and no text on the checked pages" [[feature]] id = "a11y.rule-tagged-pdf" @@ -8323,11 +8323,11 @@ title = "Rule: tagged PDF" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules"] +notes = "rule tagged-pdf: StructTreeRoot and MarkInfo Marked true" [[feature]] id = "a11y.rule-logical-order" @@ -8335,11 +8335,11 @@ title = "Rule: logical reading order (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule logical-reading-order: needs manual check" [[feature]] id = "a11y.rule-primary-language" @@ -8347,11 +8347,11 @@ title = "Rule: primary language" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "rule primary-language: catalog /Lang; fix via Document Properties or accessibility_fix" [[feature]] id = "a11y.rule-title" @@ -8359,11 +8359,11 @@ title = "Rule: title shown in the window" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "rule title: Info Title and DisplayDocTitle; Fix sets both" [[feature]] id = "a11y.rule-bookmarks" @@ -8371,11 +8371,11 @@ title = "Rule: bookmarks in long documents" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"] +notes = "rule bookmarks: more than 20 pages need bookmarks" [[feature]] id = "a11y.rule-color-contrast" @@ -8383,11 +8383,11 @@ title = "Rule: colour contrast (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "rule color-contrast: needs manual check; off by default as in Acrobat" [[feature]] id = "a11y.rule-tagged-content" @@ -8395,11 +8395,11 @@ title = "Rule: all content tagged or artifact" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"] +notes = "rule tagged-content: painting outside MCID or Artifact marked content, through form XObjects; counted per page" [[feature]] id = "a11y.rule-tagged-annotations" @@ -8407,11 +8407,11 @@ title = "Rule: tagged annotations" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"] +notes = "rule tagged-annotations: annotations other than popups, widgets and multimedia need /StructParent" [[feature]] id = "a11y.rule-tab-order" @@ -8419,11 +8419,11 @@ title = "Rule: tab order follows structure" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"] +notes = "rule tab-order: pages with annotations need /Tabs /S; Fix sets it on every page" [[feature]] id = "a11y.rule-character-encoding" @@ -8431,11 +8431,11 @@ title = "Rule: reliable character encoding" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents"] +notes = "rule character-encoding: ToUnicode, a standard encoding, or a predefined CJK collection; each font once" [[feature]] id = "a11y.rule-tagged-multimedia" @@ -8443,11 +8443,11 @@ title = "Rule: tagged multimedia" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule tagged-multimedia: Screen, Movie, Sound, RichMedia and 3D annotations need /StructParent" [[feature]] id = "a11y.rule-screen-flicker" @@ -8455,11 +8455,11 @@ title = "Rule: screen flicker (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule screen-flicker: needs manual check" [[feature]] id = "a11y.rule-scripts" @@ -8467,11 +8467,11 @@ title = "Rule: scripts (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "rule scripts: needs manual check" [[feature]] id = "a11y.rule-timed-responses" @@ -8479,11 +8479,11 @@ title = "Rule: timed responses (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule timed-responses: needs manual check" [[feature]] id = "a11y.rule-navigation-links" @@ -8491,11 +8491,11 @@ title = "Rule: navigation links (manual)" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule navigation-links: needs manual check" [[feature]] id = "a11y.rule-tagged-fields" @@ -8503,11 +8503,11 @@ title = "Rule: tagged form fields" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::a_well_tagged_document_passes"] +notes = "rule tagged-form-fields: widgets need /StructParent" [[feature]] id = "a11y.rule-field-descriptions" @@ -8515,11 +8515,11 @@ title = "Rule: field descriptions" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::page_content_and_annotation_problems_are_found"] +notes = "rule field-descriptions: fields need a tooltip (/TU)" [[feature]] id = "a11y.rule-figure-alt" @@ -8527,11 +8527,11 @@ title = "Rule: figures have alternate text" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule figures-alt-text: Figure elements need Alt or ActualText (or an ancestor's)" [[feature]] id = "a11y.rule-nested-alt" @@ -8539,11 +8539,11 @@ title = "Rule: no nested alternate text" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule nested-alt-text: Alt inside an element with Alt" [[feature]] id = "a11y.rule-alt-associated" @@ -8551,11 +8551,11 @@ title = "Rule: alternate text associated with content" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule alt-text-associated: Alt on an element with no content beneath it" [[feature]] id = "a11y.rule-alt-hides-annot" @@ -8563,11 +8563,11 @@ title = "Rule: alternate text does not hide annotations" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule alt-text-hides-annotation: Alt on an element containing an OBJR" [[feature]] id = "a11y.rule-other-alt" @@ -8575,11 +8575,11 @@ title = "Rule: other elements have alternate text" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule other-elements-alt-text: Formula elements need Alt" [[feature]] id = "a11y.rule-table-rows" @@ -8587,11 +8587,11 @@ title = "Rule: table rows" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule table-rows: TR parent is Table, THead, TBody or TFoot" [[feature]] id = "a11y.rule-table-cells" @@ -8599,11 +8599,11 @@ title = "Rule: TH and TD in rows" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule table-cells: TH/TD parent is TR" [[feature]] id = "a11y.rule-table-headers" @@ -8611,11 +8611,11 @@ title = "Rule: tables have headers" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule table-headers: at least one TH" [[feature]] id = "a11y.rule-table-regularity" @@ -8623,11 +8623,11 @@ title = "Rule: table regularity" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::row_and_column_spans_keep_a_table_regular"] +notes = "rule table-regularity: equal row widths counting ColSpan and RowSpan" [[feature]] id = "a11y.rule-table-summary" @@ -8635,11 +8635,11 @@ title = "Rule: table summary" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule table-summary: Summary table attribute" [[feature]] id = "a11y.rule-list-items" @@ -8647,11 +8647,11 @@ title = "Rule: list items" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule list-items: LI parent is L" [[feature]] id = "a11y.rule-lbl-lbody" @@ -8659,11 +8659,11 @@ title = "Rule: Lbl and LBody" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule lbl-lbody: Lbl/LBody parent is LI (TOCI and notes allowed)" [[feature]] id = "a11y.rule-heading-nesting" @@ -8671,11 +8671,11 @@ title = "Rule: heading nesting" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["a11y.check"] +tools = ["accessibility_check"] +evidence = ["crates/a11y/src/tests.rs::structure_problems_are_found"] +notes = "rule heading-nesting: H1–H6 never skip a level going down; the first is H1" [[feature]] id = "a11y.autotag"