#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub struct HocrWord {
pub text: String,
pub left: u32,
pub top: u32,
pub width: u32,
pub height: u32,
pub confidence: f64,
}
impl HocrWord {
#[cfg(test)]
#[inline]
pub(crate) fn right(&self) -> u32 {
self.left + self.width
}
#[cfg(test)]
#[inline]
pub(crate) fn bottom(&self) -> u32 {
self.top + self.height
}
#[inline]
pub(crate) fn y_center(&self) -> f64 {
self.top as f64 + (self.height as f64 / 2.0)
}
#[cfg(test)]
#[inline]
pub(crate) fn x_center(&self) -> f64 {
self.left as f64 + (self.width as f64 / 2.0)
}
}
pub(crate) fn detect_columns(words: &[HocrWord], column_threshold: u32) -> Vec<u32> {
if words.is_empty() {
return Vec::new();
}
let mut position_groups: Vec<Vec<u32>> = Vec::new();
for word in words {
let x_pos = word.left;
let mut found_group = false;
for group in &mut position_groups {
if let Some(&first_pos) = group.first()
&& x_pos.abs_diff(first_pos) <= column_threshold
{
group.push(x_pos);
found_group = true;
break;
}
}
if !found_group {
position_groups.push(vec![x_pos]);
}
}
let mut columns: Vec<u32> = position_groups
.iter()
.filter(|group| !group.is_empty())
.map(|group| {
let mut sorted = group.clone();
sorted.sort_unstable();
let mid = sorted.len() / 2;
sorted[mid]
})
.collect();
columns.sort_unstable();
columns
}
fn median_word_height(words: &[HocrWord]) -> u32 {
if words.is_empty() {
return 0;
}
let mut heights: Vec<u32> = words.iter().map(|w| w.height).collect();
heights.sort_unstable();
heights[heights.len() / 2]
}
pub(crate) fn detect_rows(words: &[HocrWord], row_threshold_ratio: f64) -> Vec<u32> {
if words.is_empty() {
return Vec::new();
}
let median_height = median_word_height(words);
let row_threshold = (median_height as f64 * row_threshold_ratio) as u32;
let mut position_groups: Vec<Vec<f64>> = Vec::new();
for word in words {
let y_center = word.y_center();
let mut found_group = false;
for group in &mut position_groups {
if let Some(&first_pos) = group.first()
&& (y_center - first_pos).abs() <= row_threshold as f64
{
group.push(y_center);
found_group = true;
break;
}
}
if !found_group {
position_groups.push(vec![y_center]);
}
}
let mut rows: Vec<u32> = position_groups
.iter()
.filter(|group| !group.is_empty())
.map(|group| {
let mut sorted = group.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let mid = sorted.len() / 2;
sorted[mid] as u32
})
.collect();
rows.sort_unstable();
rows
}
fn find_row_index(row_positions: &[u32], word: &HocrWord) -> Option<usize> {
let y_center = word.y_center() as u32;
row_positions
.iter()
.enumerate()
.min_by_key(|&(_, row_y)| row_y.abs_diff(y_center))
.map(|(idx, _)| idx)
}
fn find_column_index(col_positions: &[u32], word: &HocrWord) -> Option<usize> {
let x_pos = word.left;
col_positions
.iter()
.enumerate()
.min_by_key(|&(_, col_x)| col_x.abs_diff(x_pos))
.map(|(idx, _)| idx)
}
fn non_empty_column_mask(table: &[Vec<String>]) -> Vec<bool> {
let num_cols = table.first().map_or(0, Vec::len);
let mut mask = vec![false; num_cols];
for row in table {
for (col_idx, cell) in row.iter().enumerate() {
if !cell.trim().is_empty() {
mask[col_idx] = true;
}
}
}
mask
}
fn remove_empty_rows_and_columns(table: Vec<Vec<String>>) -> Vec<Vec<String>> {
if table.is_empty() {
return table;
}
let non_empty_cols = non_empty_column_mask(&table);
table
.into_iter()
.filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
.map(|row| {
row.into_iter()
.enumerate()
.filter(|(idx, _)| non_empty_cols[*idx])
.map(|(_, cell)| cell)
.collect()
})
.collect()
}
pub(crate) const CELL_MERGE_GAP_HEIGHT_RATIO: f64 = 0.6;
fn merge_words_into_cell_tokens(words: &[HocrWord], row_positions: &[u32]) -> Vec<HocrWord> {
if words.len() <= 1 || row_positions.is_empty() {
return words.to_vec();
}
let merge_gap = median_word_height(words) as f64 * CELL_MERGE_GAP_HEIGHT_RATIO;
let mut rows: Vec<Vec<&HocrWord>> = vec![Vec::new(); row_positions.len()];
for word in words {
if let Some(row_index) = find_row_index(row_positions, word) {
rows[row_index].push(word);
}
}
let mut tokens = Vec::with_capacity(words.len());
for mut row_words in rows {
row_words.sort_by_key(|w| w.left);
let mut current: Option<HocrWord> = None;
for word in row_words {
current = Some(match current.take() {
None => word.clone(),
Some(mut token) => {
let gap = word.left as f64 - (token.left + token.width) as f64;
if gap <= merge_gap {
let new_right = (word.left + word.width).max(token.left + token.width);
let new_bottom = (word.top + word.height).max(token.top + token.height);
token.top = token.top.min(word.top);
token.width = new_right.saturating_sub(token.left);
token.height = new_bottom.saturating_sub(token.top);
token.text.push(' ');
token.text.push_str(&word.text);
token
} else {
tokens.push(token);
word.clone()
}
}
});
}
if let Some(token) = current {
tokens.push(token);
}
}
tokens
}
pub(crate) fn reconstruct_table(
words: &[HocrWord],
column_threshold: u32,
row_threshold_ratio: f64,
) -> Vec<Vec<String>> {
reconstruct_table_with_columns(words, column_threshold, row_threshold_ratio).0
}
pub(crate) fn reconstruct_table_with_columns(
words: &[HocrWord],
column_threshold: u32,
row_threshold_ratio: f64,
) -> (Vec<Vec<String>>, Vec<u32>) {
if words.is_empty() {
return (Vec::new(), Vec::new());
}
let row_positions = detect_rows(words, row_threshold_ratio);
let cell_tokens = merge_words_into_cell_tokens(words, &row_positions);
let col_positions = detect_columns(&cell_tokens, column_threshold);
if col_positions.is_empty() || row_positions.is_empty() {
return (Vec::new(), Vec::new());
}
let result = assign_words_to_cells(words, &row_positions, &col_positions);
let non_empty_cols = non_empty_column_mask(&result);
let kept_col_positions: Vec<u32> = col_positions
.iter()
.zip(non_empty_cols.iter())
.filter(|&(_, &keep)| keep)
.map(|(&pos, _)| pos)
.collect();
(remove_empty_rows_and_columns(result), kept_col_positions)
}
fn assign_words_to_cells(words: &[HocrWord], row_positions: &[u32], col_positions: &[u32]) -> Vec<Vec<String>> {
let num_rows = row_positions.len();
let num_cols = col_positions.len();
let mut table: Vec<Vec<Vec<String>>> = vec![vec![vec![]; num_cols]; num_rows];
for word in words {
if let (Some(r), Some(c)) = (
find_row_index(row_positions, word),
find_column_index(col_positions, word),
) && r < num_rows
&& c < num_cols
{
table[r][c].push(word.text.clone());
}
}
table
.into_iter()
.map(|row| {
row.into_iter()
.map(|cell_words| {
if cell_words.is_empty() {
String::new()
} else {
cell_words.join(" ")
}
})
.collect()
})
.collect()
}
pub(crate) fn table_to_markdown(table: &[Vec<String>]) -> String {
crate::rendering::common::render_table_markdown(table)
}
#[cfg(any(feature = "ocr", paddle_ocr))]
pub(crate) const TABLE_REGION_GAP_HEIGHT_MULTIPLIER: u32 = 3;
#[cfg(any(feature = "ocr", paddle_ocr))]
pub(crate) const MIN_TABLE_CANDIDATE_WORDS: usize = 6;
#[cfg(any(feature = "ocr", paddle_ocr))]
pub(crate) fn cluster_words_into_table_regions(words: &[HocrWord]) -> Vec<Vec<HocrWord>> {
if words.is_empty() {
return Vec::new();
}
let mut sorted: Vec<&HocrWord> = words.iter().collect();
sorted.sort_by(|a, b| a.top.cmp(&b.top).then(a.left.cmp(&b.left)));
let avg_height: u32 = {
let total: u32 = sorted.iter().map(|w| w.height).sum();
(total / sorted.len() as u32).max(1)
};
let region_gap_threshold = avg_height * TABLE_REGION_GAP_HEIGHT_MULTIPLIER;
let mut regions: Vec<Vec<HocrWord>> = Vec::new();
let mut current_region: Vec<HocrWord> = Vec::new();
let mut current_bottom: u32 = 0;
for word in sorted {
let word_bottom = word.top + word.height;
let is_new_region =
!current_region.is_empty() && word.top.saturating_sub(current_bottom) > region_gap_threshold;
if is_new_region {
regions.push(std::mem::take(&mut current_region));
current_bottom = 0;
}
current_bottom = current_bottom.max(word_bottom);
current_region.push(word.clone());
}
if !current_region.is_empty() {
regions.push(current_region);
}
regions
}
#[cfg(test)]
mod tests {
use super::*;
fn word(text: &str, left: u32, top: u32, width: u32, height: u32) -> HocrWord {
HocrWord {
text: text.to_string(),
left,
top,
width,
height,
confidence: 95.0,
}
}
#[test]
fn test_detect_rows_zero_height_words_grouped_into_one_row() {
let words = vec![
HocrWord {
text: "A".to_string(),
left: 0,
top: 10,
width: 5,
height: 0,
confidence: 0.0,
},
HocrWord {
text: "B".to_string(),
left: 0,
top: 10,
width: 5,
height: 0,
confidence: 0.0,
},
];
let rows = detect_rows(&words, 0.5);
assert_eq!(rows.len(), 1);
}
#[test]
fn test_nan_safe_sort_does_not_panic() {
let mut values: Vec<f64> = vec![1.0, f64::NAN, 2.0];
values.sort_by(|a, b| a.total_cmp(b));
assert_eq!(values.len(), 3);
assert!(!values[0].is_nan());
assert!(!values[1].is_nan());
assert!(values[2].is_nan(), "NaN sorts last in ascending total_cmp order");
}
#[test]
fn test_hocr_word_methods() {
let word = HocrWord {
text: "Hello".to_string(),
left: 100,
top: 50,
width: 80,
height: 30,
confidence: 95.5,
};
assert_eq!(word.right(), 180);
assert_eq!(word.bottom(), 80);
assert_eq!(word.y_center(), 65.0);
assert_eq!(word.x_center(), 140.0);
}
#[test]
fn test_detect_columns() {
let words = vec![
HocrWord {
text: "A".to_string(),
left: 100,
top: 50,
width: 20,
height: 30,
confidence: 95.0,
},
HocrWord {
text: "B".to_string(),
left: 300,
top: 50,
width: 20,
height: 30,
confidence: 95.0,
},
HocrWord {
text: "C".to_string(),
left: 105,
top: 100,
width: 20,
height: 30,
confidence: 95.0,
},
HocrWord {
text: "D".to_string(),
left: 295,
top: 100,
width: 20,
height: 30,
confidence: 95.0,
},
];
let cols = detect_columns(&words, 20);
assert_eq!(cols.len(), 2);
}
#[test]
fn test_detect_rows() {
let words = vec![
HocrWord {
text: "A".to_string(),
left: 100,
top: 50,
width: 20,
height: 30,
confidence: 95.0,
},
HocrWord {
text: "B".to_string(),
left: 200,
top: 52,
width: 20,
height: 30,
confidence: 95.0,
},
HocrWord {
text: "C".to_string(),
left: 100,
top: 100,
width: 20,
height: 30,
confidence: 95.0,
},
];
let rows = detect_rows(&words, 0.5);
assert_eq!(rows.len(), 2);
}
#[test]
fn test_reconstruct_table_basic() {
let words = vec![
HocrWord {
text: "Name".to_string(),
left: 100,
top: 50,
width: 40,
height: 20,
confidence: 95.0,
},
HocrWord {
text: "Value".to_string(),
left: 300,
top: 50,
width: 40,
height: 20,
confidence: 95.0,
},
HocrWord {
text: "Alice".to_string(),
left: 100,
top: 100,
width: 40,
height: 20,
confidence: 95.0,
},
HocrWord {
text: "42".to_string(),
left: 300,
top: 100,
width: 20,
height: 20,
confidence: 95.0,
},
];
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 2);
assert_eq!(table[0].len(), 2);
assert_eq!(table[0][0], "Name");
assert_eq!(table[0][1], "Value");
assert_eq!(table[1][0], "Alice");
assert_eq!(table[1][1], "42");
}
#[test]
fn test_table_to_markdown_basic() {
let table = vec![
vec!["Name".to_string(), "Value".to_string()],
vec!["Alice".to_string(), "42".to_string()],
];
let md = table_to_markdown(&table);
assert!(md.contains("| Name | Value |"));
assert!(md.contains("| --- | --- |"));
assert!(md.contains("| Alice | 42 |"));
}
#[test]
fn test_table_to_markdown_empty() {
assert_eq!(table_to_markdown(&[]), String::new());
}
#[test]
fn test_table_to_markdown_escapes_pipes() {
let table = vec![vec!["Header".to_string()], vec!["a|b".to_string()]];
let md = table_to_markdown(&table);
assert!(md.contains("a\\|b"));
}
#[test]
fn test_reconstruct_table_intra_cell_word_spacing() {
let words = vec![
HocrWord {
text: "Chose".to_string(),
left: 57,
top: 496,
width: 30,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "Truc".to_string(),
left: 306,
top: 496,
width: 23,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "Chose".to_string(),
left: 57,
top: 510,
width: 28,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "1".to_string(),
left: 90,
top: 510,
width: 6,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "Truc".to_string(),
left: 306,
top: 510,
width: 21,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "1".to_string(),
left: 332,
top: 510,
width: 5,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "Chose".to_string(),
left: 57,
top: 524,
width: 28,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "2".to_string(),
left: 90,
top: 524,
width: 6,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "Truc".to_string(),
left: 306,
top: 524,
width: 21,
height: 12,
confidence: 95.0,
},
HocrWord {
text: "2".to_string(),
left: 332,
top: 524,
width: 5,
height: 12,
confidence: 95.0,
},
];
let table = reconstruct_table(&words, 60, 0.5);
assert_eq!(table.len(), 3, "Expected 3 rows, got {}", table.len());
assert_eq!(table[0].len(), 2, "Expected 2 columns in row 0, got {}", table[0].len());
assert_eq!(table[0][0], "Chose", "Header row 1, col 1");
assert_eq!(table[0][1], "Truc", "Header row 1, col 2");
assert_eq!(table[1][0], "Chose 1", "Row 2, col 1 should contain merged text");
assert_eq!(table[1][1], "Truc 1", "Row 2, col 2 should contain merged text");
assert_eq!(table[2][0], "Chose 2", "Row 3, col 1 should contain merged text");
assert_eq!(table[2][1], "Truc 2", "Row 3, col 2 should contain merged text");
}
#[test]
fn test_reconstruct_table_never_drops_words_including_far_outliers() {
let mut words = vec![
HocrWord {
text: "A1".to_string(),
left: 0,
top: 0,
width: 10,
height: 10,
confidence: 95.0,
},
HocrWord {
text: "B1".to_string(),
left: 200,
top: 0,
width: 10,
height: 10,
confidence: 95.0,
},
HocrWord {
text: "A2".to_string(),
left: 0,
top: 200,
width: 10,
height: 10,
confidence: 95.0,
},
HocrWord {
text: "B2".to_string(),
left: 200,
top: 200,
width: 10,
height: 10,
confidence: 95.0,
},
];
words.push(HocrWord {
text: "Outlier".to_string(),
left: 50_000,
top: 50_000,
width: 10,
height: 10,
confidence: 95.0,
});
let input_word_count = words.len();
let table = reconstruct_table(&words, 20, 0.5);
let output_word_count: usize = table
.iter()
.flat_map(|row| row.iter())
.flat_map(|cell| cell.split_whitespace())
.count();
assert_eq!(
output_word_count, input_word_count,
"every input word (including the far outlier) must appear exactly once in the output; \
reconstruct_table's nearest-row/nearest-column assignment never returns None here"
);
let all_text: Vec<&str> = table
.iter()
.flat_map(|row| row.iter())
.flat_map(|c| c.split_whitespace())
.collect();
assert!(
all_text.contains(&"Outlier"),
"the outlier word must not be silently dropped"
);
}
#[test]
fn test_reconstruct_table_multiword_cell_no_spurious_column() {
let words = vec![
word("Name", 100, 50, 40, 20),
word("Value", 300, 50, 40, 20),
word("Alice", 100, 100, 40, 20),
word("Smith", 145, 100, 30, 20),
word("42", 300, 100, 20, 20),
];
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 2);
assert_eq!(
table[0].len(),
2,
"expected 2 columns; without the fix 'Smith' (x=145) mints a spurious 3rd column"
);
assert_eq!(table[0], vec!["Name".to_string(), "Value".to_string()]);
assert_eq!(table[1], vec!["Alice Smith".to_string(), "42".to_string()]);
}
#[test]
fn test_reconstruct_table_close_but_distinct_columns_not_merged() {
let words = vec![
word("Name", 0, 0, 20, 20),
word("Wid", 150, 0, 15, 20),
word("Zone", 180, 0, 20, 20),
word("Foo", 0, 40, 20, 20),
word("Bar", 25, 40, 15, 20),
word("Val1", 150, 40, 15, 20),
word("Val2", 180, 40, 20, 20),
];
let table = reconstruct_table(&words, 10, 0.5);
assert_eq!(table.len(), 2);
assert_eq!(
table[0].len(),
3,
"Wid/Zone must stay 2 distinct columns, not collapsed by the Foo/Bar merge fix"
);
assert_eq!(
table[0],
vec!["Name".to_string(), "Wid".to_string(), "Zone".to_string()]
);
assert_eq!(
table[1],
vec!["Foo Bar".to_string(), "Val1".to_string(), "Val2".to_string()],
"Foo+Bar merge into one cell; Val1 and Val2 remain distinct cells, not merged together"
);
}
#[test]
fn test_reconstruct_table_column_count_stable_across_thresholds() {
let words = vec![
word("H1", 0, 0, 20, 20),
word("H2", 300, 0, 30, 20),
word("A", 0, 40, 10, 20),
word("B", 15, 40, 10, 20),
word("C", 30, 40, 10, 20),
word("Val", 300, 40, 20, 20),
];
let table_tight = reconstruct_table(&words, 10, 0.5);
let table_loose = reconstruct_table(&words, 30, 0.5);
assert_eq!(table_tight[0].len(), 2);
assert_eq!(
table_tight[0].len(),
table_loose[0].len(),
"column count must not depend on column_threshold once cells are pre-merged"
);
assert_eq!(table_tight, table_loose);
assert_eq!(table_tight[0], vec!["H1".to_string(), "H2".to_string()]);
assert_eq!(table_tight[1], vec!["A B C".to_string(), "Val".to_string()]);
}
#[test]
fn test_reconstruct_table_empty_words_returns_empty_table() {
let table = reconstruct_table(&[], 20, 0.5);
assert!(table.is_empty());
}
#[test]
fn test_reconstruct_table_single_word_no_panic() {
let words = vec![word("Solo", 10, 10, 20, 20)];
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table, vec![vec!["Solo".to_string()]]);
}
#[test]
fn test_reconstruct_table_zero_height_words_merge_on_touching_gap() {
let words = vec![word("X", 0, 0, 10, 0), word("Y", 10, 0, 10, 0), word("Z", 50, 0, 10, 0)];
let table = reconstruct_table(&words, 5, 0.5);
assert_eq!(table.len(), 1);
assert_eq!(
table[0].len(),
2,
"X and Y touch with a 0px gap and must merge into one cell"
);
assert_eq!(table[0], vec!["X Y".to_string(), "Z".to_string()]);
}
#[test]
fn test_reconstruct_table_all_words_one_row() {
let words = vec![
word("A", 0, 0, 10, 10),
word("B", 15, 0, 10, 10),
word("C", 100, 0, 10, 10),
];
let table = reconstruct_table(&words, 5, 0.5);
assert_eq!(table.len(), 1);
assert_eq!(table[0].len(), 2);
assert_eq!(table[0], vec!["A B".to_string(), "C".to_string()]);
}
#[test]
fn test_reconstruct_table_with_columns_positions_correlate_with_grid() {
let words = vec![
word("Left", 0, 0, 20, 20),
word("Right", 300, 0, 20, 20),
word("L2", 0, 40, 20, 20),
word("R2", 300, 40, 20, 20),
];
let (grid, positions) = reconstruct_table_with_columns(&words, 20, 0.5);
assert!(!grid.is_empty());
assert_eq!(
positions.len(),
grid[0].len(),
"column_positions must have exactly one entry per surviving grid column"
);
assert_eq!(positions, vec![0, 300]);
assert_eq!(grid[0], vec!["Left".to_string(), "Right".to_string()]);
assert_eq!(grid[1], vec!["L2".to_string(), "R2".to_string()]);
}
}