From 486949424cd99139bf161ae840a9b2fee99820a7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 10:29:43 -0400 Subject: [PATCH] refactor(a11y): extract text_spans and xobject_bbox helpers (Sonar S3776) (#4407) Co-authored-by: Claude Sonnet 5.5 --- crates/a11y/src/alt.rs | 34 ++++++++++++--------- crates/a11y/src/autotag.rs | 60 +++++++++++++++++++++----------------- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 87cfce5acb..720b8a6a1e 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -211,6 +211,25 @@ fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap 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 = + 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) -> HashMap { @@ -274,20 +293,7 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> 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 = - 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])), diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 4462025cf5..9b65f6972b 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -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, 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, AutoTagError> { let root = doc.root().ok_or(AutoTagError::NoCatalog)?; let catalog = doc.get(root); @@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result, 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 }); }