diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab38..ed91f9e50 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -183,16 +183,19 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } /// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + // Whether each open marked-content sequence is one of `ids`, and how many of them are. let mut marks: Vec = Vec::new(); + let mut matching = 0usize; let mut bbox = None; let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -207,9 +210,15 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), + b"BDC" => { + let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); + matching += usize::from(wanted); + marks.push(wanted); + } b"EMC" => { - marks.pop(); + if marks.pop() == Some(true) { + matching = matching.saturating_sub(1); + } } b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 537ed908d..d3a478cb9 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. @@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c9..22fac756e 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,20 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e8..b5eb54742 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1023,6 +1023,46 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();