fix(a11y): read the enclosing marked content in one step per operator

Marked-text extraction and figure bounds searched the whole marked-content
stack before every operator, so deeply nested unmarked BMC blocks made a page
quadratic. The text stack now holds each level's effective id (its own, else
the enclosing one's), and the bounds pass counts the open sequences that
match.
This commit is contained in:
aiongg
2026-10-22 14:15:50 -04:00
committed by Dylan M. Taylor
parent 241c5512d6
commit 110b7c678a
4 changed files with 80 additions and 12 deletions
+13 -4
View File
@@ -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<i64>) -> Option<[f64; 4]> {
pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> 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<bool> = 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<i64>) -> 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>() {
+13 -8
View File
@@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
// The id each open marked-content sequence is in: its own, else the enclosing one's.
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = 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<i64, String> {
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();
}
+14
View File
@@ -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<usize> = 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;
+40
View File
@@ -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<String> = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();