From 70bbb1fbdcb9af001f70d7553a99993fc2012052 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:31:35 +0900 Subject: [PATCH] M9: stream structure-tree checks without a total-element cutoff (#926) * M9: let the structure reader take an element limit for tests Tree::read keeps MAX_ELEMENTS; Tree::read_with_limit takes the limit, so a unit test can reach the cut-off with a few elements. The characterisation test records today's behaviour: a figure past the limit is not checked. Co-Authored-By: Claude Haiku 5.5 * M9: stream structure-tree checks without a total-element cutoff The structure checks built the whole tree as a Vec of elements, and stopped adding elements at 2,000,000, so later elements were never checked. One walk now reads the tree with an explicit stack and reports each element as it opens and closes. Each rule keeps only what it needs about the open elements and, for tables, the row spans that reach into later rows. Findings stay in document order, and the report still shows the first 200 per rule. Co-Authored-By: Claude Haiku 5.5 --------- Co-authored-by: Claude Haiku 5.5 --- crates/a11y/src/content.rs | 4 +- crates/a11y/src/lib.rs | 24 +- crates/a11y/src/structure.rs | 979 ++++++++++++++++++++++++----------- crates/a11y/src/tests.rs | 129 +++++ 4 files changed, 815 insertions(+), 321 deletions(-) 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();