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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 05:05:56 -04:00
parent 1e69a92c41
commit 0b628d5515
26 changed files with 2519 additions and 179 deletions

12
Cargo.lock generated
View File

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

View File

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

17
crates/a11y/Cargo.toml Normal file
View File

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

24
crates/a11y/README.md Normal file
View File

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

348
crates/a11y/src/content.rs Normal file
View File

@ -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<usize, usize>,
/// Page → fonts used.
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
}
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<u8> {
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<ObjRef>,
}
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<bool> = 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<String> {
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<String>) -> Vec<Finding> {
vec![Finding { page: None, message: message.into() }]
}
pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec<Finding> {
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<Finding> {
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")
}
},
}
}

491
crates/a11y/src/lib.rs Normal file
View File

@ -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> {
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<usize>,
pub message: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuleResult {
pub rule: Rule,
pub status: Status,
pub findings: Vec<Finding>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Options {
/// The rules to run; the others are reported as Skipped.
pub rules: BTreeSet<Rule>,
/// 0-based pages for the page rules (`None`: all pages).
pub pages: Option<Vec<usize>>,
}
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<RuleResult>,
}
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<Item = &RuleResult> {
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<usize> = 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;

55
crates/a11y/src/report.rs Normal file
View File

@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// 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("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility Report</title>\n<style>\n");
h.push_str(
"body{font:14px/1.45 system-ui,sans-serif;margin:24px;color:#1d1d1f}h1{font-size:22px}h2{font-size:17px;margin-top:28px}\
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:6px 8px;text-align:left;vertical-align:top}\
th{background:#f2f2f2}.Failed{color:#b3261e;font-weight:600}.Passed{color:#1e7b34}ul{margin:4px 0 0;padding-left:18px}\n",
);
h.push_str("</style></head><body>\n<h1>Accessibility Report</h1>\n");
h.push_str(&format!("<p>Filename: <b>{}</b><br>Report created: {}</p>\n", esc(file_name), esc(date)));
h.push_str("<h2>Summary</h2>\n<p>");
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("</p>\n<ul>\n");
for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] {
h.push_str(&format!("<li>{}: {}</li>\n", s.label(), report.count(s)));
}
h.push_str("</ul>\n<h2>Detailed Report</h2>\n");
for c in Category::ALL {
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>Rule Name</th><th>Status</th><th>Description</th></tr>\n", c.label()));
for r in report.in_category(c) {
h.push_str(&format!(
"<tr><td>{}</td><td class=\"{:?}\">{}</td><td>{}",
esc(r.rule.name()),
r.status,
r.status.label(),
esc(r.rule.description())
));
if !r.findings.is_empty() {
h.push_str("<ul>");
for f in &r.findings {
h.push_str(&format!("<li>{}</li>", esc(&f.message)));
}
h.push_str("</ul>");
}
h.push_str("</td></tr>\n");
}
h.push_str("</table>\n");
}
h.push_str("</body></html>\n");
h
}

View File

@ -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<u8>,
pub parent: Option<usize>,
pub children: Vec<usize>,
/// 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<ObjRef>,
pub page: Option<usize>,
pub attrs: Vec<Dict>,
}
#[derive(Debug, Default)]
pub(crate) struct Tree {
/// A /StructTreeRoot exists.
pub exists: bool,
pub elems: Vec<Elem>,
/// Top-level elements in order.
pub roots: Vec<usize>,
}
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<ObjRef, usize> = 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<usize> {
let mut out = Vec::with_capacity(self.elems.len());
let mut stack: Vec<usize> = 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<Item = usize> + '_ {
std::iter::successors(self.elems[i].parent, move |p| self.elems[*p].parent)
}
/// `i` and everything under it.
fn subtree(&self, i: usize) -> Vec<usize> {
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<Finding> {
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<usize> {
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<String> {
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<usize> = 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::<Vec<_>>();
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<ObjRef, usize>,
seen: &'a mut HashSet<ObjRef>,
}
impl Reader<'_> {
fn standard(&self, ty: &[u8]) -> Vec<u8> {
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<usize> {
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<usize>, page: Option<usize>, out: &mut Vec<usize>, 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<usize>, page: Option<usize>, out: &mut Vec<usize>, 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);
}
}
}

224
crates/a11y/src/tests.rs Normal file
View File

@ -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<Rule> {
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<String> {
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::<Vec<_>>());
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::<Vec<_>>().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<usize> = 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<b>.pdf", "2026-10-02");
assert!(html.contains("a&lt;b&gt;.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5"));
assert!(html.contains("<h3>Headings</h3>"));
}

View File

@ -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<Value> = r
.results
.iter()
.map(|x| {
let findings: Vec<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
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<Options> {
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::<Result<_>>()?;
}
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<Value> {
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<Value> {
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<Value> {
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)) }))
}
}

View File

@ -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)?,

View File

@ -673,6 +673,35 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.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 (`<name>_Page_<n>_Image_<k>.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(

View File

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

View File

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

View File

@ -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")),
],
}],
},

View File

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

View File

@ -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<a11y::Report> {
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<Edit, String> {
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()
}

View File

@ -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<Rule>,
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<Category>,
pub expanded: BTreeSet<Rule>,
/// The rule whose explanation is showing.
pub explain: Option<Rule>,
}
#[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<PanelAction> {
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 => {}
}
}
}
}
}
}
}

View File

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

View File

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

View File

@ -49,6 +49,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut duplicate_now: Option<Edit> = None;
let mut replace_now = false;
let mut open_revision: Option<usize> = 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.

View File

@ -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<DigitalIdEntry>,
/// Signatures panel: expanded entries (field names).
pub sig_expanded: Vec<String>,
/// 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<printcraft_engine::a11y::Rule>,
/// 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" => {

View File

@ -340,6 +340,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
let mut panel_edit: Option<printcraft_engine::Edit> = None;
let mut panel_command: Option<&'static str> = None;
let mut sig_action: Option<crate::sign_ui::PanelAction> = None;
let mut a11y_action: Option<crate::a11y_ui::PanelAction> = 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),

View File

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

View File

@ -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() {

View File

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