fix(a11y): find every figure's bounds on a page in one pass

Figure listing decoded and parsed a page's content once per figure on it,
so many small Figure elements pointing at one page multiplied the work.
The figures are grouped by page and one pass gathers the bounds of every
marked-content id they own; what a sequence draws also counts for the
sequences around it, as before.
This commit is contained in:
aiongg
2026-10-22 14:15:50 -04:00
committed by Dylan M. Taylor
parent 110b7c678a
commit 38211588c7
3 changed files with 89 additions and 22 deletions
+65 -21
View File
@@ -182,16 +182,47 @@ 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).
pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked
/// for, and the bounds of what has been drawn in it so far.
struct Mark {
id: Option<i64>,
drawn: Option<[f64; 4]>,
}
/// Adds `b` to what the innermost open sequence has drawn.
fn draw(marks: &mut [Mark], b: [f64; 4]) {
if let Some(m) = marks.last_mut() {
m.drawn = Some(union(m.drawn, b));
}
}
/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it.
fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f64; 4]>) {
let Some(m) = marks.pop() else { return };
if m.id.is_some() {
*matching = matching.saturating_sub(1);
}
let Some(b) = m.drawn else { return };
if let Some(id) = m.id {
let e = out.entry(id).or_insert(b);
*e = union(Some(*e), b);
}
draw(marks, b);
}
/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> HashMap<i64, [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);
#[cfg(test)]
crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1));
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();
// The open marked-content sequences, and how many of them are one of `ids`.
let mut marks: Vec<Mark> = Vec::new();
let mut matching = 0usize;
let mut bbox = None;
let mut out = HashMap::new();
let mut path: Vec<(f64, f64)> = Vec::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
crate::count_mark_reads(1);
@@ -209,17 +240,13 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> O
*t = m.then(&top);
}
}
b"BMC" => marks.push(false),
b"BMC" => marks.push(Mark { id: None, drawn: None }),
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" => {
if marks.pop() == Some(true) {
matching = matching.saturating_sub(1);
}
let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n));
matching += usize::from(id.is_some());
marks.push(Mark { id, drawn: None });
}
b"EMC" => close(&mut marks, &mut matching, &mut out),
b"m" | b"l" => {
if let Some([x, y]) = op.nums::<2>() {
path.push(top.apply(x, y));
@@ -240,7 +267,7 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> O
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
if inside && !matches!(op.op.as_slice(), b"n") {
for (x, y) in &path {
bbox = Some(union(bbox, [*x, *y, *x, *y]));
draw(&mut marks, [*x, *y, *x, *y]);
}
}
path.clear();
@@ -260,13 +287,16 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> O
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
bbox = Some(union(bbox, unit));
draw(&mut marks, unit);
}
b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
_ => {}
}
}
bbox
while !marks.is_empty() {
close(&mut marks, &mut matching, &mut out);
}
out
}
/// The figures, in document order.
@@ -275,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
let figures: Vec<(Element, Option<usize>)> = elements(doc)
.into_iter()
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
(e, page)
})
.collect();
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
for (e, page) in &figures {
if let Some(p) = page {
wanted.entry(*p).or_default().extend(mcids(&e.dict));
}
}
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect();
figures
.into_iter()
.map(|(e, page)| {
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
let on_page = page.and_then(|p| bounds.get(&p));
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
Figure { obj: e.object, page, alt, bbox }
})
.collect()
+2
View File
@@ -26,6 +26,8 @@ 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) };
/// Page content passes made to find figure bounds.
static BOUNDS_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` marked-content stack reads (tests only).
+22 -1
View File
@@ -1058,11 +1058,32 @@ fn figure_bounds_read_the_marked_content_state_in_constant_steps() {
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]));
crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7]));
});
assert!(reads <= 4 * 15_000, "{reads} stack reads");
}
#[test]
fn figure_bounds_take_one_pass_over_each_page() {
let n = 50;
let content: Vec<String> = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
stream(&content.join(" ")),
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
];
objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>")));
let doc = pdf(&objs, "");
BOUNDS_PASSES.with(|c| c.set(0));
let f = figures(&doc);
assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes");
assert_eq!(f.len(), n);
assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0])));
}
/// 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();