use crate::{
Quad, config::RecImage, error::Result, table::decode::CellSlot,
vision::rotate_crop::rotate_crop_image,
};
pub const TABLE_MATCH_MIN_IOU: f64 = 1e-8;
#[derive(Debug, Clone, PartialEq)]
pub struct TextLine {
pub bbox: [f32; 4],
pub text: String,
pub score: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellMatchKind {
BlankBox,
Unmatched,
Single,
Multiple,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BackfillOutcome {
pub texts: Vec<String>,
pub text_scores: Vec<Option<f32>>,
pub kinds: Vec<CellMatchKind>,
pub matched_lines: Vec<Vec<usize>>,
pub cells: usize,
pub blank_cells: usize,
pub unmatched_cells: usize,
pub single_line_cells: usize,
pub multi_line_cells: usize,
pub lines_above_table: usize,
pub lines_considered: usize,
pub matched_lines_total: usize,
pub text_bands_applied: bool,
pub recognizer_calls: usize,
}
impl BackfillOutcome {
pub fn non_empty(&self) -> usize {
self.texts.iter().filter(|text| !text.is_empty()).count()
}
pub fn non_empty_rate(&self) -> f64 {
if self.cells == 0 {
0.0
} else {
self.non_empty() as f64 / self.cells as f64
}
}
pub fn text_confidence(&self) -> TextConfidence {
let mut sum = 0.0f64;
let mut scored = 0usize;
for score in self.text_scores.iter().flatten() {
sum += *score as f64;
scored += 1;
}
TextConfidence {
cells: self.cells,
scored_cells: scored,
mean: if scored == 0 {
None
} else {
Some((sum / scored as f64) as f32)
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextConfidence {
pub cells: usize,
pub scored_cells: usize,
pub mean: Option<f32>,
}
pub trait CellRecognizer {
fn recognize(&mut self, region: &RecImage) -> Result<(String, f32)>;
}
pub enum BackfillSource<'a> {
Reuse {
lines: &'a [TextLine],
offset: [f32; 2],
},
Crop {
image: &'a RecImage,
recognizer: &'a mut dyn CellRecognizer,
},
}
pub fn axis_aligned_bbox(polygon: &[f32; 8]) -> [f32; 4] {
crate::vision::geometry::aabb_from_flat8(polygon)
}
pub fn bbox_to_quad(bbox: [f32; 4]) -> Quad {
[
[bbox[0], bbox[1]],
[bbox[2], bbox[1]],
[bbox[2], bbox[3]],
[bbox[0], bbox[3]],
]
}
pub fn rectangle_distance(a: [f32; 4], b: [f32; 4]) -> f64 {
let (x1, y1, x2, y2) = (a[0] as f64, a[1] as f64, a[2] as f64, a[3] as f64);
let (x3, y3, x4, y4) = (b[0] as f64, b[1] as f64, b[2] as f64, b[3] as f64);
let corner_start = (x3 - x1).abs() + (y3 - y1).abs();
let corner_end = (x4 - x2).abs() + (y4 - y2).abs();
corner_start + corner_end + corner_start.min(corner_end)
}
pub fn rectangle_iou(a: [f32; 4], b: [f32; 4]) -> f64 {
crate::vision::geometry::aabb_iou(a, b)
}
pub fn join_line_texts(texts: &[&str]) -> String {
if texts.is_empty() {
return String::new();
}
if texts.len() == 1 {
return texts[0].to_string();
}
let mut out = String::new();
for (index, raw) in texts.iter().enumerate() {
let mut content = *raw;
if content.is_empty() {
continue;
}
if content.starts_with(' ') {
content = &content[1..];
}
if content.is_empty() {
continue;
}
out.push_str(content);
if index != texts.len() - 1 && !content.ends_with(' ') {
out.push(' ');
}
}
out
}
pub fn join_text_lines(lines: &[TextLine], indices: &[usize]) -> String {
if indices.is_empty() {
return String::new();
}
if indices.len() == 1 {
return lines[indices[0]].text.clone();
}
let mut selected: Vec<&TextLine> = indices.iter().map(|&index| &lines[index]).collect();
selected.sort_by(|a, b| {
let a_center = (a.bbox[1] + a.bbox[3]) / 2.0;
let b_center = (b.bbox[1] + b.bbox[3]) / 2.0;
a_center
.total_cmp(&b_center)
.then_with(|| a.bbox[0].total_cmp(&b.bbox[0]))
});
let mut heights: Vec<f32> = selected
.iter()
.map(|line| (line.bbox[3] - line.bbox[1]).max(0.0))
.filter(|height| height.is_finite() && *height > 0.0)
.collect();
heights.sort_by(f32::total_cmp);
let median_height = heights.get(heights.len() / 2).copied().unwrap_or(1.0);
let same_line_center_delta = (median_height * 0.75).max(1.0);
let mut visual_lines: Vec<Vec<&str>> = Vec::new();
let mut previous_center: Option<f32> = None;
for line in selected {
let center = (line.bbox[1] + line.bbox[3]) / 2.0;
let same_visual_line = previous_center
.is_some_and(|previous| (center - previous).abs() <= same_line_center_delta);
if same_visual_line {
visual_lines
.last_mut()
.expect("a previous center implies a visual line")
.push(line.text.as_str());
} else {
visual_lines.push(vec![line.text.as_str()]);
}
previous_center = Some(center);
}
visual_lines
.iter()
.map(|texts| join_line_texts(texts))
.collect::<Vec<_>>()
.join("\n")
}
pub fn match_cells(cells: &[CellSlot], lines: &[TextLine]) -> BackfillOutcome {
match_cells_with_min_iou(cells, lines, [0.0, 0.0], TABLE_MATCH_MIN_IOU)
}
pub fn match_cells_with_offset(
cells: &[CellSlot],
lines: &[TextLine],
offset: [f32; 2],
) -> BackfillOutcome {
match_cells_with_min_iou(cells, lines, offset, TABLE_MATCH_MIN_IOU)
}
fn assign_rows_from_text(
lines: &[TextLine],
kept: &[usize],
table_top: f32,
table_bottom: f32,
rows: usize,
) -> Option<Vec<Option<usize>>> {
if rows == 0 {
return None;
}
let center = |index: usize| (lines[index].bbox[1] + lines[index].bbox[3]) / 2.0;
let mut inside: Vec<usize> = kept
.iter()
.copied()
.filter(|&index| lines[index].bbox[3] >= table_top && lines[index].bbox[1] <= table_bottom)
.collect();
if inside.is_empty() {
return None;
}
let mut assigned = vec![None; lines.len()];
if rows == 1 {
for &index in &inside {
assigned[index] = Some(0);
}
return Some(assigned);
}
inside.sort_by(|&a, &b| center(a).total_cmp(¢er(b)).then(a.cmp(&b)));
let mut gaps: Vec<f64> = Vec::new();
for pair in inside.windows(2) {
let (upper, lower) = (pair[0], pair[1]);
if lines[upper].bbox[3] <= lines[lower].bbox[1] {
gaps.push(((lines[upper].bbox[3] + lines[lower].bbox[1]) / 2.0) as f64);
}
}
if gaps.len() < rows - 1 {
return None;
}
let mut ranked: Vec<usize> = (0..gaps.len()).collect();
ranked.sort_by(|&a, &b| gaps[b].total_cmp(&gaps[a]).then(a.cmp(&b)));
let boundaries = &ranked[..rows - 1];
let chosen_min = boundaries
.iter()
.map(|&index| gaps[index])
.fold(f64::INFINITY, f64::min);
let unchosen_max = (0..gaps.len())
.filter(|index| !boundaries.contains(index))
.map(|index| gaps[index])
.fold(f64::NEG_INFINITY, f64::max);
if chosen_min <= unchosen_max {
return None;
}
let mut snapped: Vec<f64> = boundaries.iter().map(|&index| gaps[index]).collect();
snapped.sort_by(f64::total_cmp);
for &index in &inside {
let y = center(index) as f64;
let mut row = 0usize;
while row < snapped.len() && y > snapped[row] {
row += 1;
}
assigned[index] = Some(row);
}
Some(assigned)
}
pub fn match_cells_with_min_iou(
cells: &[CellSlot],
lines: &[TextLine],
offset: [f32; 2],
min_iou: f64,
) -> BackfillOutcome {
let mut outcome = BackfillOutcome {
texts: vec![String::new(); cells.len()],
text_scores: vec![None; cells.len()],
kinds: vec![CellMatchKind::BlankBox; cells.len()],
matched_lines: vec![Vec::new(); cells.len()],
cells: cells.len(),
blank_cells: 0,
unmatched_cells: 0,
single_line_cells: 0,
multi_line_cells: 0,
lines_above_table: 0,
lines_considered: 0,
matched_lines_total: 0,
recognizer_calls: 0,
text_bands_applied: false,
};
let candidates: Vec<(usize, [f32; 4])> = cells
.iter()
.enumerate()
.filter_map(|(index, cell)| {
cell.bbox.map(|bbox| {
(
index,
[
bbox[0] + offset[0],
bbox[1] + offset[1],
bbox[2] + offset[0],
bbox[3] + offset[1],
],
)
})
})
.collect();
outcome.blank_cells = cells.len() - candidates.len();
for (index, _) in &candidates {
outcome.kinds[*index] = CellMatchKind::Unmatched;
}
if candidates.is_empty() {
outcome.lines_above_table = lines.len();
outcome.unmatched_cells = 0;
return outcome;
}
let table_top = candidates
.iter()
.map(|(_, bbox)| bbox[1] as f64)
.fold(f64::INFINITY, f64::min);
let kept: Vec<usize> = (0..lines.len())
.filter(|&index| lines[index].bbox[3] as f64 >= table_top)
.collect();
outcome.lines_above_table = lines.len() - kept.len();
outcome.lines_considered = kept.len();
let rows = cells
.iter()
.map(|cell| cell.logic.row_start + cell.rowspan.max(1))
.max()
.unwrap_or(0);
let table_bottom = candidates
.iter()
.map(|(_, bbox)| bbox[3])
.fold(f32::NEG_INFINITY, f32::max);
let assigned_rows = assign_rows_from_text(lines, &kept, table_top as f32, table_bottom, rows);
outcome.text_bands_applied = assigned_rows.is_some();
let per_row: Vec<Vec<usize>> = match &assigned_rows {
None => Vec::new(),
Some(_) => (0..rows)
.map(|row| {
(0..candidates.len())
.filter(|&position| {
let logic = &cells[candidates[position].0].logic;
row >= logic.row_start && row <= logic.row_end
})
.collect()
})
.collect(),
};
let pick = |positions: &[usize], line_bbox: [f32; 4]| -> Option<(usize, f64, f64)> {
let mut best: Option<(usize, f64, f64)> = None;
for &position in positions {
let (_, cell_bbox) = candidates[position];
let iou_term = 1.0 - rectangle_iou(line_bbox, cell_bbox);
let distance = rectangle_distance(line_bbox, cell_bbox);
let better = match best {
None => true,
Some((_, best_iou, best_distance)) => {
(iou_term, distance) < (best_iou, best_distance)
}
};
if better {
best = Some((position, iou_term, distance));
}
}
best
};
let acceptable = |best: Option<(usize, f64, f64)>| matches!(best, Some((_, iou_term, _)) if iou_term < 1.0 - min_iou);
let all_positions: Vec<usize> = (0..candidates.len()).collect();
for &line_index in &kept {
let line_bbox = lines[line_index].bbox;
let banded = assigned_rows
.as_ref()
.and_then(|assigned| assigned[line_index])
.and_then(|row| pick(per_row[row].as_slice(), line_bbox));
let mut best = banded;
if !acceptable(best) {
let fallback = pick(&all_positions, line_bbox);
if acceptable(fallback) {
best = fallback;
}
}
let Some((position, iou_term, _)) = best else {
continue;
};
if iou_term >= 1.0 - min_iou {
continue;
}
let (slot_index, _) = candidates[position];
outcome.matched_lines[slot_index].push(line_index);
}
for index in 0..cells.len() {
let rows = &outcome.matched_lines[index];
if rows.is_empty() {
continue;
}
outcome.texts[index] = join_text_lines(lines, rows);
let sum: f64 = rows.iter().map(|&row| lines[row].score as f64).sum();
outcome.text_scores[index] = Some((sum / rows.len() as f64) as f32);
if rows.len() > 1 {
outcome.kinds[index] = CellMatchKind::Multiple;
outcome.multi_line_cells += 1;
} else {
outcome.kinds[index] = CellMatchKind::Single;
outcome.single_line_cells += 1;
}
outcome.matched_lines_total += rows.len();
}
outcome.unmatched_cells = outcome
.kinds
.iter()
.filter(|kind| **kind == CellMatchKind::Unmatched)
.count();
outcome
}
pub fn backfill(cells: &[CellSlot], source: BackfillSource<'_>) -> Result<BackfillOutcome> {
match source {
BackfillSource::Reuse { lines, offset } => Ok(match_cells_with_min_iou(
cells,
lines,
offset,
TABLE_MATCH_MIN_IOU,
)),
BackfillSource::Crop { image, recognizer } => {
let mut outcome = BackfillOutcome {
texts: vec![String::new(); cells.len()],
text_scores: vec![None; cells.len()],
kinds: vec![CellMatchKind::BlankBox; cells.len()],
matched_lines: vec![Vec::new(); cells.len()],
cells: cells.len(),
blank_cells: 0,
unmatched_cells: 0,
single_line_cells: 0,
multi_line_cells: 0,
lines_above_table: 0,
lines_considered: 0,
matched_lines_total: 0,
recognizer_calls: 0,
text_bands_applied: false,
};
for (index, cell) in cells.iter().enumerate() {
let Some(bbox) = cell.bbox else {
continue;
};
outcome.kinds[index] = CellMatchKind::Unmatched;
let Some(quad) = clamped_quad(bbox, image.width(), image.height()) else {
continue;
};
let region = rotate_crop_image(image, quad)?;
let (text, score) = recognizer.recognize(®ion)?;
outcome.recognizer_calls += 1;
outcome.kinds[index] = CellMatchKind::Single;
outcome.texts[index] = text;
outcome.text_scores[index] = Some(score);
}
outcome.blank_cells = outcome
.kinds
.iter()
.filter(|kind| **kind == CellMatchKind::BlankBox)
.count();
outcome.unmatched_cells = outcome
.kinds
.iter()
.filter(|kind| **kind == CellMatchKind::Unmatched)
.count();
outcome.single_line_cells = outcome
.kinds
.iter()
.filter(|kind| **kind == CellMatchKind::Single)
.count();
Ok(outcome)
}
}
}
fn clamped_quad(bbox: [f32; 4], width: usize, height: usize) -> Option<Quad> {
if width == 0 || height == 0 {
return None;
}
let max_x = width.saturating_sub(1) as f32;
let max_y = height.saturating_sub(1) as f32;
let x_min = bbox[0].clamp(0.0, max_x);
let y_min = bbox[1].clamp(0.0, max_y);
let x_max = bbox[2].clamp(0.0, max_x);
let y_max = bbox[3].clamp(0.0, max_y);
if x_max <= x_min || y_max <= y_min {
return None;
}
Some(bbox_to_quad([x_min, y_min, x_max, y_max]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::table::decode::LogicCell;
fn slot(index: usize, bbox: Option<[f32; 4]>) -> CellSlot {
CellSlot {
cell_index: index,
tag: "<td></td>".to_string(),
rowspan: 1,
colspan: 1,
score: 0.9,
bbox,
logic: LogicCell {
row_start: index,
row_end: index,
col_start: 0,
col_end: 0,
},
}
}
fn line(bbox: [f32; 4], text: &str) -> TextLine {
TextLine {
bbox,
text: text.to_string(),
score: 0.8,
}
}
fn solid(width: usize, height: usize) -> RecImage {
RecImage::from_bgr_u8(width, height, vec![0u8; width * height * 3])
.expect("synthetic image must be valid")
}
#[derive(Default)]
struct CountingRecognizer {
calls: usize,
regions: Vec<(usize, usize)>,
}
impl CellRecognizer for CountingRecognizer {
fn recognize(&mut self, region: &RecImage) -> Result<(String, f32)> {
self.calls += 1;
self.regions.push((region.width(), region.height()));
Ok((format!("cell{}", self.calls), 0.5))
}
}
#[test]
fn the_reference_distance_formula_is_reproduced() {
assert_eq!(
rectangle_distance([0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 30.0, 30.0]),
120.0
);
assert_eq!(
rectangle_distance([0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]),
0.0
);
}
#[test]
fn the_swapped_parameter_names_do_not_change_the_iou() {
assert!((rectangle_iou([0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 5.0, 5.0]) - 0.25).abs() < 1e-12);
assert!((rectangle_iou([0.0, 0.0, 4.0, 4.0], [0.0, 0.0, 2.0, 2.0]) - 0.25).abs() < 1e-12);
}
#[test]
fn disjoint_and_edge_touching_rectangles_have_zero_iou() {
assert_eq!(
rectangle_iou([0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 30.0, 30.0]),
0.0
);
assert_eq!(
rectangle_iou([0.0, 0.0, 10.0, 10.0], [10.0, 0.0, 20.0, 10.0]),
0.0
);
}
#[test]
fn degenerate_zero_area_rectangles_do_not_produce_nan() {
let iou = rectangle_iou([5.0, 5.0, 5.0, 5.0], [0.0, 0.0, 10.0, 10.0]);
assert_eq!(iou, 0.0);
assert!(!iou.is_nan());
let iou = rectangle_iou([5.0, 5.0, 5.0, 5.0], [5.0, 5.0, 5.0, 5.0]);
assert_eq!(iou, 0.0);
assert!(!iou.is_nan());
}
#[test]
fn the_min_iou_threshold_matches_the_reference_magic_number() {
let reference = 0.1f64.powi(8);
assert_ne!(TABLE_MATCH_MIN_IOU.to_bits(), reference.to_bits());
assert_eq!(
(1.0 - TABLE_MATCH_MIN_IOU).to_bits(),
(1.0 - reference).to_bits()
);
assert_eq!(1.0 - TABLE_MATCH_MIN_IOU, 0.99999999);
}
#[test]
fn a_line_lands_in_the_cell_that_contains_it() {
let cells = vec![
slot(0, Some([0.0, 0.0, 50.0, 20.0])),
slot(1, Some([50.0, 0.0, 100.0, 20.0])),
];
let lines = vec![
line([5.0, 2.0, 45.0, 18.0], "left"),
line([55.0, 2.0, 95.0, 18.0], "right"),
];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.texts, vec!["left".to_string(), "right".to_string()]);
assert_eq!(outcome.matched_lines, vec![vec![0], vec![1]]);
assert_eq!(
outcome.kinds,
vec![CellMatchKind::Single, CellMatchKind::Single]
);
assert_eq!(outcome.matched_lines_total, 2);
assert_eq!(outcome.non_empty(), 2);
assert_eq!(outcome.recognizer_calls, 0);
}
#[test]
fn a_single_matched_line_is_used_verbatim() {
let cells = vec![slot(0, Some([0.0, 0.0, 100.0, 20.0]))];
let lines = vec![line([5.0, 2.0, 95.0, 18.0], " padded ")];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.texts, vec![" padded ".to_string()]);
}
#[test]
fn a_cell_can_receive_several_lines_and_they_are_joined_with_line_breaks() {
let cells = vec![slot(0, Some([0.0, 0.0, 100.0, 60.0]))];
let lines = vec![
line([5.0, 2.0, 95.0, 18.0], "first"),
line([5.0, 22.0, 95.0, 38.0], "second"),
line([5.0, 42.0, 95.0, 58.0], "third"),
];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.texts, vec!["first\nsecond\nthird".to_string()]);
assert_eq!(outcome.kinds, vec![CellMatchKind::Multiple]);
assert_eq!(outcome.multi_line_cells, 1);
assert_eq!(outcome.matched_lines_total, 3);
assert_eq!(outcome.text_scores, vec![Some(0.8)]);
}
#[test]
fn join_line_texts_skips_empty_lines_and_spaces_only_the_non_final_ones() {
assert_eq!(join_line_texts(&["a"]), "a");
assert_eq!(join_line_texts(&["a", "b"]), "a b");
assert_eq!(join_line_texts(&["a", "", "b"]), "a b");
assert_eq!(join_line_texts(&[" a ", " b "]), "a b ");
assert_eq!(join_line_texts(&["a", ""]), "a ");
assert_eq!(join_line_texts(&[]), "");
}
#[test]
fn horizontally_split_boxes_stay_on_one_visual_line() {
let lines = vec![
line([0.0, 2.0, 30.0, 18.0], "left"),
line([31.0, 3.0, 60.0, 19.0], "right"),
];
assert_eq!(join_text_lines(&lines, &[0, 1]), "left right");
}
#[test]
fn vertically_separated_boxes_keep_a_cell_line_break() {
let lines = vec![
line([0.0, 2.0, 60.0, 18.0], "first"),
line([0.0, 22.0, 60.0, 38.0], "second"),
];
assert_eq!(join_text_lines(&lines, &[0, 1]), "first\nsecond");
}
#[test]
fn a_line_above_every_cell_is_dropped_before_matching() {
let cells = vec![slot(0, Some([0.0, 100.0, 100.0, 120.0]))];
let lines = vec![
line([5.0, 10.0, 95.0, 30.0], "page header"),
line([5.0, 102.0, 95.0, 118.0], "cell"),
];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.lines_above_table, 1);
assert_eq!(outcome.lines_considered, 1);
assert_eq!(outcome.texts, vec!["cell".to_string()]);
}
#[test]
fn a_line_that_overlaps_nothing_is_rejected_rather_than_forced() {
let cells = vec![slot(0, Some([0.0, 0.0, 10.0, 10.0]))];
let lines = vec![line([500.0, 500.0, 600.0, 600.0], "far away")];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.texts, vec![String::new()]);
assert_eq!(outcome.kinds, vec![CellMatchKind::Unmatched]);
assert_eq!(outcome.unmatched_cells, 1);
assert_eq!(outcome.non_empty(), 0);
assert_eq!(outcome.non_empty_rate(), 0.0);
assert_eq!(outcome.text_confidence().mean, None);
}
#[test]
fn a_barely_overlapping_box_is_accepted() {
let cells = vec![slot(0, Some([0.0, 0.0, 10.0, 10.0]))];
let lines = vec![line([9.0, 9.0, 20.0, 20.0], "corner")];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.texts, vec!["corner".to_string()]);
}
#[test]
fn the_tie_break_prefers_the_first_candidate() {
let cells = vec![
slot(0, Some([0.0, 0.0, 10.0, 10.0])),
slot(1, Some([0.0, 0.0, 10.0, 10.0])),
];
let lines = vec![line([0.0, 0.0, 10.0, 10.0], "ambiguous")];
let outcome = match_cells(&cells, &lines);
assert_eq!(outcome.matched_lines, vec![vec![0], Vec::new()]);
assert_eq!(outcome.texts, vec!["ambiguous".to_string(), String::new()]);
assert_eq!(
outcome.kinds,
vec![CellMatchKind::Single, CellMatchKind::Unmatched]
);
}
#[test]
fn a_blank_placeholder_keeps_its_slot_index() {
let cells = vec![slot(0, None), slot(1, Some([0.0, 0.0, 50.0, 20.0]))];
let lines = vec![line([5.0, 2.0, 45.0, 18.0], "second slot")];
let outcome = match_cells(&cells, &lines);
assert_eq!(
outcome.texts,
vec![String::new(), "second slot".to_string()]
);
assert_eq!(outcome.matched_lines, vec![Vec::new(), vec![0]]);
assert_eq!(outcome.blank_cells, 1);
assert_eq!(outcome.kinds[0], CellMatchKind::BlankBox);
assert_eq!(outcome.kinds[1], CellMatchKind::Single);
assert_eq!(outcome.non_empty_rate(), 0.5);
}
#[test]
fn a_cell_without_a_bbox_is_a_blank_box_not_an_unmatched_cell() {
let cells = vec![slot(0, None), slot(1, Some([0.0, 0.0, 10.0, 10.0]))];
let outcome = match_cells(&cells, &[]);
assert_eq!(outcome.blank_cells, 1);
assert_eq!(outcome.unmatched_cells, 1);
assert_eq!(
outcome.kinds,
vec![CellMatchKind::BlankBox, CellMatchKind::Unmatched]
);
}
#[test]
fn the_offset_shifts_the_cells_into_page_coordinates() {
let cells = vec![slot(0, Some([0.0, 0.0, 50.0, 20.0]))];
let lines = vec![line([105.0, 202.0, 145.0, 218.0], "inside")];
assert_eq!(match_cells(&cells, &lines).texts, vec![String::new()]);
let shifted = match_cells_with_offset(&cells, &lines, [100.0, 200.0]);
assert_eq!(shifted.texts, vec!["inside".to_string()]);
}
#[test]
fn an_empty_input_does_not_panic_and_reports_counts_not_ratios() {
let outcome = match_cells(&[], &[line([0.0, 0.0, 1.0, 1.0], "x")]);
assert_eq!(outcome.cells, 0);
assert_eq!(outcome.non_empty_rate(), 0.0);
assert_eq!(outcome.text_confidence().mean, None);
assert_eq!(outcome.lines_above_table, 1);
assert_eq!(outcome.lines_considered, 0);
}
#[test]
fn the_reuse_path_calls_the_recognizer_zero_times() {
let cells = vec![
slot(0, Some([0.0, 0.0, 50.0, 20.0])),
slot(1, Some([0.0, 20.0, 50.0, 40.0])),
slot(2, None),
];
let lines = vec![
line([2.0, 2.0, 48.0, 18.0], "a"),
line([2.0, 22.0, 48.0, 38.0], "b"),
];
let reuse = backfill(
&cells,
BackfillSource::Reuse {
lines: &lines,
offset: [0.0, 0.0],
},
)
.expect("the reuse path cannot fail");
assert_eq!(reuse.recognizer_calls, 0);
assert_eq!(
reuse.texts,
vec!["a".to_string(), "b".to_string(), String::new()]
);
assert_eq!(reuse.non_empty(), 2);
}
#[test]
fn the_crop_control_calls_the_recognizer_once_per_cell_with_a_bbox() {
let cells = vec![
slot(0, Some([0.0, 0.0, 50.0, 20.0])),
slot(1, Some([0.0, 20.0, 50.0, 40.0])),
slot(2, None),
];
let image = solid(200, 100);
let mut recognizer = CountingRecognizer::default();
let cropped = backfill(
&cells,
BackfillSource::Crop {
image: &image,
recognizer: &mut recognizer,
},
)
.expect("an axis-aligned crop inside the image must succeed");
assert_eq!(cropped.recognizer_calls, 2);
assert_eq!(recognizer.calls, 2);
assert_eq!(
cropped.texts,
vec!["cell1".to_string(), "cell2".to_string(), String::new()]
);
assert_eq!(cropped.blank_cells, 1);
assert_eq!(cropped.unmatched_cells, 0);
assert_eq!(cropped.kinds[0], CellMatchKind::Single);
assert_eq!(cropped.kinds[2], CellMatchKind::BlankBox);
assert_eq!(cropped.non_empty_rate(), 2.0 / 3.0);
}
#[test]
fn the_two_paths_agree_on_which_cells_are_empty() {
let cells = vec![slot(0, Some([0.0, 0.0, 50.0, 20.0])), slot(1, None)];
let lines = vec![line([2.0, 2.0, 48.0, 18.0], "a")];
let reuse = backfill(
&cells,
BackfillSource::Reuse {
lines: &lines,
offset: [0.0, 0.0],
},
)
.unwrap();
let image = solid(200, 100);
let mut recognizer = CountingRecognizer::default();
let cropped = backfill(
&cells,
BackfillSource::Crop {
image: &image,
recognizer: &mut recognizer,
},
)
.unwrap();
assert_eq!(reuse.non_empty(), 1);
assert_eq!(cropped.non_empty(), 1);
assert_eq!(reuse.non_empty_rate(), cropped.non_empty_rate());
}
#[test]
fn text_confidence_is_reported_separately_from_the_structure_confidence() {
let cells = vec![
slot(0, Some([0.0, 0.0, 50.0, 20.0])),
slot(1, Some([0.0, 20.0, 50.0, 40.0])),
];
let mut lines = vec![
line([2.0, 2.0, 48.0, 18.0], "a"),
line([2.0, 22.0, 48.0, 38.0], "b"),
];
lines[0].score = 0.6;
lines[1].score = 0.9;
let outcome = match_cells(&cells, &lines);
let confidence = outcome.text_confidence();
assert_eq!(confidence.cells, 2);
assert_eq!(confidence.scored_cells, 2);
assert_eq!(confidence.mean, Some(0.75));
assert_ne!(confidence.mean, Some(cells[0].score));
}
#[test]
fn a_partially_scored_outcome_averages_only_the_scored_cells() {
let cells = vec![
slot(0, Some([0.0, 0.0, 50.0, 20.0])),
slot(1, Some([0.0, 20.0, 50.0, 40.0])),
];
let mut lines = vec![line([2.0, 2.0, 48.0, 18.0], "a")];
lines[0].score = 0.4;
let outcome = match_cells(&cells, &lines);
let confidence = outcome.text_confidence();
assert_eq!(confidence.cells, 2);
assert_eq!(confidence.scored_cells, 1);
assert_eq!(confidence.mean, Some(0.4));
}
fn cell(row: usize, col: usize, bbox: [f32; 4]) -> CellSlot {
CellSlot {
cell_index: row * 16 + col,
tag: "<td></td>".to_string(),
rowspan: 1,
colspan: 1,
score: 0.9,
bbox: Some(bbox),
logic: LogicCell {
row_start: row,
row_end: row,
col_start: col,
col_end: col,
},
}
}
fn real_wrapped_rows() -> (Vec<CellSlot>, Vec<TextLine>) {
let cells = vec![
cell(0, 0, [1.8, 334.0, 171.4, 397.0]),
cell(0, 1, [181.1, 332.7, 972.5, 397.5]),
cell(1, 0, [1.9, 394.2, 171.9, 458.0]),
cell(1, 1, [168.3, 392.5, 971.9, 459.7]),
];
let lines = vec![
line([185.0, 324.0, 955.0, 346.0], "申博康第二批吃饭,内径"), line([7.0, 338.0, 154.0, 363.0], "12:00~13:00"), line([469.0, 351.0, 670.0, 375.0], "磨冷却液小,打维修"), line([182.0, 383.0, 956.0, 407.0], "大圈正常出料,内径精磨"), line([7.0, 398.0, 154.0, 423.0], "13:00~14:00"), line([553.0, 410.0, 584.0, 438.0], "序"), ];
(cells, lines)
}
#[test]
fn a_wrapped_continuation_stays_in_its_own_row() {
let (cells, lines) = real_wrapped_rows();
let outcome = match_cells(&cells, &lines);
assert!(
outcome.text_bands_applied,
"the row bands must engage on this input: the text gaps are 15.5/12.5 px inside the \
rows and 32 px between them"
);
assert_eq!(outcome.texts[0], "12:00~13:00");
assert_eq!(outcome.texts[2], "13:00~14:00");
assert_eq!(
outcome.texts[1], "申博康第二批吃饭,内径\n磨冷却液小,打维修",
"the 12:00 cell must hold the 12:00 row only"
);
assert_eq!(
outcome.texts[3], "大圈正常出料,内径精磨\n序",
"the 13:00 cell must hold the 13:00 row only — this is the E25 defect: before the \
fix the 13:00 first line landed in the 12:00 cell (IoU 0.195 vs 0.189)"
);
assert_eq!(
outcome.kinds,
vec![
CellMatchKind::Single,
CellMatchKind::Multiple,
CellMatchKind::Single,
CellMatchKind::Multiple,
]
);
assert_eq!(outcome.matched_lines_total, lines.len());
}
#[test]
fn rows_come_from_the_grid_and_boundaries_from_the_text() {
let cells = vec![
cell(0, 0, [0.0, 4.0, 40.0, 20.0]),
cell(0, 1, [60.0, 4.0, 200.0, 20.0]),
cell(1, 0, [0.0, 26.0, 40.0, 44.0]),
cell(1, 1, [60.0, 22.0, 200.0, 58.0]),
cell(2, 0, [0.0, 60.0, 40.0, 80.0]),
cell(2, 1, [60.0, 56.0, 200.0, 80.0]),
];
let lines = vec![
line([2.0, 0.0, 38.0, 16.0], "h0"),
line([62.0, 0.0, 198.0, 16.0], "h1"),
line([2.0, 24.0, 38.0, 40.0], "t1"),
line([62.0, 20.0, 198.0, 32.0], "r1a"),
line([62.0, 36.0, 198.0, 50.0], "r1b"),
line([2.0, 62.0, 38.0, 78.0], "t2"),
line([62.0, 60.0, 198.0, 74.0], "r2"),
];
let outcome = match_cells(&cells, &lines);
assert!(
outcome.text_bands_applied,
"the gaps are 24 px / 18 px apart from 3–11 px inside the rows"
);
assert_eq!(outcome.texts[0], "h0");
assert_eq!(outcome.texts[1], "h1");
assert_eq!(outcome.texts[2], "t1");
assert_eq!(outcome.texts[3], "r1a\nr1b");
assert_eq!(outcome.texts[4], "t2");
assert_eq!(outcome.texts[5], "r2");
assert_eq!(outcome.kinds[3], CellMatchKind::Multiple);
}
#[test]
fn a_spanning_cell_collects_lines_from_both_of_its_rows() {
let mut spanning = cell(0, 0, [0.0, 0.0, 40.0, 90.0]);
spanning.rowspan = 2;
spanning.logic.row_end = 1;
let cells = vec![
spanning,
cell(0, 1, [60.0, 4.0, 200.0, 20.0]),
cell(1, 1, [60.0, 40.0, 200.0, 56.0]),
cell(2, 0, [0.0, 96.0, 40.0, 112.0]),
cell(2, 1, [60.0, 96.0, 200.0, 112.0]),
];
let lines = vec![
line([2.0, 4.0, 38.0, 20.0], "merged-a"),
line([62.0, 4.0, 198.0, 20.0], "a"),
line([2.0, 40.0, 38.0, 56.0], "merged-b"),
line([62.0, 40.0, 198.0, 56.0], "b"),
line([2.0, 96.0, 38.0, 112.0], "c0"),
line([62.0, 96.0, 198.0, 112.0], "c1"),
];
let outcome = match_cells(&cells, &lines);
assert!(outcome.text_bands_applied);
assert_eq!(
outcome.texts[0], "merged-a\nmerged-b",
"a rowspan=2 cell must be a candidate in every row it covers"
);
assert_eq!(outcome.kinds[0], CellMatchKind::Multiple);
assert_eq!(outcome.texts[3], "c0");
assert_eq!(outcome.texts[4], "c1");
}
#[test]
fn a_line_its_own_row_cannot_reach_is_still_matched_not_dropped() {
let cells = vec![
cell(0, 0, [0.0, 0.0, 100.0, 55.0]),
cell(1, 0, [0.0, 60.0, 100.0, 80.0]),
];
let lines = vec![
line([5.0, 0.0, 95.0, 12.0], "a"),
line([5.0, 14.0, 95.0, 26.0], "b"),
line([5.0, 40.0, 95.0, 50.0], "c"),
];
let outcome = match_cells(&cells, &lines);
assert!(outcome.text_bands_applied, "gaps 14 px / 21 px → rows = 2");
assert_eq!(outcome.texts[0], "a\nb\nc");
assert_eq!(outcome.kinds[0], CellMatchKind::Multiple);
assert_eq!(outcome.kinds[1], CellMatchKind::Unmatched);
assert_eq!(
outcome.matched_lines_total, 3,
"no line may be dropped by the band restriction"
);
}
#[test]
fn the_text_gap_replaces_the_models_drifting_boundary() {
let (_, lines) = real_wrapped_rows();
let kept: Vec<usize> = (0..lines.len()).collect();
let table_top = 332.7;
let table_bottom = 459.7;
let assigned = assign_rows_from_text(&lines, &kept, table_top, table_bottom, 2)
.expect("the single eligible gap is enough for rows = 2");
assert_eq!(
assigned,
vec![Some(0), Some(0), Some(0), Some(1), Some(1), Some(1)],
"if the model's boundary at 395 were kept, the 13:00 first line would stay on row 1"
);
}
#[test]
fn a_header_row_of_side_by_side_cells_is_not_split_by_its_own_noise() {
let mut cells = Vec::new();
for row in 0..2 {
for col in 0..6 {
let left = col as f32 * 40.0;
let top = row as f32 * 40.0;
cells.push(cell(row, col, [left, top, left + 30.0, top + 20.0]));
}
}
let lines: Vec<TextLine> = (0..6)
.map(|col| {
let left = col as f32 * 40.0;
let top = 1.0 + col as f32 * 0.4;
line(
[left + 2.0, top, left + 28.0, top + 18.0],
&format!("c{col}"),
)
})
.collect();
let outcome = match_cells(&cells, &lines);
assert!(
!outcome.text_bands_applied,
"there is not a single eligible gap here, so the row assignment must stay off"
);
for col in 0..6 {
assert_eq!(
outcome.texts[col],
format!("c{col}"),
"the whole header row must stay on row 0"
);
assert_eq!(outcome.kinds[col], CellMatchKind::Single);
assert_eq!(outcome.kinds[6 + col], CellMatchKind::Unmatched);
}
}
#[test]
fn a_cell_with_several_stacked_lines_keeps_all_of_them() {
let mut cells = Vec::new();
for row in 0..3 {
let (top, bottom) = match row {
0 => (0.0, 95.0),
1 => (100.0, 140.0),
_ => (140.0, 180.0),
};
for col in 0..4 {
let left = col as f32 * 40.0;
cells.push(cell(row, col, [left, top, left + 36.0, bottom]));
}
}
let mut lines = vec![
line([5.0, 5.0, 55.0, 25.0], "标号"),
line([5.0, 35.0, 55.0, 55.0], "张数"),
line([5.0, 65.0, 55.0, 85.0], "箱"),
];
for (index, text) in ["0", "1", "2"].iter().enumerate() {
let left = 44.0 + index as f32 * 40.0;
lines.push(line([left, 35.0, left + 32.0, 55.0], text));
}
for (row, texts) in [(1usize, ["A", "2", "1", "3"]), (2, ["B", "2", "1", "2"])] {
let top = if row == 1 { 105.0 } else { 145.0 };
for (index, text) in texts.iter().enumerate() {
let left = 4.0 + index as f32 * 40.0;
lines.push(line([left, top, left + 32.0, top + 20.0], text));
}
}
let outcome = match_cells(&cells, &lines);
assert!(outcome.text_bands_applied);
assert_eq!(
outcome.texts[0], "标号\n张数\n箱",
"a tall cell must collect every stacked line inside it"
);
assert_eq!(outcome.kinds[0], CellMatchKind::Multiple);
assert_eq!(outcome.texts[1], "0");
assert_eq!(outcome.texts[2], "1");
assert_eq!(outcome.texts[3], "2");
assert_eq!(
&outcome.texts[4..8],
["A", "2", "1", "3"],
"the data rows must stay on their own rows"
);
assert_eq!(&outcome.texts[8..12], ["B", "2", "1", "2"]);
assert_eq!(outcome.matched_lines_total, lines.len());
}
#[test]
fn the_polygon_reduction_matches_the_reference_min_max_form() {
let polygon = [10.0, 30.0, 50.0, 5.0, 20.0, 40.0, 5.0, 12.0];
assert_eq!(axis_aligned_bbox(&polygon), [5.0, 5.0, 50.0, 40.0]);
}
#[test]
fn a_quad_walks_the_box_clockwise_from_the_top_left() {
assert_eq!(
bbox_to_quad([1.0, 2.0, 3.0, 4.0]),
[[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]
);
}
#[test]
fn a_crop_that_collapses_after_clamping_is_skipped_rather_than_recognized() {
let cells = vec![slot(0, Some([500.0, 500.0, 600.0, 600.0]))];
let image = solid(200, 100);
let mut recognizer = CountingRecognizer::default();
let outcome = backfill(
&cells,
BackfillSource::Crop {
image: &image,
recognizer: &mut recognizer,
},
)
.unwrap();
assert_eq!(recognizer.calls, 0);
assert_eq!(outcome.recognizer_calls, 0);
assert_eq!(outcome.blank_cells, 0);
assert_eq!(outcome.unmatched_cells, 1);
assert_eq!(outcome.kinds, vec![CellMatchKind::Unmatched]);
}
#[test]
fn a_zero_sided_image_is_refused_rather_than_cropped() {
assert_eq!(clamped_quad([0.0, 0.0, 1.0, 1.0], 0, 10), None);
assert_eq!(clamped_quad([0.0, 0.0, 1.0, 1.0], 10, 0), None);
assert!(clamped_quad([0.0, 0.0, 1.0, 1.0], 10, 10).is_some());
}
}