Files
pdfcraft/crates/a11y/src/lib.rs
T

494 lines
18 KiB
Rust

//! 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 alt;
mod content;
mod report;
mod structure;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
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;