mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
+65
-21
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user