refactor(a11y): extract text_spans and xobject_bbox helpers (Sonar S3776) (#4407)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-24 10:29:43 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent ff267d6883
commit 486949424c
2 changed files with 53 additions and 41 deletions
+20 -14
View File
@@ -211,6 +211,25 @@ fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f6
draw(marks, b);
}
/// The page-space box a `Do` operator paints: a form's transformed `/BBox`, or the unit square for an image.
/// `None` when the operand is not a resolvable XObject or the form's box is malformed.
fn xobject_bbox(doc: &Document, xobjects: &Dict, op: &pdfcraft_content::Op, top: &Matrix) -> Option<[f64; 4]> {
let r = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)?;
Some(match &*doc.get(r) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
let bb: Vec<f64> =
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match bb[..] {
[a, b, c, d] => m.then(top).bbox([a, b, c, d]),
_ => return None,
}
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
})
}
/// 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]> {
@@ -274,20 +293,7 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) ->
path.clear();
}
b"Do" if inside => {
let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue };
let unit = match &*doc.get(r) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
let bb: Vec<f64> =
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match bb[..] {
[a, b, c, d] => m.then(&top).bbox([a, b, c, d]),
_ => continue,
}
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
let Some(unit) = xobject_bbox(doc, &xobjects, &op, &top) else { continue };
draw(&mut marks, unit);
}
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
+33 -27
View File
@@ -132,6 +132,38 @@ fn harmless_inside(op: &[u8]) -> bool {
)
}
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in ops.iter().enumerate() {
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
spans.push((first, at));
if spans.len() > MAX_TEXT_OBJECTS {
return Err(unsupported(index, "too many text objects (maximum 4096)"));
}
}
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
Ok(spans)
}
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
let catalog = doc.get(root);
@@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in parsed.ops.iter().enumerate() {
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
spans.push((first, at));
if spans.len() > MAX_TEXT_OBJECTS {
return Err(unsupported(index, "too many text objects (maximum 4096)"));
}
}
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
let spans = text_spans(&parsed.ops, index)?;
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
}