diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 2c6a100ef..7e8c14a85 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,6 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; -use crate::structure::Tree; use crate::{Finding, Rule}; /// What the page content holds, per checked page. @@ -212,9 +211,8 @@ 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 { +pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec { let mut out = Vec::new(); - let tagged = tree.exists; let annots = |p: &Page| -> Vec<(ObjRef, Dict)> { let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); a.iter() diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 203139d5d..514c5b71b 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -465,8 +465,9 @@ pub fn check(doc: &Document, options: &Options) -> Report { Some(p) => p.iter().copied().filter(|p| *p < pages.len()).collect(), None => (0..pages.len()).collect(), }; - let tree = structure::Tree::read(doc, &pages); + let tagged = structure::has_struct_tree(doc); let scan = content::Scan::run(doc, &pages, &page_list); + let mut tree_rules = structure::run(doc, &pages, &options.rules); let mut results = Vec::with_capacity(32); for rule in Rule::ALL { if !options.rules.contains(&rule) { @@ -477,13 +478,22 @@ pub fn check(doc: &Document, options: &Options) -> Report { 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), + // `total` counts every finding; `findings` holds the first MAX_FINDINGS of them. + let (mut findings, total) = match rule.category() { + Category::Document => { + let found = content::document_rule(doc, rule, &pages, &scan); + let n = found.len(); + (found, n) + } + Category::PageContent | Category::Forms => { + let found = content::page_rule(doc, rule, &pages, &page_list, &scan, tagged); + let n = found.len(); + (found, n) + } + _ => tree_rules.remove(&rule).unwrap_or_default(), }; - if findings.len() > MAX_FINDINGS { - let more = findings.len() - MAX_FINDINGS; + if total > MAX_FINDINGS { + let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); findings.push(Finding { page: None, message: format!("… and {more} more") }); } diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs index 9f9783613..0ebb8d718 100644 --- a/crates/a11y/src/structure.rs +++ b/crates/a11y/src/structure.rs @@ -1,12 +1,19 @@ //! 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). +//! +//! The tree is never built as a whole. [`walk`] reads it depth first with an explicit stack and +//! tells a [`Visitor`] about each element as it opens and as it closes. What the rules keep is +//! bounded by the elements still open, plus, for a table, the row spans that reach into later +//! rows. Findings are kept in document order, up to the report's limit. -use std::collections::{HashMap, HashSet}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; -use crate::{Finding, Rule}; +use crate::{Finding, MAX_FINDINGS, Rule}; /// Standard structure types (PDF 1.7 and 2.0); anything else goes through the role map. const STANDARD: &[&[u8]] = &[ @@ -73,285 +80,328 @@ pub(crate) fn is_standard(t: &[u8]) -> bool { STANDARD.contains(&t) } -/// Elements beyond this are not read (a guard against absurd trees). -const MAX_ELEMENTS: usize = 2_000_000; +/// Structure nested deeper than this is not read (a guard against absurd trees). +const MAX_DEPTH: usize = 256; -#[derive(Clone, Debug, Default)] -pub(crate) struct Elem { - /// The standard type after the role map (or the type as written if it maps to nothing). - pub ty: Vec, - pub parent: Option, - pub children: Vec, - /// Has /Alt (or /ActualText, which also replaces the content). - pub alt: bool, - /// Marked content or an object reference directly under it. - pub content: bool, - /// Annotations it references (OBJR). - pub annots: Vec, - pub page: Option, - pub attrs: Vec, +/// A span (ColSpan or RowSpan) counts as at most this many columns or rows. +const MAX_SPAN: usize = 10_000; + +/// The rules this module evaluates. +const RULES: [Rule; 13] = [ + 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 standard types the rules tell apart. Any other type is `Other`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Figure, + Formula, + Table, + /// THead, TBody or TFoot. + Section, + Tr, + Th, + Td, + L, + Li, + Lbl, + LBody, + /// TOCI, BibEntry, Note or FENote: places where a label may sit outside a list item. + NoteOrToc, + /// H1 to H6. + Heading(u8), + Other, } -#[derive(Debug, Default)] -pub(crate) struct Tree { - /// A /StructTreeRoot exists. - pub exists: bool, - pub elems: Vec, - /// Top-level elements in order. - pub roots: Vec, +fn kind_of(ty: &[u8]) -> Kind { + match ty { + b"Figure" => Kind::Figure, + b"Formula" => Kind::Formula, + b"Table" => Kind::Table, + b"THead" | b"TBody" | b"TFoot" => Kind::Section, + b"TR" => Kind::Tr, + b"TH" => Kind::Th, + b"TD" => Kind::Td, + b"L" => Kind::L, + b"LI" => Kind::Li, + b"Lbl" => Kind::Lbl, + b"LBody" => Kind::LBody, + b"TOCI" | b"BibEntry" | b"Note" | b"FENote" => Kind::NoteOrToc, + [b'H', d @ b'1'..=b'6'] => Kind::Heading(d - b'0'), + _ => Kind::Other, + } } -impl Tree { - pub fn read(doc: &Document, pages: &[Page]) -> Tree { - let mut t = Tree::default(); - let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return t }; - let Some(root) = cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else { return t }; - t.exists = true; - let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let page_index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let mut seen = HashSet::new(); - let mut reader = Reader { doc, role_map: &role_map, page_index: &page_index, seen: &mut seen }; - if let Some(k) = root.get(b"K") { - let mut roots = Vec::new(); - reader.kids(&mut t, k, None, None, &mut roots, 0); - t.roots = roots; +/// An element while it is open. What only becomes known later (its content, what lies below it) +/// is filled in as the walk goes on. +#[derive(Debug)] +pub(crate) struct Frame { + /// The standard type after the role map, or the type as written when it maps to nothing. + ty: Vec, + kind: Kind, + /// Its position in document order: the number it has in the tree read in full. + index: usize, + /// Its page: its own /Pg, else its parent's. + page: Option, + /// Has /Alt or /ActualText. + alt: bool, + /// An element above it has alternate text. + alt_above: bool, + /// Has a /Summary attribute. + summary: bool, + /// ColSpan and RowSpan, each read as 1 to `MAX_SPAN`. + col_span: usize, + row_span: usize, + /// Marked content, or an object reference, directly under it. + content: bool, + /// An annotation (OBJR) directly under it. + annot: bool, + /// Content, or an annotation, under it: set as its children close. + sub_content: bool, + sub_annot: bool, +} + +impl Frame { + /// Content in this element or anywhere below it. Complete once the element has closed. + fn subtree_content(&self) -> bool { + self.content || self.sub_content + } + + /// An annotation in this element or anywhere below it. Complete once the element has closed. + fn subtree_annot(&self) -> bool { + self.annot || self.sub_annot + } +} + +/// Told about each element as it opens and as it closes. +pub(crate) trait Visitor { + /// `e` opens. `ancestors` are the elements around it, outermost first. + fn enter(&mut self, ancestors: &[Frame], e: &Frame); + + /// `e` closes, and everything below it has closed already. `ancestors` are the elements + /// still open. + fn exit(&mut self, ancestors: &[Frame], e: &Frame); +} + +/// The dictionary (or stream) that `/StructTreeRoot` names in the catalog, when there is one. +fn struct_root(doc: &Document) -> Option> { + let catalog = doc.get(doc.root()?); + let root = doc.resolve(catalog.as_dict()?.get(b"StructTreeRoot")?); + root.as_dict().is_some().then_some(root) +} + +/// Whether the document has a structure tree. +pub(crate) fn has_struct_tree(doc: &Document) -> bool { + struct_root(doc).is_some() +} + +/// Walks the structure tree in document order, telling `v` about each element. +fn walk(doc: &Document, pages: &[Page], v: &mut V) { + let Some(root) = struct_root(doc) else { return }; + let role_map = root.as_dict().and_then(|d| d.get(b"RoleMap")).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let page_index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut w = Walker { doc, role_map, page_index, seen: HashSet::new(), frames: Vec::new(), tasks: Vec::new(), opened: 0 }; + if root.as_dict().is_some_and(|d| d.contains(b"K")) { + w.tasks.push(Task::Node { loc: Loc { owner: root, path: vec![Step::K] }, depth: 0, page: None }); + } + while let Some(task) = w.tasks.pop() { + w.step(task, v); + } +} + +/// Where a value sits: the object `owner`, reached through `path`. A location holds its owner +/// (rather than a borrow), so the walk can keep positions on its stack without copying the tree. +#[derive(Clone)] +struct Loc { + owner: Arc, + path: Vec, +} + +#[derive(Clone, Copy)] +enum Step { + /// Item `n` of an array. + Index(usize), + /// The /K entry of a dictionary. + K, +} + +impl Loc { + fn value(&self) -> Option<&Object> { + self.path.iter().try_fold(&*self.owner, |cur, step| match step { + Step::Index(n) => match cur { + Object::Array(items) => items.get(*n), + _ => None, + }, + Step::K => cur.as_dict()?.get(b"K"), + }) + } + + fn with(&self, step: Step) -> Loc { + let mut path = self.path.clone(); + path.push(step); + Loc { owner: self.owner.clone(), path } + } +} + +/// Work still to do. The walk is depth first: the last task pushed runs first. +enum Task { + /// Read the value at `loc`, a kid at nesting `depth`, on page `page` if known. + Node { loc: Loc, depth: usize, page: Option }, + /// Items `next..len` of the array at `loc`, each at nesting `depth`. + Items { loc: Loc, next: usize, len: usize, depth: usize, page: Option }, + /// The innermost open element has no more kids. + Close, +} + +/// The state of a walk: what is open, what is still to read and what has been read. +struct Walker<'a> { + doc: &'a Document, + role_map: Dict, + page_index: HashMap, + /// Indirect kids already read. A kid reached again (a shared kid, or a cycle) is skipped, so a + /// kid counts once, where it first appears. + seen: HashSet, + /// The open elements, outermost first. + frames: Vec, + tasks: Vec, + /// Elements opened so far: the next element's position in document order. + opened: usize, +} + +impl Walker<'_> { + fn step(&mut self, task: Task, v: &mut V) { + match task { + Task::Node { loc, depth, page } => self.node(&loc, depth, page, v), + Task::Items { loc, next, len, depth, page } => { + // The rest of the array waits below the item that is read now. + if next + 1 < len { + self.tasks.push(Task::Items { loc: loc.clone(), next: next + 1, len, depth, page }); + } + self.tasks.push(Task::Node { loc: loc.with(Step::Index(next)), depth, page }); + } + Task::Close => self.close(v), } - t } - /// Elements in document (pre-)order. - fn preorder(&self) -> Vec { - let mut out = Vec::with_capacity(self.elems.len()); - let mut stack: Vec = self.roots.iter().rev().copied().collect(); - while let Some(i) = stack.pop() { - out.push(i); - stack.extend(self.elems[i].children.iter().rev().copied()); + /// A kid: arrays hand their items on one level deeper, a reference is read once, and only a + /// dictionary can open an element. + fn node(&mut self, loc: &Loc, depth: usize, page: Option, v: &mut V) { + if depth > MAX_DEPTH { + return; } - out - } - - fn ancestors(&self, i: usize) -> impl Iterator + '_ { - std::iter::successors(self.elems[i].parent, move |p| self.elems[*p].parent) - } - - /// `i` and everything under it. - fn subtree(&self, i: usize) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![i]; - while let Some(j) = stack.pop() { - out.push(j); - stack.extend(self.elems[j].children.iter().copied()); + let Some(value) = loc.value() else { return }; + match value { + Object::Array(items) if !items.is_empty() => { + self.tasks.push(Task::Items { loc: loc.clone(), next: 0, len: items.len(), depth: depth + 1, page }); + } + Object::Int(_) => self.mark_content(), + Object::Ref(r) => self.reference(*r, depth, page, v), + Object::Dict(_) => self.dict(loc, depth, page, v), + _ => {} } - out } - fn is(&self, i: usize, ty: &[u8]) -> bool { - self.elems[i].ty == ty + /// An indirect kid, read unless it has been read before. + fn reference(&mut self, r: ObjRef, depth: usize, page: Option, v: &mut V) { + if !self.seen.insert(r) { + return; + } + let target = self.doc.get(r); + if target.as_dict().is_some() { + self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, v); + } } - fn parent_is(&self, i: usize, types: &[&[u8]]) -> bool { - self.elems[i].parent.is_some_and(|p| types.contains(&self.elems[p].ty.as_slice())) - } - - fn finding(&self, i: usize, what: &str) -> Finding { - let e = &self.elems[i]; - let ty = String::from_utf8_lossy(&e.ty); - let at = e.page.map(|p| format!(" on page {}", p + 1)).unwrap_or_default(); - Finding { page: e.page, message: format!("{ty} element{at}: {what}") } - } - - pub fn rule(&self, rule: Rule) -> Vec { - let order = self.preorder(); - let mut out = Vec::new(); - match rule { - Rule::FiguresAltText => { - for &i in &order { - if self.is(i, b"Figure") && !self.elems[i].alt && !self.ancestors(i).any(|a| self.elems[a].alt) { - out.push(self.finding(i, "no alternate text")); - } - } - } - Rule::OtherElementsAltText => { - for &i in &order { - if self.is(i, b"Formula") && !self.elems[i].alt && !self.ancestors(i).any(|a| self.elems[a].alt) { - out.push(self.finding(i, "no alternate text")); - } - } - } - Rule::NestedAltText => { - for &i in &order { - if self.elems[i].alt && self.ancestors(i).any(|a| self.elems[a].alt) { - out.push(self.finding(i, "its alternate text is inside another element's and will never be read")); - } - } - } - Rule::AltTextAssociated => { - for &i in &order { - if self.elems[i].alt && !self.subtree(i).iter().any(|j| self.elems[*j].content) { - out.push(self.finding(i, "alternate text on an element with no page content")); - } - } - } - Rule::AltTextHidesAnnotation => { - for &i in &order { - if self.elems[i].alt && self.subtree(i).iter().any(|j| !self.elems[*j].annots.is_empty()) { - out.push(self.finding(i, "alternate text hides an annotation inside it")); - } - } - } - Rule::TableRows => { - for &i in &order { - if self.is(i, b"TR") && !self.parent_is(i, &[b"Table", b"THead", b"TBody", b"TFoot"]) { - out.push(self.finding(i, "not in a Table, THead, TBody or TFoot")); - } - } - } - Rule::TableCells => { - for &i in &order { - if (self.is(i, b"TH") || self.is(i, b"TD")) && !self.parent_is(i, &[b"TR"]) { - out.push(self.finding(i, "not in a TR")); - } - } - } - Rule::TableHeaders => { - for &i in &order { - if self.is(i, b"Table") && !self.table_cells(i).iter().any(|(_, c)| self.is(*c, b"TH")) { - out.push(self.finding(i, "no header cells (TH)")); - } - } - } - Rule::TableRegularity => { - for &i in &order { - if self.is(i, b"Table") - && let Some(why) = self.irregular(i) - { - out.push(self.finding(i, &why)); - } - } - } - Rule::TableSummary => { - for &i in &order { - if self.is(i, b"Table") && self.attr(i, b"Summary").is_none() { - out.push(self.finding(i, "no summary")); - } - } - } - Rule::ListItems => { - for &i in &order { - if self.is(i, b"LI") && !self.parent_is(i, &[b"L"]) { - out.push(self.finding(i, "not in an L (list)")); - } - } - } - Rule::LblLBody => { - for &i in &order { - if (self.is(i, b"Lbl") || self.is(i, b"LBody")) && !self.parent_is(i, &[b"LI"]) && !self.inside_toc_or_note(i) { - out.push(self.finding(i, "not in an LI (list item)")); - } - } - } - Rule::HeadingNesting => { - let mut prev = 0u8; - for &i in &order { - let level = match self.elems[i].ty.as_slice() { - [b'H', d @ b'1'..=b'6'] => d - b'0', - _ => continue, - }; - if level > prev + 1 { - let what = if prev == 0 { format!("the first heading is an H{level}") } else { format!("H{level} follows an H{prev}") }; - out.push(self.finding(i, &format!("{what}; headings should not skip levels"))); - } - prev = level; + /// A dictionary. Marked content and object references only mark the open element; a + /// dictionary with /S is an element, and its /K kids are read inside it. + fn dict(&mut self, loc: &Loc, depth: usize, page: Option, v: &mut V) { + let Some(d) = loc.value().and_then(|o| o.as_dict()) else { return }; + match d.name(b"Type") { + Some(b"MCR") => return self.mark_content(), + Some(b"OBJR") => { + self.mark_content(); + let annotation = d + .get(b"Obj") + .and_then(Object::as_ref) + .is_some_and(|o| self.doc.get(o).as_dict().is_some_and(|a| a.contains(b"Subtype") && a.contains(b"Rect"))); + if annotation && let Some(parent) = self.frames.last_mut() { + parent.annot = true; } + return; } _ => {} } - 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)); - } - } + let Some(s) = d.name(b"S") else { return }; + let page = self.page_of(d).or(page); + let alt = self.has_text(d, b"Alt") || self.has_text(d, b"ActualText"); + let ty = self.standard(s); + let kind = kind_of(&ty); + let attrs = if matches!(kind, Kind::Table | Kind::Td | Kind::Th) { self.attr_dicts(d) } else { Vec::new() }; + let frame = Frame { + ty, + kind, + index: self.opened, + page, + alt, + alt_above: self.frames.last().is_some_and(|p| p.alt || p.alt_above), + summary: attr_int(&attrs, b"Summary").is_some(), + col_span: span(&attrs, b"ColSpan"), + row_span: span(&attrs, b"RowSpan"), + content: false, + annot: false, + sub_content: false, + sub_annot: false, + }; + self.opened = self.opened.saturating_add(1); + self.frames.push(frame); + if let Some((e, ancestors)) = self.frames.split_last() { + v.enter(ancestors, e); + } + self.tasks.push(Task::Close); + if d.contains(b"K") { + self.tasks.push(Task::Node { loc: loc.with(Step::K), depth: depth + 1, page }); } - out } - fn table_rows(&self, table: usize) -> Vec { - let mut rows = Vec::new(); - for &k in &self.elems[table].children { - match self.elems[k].ty.as_slice() { - b"TR" => rows.push(k), - b"THead" | b"TBody" | b"TFoot" => rows.extend(self.elems[k].children.iter().copied().filter(|r| self.is(*r, b"TR"))), - _ => {} - } + /// The innermost element has no more kids: report it, and pass its content up to its parent. + fn close(&mut self, v: &mut V) { + let Some(frame) = self.frames.pop() else { return }; + if let Some(parent) = self.frames.last_mut() { + parent.sub_content |= frame.subtree_content(); + parent.sub_annot |= frame.subtree_annot(); } - rows + v.exit(&self.frames, &frame); } - /// Why the table's rows don't all have the same width, counting row and column spans. - fn irregular(&self, table: usize) -> Option { - let rows = self.table_rows(table); - if rows.len() < 2 { - return None; + /// Marked content, or an object reference, directly under the open element. + fn mark_content(&mut self) { + if let Some(parent) = self.frames.last_mut() { + parent.content = true; } - // carry[c] = rows still covered at column c by a cell above. - let mut carry: Vec = Vec::new(); - let mut widths = Vec::with_capacity(rows.len()); - for &row in &rows { - let mut col = 0; - let mut next = carry.iter().map(|n| n.saturating_sub(1)).collect::>(); - for &c in self.elems[row].children.iter().filter(|c| self.is(**c, b"TH") || self.is(**c, b"TD")) { - while col < carry.len() && carry[col] > 0 { - col += 1; - } - let (cs, rs) = (self.span(c, b"ColSpan"), self.span(c, b"RowSpan")); - if next.len() < col + cs { - next.resize(col + cs, 0); - } - for n in next.iter_mut().skip(col).take(cs) { - *n = (*n).max(rs - 1); - } - col += cs; - } - while col < carry.len() && carry[col] > 0 { - col += 1; - } - widths.push(col); - carry = next; - } - let (min, max) = (widths.iter().min()?, widths.iter().max()?); - (min != max).then(|| format!("rows have between {min} and {max} columns")) } -} -struct Reader<'a> { - doc: &'a Document, - role_map: &'a Dict, - page_index: &'a HashMap, - seen: &'a mut HashSet, -} + fn page_of(&self, d: &Dict) -> Option { + d.get(b"Pg").and_then(Object::as_ref).and_then(|r| self.page_index.get(&r).copied()) + } -impl Reader<'_> { + /// Whether `key` holds a string with some text in it. + fn has_text(&self, d: &Dict, key: &[u8]) -> bool { + d.get(key).map(|o| self.doc.resolve(o)).and_then(|o| o.as_string().map(|s| !s.to_text().trim().is_empty())).unwrap_or(false) + } + + /// The standard type of `ty`, following the role map (a bounded number of steps). fn standard(&self, ty: &[u8]) -> Vec { let mut t = ty.to_vec(); for _ in 0..12 { @@ -366,86 +416,393 @@ impl Reader<'_> { t } - fn page_of(&self, d: &Dict) -> Option { - d.get(b"Pg").and_then(Object::as_ref).and_then(|r| self.page_index.get(&r).copied()) + /// The attribute dictionaries of an element: the dictionaries its /A entry holds, one or an array. + fn attr_dicts(&self, d: &Dict) -> Vec> { + let Some(a) = d.get(b"A").map(|o| self.doc.resolve(o)) else { return Vec::new() }; + let held: Vec> = match &*a { + Object::Array(items) => items.iter().map(|x| self.doc.resolve(x)).collect(), + other => vec![self.doc.resolve(other)], + }; + held.into_iter().filter(|x| x.as_dict().is_some()).collect() + } +} + +/// The integer value of `key` in the first attribute dictionary that has the key. `None` when no +/// attribute dictionary has it; `Some(None)` when its value is not an integer. +fn attr_int(attrs: &[Arc], key: &[u8]) -> Option> { + attrs.iter().find_map(|a| a.as_dict()?.get(key)).map(Object::as_int) +} + +/// A span attribute: at least 1, at most `MAX_SPAN`; 1 when absent or not an integer. +fn span(attrs: &[Arc], key: &[u8]) -> usize { + attr_int(attrs, key).flatten().filter(|n| *n >= 1).map_or(1, |n| n.min(MAX_SPAN as i64) as usize) +} + +/// The depth of the table a row belongs to, given the row's ancestors: the table itself, or the +/// table around its THead, TBody or TFoot. `None` for any other row. +fn row_owner(path: &[Frame]) -> Option { + let (parent, rest) = path.split_last()?; + match parent.kind { + Kind::Table => Some(rest.len()), + Kind::Section => rest.last().filter(|grand| grand.kind == Kind::Table).map(|_| rest.len() - 1), + _ => None, + } +} + +fn finding(e: &Frame, what: &str) -> Finding { + 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}") } +} + +/// Columns `start..end` that a row span covers, with the rows still to come after this one. +#[derive(Clone, Copy, Debug)] +struct Run { + start: usize, + end: usize, + rows: usize, +} + +/// The end of the run that covers `col`, if one does. `cursor` is the first run that can still +/// cover a column from `col` on, so callers must ask for columns in non-decreasing order. +fn covered_end(carry: &[Run], cursor: &mut usize, col: usize) -> Option { + while carry.get(*cursor).is_some_and(|r| r.end <= col) { + *cursor += 1; + } + carry.get(*cursor).filter(|r| r.start <= col).map(|r| r.end) +} + +/// The runs that reach into the next row: `carry` one row shorter, merged with this row's `row` +/// runs. A column takes the longer of the two. Both inputs are sorted and disjoint. +fn carry_into_next_row(carry: &[Run], row: &[Run]) -> Vec { + let mut points: Vec = carry.iter().chain(row).flat_map(|r| [r.start, r.end]).collect(); + points.sort_unstable(); + points.dedup(); + let mut out: Vec = Vec::new(); + let (mut a, mut b) = (0, 0); + for pair in points.windows(2) { + let &[from, to] = pair else { continue }; + while carry.get(a).is_some_and(|r| r.end <= from) { + a += 1; + } + while row.get(b).is_some_and(|r| r.end <= from) { + b += 1; + } + let before = carry.get(a).filter(|r| r.start <= from).map_or(0, |r| r.rows.saturating_sub(1)); + let here = row.get(b).filter(|r| r.start <= from).map_or(0, |r| r.rows); + let rows = before.max(here); + if rows == 0 { + continue; + } + match out.last_mut() { + Some(last) if last.end == from && last.rows == rows => last.end = to, + _ => out.push(Run { start: from, end: to, rows }), + } + } + out +} + +/// A table that is open: the widths of its rows, and the spans that reach into later rows. +struct Table { + /// Its position among the open elements: the number of elements around it. + depth: usize, + /// A header cell (TH) is in one of its rows. + has_th: bool, + /// The narrowest and the widest row so far, in columns. + range: Option<(usize, usize)>, + /// Columns that spans from earlier rows still cover. Sorted and disjoint. + carry: Vec, + /// The current row: the column its next cell may start in, the cursor into `carry`, and the + /// spans its cells start (these carry into the next row). + col: usize, + cursor: usize, + starts: Vec, +} + +impl Table { + fn new(depth: usize) -> Table { + Table { depth, has_th: false, range: None, carry: Vec::new(), col: 0, cursor: 0, starts: Vec::new() } } - /// Read the kids `k` of `parent` (or of the root), appending element indexes to `out`. - fn kids(&mut self, t: &mut Tree, k: &Object, parent: Option, page: Option, out: &mut Vec, depth: usize) { - if depth > 256 || t.elems.len() >= MAX_ELEMENTS { - return; + fn start_row(&mut self) { + self.col = 0; + self.cursor = 0; + self.starts.clear(); + } + + /// Places a cell in the current row, at the first column that no earlier row covers. + fn place(&mut self, col_span: usize, row_span: usize) { + let mut start = self.col; + while let Some(end) = covered_end(&self.carry, &mut self.cursor, start) { + start = end; } - match k { - Object::Array(a) => { - for x in a { - self.kids(t, x, parent, page, out, depth + 1); + let end = start.saturating_add(col_span); + if row_span > 1 { + self.starts.push(Run { start, end, rows: row_span.saturating_sub(1) }); + } + self.col = end; + } + + /// Ends the current row. Its width is where its cells end, plus any span from earlier rows + /// that runs on past them. + fn end_row(&mut self) { + let mut width = self.col; + while let Some(end) = covered_end(&self.carry, &mut self.cursor, width) { + width = end; + } + self.range = Some(match self.range { + None => (width, width), + Some((narrow, wide)) => (narrow.min(width), wide.max(width)), + }); + self.carry = carry_into_next_row(&self.carry, &self.starts); + self.starts.clear(); + } +} + +/// One rule's findings during the walk: how many there are, and the first `MAX_FINDINGS` of them +/// in document order. The text of the others is never built. +#[derive(Default)] +struct Found { + total: usize, + kept: BinaryHeap, +} + +/// A finding that is kept. The heap orders them by document position, so its top is the last one +/// to keep. +struct Kept { + index: usize, + finding: Finding, +} + +impl PartialEq for Kept { + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl Eq for Kept {} + +impl PartialOrd for Kept { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Kept { + fn cmp(&self, other: &Self) -> Ordering { + self.index.cmp(&other.index) + } +} + +impl Found { + /// Counts a finding at document position `index`. Its text is built only if it is kept. + fn add(&mut self, index: usize, make: impl FnOnce() -> Finding) { + self.total = self.total.saturating_add(1); + let keep = self.kept.len() < MAX_FINDINGS || self.kept.peek().is_some_and(|last| index < last.index); + if keep { + if self.kept.len() >= MAX_FINDINGS { + self.kept.pop(); + } + self.kept.push(Kept { index, finding: make() }); + } + } + + fn into_result(self) -> (Vec, usize) { + let findings = self.kept.into_sorted_vec().into_iter().map(|k| k.finding).collect(); + (findings, self.total) + } +} + +/// The structure rules during a walk. +struct Checks { + found: BTreeMap, + /// The level of the last heading read (0 before the first). + heading: u8, + /// The open tables, outermost first. + tables: Vec, +} + +impl Checks { + fn add(&mut self, rule: Rule, index: usize, make: impl FnOnce() -> Finding) { + if let Some(found) = self.found.get_mut(&rule) { + found.add(index, make); + } + } + + /// The open table at `depth`, if it is the innermost open table. + fn table_at(&mut self, depth: usize) -> Option<&mut Table> { + self.tables.last_mut().filter(|t| t.depth == depth) + } +} + +impl Visitor for Checks { + fn enter(&mut self, ancestors: &[Frame], e: &Frame) { + let parent = ancestors.last().map(|p| p.kind); + if e.alt && e.alt_above { + self.add(Rule::NestedAltText, e.index, || finding(e, "its alternate text is inside another element's and will never be read")); + } + match e.kind { + Kind::Figure if !e.alt && !e.alt_above => { + self.add(Rule::FiguresAltText, e.index, || finding(e, "no alternate text")); + } + Kind::Formula if !e.alt && !e.alt_above => { + self.add(Rule::OtherElementsAltText, e.index, || finding(e, "no alternate text")); + } + Kind::Table => { + self.tables.push(Table::new(ancestors.len())); + if !e.summary { + self.add(Rule::TableSummary, e.index, || finding(e, "no summary")); } } - Object::Int(_) => { - if let Some(p) = parent { - t.elems[p].content = true; + Kind::Tr => { + if !matches!(parent, Some(Kind::Table | Kind::Section)) { + self.add(Rule::TableRows, e.index, || finding(e, "not in a Table, THead, TBody or TFoot")); + } + if let Some(table) = row_owner(ancestors).and_then(|depth| self.table_at(depth)) { + table.start_row(); } } - Object::Ref(r) => { - if !self.seen.insert(*r) { - return; + Kind::Th | Kind::Td => { + let in_row = parent == Some(Kind::Tr); + if !in_row { + self.add(Rule::TableCells, e.index, || finding(e, "not in a TR")); } - let obj = self.doc.get(*r); - if let Some(d) = obj.as_dict() { - self.dict(t, d, parent, page, out, depth); + if in_row + && let Some((_, row_path)) = ancestors.split_last() + && let Some(table) = row_owner(row_path).and_then(|depth| self.table_at(depth)) + { + table.place(e.col_span, e.row_span); + table.has_th |= e.kind == Kind::Th; } } - Object::Dict(d) => self.dict(t, d, parent, page, out, depth), + Kind::Li if parent != Some(Kind::L) => { + self.add(Rule::ListItems, e.index, || finding(e, "not in an L (list)")); + } + Kind::Lbl | Kind::LBody if !matches!(parent, Some(Kind::Li | Kind::NoteOrToc)) => { + self.add(Rule::LblLBody, e.index, || finding(e, "not in an LI (list item)")); + } + Kind::Heading(level) => { + let prev = self.heading; + if level > prev + 1 { + self.add(Rule::HeadingNesting, e.index, || { + let what = if prev == 0 { format!("the first heading is an H{level}") } else { format!("H{level} follows an H{prev}") }; + finding(e, &format!("{what}; headings should not skip levels")) + }); + } + self.heading = level; + } _ => {} } } - fn dict(&mut self, t: &mut Tree, d: &Dict, parent: Option, page: Option, out: &mut Vec, depth: usize) { - match d.name(b"Type") { - Some(b"MCR") => { - if let Some(p) = parent { - t.elems[p].content = true; - } - return; - } - Some(b"OBJR") => { - if let Some(p) = parent { - t.elems[p].content = true; - if let Some(o) = d.get(b"Obj").and_then(Object::as_ref) - && self.doc.get(o).as_dict().is_some_and(|a| a.contains(b"Subtype") && a.contains(b"Rect")) - { - t.elems[p].annots.push(o); + fn exit(&mut self, ancestors: &[Frame], e: &Frame) { + match e.kind { + Kind::Table => { + let is_ours = self.tables.last().is_some_and(|t| t.depth == ancestors.len()); + let table = if is_ours { self.tables.pop() } else { None }; + if let Some(table) = table { + if !table.has_th { + self.add(Rule::TableHeaders, e.index, || finding(e, "no header cells (TH)")); + } + if let Some((narrow, wide)) = table.range.filter(|(lo, hi)| lo != hi) { + self.add(Rule::TableRegularity, e.index, || finding(e, &format!("rows have between {narrow} and {wide} columns"))); } } - return; + } + Kind::Tr => { + if let Some(table) = row_owner(ancestors).and_then(|depth| self.table_at(depth)) { + table.end_row(); + } } _ => {} } - 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()); - } + if e.alt { + if !e.subtree_content() { + self.add(Rule::AltTextAssociated, e.index, || finding(e, "alternate text on an element with no page content")); + } + if e.subtree_annot() { + self.add(Rule::AltTextHidesAnnotation, e.index, || finding(e, "alternate text hides an annotation inside it")); } } - 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); + } +} + +/// Evaluates the structure rules in `selected` in one walk of the tree. Each rule's result is its +/// first `MAX_FINDINGS` findings in document order, and the total number of findings. +pub(crate) fn run(doc: &Document, pages: &[Page], selected: &BTreeSet) -> BTreeMap, usize)> { + let found: BTreeMap = RULES.into_iter().filter(|r| selected.contains(r)).map(|r| (r, Found::default())).collect(); + if found.is_empty() { + return BTreeMap::new(); + } + let mut checks = Checks { found, heading: 0, tables: Vec::new() }; + walk(doc, pages, &mut checks); + checks.found.into_iter().map(|(rule, found)| (rule, found.into_result())).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The column-by-column computation that the check used before it streamed: each row's width, + /// as the (narrowest, widest) range over the rows. Rows are lists of (ColSpan, RowSpan). + fn dense_range(rows: &[Vec<(usize, usize)>]) -> Option<(usize, usize)> { + let mut carry: Vec = Vec::new(); + let mut widths = Vec::new(); + for row in rows { + let mut col = 0; + let mut next: Vec = carry.iter().map(|n| n.saturating_sub(1)).collect(); + for &(cs, rs) in row { + while col < carry.len() && carry[col] > 0 { + col += 1; + } + 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; } - 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); + Some((*widths.iter().min()?, *widths.iter().max()?)) + } + + /// A small deterministic generator, so the random tables are the same on every run. + struct Lcg(u64); + + impl Lcg { + fn below(&mut self, n: usize) -> usize { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 33) as usize) % n + } + } + + #[test] + fn spans_carry_into_later_rows_like_the_dense_computation() { + let mut rng = Lcg(7); + for _ in 0..4_000 { + let mut rows = Vec::new(); + for _ in 0..1 + rng.below(6) { + let mut row = Vec::new(); + for _ in 0..rng.below(6) { + row.push((1 + rng.below(12), 1 + rng.below(5))); + } + rows.push(row); + } + let mut table = Table::new(0); + for row in &rows { + table.start_row(); + for &(cs, rs) in row { + table.place(cs, rs); + } + table.end_row(); + } + assert_eq!(table.range, dense_range(&rows), "{rows:?}"); } } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 990ad453c..24a6a348c 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -157,6 +157,135 @@ fn row_and_column_spans_keep_a_table_regular() { assert_eq!(status(&r, Rule::TableRegularity), Status::Passed, "{:?}", r.result(Rule::TableRegularity)); } +/// A tagged document whose structure tree is the Document element `document` (object 3). +fn structure_doc(objects: &[String]) -> Document { + let mut objs = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), "<< /Type /StructTreeRoot /K 3 0 R >>".to_string()]; + objs.extend_from_slice(objects); + pdf(&objs, "") +} + +fn checked(doc: &Document, rules: &[Rule]) -> Report { + check(doc, &Options { rules: rules.iter().copied().collect(), pages: None }) +} + +#[test] +fn figures_and_tables_past_the_old_element_cut_off_are_checked() { + // Five thousand paragraphs, then a figure without alternate text and a table whose second row + // is narrower than its first. + let mut kids = vec!["<< /S /P >>".to_string(); 5_000]; + kids.push("<< /S /Figure >>".into()); + kids.push( + "<< /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [ << /S /TD >> << /S /TD >> ] >> << /S /TR /K [ << /S /TD >> ] >> ] >>" + .into(), + ); + let doc = structure_doc(&[format!("<< /S /Document /K [{}] >>", kids.join(" "))]); + let r = checked(&doc, &[Rule::FiguresAltText, Rule::TableRegularity]); + assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1); + assert!(r.result(Rule::TableRegularity).unwrap().findings[0].message.contains("between 1 and 2")); +} + +#[test] +fn findings_are_listed_in_document_order_not_in_the_order_they_close() { + // Each element has alternate text but no content. The outer ones close last. + let doc = structure_doc(&["<< /S /Document /K [ << /S /Sect /Alt (a) /K [ << /S /P /Alt (b) /K [ << /S /Div /Alt (c) >> ] >> ] >> ] >>".into()]); + let r = checked(&doc, &[Rule::AltTextAssociated]); + let kinds: Vec<&str> = r.result(Rule::AltTextAssociated).unwrap().findings.iter().map(|f| f.message.split(' ').next().unwrap_or("")).collect(); + assert_eq!(kinds, ["Sect", "P", "Div"]); +} + +#[test] +fn a_rule_lists_its_first_findings_and_counts_the_rest() { + let figures = vec!["<< /S /Figure >>".to_string(); 300].join(" "); + let doc = structure_doc(&[format!("<< /S /Document /K [{figures}] >>")]); + let r = checked(&doc, &[Rule::FiguresAltText]); + let found = &r.result(Rule::FiguresAltText).unwrap().findings; + assert_eq!(found.len(), 201); + assert_eq!(found[200].message, "… and 100 more"); + assert!(found[..200].iter().all(|f| f.message == "Figure element: no alternate text")); +} + +#[test] +fn cycles_shared_kids_and_missing_kids_are_read_once() { + // Object 4 holds itself, a figure twice, and a missing object is listed too. + let doc = structure_doc(&[ + "<< /S /Document /K [4 0 R 4 0 R 99 0 R] >>".into(), + "<< /S /Sect /K [4 0 R 5 0 R 5 0 R] >>".into(), + "<< /S /Figure >>".into(), + ]); + let r = checked(&doc, &[Rule::FiguresAltText]); + assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1); +} + +#[test] +fn nesting_is_read_to_the_depth_limit_and_no_further() { + // Sections, each the kid of the one before, ending in a figure without alternate text. The + // figure is at depth `sections`; the walk reads depths up to 256. + let figure_within = |sections: usize| { + let mut objs: Vec = (0..sections).map(|i| format!("<< /S /Sect /K {} 0 R >>", 4 + i)).collect(); + objs.push("<< /S /Figure >>".into()); + let r = checked(&structure_doc(&objs), &[Rule::FiguresAltText]); + r.result(Rule::FiguresAltText).unwrap().findings.len() + }; + assert_eq!(figure_within(250), 1, "within the depth limit"); + assert_eq!(figure_within(300), 0, "past the depth limit"); +} + +#[test] +fn arrays_nested_deeper_than_the_limit_are_not_read() { + let nested = |n: usize| format!("<< /S /Document /K {}<< /S /Figure >>{} >>", "[".repeat(n), "]".repeat(n)); + let shallow = checked(&structure_doc(&[nested(100)]), &[Rule::FiguresAltText]); + assert_eq!(shallow.result(Rule::FiguresAltText).unwrap().findings.len(), 1); + let deep = checked(&structure_doc(&[nested(300)]), &[Rule::FiguresAltText]); + assert_eq!(deep.result(Rule::FiguresAltText).unwrap().findings.len(), 0); +} + +#[test] +fn a_long_list_of_marked_content_counts_as_content() { + // The first paragraph holds 20,000 marked-content items, so its alternate text has content. + let items = (0..20_000).map(|i| i.to_string()).collect::>().join(" "); + let doc = structure_doc(&[format!("<< /S /Document /K [ << /S /P /Alt (a) /K [{items}] >> << /S /P /Alt (b) >> ] >>")]); + let r = checked(&doc, &[Rule::AltTextAssociated]); + assert_eq!(r.result(Rule::AltTextAssociated).unwrap().findings.len(), 1); +} + +#[test] +fn huge_spans_do_not_size_the_table() { + // A row of 2,000 cells, each spanning 10,000 columns, then a row of one cell. + let wide = vec!["<< /S /TD /A << /O /Table /ColSpan 10000 >> >>".to_string(); 2_000].join(" "); + let doc = structure_doc(&[format!( + "<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{wide}] >> << /S /TR /K [ << /S /TD >> ] >> ] >> ] >>" + )]); + let r = checked(&doc, &[Rule::TableRegularity]); + let found = &r.result(Rule::TableRegularity).unwrap().findings; + assert_eq!(found[0].message, "Table element: rows have between 1 and 20000000 columns"); +} + +#[test] +fn row_spans_carry_past_their_row() { + // Row 1: 3,000 cells, each spanning 10,000 rows. Row 2: 3,000 cells, which start after them. + let row1 = vec!["<< /S /TD /A << /O /Table /RowSpan 10000 >> >>".to_string(); 3_000].join(" "); + let row2 = vec!["<< /S /TD >>".to_string(); 3_000].join(" "); + let doc = structure_doc(&[format!( + "<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{row1}] >> << /S /TR /K [{row2}] >> ] >> ] >>" + )]); + let r = checked(&doc, &[Rule::TableRegularity]); + let found = &r.result(Rule::TableRegularity).unwrap().findings; + assert_eq!(found[0].message, "Table element: rows have between 3000 and 6000 columns"); +} + +#[test] +fn spans_that_are_not_positive_integers_read_as_one() { + // ColSpan -3 and 0 read as 1, and 2.0 reads as 2; the first row is 4 columns wide, the second 5. + let doc = structure_doc(&[ + "<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ \ + << /S /TR /K [ << /S /TD /A << /O /Table /ColSpan -3 >> >> << /S /TD /A << /O /Table /ColSpan 0 >> >> << /S /TD /A << /O /Table /ColSpan 2.0 >> >> ] >> \ + << /S /TR /K [ << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> ] >> ] >> ] >>" + .into(), + ]); + let r = checked(&doc, &[Rule::TableRegularity]); + assert_eq!(r.result(Rule::TableRegularity).unwrap().findings[0].message, "Table element: rows have between 4 and 5 columns"); +} + #[test] fn page_content_and_annotation_problems_are_found() { let mut objs = good();