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:
parent
1e69a92c41
commit
0b628d5515
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
//! The accessibility report: a self-contained HTML page with the summary and, per category,
|
||||
//! each rule's status, description and problems.
|
||||
|
||||
use crate::{Category, Report, Status};
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// The report for `file_name`, dated `date` (as the caller formats it).
|
||||
pub fn report_html(report: &Report, file_name: &str, date: &str) -> String {
|
||||
let mut h = String::new();
|
||||
h.push_str("<!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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<b>.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5"));
|
||||
assert!(html.contains("<h3>Headings</h3>"));
|
||||
}
|
||||
|
|
@ -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)) }))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue