#[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)
}
}
const CELL_SYMBOLS: &[char] = &[
'-', '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2212}', '(', ')', '[', ']', '{', '}', '$',
'\u{a2}', '\u{a3}', '\u{a5}', '\u{20ac}', '%', '*',
];
pub(crate) fn is_lone_cell_symbol(text: &str) -> bool {
let mut chars = text.chars();
matches!((chars.next(), chars.next()), (Some(symbol), None) if CELL_SYMBOLS.contains(&symbol))
}
#[cfg(feature = "ocr")]
pub(crate) fn is_value(text: &[char]) -> bool {
chars_read_as_value(text.iter().copied())
}
pub(crate) fn text_is_value(text: &str) -> bool {
chars_read_as_value(text.chars())
}
fn chars_read_as_value(chars: impl Iterator<Item = char>) -> bool {
let mut has_digit = false;
for character in chars {
if character.is_alphabetic() {
return false;
}
has_digit = has_digit || character.is_ascii_digit();
}
has_digit
}
pub(crate) fn is_cell_value_text(text: &str) -> bool {
let text = text.trim();
text_is_value(text) || is_lone_cell_symbol(text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ColumnTrack {
left: u32,
right: u32,
right_aligned: bool,
has_data: bool,
label_rows: Vec<usize>,
lefts: Vec<u32>,
number_rows: Vec<usize>,
}
impl ColumnTrack {
#[cfg(test)]
fn left_aligned(left: u32) -> Self {
Self {
left,
right: left,
right_aligned: false,
has_data: true,
label_rows: Vec::new(),
lefts: vec![left],
number_rows: Vec::new(),
}
}
fn distance_to(&self, word: &HocrWord) -> u32 {
self.lefts
.iter()
.map(|left| left.abs_diff(word.left))
.min()
.unwrap_or_else(|| self.left.abs_diff(word.left))
}
}
pub(crate) fn detect_columns(words: &[HocrWord], row_positions: &[u32], column_threshold: u32) -> Vec<ColumnTrack> {
if words.is_empty() {
return Vec::new();
}
let header_rows = header_band_row_count(words, row_positions);
let groups = cluster_by_edge(words, column_threshold, |word| word.left);
let groups = split_row_sharing_groups(groups, row_positions, column_threshold);
let mut columns: Vec<ColumnTrack> = groups
.iter()
.map(|group| column_track(group.as_slice(), row_positions, header_rows))
.collect();
columns.sort_by_key(|column| column.left);
fold_right_aligned_tracks(&mut columns, column_threshold);
columns
}
fn cluster_by_edge<'a>(
words: impl IntoIterator<Item = &'a HocrWord>,
column_threshold: u32,
edge: impl Fn(&HocrWord) -> u32,
) -> Vec<Vec<&'a HocrWord>> {
let mut groups: Vec<Vec<&HocrWord>> = Vec::new();
for word in words {
match groups.iter_mut().find(|group| {
group
.first()
.is_some_and(|first| edge(word).abs_diff(edge(first)) <= column_threshold)
}) {
Some(group) => group.push(word),
None => groups.push(vec![word]),
}
}
groups
}
fn right_edge(word: &HocrWord) -> u32 {
word.left.saturating_add(word.width)
}
fn shares_a_row(group: &[&HocrWord], row_positions: &[u32]) -> bool {
let mut seen = vec![false; row_positions.len()];
group
.iter()
.filter_map(|word| find_row_index(row_positions, word))
.any(|row| {
let already = seen[row];
seen[row] = true;
already
})
}
fn split_row_sharing_groups<'a>(
groups: Vec<Vec<&'a HocrWord>>,
row_positions: &[u32],
column_threshold: u32,
) -> Vec<Vec<&'a HocrWord>> {
let mut split = Vec::with_capacity(groups.len());
for group in groups {
match shares_a_row(&group, row_positions)
.then(|| split_by_right_edge(&group, row_positions, column_threshold))
.flatten()
{
Some(parts) => split.extend(parts),
None => split.push(group),
}
}
split
}
fn header_band_row_count(words: &[HocrWord], row_positions: &[u32]) -> usize {
number_rows(words, row_positions)
.first()
.map_or(1, |&first_value_row| first_value_row.max(1))
}
fn split_header_band<'a>(
group: &[&'a HocrWord],
row_positions: &[u32],
header_rows: usize,
) -> (Vec<&'a HocrWord>, Vec<&'a HocrWord>) {
group
.iter()
.copied()
.partition(|word| find_row_index(row_positions, word).is_some_and(|row| row < header_rows))
}
fn split_by_right_edge<'a>(
group: &[&'a HocrWord],
row_positions: &[u32],
column_threshold: u32,
) -> Option<Vec<Vec<&'a HocrWord>>> {
let (header, data) = split_header_band(group, row_positions, 1);
let mut parts = cluster_by_edge(data, column_threshold, right_edge);
if parts.len() != 2 {
return None;
}
let lefts: Vec<u32> = parts
.iter()
.map(|part| median_of(part.iter().map(|word| word.left).collect()))
.collect();
for word in header {
let nearest = (0..parts.len()).min_by_key(|&index| lefts[index].abs_diff(word.left))?;
parts[nearest].push(word);
}
if parts.iter().any(|part| part.len() < 2) {
return None;
}
(!parts.iter().any(|part| shares_a_row(part, row_positions))).then_some(parts)
}
fn reads_as_values(group: &[&HocrWord], row_positions: &[u32], header_rows: usize) -> bool {
let (header, data) = split_header_band(group, row_positions, header_rows);
let tokens = if data.is_empty() { header } else { data };
let values = tokens.iter().filter(|word| is_cell_value_text(&word.text)).count();
values > tokens.len() - values
}
fn column_track(group: &[&HocrWord], row_positions: &[u32], header_rows: usize) -> ColumnTrack {
let left = median_of(group.iter().map(|word| word.left).collect());
let (header, data) = split_header_band(group, row_positions, header_rows);
ColumnTrack {
left,
right: median_of(group.iter().map(|word| right_edge(word)).collect()),
right_aligned: reads_as_values(group, row_positions, header_rows),
has_data: !data.is_empty(),
label_rows: header
.iter()
.filter_map(|word| find_row_index(row_positions, word))
.collect(),
lefts: vec![left],
number_rows: number_rows(group.iter().copied(), row_positions),
}
}
fn number_rows<'a>(words: impl IntoIterator<Item = &'a HocrWord>, row_positions: &[u32]) -> Vec<usize> {
let mut rows: Vec<usize> = words
.into_iter()
.filter(|word| text_is_value(word.text.trim()))
.filter_map(|word| find_row_index(row_positions, word))
.collect();
rows.sort_unstable();
rows.dedup();
rows
}
fn fold_right_aligned_tracks(columns: &mut Vec<ColumnTrack>, column_threshold: u32) {
let mut index = 0;
while index + 1 < columns.len() {
let (current, next) = (&columns[index], &columns[index + 1]);
let values_meet = current.right_aligned && next.right_aligned;
let label_meets_values = ((!current.has_data && next.right_aligned)
|| (current.right_aligned && !next.has_data))
&& !share_a_label_row(current, next);
let foldable = (values_meet || label_meets_values)
&& current.right.abs_diff(next.right) <= column_threshold
&& !share_a_number_row(current, next);
if foldable {
let next = columns.remove(index + 1);
let current = &mut columns[index];
(current.left, current.right_aligned) = match (current.has_data, next.has_data) {
(true, true) => (current.left.min(next.left), current.right_aligned),
(true, false) => (current.left, current.right_aligned),
(false, true) => (next.left, next.right_aligned),
(false, false) => (current.left.min(next.left), current.right_aligned || next.right_aligned),
};
current.right = current.right.max(next.right);
current.has_data |= next.has_data;
current.label_rows.extend(next.label_rows);
current.lefts.extend(next.lefts);
current.number_rows.extend(next.number_rows);
} else {
index += 1;
}
}
}
fn share_a_number_row(left: &ColumnTrack, right: &ColumnTrack) -> bool {
left.number_rows.iter().any(|row| right.number_rows.contains(row))
}
fn share_a_label_row(left: &ColumnTrack, right: &ColumnTrack) -> bool {
left.label_rows.iter().any(|row| right.label_rows.contains(row))
}
pub(crate) fn median_word_height(words: &[HocrWord]) -> u32 {
median_of(words.iter().map(|w| w.height).collect())
}
pub(crate) fn median_of(mut values: Vec<u32>) -> u32 {
values.sort_unstable();
values.get(values.len() / 2).copied().unwrap_or(0)
}
fn median_row_position(group: &[f64]) -> Option<u32> {
if group.is_empty() {
return None;
}
let mut sorted = group.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
Some(sorted[sorted.len() / 2] as u32)
}
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 maximum_seed_height = median_height.saturating_add(median_height / 2);
let (seed_words, tall_words): (Vec<&HocrWord>, Vec<&HocrWord>) =
words.iter().partition(|word| word.height <= maximum_seed_height);
let (seed_words, tall_words) = if seed_words.is_empty() {
(words.iter().collect(), Vec::new())
} else {
(seed_words, tall_words)
};
let mut position_groups: Vec<Vec<f64>> = Vec::new();
for word in seed_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 half_height = median_height / 2;
for word in tall_words {
let bottom = word.top.saturating_add(word.height);
let overlaps_seeded_row = position_groups
.iter()
.filter_map(|group| median_row_position(group))
.any(|row_y| {
let band_top = row_y.saturating_sub(half_height);
let band_bottom = row_y.saturating_add(half_height);
bottom.min(band_bottom).saturating_sub(word.top.max(band_top)) > 0
});
if !overlaps_seeded_row {
position_groups.push(vec![word.y_center()]);
}
}
let mut rows: Vec<u32> = position_groups
.iter()
.filter_map(|group| median_row_position(group))
.collect();
rows.sort_unstable();
rows
}
pub(crate) 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_row_index_by_overlap(row_positions: &[u32], word: &HocrWord, median_height: u32) -> Option<usize> {
let half_height = median_height / 2;
let word_bottom = word.top.saturating_add(word.height);
let word_center = word.y_center() as u32;
row_positions
.iter()
.enumerate()
.min_by_key(|&(_, row_y)| {
let band_top = row_y.saturating_sub(half_height);
let band_bottom = row_y.saturating_add(half_height);
let overlap = word_bottom.min(band_bottom).saturating_sub(word.top.max(band_top));
(std::cmp::Reverse(overlap), row_y.abs_diff(word_center))
})
.map(|(index, _)| index)
}
fn find_column_index(columns: &[ColumnTrack], word: &HocrWord) -> Option<usize> {
columns
.iter()
.enumerate()
.min_by_key(|&(_, column)| column.distance_to(word))
.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;
const PUNCTUATION_GLUE_GAP_MULTIPLIER: f64 = 2.0;
fn is_pure_punctuation(text: &str) -> bool {
let trimmed = text.trim();
!trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_punctuation())
}
fn group_words_into_cell_tokens<'a>(
words: &'a [HocrWord],
row_positions: &[u32],
) -> Vec<(HocrWord, Vec<&'a HocrWord>)> {
if words.len() <= 1 || row_positions.is_empty() {
return words.iter().map(|word| (word.clone(), vec![word])).collect();
}
let median_height = median_word_height(words);
let merge_gap = median_height 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_by_overlap(row_positions, word, median_height) {
rows[row_index].push(word);
}
}
let mut groups: Vec<(HocrWord, Vec<&HocrWord>)> = Vec::with_capacity(words.len());
for mut row_words in rows {
row_words.sort_by_key(|w| w.left);
groups.extend(merge_row_into_cell_tokens(&row_words, merge_gap));
}
groups
}
fn allowed_merge_gap(
word: &HocrWord,
next_word: Option<&HocrWord>,
base_gap: f64,
previous_was_connector: bool,
) -> (f64, bool) {
if is_pure_punctuation(&word.text) {
let bridges_forward = next_word.is_some_and(|next| {
let gap_to_next = next.left as f64 - (word.left + word.width) as f64;
gap_to_next <= base_gap * PUNCTUATION_GLUE_GAP_MULTIPLIER
});
return if bridges_forward {
(base_gap * PUNCTUATION_GLUE_GAP_MULTIPLIER, true)
} else {
(base_gap, false)
};
}
if previous_was_connector {
(base_gap * PUNCTUATION_GLUE_GAP_MULTIPLIER, false)
} else {
(base_gap, false)
}
}
fn merge_row_into_cell_tokens<'a>(row_words: &[&'a HocrWord], merge_gap: f64) -> Vec<(HocrWord, Vec<&'a HocrWord>)> {
let mut groups: Vec<(HocrWord, Vec<&HocrWord>)> = Vec::new();
let mut current: Option<(HocrWord, Vec<&HocrWord>)> = None;
let mut previous_was_connector = false;
for (index, &word) in row_words.iter().enumerate() {
let next_word = row_words.get(index + 1).copied();
current = Some(match current.take() {
None => (word.clone(), vec![word]),
Some((mut token, mut members)) => {
let gap = word.left as f64 - (token.left + token.width) as f64;
let (allowed_gap, is_connector) = allowed_merge_gap(word, next_word, merge_gap, previous_was_connector);
if gap <= allowed_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);
members.push(word);
previous_was_connector = is_connector;
(token, members)
} else {
groups.push((token, members));
previous_was_connector = false;
(word.clone(), vec![word])
}
}
});
}
if let Some(group) = current {
groups.push(group);
}
groups
}
#[cfg(any(feature = "pdf", paddle_ocr, test))]
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 median_height = median_word_height(words);
let groups = group_words_into_cell_tokens(words, &row_positions);
let cell_tokens: Vec<HocrWord> = groups.iter().map(|(token, _)| token.clone()).collect();
let columns = detect_columns(&cell_tokens, &row_positions, column_threshold);
if columns.is_empty() || row_positions.is_empty() {
return (Vec::new(), Vec::new());
}
let mut result = assign_grouped_words_to_cells(&groups, &row_positions, median_height, &columns);
let mut col_positions: Vec<u32> = columns.iter().map(|column| column.left).collect();
merge_header_fragments_by_geometry(&mut result, &mut 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 is_rtl_letter(character: char) -> bool {
character.is_alphabetic()
&& matches!(character,
'\u{0590}'..='\u{08ff}' | '\u{fb1d}'..='\u{fdff}' | '\u{fe70}'..='\u{feff}')
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DirectionVote {
Rtl,
Ltr,
Neutral,
}
fn direction_vote(text: &str) -> DirectionVote {
if text.chars().any(is_rtl_letter) {
return DirectionVote::Rtl;
}
let has_latin = text.chars().any(|character| character.is_ascii_alphabetic());
if !has_latin {
return DirectionVote::Neutral;
}
let lower = text.to_ascii_lowercase();
let identifier_like = text.chars().any(|character| character.is_ascii_digit())
|| text.contains(['/', '\\', '@', '_'])
|| text.contains("://")
|| lower.starts_with("www.")
|| (!text.contains(char::is_whitespace) && text.chars().count() > 20)
|| (text.chars().filter(|character| character.is_ascii_alphabetic()).count() > 1
&& text
.chars()
.filter(|character| character.is_ascii_alphabetic())
.all(|character| character.is_ascii_uppercase()));
if identifier_like {
DirectionVote::Neutral
} else {
DirectionVote::Ltr
}
}
pub(crate) fn hocr_words_are_rtl<'a>(words: impl Iterator<Item = &'a HocrWord>) -> bool {
let mut rtl_count = 0usize;
let mut ltr_count = 0usize;
let mut rtl_x = 0u64;
let mut ltr_x = 0u64;
for word in words {
match direction_vote(&word.text) {
DirectionVote::Rtl => {
rtl_count += 1;
rtl_x += word.left as u64;
}
DirectionVote::Ltr => {
ltr_count += 1;
ltr_x += word.left as u64;
}
DirectionVote::Neutral => {}
}
}
if rtl_count != ltr_count {
return rtl_count > ltr_count;
}
rtl_count > 0 && rtl_x * ltr_count as u64 > ltr_x * rtl_count as u64
}
#[cfg(any(feature = "ocr", test))]
pub(crate) fn orient_table_for_reading_order(
table: &mut [Vec<String>],
column_positions: &mut [u32],
words: &[HocrWord],
) {
if !hocr_words_are_rtl(words.iter()) || !column_positions.windows(2).all(|pair| pair[0] <= pair[1]) {
return;
}
for row in table {
row.reverse();
}
column_positions.reverse();
}
const MIN_HEADER_NEIGHBOR_SUPPORT: usize = 2;
fn merge_header_fragments_by_geometry(table: &mut [Vec<String>], column_positions: &mut Vec<u32>) {
let column_count = table.first().map_or(0, Vec::len);
if table.len() < 3 || column_count < 3 || column_positions.len() != column_count {
return;
}
if !has_split_invoice_header(&table[0]) {
return;
}
let support: Vec<usize> = (0..column_count)
.map(|column| {
table
.iter()
.skip(1)
.filter(|row| row.get(column).is_some_and(|cell| !cell.trim().is_empty()))
.count()
})
.collect();
let targets = header_fragment_targets(table, column_positions, &support);
if targets.iter().all(Option::is_none) {
return;
}
rebuild_table_without_header_fragments(table, column_positions, &targets);
}
fn has_split_invoice_header(header: &[String]) -> bool {
let has = |expected: &str| header.iter().any(|cell| cell.trim().eq_ignore_ascii_case(expected));
has("QTY") && ((has("UNIT") && has("PRICE")) || (has("LINE") && has("TOTAL")))
}
fn header_fragment_targets(table: &[Vec<String>], column_positions: &[u32], support: &[usize]) -> Vec<Option<usize>> {
let column_count = support.len();
let mut targets = vec![None; column_count];
let mut left = None;
for column in 0..column_count {
targets[column] = left;
if support[column] >= MIN_HEADER_NEIGHBOR_SUPPORT {
left = Some(column);
}
}
let mut right = None;
for column in (0..column_count).rev() {
if support[column] >= MIN_HEADER_NEIGHBOR_SUPPORT {
targets[column] = None;
right = Some(column);
continue;
}
let Some(left) = targets[column] else {
continue;
};
let Some(right) = right else {
continue;
};
if support[column] != 0 || !is_split_invoice_fragment(&table[0][column], &table[0][right]) {
targets[column] = None;
continue;
}
targets[column] = sufficiently_near_matching_column(column_positions, left, column, right);
}
targets
}
fn is_split_invoice_fragment(fragment: &str, destination: &str) -> bool {
let fragment = fragment.trim();
let destination = destination.trim();
(fragment.eq_ignore_ascii_case("UNIT") && destination.eq_ignore_ascii_case("PRICE"))
|| (fragment.eq_ignore_ascii_case("LINE") && destination.eq_ignore_ascii_case("TOTAL"))
}
fn sufficiently_near_matching_column(positions: &[u32], left: usize, fragment: usize, right: usize) -> Option<usize> {
let span = positions[right].abs_diff(positions[left]);
let right_distance = positions[fragment].abs_diff(positions[right]);
(right_distance.saturating_mul(3) < span).then_some(right)
}
fn rebuild_table_without_header_fragments(
table: &mut [Vec<String>],
column_positions: &mut Vec<u32>,
targets: &[Option<usize>],
) {
let column_count = targets.len();
let (prefixes, suffixes) = header_fragment_affixes(table, targets);
let kept_columns = targets.iter().filter(|target| target.is_none()).count();
for (row_index, row) in table.iter_mut().enumerate() {
let mut rebuilt = Vec::with_capacity(kept_columns);
for column in 0..column_count {
if targets[column].is_some() {
continue;
}
if row_index == 0 {
rebuilt.push(rebuilt_header(&row[column], &prefixes[column], &suffixes[column]));
} else {
rebuilt.push(std::mem::take(&mut row[column]));
}
}
*row = rebuilt;
}
let mut position_index = 0usize;
column_positions.retain(|_| {
let keep_position = targets[position_index].is_none();
position_index += 1;
keep_position
});
}
fn header_fragment_affixes(table: &[Vec<String>], targets: &[Option<usize>]) -> (Vec<String>, Vec<String>) {
let column_count = targets.len();
let mut prefixes = vec![String::new(); column_count];
let mut suffixes = vec![String::new(); column_count];
for (column, target) in targets.iter().copied().enumerate() {
let Some(target) = target else {
continue;
};
let fragment = table[0][column].trim();
let destination = if target < column {
&mut suffixes[target]
} else {
&mut prefixes[target]
};
if !destination.is_empty() {
destination.push(' ');
}
destination.push_str(fragment);
}
(prefixes, suffixes)
}
fn rebuilt_header(original: &str, prefix: &str, suffix: &str) -> String {
let original = original.trim();
let capacity = prefix.len() + original.len() + suffix.len() + 2;
let mut header = String::with_capacity(capacity);
for part in [prefix, original, suffix] {
if part.is_empty() {
continue;
}
if !header.is_empty() {
header.push(' ');
}
header.push_str(part);
}
header
}
const CELL_LINE_GROUP_HEIGHT_RATIO: f64 = 0.5;
fn order_cell_words_in_reading_order(mut cell_words: Vec<&HocrWord>) -> Vec<&HocrWord> {
if cell_words.len() <= 1 {
return cell_words;
}
let mut heights: Vec<u32> = cell_words.iter().map(|word| word.height).collect();
heights.sort_unstable();
let line_gap = heights[heights.len() / 2] as f64 * CELL_LINE_GROUP_HEIGHT_RATIO;
cell_words.sort_by(|a, b| a.y_center().total_cmp(&b.y_center()).then_with(|| a.left.cmp(&b.left)));
let mut lines: Vec<Vec<&HocrWord>> = Vec::new();
for word in cell_words {
let same_line = lines.last().is_some_and(|line| {
let centre = line.iter().map(|w| w.y_center()).sum::<f64>() / line.len() as f64;
(word.y_center() - centre).abs() <= line_gap
});
match lines.last_mut() {
Some(line) if same_line => line.push(word),
_ => lines.push(vec![word]),
}
}
for line in &mut lines {
if hocr_words_are_rtl(line.iter().copied()) {
line.sort_by_key(|word| std::cmp::Reverse(word.left));
} else {
line.sort_by_key(|word| word.left);
}
}
lines.into_iter().flatten().collect()
}
#[cfg(test)]
fn assign_words_to_cells(words: &[HocrWord], row_positions: &[u32], col_positions: &[u32]) -> Vec<Vec<String>> {
let columns: Vec<ColumnTrack> = col_positions.iter().copied().map(ColumnTrack::left_aligned).collect();
let num_rows = row_positions.len();
let num_cols = columns.len();
let mut table: Vec<Vec<Vec<&HocrWord>>> = 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(&columns, word))
&& r < num_rows
&& c < num_cols
{
table[r][c].push(word);
}
}
finish_cell_assignment(table)
}
fn assign_grouped_words_to_cells<'a>(
groups: &[(HocrWord, Vec<&'a HocrWord>)],
row_positions: &[u32],
median_height: u32,
columns: &[ColumnTrack],
) -> Vec<Vec<String>> {
let num_rows = row_positions.len();
let num_cols = columns.len();
let mut table: Vec<Vec<Vec<&'a HocrWord>>> = vec![vec![vec![]; num_cols]; num_rows];
let data_supported = columns_with_data_support(groups, row_positions, median_height, columns);
for (token, members) in groups {
let Some(row) = find_row_index_by_overlap(row_positions, token, median_height) else {
continue;
};
if row >= num_rows {
continue;
}
if row == 0 {
let token_column = find_column_index(columns, token);
if let Some(col) = token_column.filter(|&col| col < num_cols && data_supported[col]) {
table[row][col].extend(members.iter().copied());
} else {
assign_words_to_row(&mut table[row], members, columns);
}
} else if let Some(col) = find_column_index(columns, token)
&& col < num_cols
{
table[row][col].extend(members.iter().copied());
}
}
finish_cell_assignment(table)
}
fn columns_with_data_support(
groups: &[(HocrWord, Vec<&HocrWord>)],
row_positions: &[u32],
median_height: u32,
columns: &[ColumnTrack],
) -> Vec<bool> {
let mut supported = vec![false; columns.len()];
for (token, _) in groups {
if find_row_index_by_overlap(row_positions, token, median_height) != Some(0)
&& let Some(column) = find_column_index(columns, token)
{
supported[column] = true;
}
}
supported
}
fn assign_words_to_row<'a>(row: &mut [Vec<&'a HocrWord>], words: &[&'a HocrWord], columns: &[ColumnTrack]) {
for &word in words {
if let Some(column) = find_column_index(columns, word) {
row[column].push(word);
}
}
}
fn finish_cell_assignment(table: Vec<Vec<Vec<&HocrWord>>>) -> Vec<Vec<String>> {
table
.into_iter()
.map(|row| {
row.into_iter()
.map(|cell_words| {
order_cell_words_in_reading_order(cell_words)
.into_iter()
.flat_map(|word| word.text.split_whitespace())
.collect::<Vec<_>>()
.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))]
const MAX_ALIGNED_FRAGMENT_GAP_HEIGHT_MULTIPLIER: u32 = 12;
#[cfg(any(feature = "ocr", paddle_ocr))]
const MAX_ALIGNED_FULL_REGION_GAP_HEIGHT_MULTIPLIER: u32 = 4;
#[cfg(any(paddle_ocr, all(test, feature = "ocr")))]
pub(crate) fn cluster_words_into_table_regions(words: &[HocrWord]) -> Vec<Vec<HocrWord>> {
cluster_word_indices_into_table_regions(words)
.into_iter()
.map(|region| region.into_iter().map(|index| words[index].clone()).collect())
.collect()
}
#[cfg(any(feature = "ocr", paddle_ocr))]
pub(crate) fn cluster_word_indices_into_table_regions(words: &[HocrWord]) -> Vec<Vec<usize>> {
if words.is_empty() {
return Vec::new();
}
let mut sorted: Vec<usize> = (0..words.len()).collect();
sorted.sort_by(|&a, &b| words[a].top.cmp(&words[b].top).then(words[a].left.cmp(&words[b].left)));
let avg_height: u32 = {
let total: u32 = words.iter().map(|w| w.height).sum();
(total / words.len() as u32).max(1)
};
let region_gap_threshold = avg_height * TABLE_REGION_GAP_HEIGHT_MULTIPLIER;
let mut regions: Vec<Vec<usize>> = Vec::new();
let mut current_region: Vec<usize> = Vec::new();
let mut current_bottom: u32 = 0;
for index in sorted {
let word = &words[index];
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(index);
}
if !current_region.is_empty() {
regions.push(current_region);
}
merge_aligned_regions(&mut regions, words, avg_height);
regions
}
#[cfg(any(feature = "ocr", paddle_ocr))]
fn merge_aligned_regions(regions: &mut Vec<Vec<usize>>, words: &[HocrWord], tolerance: u32) {
let mut merged: Vec<Vec<usize>> = Vec::with_capacity(regions.len());
for region in regions.drain(..) {
if let Some(previous) = merged.last_mut()
&& regions_continue(previous, ®ion, words, tolerance)
{
previous.extend(region);
continue;
}
merged.push(region);
}
let mut index = 0;
while index + 1 < merged.len() {
if merged[index].len() < MIN_TABLE_CANDIDATE_WORDS
&& columns_align(&merged[index + 1], &merged[index], words, tolerance, 2)
&& region_gap(&merged[index + 1], &merged[index], words)
<= tolerance.saturating_mul(MAX_ALIGNED_FRAGMENT_GAP_HEIGHT_MULTIPLIER)
{
let fragment = merged.remove(index);
merged[index].extend(fragment);
continue;
}
index += 1;
}
*regions = merged;
}
#[cfg(any(feature = "ocr", paddle_ocr))]
fn regions_continue(first: &[usize], second: &[usize], words: &[HocrWord], tolerance: u32) -> bool {
let small = second.len() < MIN_TABLE_CANDIDATE_WORDS;
let min_columns = if small { 2 } else { 3 };
let max_gap = if small {
MAX_ALIGNED_FRAGMENT_GAP_HEIGHT_MULTIPLIER
} else {
MAX_ALIGNED_FULL_REGION_GAP_HEIGHT_MULTIPLIER
};
(small || first_row_has_two_values(second, words, tolerance))
&& columns_align(first, second, words, tolerance, min_columns)
&& region_gap(first, second, words) <= tolerance.saturating_mul(max_gap)
}
#[cfg(any(feature = "ocr", paddle_ocr))]
fn first_row_has_two_values(region: &[usize], words: &[HocrWord], tolerance: u32) -> bool {
let first_top = region.iter().map(|&index| words[index].top).min().unwrap_or(0);
region
.iter()
.filter(|&&index| words[index].top.saturating_sub(first_top) <= tolerance / 2)
.filter(|&&index| text_is_value(&words[index].text))
.take(2)
.count()
== 2
}
#[cfg(any(feature = "ocr", paddle_ocr))]
fn region_gap(first: &[usize], second: &[usize], words: &[HocrWord]) -> u32 {
let first_top = first.iter().map(|&index| words[index].top).min().unwrap_or(0);
let first_bottom = first
.iter()
.map(|&index| words[index].top.saturating_add(words[index].height))
.max()
.unwrap_or(0);
let second_top = second.iter().map(|&index| words[index].top).min().unwrap_or(0);
let second_bottom = second
.iter()
.map(|&index| words[index].top.saturating_add(words[index].height))
.max()
.unwrap_or(0);
first_top
.saturating_sub(second_bottom)
.max(second_top.saturating_sub(first_bottom))
}
#[cfg(any(feature = "ocr", paddle_ocr))]
fn columns_align(anchor: &[usize], fragment: &[usize], words: &[HocrWord], tolerance: u32, minimum: usize) -> bool {
let mut matched = 0usize;
let mut seen: Vec<u32> = Vec::new();
for &fragment_index in fragment {
let left = words[fragment_index].left;
if seen.iter().any(|&seen_left| seen_left.abs_diff(left) <= tolerance) {
continue;
}
seen.push(left);
if anchor
.iter()
.any(|&anchor_index| words[anchor_index].left.abs_diff(left) <= tolerance)
{
matched += 1;
if matched >= minimum {
return true;
}
}
}
false
}
#[cfg(feature = "ocr")]
pub(crate) const DISJOINT_NUMERIC_COLUMN_MERGE_HEIGHT_MULTIPLIER: f64 = 3.0;
#[cfg(feature = "ocr")]
fn looks_like_amount(cell: &str) -> bool {
let trimmed = cell.trim();
!trimmed.is_empty()
&& trimmed.chars().any(|ch| ch.is_ascii_digit())
&& trimmed
.chars()
.all(|ch| ch.is_ascii_digit() || "$,.-()%€£¥+".contains(ch))
}
#[cfg(feature = "ocr")]
fn has_no_alphanumeric(cell: &str) -> bool {
!cell.chars().any(char::is_alphanumeric)
}
#[cfg(feature = "ocr")]
fn leading_header_row_count(table: &[Vec<String>]) -> usize {
table
.iter()
.position(|row| row.iter().any(|cell| looks_like_amount(cell)))
.unwrap_or(table.len())
.max(1)
}
#[cfg(feature = "ocr")]
fn columns_are_disjoint_and_numeric(table: &[Vec<String>], header_rows: usize, left: usize, right: usize) -> bool {
let mut any_data = false;
for row in table.iter().skip(header_rows) {
let (Some(left_cell), Some(right_cell)) = (row.get(left), row.get(right)) else {
return false;
};
let left_empty = has_no_alphanumeric(left_cell);
let right_empty = has_no_alphanumeric(right_cell);
match (left_empty, right_empty) {
(true, true) => {}
(false, false) => return false,
(true, false) if is_lone_cell_symbol(left_cell.trim()) => return false,
(false, true) => {
if !looks_like_amount(left_cell) {
return false;
}
any_data = true;
}
(true, false) => {
if !looks_like_amount(right_cell) {
return false;
}
any_data = true;
}
}
}
any_data
}
#[cfg(feature = "ocr")]
fn at_most_one_column_has_its_own_header(table: &[Vec<String>], header_rows: usize, left: usize, right: usize) -> bool {
let header = &table[..header_rows.min(table.len())];
let labeled = |column: usize| {
header
.iter()
.any(|row| row.get(column).is_some_and(|cell| !has_no_alphanumeric(cell)))
};
!(labeled(left) && labeled(right))
}
#[cfg(feature = "ocr")]
fn merge_column_into(table: &mut [Vec<String>], left: usize, right: usize) {
for row in table.iter_mut() {
let right_cell = row.remove(right);
let right_cell = right_cell.trim();
let left_cell = row[left].trim();
row[left] = match (has_no_alphanumeric(left_cell), has_no_alphanumeric(right_cell)) {
(false, true) => left_cell.to_string(),
(true, false) => right_cell.to_string(),
_ => [left_cell, right_cell]
.into_iter()
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>()
.join(" "),
};
}
}
#[cfg(feature = "ocr")]
fn data_support_count(table: &[Vec<String>], header_rows: usize, column: usize) -> usize {
table
.iter()
.skip(header_rows)
.filter(|row| !has_no_alphanumeric(&row[column]))
.count()
}
#[cfg(feature = "ocr")]
pub(crate) fn merge_disjoint_numeric_columns(
table: &mut [Vec<String>],
column_positions: &mut Vec<u32>,
median_height: u32,
) {
if table.len() < 2 || column_positions.len() < 2 {
return;
}
let max_gap = median_height as f64 * DISJOINT_NUMERIC_COLUMN_MERGE_HEIGHT_MULTIPLIER;
let header_rows = leading_header_row_count(table);
let mut column = 0;
while column + 1 < column_positions.len() {
let gap = column_positions[column + 1].abs_diff(column_positions[column]) as f64;
if gap <= max_gap
&& at_most_one_column_has_its_own_header(table, header_rows, column, column + 1)
&& columns_are_disjoint_and_numeric(table, header_rows, column, column + 1)
{
if data_support_count(table, header_rows, column + 1) > data_support_count(table, header_rows, column) {
column_positions[column] = column_positions[column + 1];
}
merge_column_into(table, column, column + 1);
column_positions.remove(column + 1);
} else {
column += 1;
}
}
}
#[cfg(feature = "ocr")]
pub(crate) fn drop_leading_caption_row(table: &mut Vec<Vec<String>>) {
if table.len() < 3 {
return;
}
let column_count = table[0].len();
if column_count < 3 {
return;
}
let first_non_empty: Vec<usize> = table[0]
.iter()
.enumerate()
.filter(|(_, cell)| !cell.trim().is_empty())
.map(|(index, _)| index)
.collect();
let second_non_empty_count = table[1].iter().filter(|cell| !cell.trim().is_empty()).count();
if first_non_empty == [0] && second_non_empty_count >= 3 {
table.remove(0);
}
}
#[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 median_of_takes_the_middle_of_an_odd_count() {
assert_eq!(median_of(vec![50, 10, 30]), 30);
}
#[test]
fn median_of_takes_the_upper_middle_of_an_even_count() {
assert_eq!(median_of(vec![40, 10, 30, 20]), 30);
}
#[test]
fn median_of_no_values_is_zero() {
assert_eq!(median_of(Vec::new()), 0);
assert_eq!(median_word_height(&[]), 0);
}
#[test]
fn rtl_table_columns_and_cell_words_follow_logical_geometry() {
let words = vec![
word("כמות", 100, 0, 40, 20),
word("קוד", 200, 0, 40, 20),
word("מצב", 300, 0, 40, 20),
word("42", 100, 60, 40, 20),
word("REQ-2026-104", 200, 60, 80, 20),
word("מוכן", 300, 60, 40, 20),
];
let (mut table, mut positions) = reconstruct_table_with_columns(&words, 20, 0.5);
orient_table_for_reading_order(&mut table, &mut positions, &words);
assert_eq!(table[0], ["מצב", "קוד", "כמות"]);
assert_eq!(table[1], ["מוכן", "REQ-2026-104", "42"]);
assert!(positions.windows(2).all(|pair| pair[0] > pair[1]));
let right = word("שלבי", 300, 120, 40, 20);
let left = word("הבדיקה", 240, 122, 50, 20);
let ordered = order_cell_words_in_reading_order(vec![&left, &right]);
assert_eq!(
ordered.iter().map(|word| word.text.as_str()).collect::<Vec<_>>(),
["שלבי", "הבדיקה"]
);
}
#[test]
fn numeric_and_ltr_tables_keep_left_to_right_geometry() {
let words = vec![
word("17", 100, 0, 40, 20),
word("09", 200, 0, 40, 20),
word("42", 300, 0, 40, 20),
word("left", 100, 60, 40, 20),
word("middle", 200, 60, 50, 20),
word("right", 300, 60, 40, 20),
];
let (mut table, mut positions) = reconstruct_table_with_columns(&words, 20, 0.5);
orient_table_for_reading_order(&mut table, &mut positions, &words);
assert_eq!(table[0], ["17", "09", "42"]);
assert_eq!(table[1], ["left", "middle", "right"]);
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn code_heavy_rtl_table_ignores_long_latin_identifiers_for_base_direction() {
let words = vec![
word("https://example.test/a/very/long/resource-name", 100, 0, 180, 20),
word("REQ-2026-104-ALPHA", 200, 60, 150, 20),
word("Ø§Ù„ØØ§Ù„Ø©", 400, 0, 60, 20),
];
let mut table = vec![vec![
"https://example.test/a/very/long/resource-name".to_string(),
"REQ-2026-104-ALPHA".to_string(),
"Ø§Ù„ØØ§Ù„Ø©".to_string(),
]];
let mut positions = vec![100, 200, 400];
orient_table_for_reading_order(&mut table, &mut positions, &words);
assert_eq!(table[0][0], "Ø§Ù„ØØ§Ù„Ø©");
assert_eq!(table[0][2], "https://example.test/a/very/long/resource-name");
assert_eq!(positions, [400, 200, 100]);
}
#[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, &detect_rows(&words, 0.5), 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 detect_rows_checks_tall_overlap_against_the_order_independent_row_median() {
let low_center = word("low", 0, 388, 20, 24);
let median_a = word("median-a", 30, 399, 20, 24);
let median_b = word("median-b", 60, 399, 20, 24);
let tall = word("tall", 90, 413, 20, 50);
let low_first = vec![low_center.clone(), median_a.clone(), median_b.clone(), tall.clone()];
let median_first = vec![median_a, low_center, median_b, tall];
assert_eq!(detect_rows(&low_first, 0.5), vec![411]);
assert_eq!(detect_rows(&median_first, 0.5), vec![411]);
}
fn table_with_tall_words(tall_top: u32, tall_height: u32, tall_words_first: bool) -> Vec<HocrWord> {
let mut words = Vec::new();
let mut tall_words = Vec::new();
for row in 0..12 {
let top = row * 40;
words.push(word(&format!("Item {row}"), 0, top, 50, 24));
for column in 1..=6 {
let amount = word(&format!("{}00", row * 10 + column), column * 100, top, 40, 24);
if row == 10 && column >= 5 {
tall_words.push(word(&amount.text, amount.left, tall_top, amount.width, tall_height));
} else {
words.push(amount);
}
}
}
if tall_words_first {
tall_words.extend(words);
tall_words
} else {
words.extend(tall_words);
words
}
}
#[test]
fn reconstruct_table_keeps_downward_tall_words_in_their_row() {
let words = table_with_tall_words(400, 50, false);
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 12);
assert_eq!(table[10][5], "10500");
assert_eq!(table[10][6], "10600");
}
#[test]
fn reconstruct_table_keeps_upward_tall_word_in_its_row_when_seen_first() {
let words = table_with_tall_words(370, 54, true);
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 12);
assert_eq!(table[10][5], "10500");
assert_eq!(table[10][6], "10600");
}
#[test]
fn reconstruct_table_keeps_a_standalone_tall_header_row() {
let words = vec![
word("Heading", 0, 0, 50, 50),
word("Total", 100, 0, 40, 50),
word("First", 0, 100, 50, 24),
word("100", 100, 100, 40, 24),
word("Second", 0, 140, 50, 24),
word("200", 100, 140, 40, 24),
];
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 3);
assert_eq!(table[0], ["Heading", "Total"]);
}
#[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 issue_1934_two_word_header_stays_in_its_supported_column() {
let words = vec![
word("Code", 0, 0, 50, 20),
word("Amount", 100, 0, 70, 20),
word("Unit", 200, 0, 70, 20),
word("cost", 280, 0, 45, 20),
word("Total", 350, 0, 60, 20),
word("A", 0, 60, 20, 20),
word("10", 100, 60, 30, 20),
word("2.50", 200, 60, 50, 20),
word("25.00", 350, 60, 60, 20),
word("B", 0, 120, 20, 20),
word("20", 100, 120, 30, 20),
word("3.00", 200, 120, 50, 20),
word("60.00", 350, 120, 60, 20),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["Code", "Amount", "Unit cost", "Total"]);
assert_eq!(table[1], vec!["A", "10", "2.50", "25.00"]);
assert_eq!(column_positions, vec![0, 100, 200, 350]);
}
#[test]
fn reconstruct_table_attaches_header_fragments_to_nearest_supported_column() {
let words = vec![
word("DESCRIPTION", 279, 100, 250, 40),
word("QTY", 1_699, 100, 90, 40),
word("UNIT", 2_096, 100, 105, 40),
word("PRICE", 2_219, 100, 135, 40),
word("LINE", 2_663, 100, 98, 40),
word("TOTAL", 2_778, 100, 143, 40),
word("Espresso Beans", 279, 200, 400, 40),
word("10", 1_740, 200, 48, 40),
word("$45.00", 2_206, 200, 150, 40),
word("$450.00", 2_744, 200, 175, 40),
word("Cups", 279, 300, 100, 40),
word("200", 1_709, 300, 75, 40),
word("$1.20", 2_233, 300, 125, 40),
word("$240.00", 2_744, 300, 175, 40),
word("Cleaning Tablets", 279, 400, 360, 40),
word("$18.50", 2_206, 400, 150, 40),
word("$92.50", 2_772, 400, 150, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["DESCRIPTION", "QTY", "UNIT PRICE", "LINE TOTAL"]);
assert_eq!(table[3], vec!["Cleaning Tablets", "", "$18.50", "$92.50"]);
assert_eq!(column_positions, vec![279, 1_709, 2_206, 2_744]);
}
#[test]
fn reconstruct_table_keeps_fragment_far_from_matching_right_column() {
let words = vec![
word("DESCRIPTION", 100, 100, 250, 40),
word("QTY", 1_000, 100, 90, 40),
word("UNIT", 1_150, 100, 105, 40),
word("PRICE", 1_500, 100, 135, 40),
word("Item A", 100, 200, 160, 40),
word("10", 1_000, 200, 48, 40),
word("$45.00", 1_500, 200, 150, 40),
word("Item B", 100, 300, 160, 40),
word("20", 1_000, 300, 48, 40),
word("$50.00", 1_500, 300, 150, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["DESCRIPTION", "QTY", "UNIT", "PRICE"]);
assert_eq!(column_positions, vec![100, 1_000, 1_150, 1_500]);
}
#[test]
fn reconstruct_table_keeps_far_standalone_header_column() {
let words = vec![
word("QTY", 0, 100, 80, 40),
word("UNIT", 400, 100, 120, 40),
word("PRICE", 1_000, 100, 100, 40),
word("1", 0, 200, 30, 40),
word("$10", 1_000, 200, 60, 40),
word("2", 0, 300, 30, 40),
word("$20", 1_000, 300, 60, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["QTY", "UNIT", "PRICE"]);
assert_eq!(column_positions, vec![0, 400, 1_000]);
}
#[test]
fn reconstruct_table_keeps_centered_group_header_column() {
let words = vec![
word("QTY", 0, 100, 80, 40),
word("UNIT", 500, 100, 120, 40),
word("PRICE", 1_000, 100, 100, 40),
word("1", 0, 200, 30, 40),
word("$10", 1_000, 200, 60, 40),
word("2", 0, 300, 30, 40),
word("$20", 1_000, 300, 60, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["QTY", "UNIT", "PRICE"]);
assert_eq!(column_positions, vec![0, 500, 1_000]);
}
#[test]
fn reconstruct_table_keeps_non_header_singleton_column() {
let words = vec![
word("QTY", 0, 100, 80, 40),
word("note", 700, 100, 80, 40),
word("UNIT", 850, 100, 80, 40),
word("PRICE", 1_000, 100, 100, 40),
word("1", 0, 200, 30, 40),
word("$10", 1_000, 200, 60, 40),
word("2", 0, 300, 30, 40),
word("$20", 1_000, 300, 60, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["QTY", "note", "UNIT PRICE"]);
assert_eq!(column_positions, vec![0, 700, 1_000]);
}
#[test]
fn reconstruct_table_keeps_unrelated_uppercase_header_column() {
let words = vec![
word("QTY", 0, 100, 80, 40),
word("DISCOUNT", 700, 100, 120, 40),
word("UNIT", 850, 100, 80, 40),
word("PRICE", 1_000, 100, 100, 40),
word("1", 0, 200, 30, 40),
word("$10", 1_000, 200, 60, 40),
word("2", 0, 300, 30, 40),
word("$20", 1_000, 300, 60, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["QTY", "DISCOUNT", "UNIT PRICE"]);
assert_eq!(column_positions, vec![0, 700, 1_000]);
}
#[test]
fn reconstruct_table_keeps_every_supported_column() {
let words = vec![
word("A", 0, 100, 40, 40),
word("B", 500, 100, 40, 40),
word("C", 1_000, 100, 40, 40),
word("1", 0, 200, 30, 40),
word("2", 500, 200, 30, 40),
word("3", 1_000, 200, 30, 40),
word("4", 0, 300, 30, 40),
word("5", 500, 300, 30, 40),
word("6", 1_000, 300, 30, 40),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["A", "B", "C"]);
assert_eq!(column_positions, vec![0, 500, 1_000]);
}
#[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 issue_1628_cell_words_join_in_left_to_right_order() {
let words = vec![
word("eta", 206, 100, 7, 12),
word("%", 253, 100, 6, 12),
word("S", 211, 104, 3, 7),
];
let table = assign_words_to_cells(&words, &[100], &[206]);
assert_eq!(
table,
vec![vec!["eta S %".to_string()]],
"a subscript must join between the symbol it annotates and the next unit, not after it"
);
}
#[test]
fn issue_1628_cell_with_two_visual_lines_does_not_interleave_them() {
let words = vec![
word("Hello", 100, 100, 30, 12),
word("world", 140, 100, 30, 12),
word("again", 100, 116, 30, 12),
word("here", 140, 116, 30, 12),
];
let table = assign_words_to_cells(&words, &[106], &[100]);
assert_eq!(table, vec![vec!["Hello world again here".to_string()]]);
}
#[test]
fn issue_1628_cell_join_does_not_stack_a_word_s_own_trailing_space() {
let words = vec![word("Q ", 206, 100, 8, 12), word("HE", 213, 104, 7, 7)];
let table = assign_words_to_cells(&words, &[100], &[206]);
assert_eq!(table, vec![vec!["Q HE".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()]);
}
#[test]
fn issue_1649_wide_description_word_stays_in_its_own_column_not_the_nearest_anchor() {
let words = vec![
word("DATE", 126, 1292, 152, 43),
word("DESCRIPTION", 746, 1292, 412, 44),
word("WITHDRAWAL", 2394, 1292, 418, 43),
word("2026-01-05", 125, 1673, 350, 49),
word("ACH", 741, 1671, 139, 52),
word("Deposit", 908, 1672, 228, 63),
word("-", 1159, 1699, 20, 7),
word("EMPLOYER", 1206, 1671, 360, 52),
word("INC", 1591, 1671, 108, 52),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(table[0], vec!["DATE", "DESCRIPTION", "WITHDRAWAL"]);
assert_eq!(
table[1],
vec!["2026-01-05", "ACH Deposit - EMPLOYER INC", ""],
"INC must join the description cell it visually belongs to, not the far column its \
own x-position happens to be nearest to"
);
}
#[cfg(feature = "ocr")]
#[test]
fn issue_1649_merge_disjoint_numeric_columns_folds_a_drift_split_column() {
let mut table = vec![
vec!["WITHDRAWAL".to_string(), "".to_string(), "DEPOSIT".to_string()],
vec!["".to_string(), "$1,250.00".to_string(), "".to_string()],
vec!["".to_string(), "$87.32".to_string(), "".to_string()],
vec!["".to_string(), "".to_string(), "$500.00".to_string()],
];
let mut positions = vec![100_u32, 120, 600];
merge_disjoint_numeric_columns(&mut table, &mut positions, 50);
assert_eq!(positions.len(), 2, "the drift-split pair must fold into one column");
assert_eq!(table[0], vec!["WITHDRAWAL".to_string(), "DEPOSIT".to_string()]);
assert_eq!(table[1], vec!["$1,250.00".to_string(), "".to_string()]);
assert_eq!(table[2], vec!["$87.32".to_string(), "".to_string()]);
assert_eq!(table[3], vec!["".to_string(), "$500.00".to_string()]);
}
#[cfg(feature = "ocr")]
#[test]
fn issue_1649_merge_disjoint_numeric_columns_leaves_independently_headed_columns_alone() {
let mut table = vec![
vec!["Debit".to_string(), "Credit".to_string()],
vec!["$1,250.00".to_string(), "".to_string()],
vec!["".to_string(), "$87.32".to_string()],
];
let mut positions = vec![100_u32, 120];
merge_disjoint_numeric_columns(&mut table, &mut positions, 50);
assert_eq!(
positions.len(),
2,
"two independently headed columns must not be merged"
);
assert_eq!(table[0], vec!["Debit".to_string(), "Credit".to_string()]);
}
#[cfg(feature = "ocr")]
#[test]
fn issue_1649_drop_leading_caption_row_removes_a_lone_leading_caption() {
let mut table = vec![
vec!["TRANSACTIONS".to_string(), "".to_string(), "".to_string()],
vec!["DATE".to_string(), "DESCRIPTION".to_string(), "BALANCE".to_string()],
vec![
"2026-01-02".to_string(),
"Opening Balance".to_string(),
"$10,500.00".to_string(),
],
];
drop_leading_caption_row(&mut table);
assert_eq!(table.len(), 2, "the caption row must be dropped");
assert_eq!(table[0], vec!["DATE", "DESCRIPTION", "BALANCE"]);
}
#[cfg(feature = "ocr")]
#[test]
fn issue_1649_drop_leading_caption_row_leaves_a_two_row_summary_card_alone() {
let mut table = vec![
vec![
"ACCOUNT TYPE".to_string(),
"STATEMENT PERIOD".to_string(),
"CLOSING BALANCE".to_string(),
],
vec![
"Checking".to_string(),
"Jan 1 - Jan 31, 2026".to_string(),
"$12,847.65".to_string(),
],
];
drop_leading_caption_row(&mut table);
assert_eq!(table.len(), 2, "a two-row table has no caption to drop");
assert_eq!(table[0][0], "ACCOUNT TYPE");
}
#[cfg(feature = "ocr")]
fn grid(rows: &[&[&str]]) -> Vec<Vec<String>> {
rows.iter()
.map(|row| row.iter().map(|cell| (*cell).to_string()).collect())
.collect()
}
#[cfg(feature = "ocr")]
#[test]
fn split_amount_column_with_rule_marks_is_merged() {
let mut table = grid(&[
&["Item", "Column A", "Column B", ""],
&["Item 1", "1,250", "", "310"],
&["Item 2", "", "87", "-"],
&["Item 3", "940", "", "——————"],
&["Item 4", "", "4,020", ""],
&["Item 5", "", "", ":"],
]);
let mut positions = vec![100_u32, 400, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions.len(), 3, "the split amount column must fold into one column");
assert_eq!(
table,
grid(&[
&["Item", "Column A", "Column B"],
&["Item 1", "1,250", "310"],
&["Item 2", "", "87"],
&["Item 3", "940", "——————"],
&["Item 4", "", "4,020"],
&["Item 5", "", ":"],
])
);
}
#[cfg(feature = "ocr")]
#[test]
fn rule_marks_in_the_header_band_do_not_block_a_split_column_merge() {
let mut table = grid(&[
&["Item", "Column B", "~"],
&["", "a", ""],
&["Item 1", "1,250", ""],
&["Item 2", "", "87"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions.len(), 2, "the split amount column must fold into one column");
assert_eq!(table[0], vec!["Item".to_string(), "Column B".to_string()]);
assert_eq!(table[2], vec!["Item 1".to_string(), "1,250".to_string()]);
assert_eq!(table[3], vec!["Item 2".to_string(), "87".to_string()]);
}
#[cfg(feature = "ocr")]
#[test]
fn a_word_cell_in_a_data_row_still_blocks_a_split_column_merge() {
let mut table = grid(&[
&["Item", "Column B", ""],
&["Item 1", "1,250", ""],
&["Item 2", "", "see note"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions.len(), 3, "a track holding words must not be merged");
}
#[cfg(feature = "ocr")]
#[test]
fn a_symbol_in_front_of_a_value_blocks_a_split_column_merge() {
for symbol in ["-", "$", "("] {
let mut table = grid(&[
&["Item", "Column B", ""],
&["Item 1", "1,250", ""],
&["Item 2", symbol, "87"],
&["Item 3", "", "56"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(
positions.len(),
3,
"a {symbol:?} in front of a value must not be merged away"
);
assert_eq!(
table[2],
vec!["Item 2".to_string(), symbol.to_string(), "87".to_string()]
);
}
}
#[cfg(feature = "ocr")]
#[test]
fn rule_marks_do_not_decide_the_position_of_a_merged_column() {
let mut table = grid(&[
&["Item", "Column B", ""],
&["Item 1", "1,250", "-"],
&["Item 2", "940", "——"],
&["Item 3", "", "87"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions, vec![100, 700]);
}
#[cfg(feature = "ocr")]
#[test]
fn a_header_row_holding_a_number_is_still_the_header() {
let mut table = grid(&[
&["2024", "Column B", ""],
&["Item 1", "1,250", ""],
&["Item 2", "", "87"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions.len(), 2, "the split amount column must fold into one column");
assert_eq!(table[0], vec!["2024".to_string(), "Column B".to_string()]);
}
#[cfg(feature = "ocr")]
#[test]
fn a_number_only_row_under_the_header_is_data() {
let mut table = grid(&[
&["Item", "Column B", ""],
&["", "", "2024"],
&["Item 1", "1,250", ""],
&["Item 2", "", "87"],
]);
let mut positions = vec![100_u32, 700, 740];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
assert_eq!(positions.len(), 2, "the split amount column must fold into one column");
assert_eq!(table[1], vec![String::new(), "2024".to_string()]);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn table_with_a_split_amount_column_and_rule_marks_is_kept() {
let mut table = grid(&[
&["Item", "Column A", "Column B", "Column C", ""],
&["Item 1", "2,110", "3,040", "1,250", ""],
&["Item 2", "2,380", "3,150", "", "87"],
&["Item 3", "2,470", "3,260", "940", "——"],
&["Item 4", "2,560", "3,370", "4,020", ""],
&["Item 5", "2,650", "3,480", "", "56"],
&["Item 6", "2,740", "3,590", "1,330", ""],
&["Item 7", "2,830", "3,600", "", "72"],
&["Item 8", "2,920", "3,710", "2,480", ""],
&["Item 9", "3,010", "3,820", "1,960", ""],
&["Item 10", "3,100", "3,930", "3,570", ""],
]);
let mut positions = vec![100_u32, 400, 700, 1000, 1040];
merge_disjoint_numeric_columns(&mut table, &mut positions, 25);
let kept = crate::pdf::table_reconstruct::post_process_table(table, false, false)
.expect("the table must survive the OCR table cleanup");
assert_eq!(kept[0].len(), 4, "the split column must come back as one column");
let column_c: Vec<&str> = kept[1..].iter().map(|row| row[3].as_str()).collect();
assert_eq!(
column_c,
[
"1,250", "87", "940", "4,020", "56", "1,330", "72", "2,480", "1,960", "3,570"
]
);
}
#[test]
fn issue_1649_punctuation_glyph_merges_across_a_widened_gap() {
let words = vec![
word("Jan", 0, 0, 30, 20),
word("1", 35, 0, 10, 20),
word("-", 57, 0, 10, 20),
word("Jan", 80, 0, 30, 20),
word("31,", 115, 0, 25, 20),
word("2026", 145, 0, 40, 20),
];
let table = reconstruct_table(&words, 500, 0.5);
assert_eq!(table.len(), 1);
assert_eq!(
table[0],
vec!["Jan 1 - Jan 31, 2026".to_string()],
"the dash must glue the date range into a single cell, not split it"
);
}
#[test]
fn issue_1649_lone_punctuation_cell_keeps_its_own_column() {
let words = vec![
word("Debit", 0, 0, 50, 20),
word("-", 63, 0, 10, 20),
word("Credit", 130, 0, 60, 20),
];
let table = reconstruct_table(&words, 20, 0.5);
assert_eq!(table.len(), 1);
assert_eq!(
table[0],
vec!["Debit".to_string(), "-".to_string(), "Credit".to_string()],
"an isolated punctuation cell with no genuine neighbour must not glue to the preceding word"
);
}
#[test]
fn issue_1886_right_aligned_amount_column_stays_one_column() {
let words = vec![
word("Alpha", 0, 0, 100, 30),
word("5", 480, 0, 20, 30),
word("Beta", 0, 60, 100, 30),
word("73", 460, 60, 40, 30),
word("Gamma", 0, 120, 100, 30),
word("1,234,567", 340, 120, 160, 30),
word("Delta", 0, 180, 100, 30),
word("\u{2014}", 490, 180, 10, 30),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Alpha".to_string(), "5".to_string()],
vec!["Beta".to_string(), "73".to_string()],
vec!["Gamma".to_string(), "1,234,567".to_string()],
vec!["Delta".to_string(), "\u{2014}".to_string()],
]
);
assert_eq!(
column_positions,
vec![0, 340],
"the folded column keeps a left edge in page pixels, the unit every consumer reads"
);
}
#[test]
fn issue_1886_text_tokens_of_different_widths_keep_clustering_by_their_left_edge() {
let words = vec![
word("Alpha", 100, 0, 40, 20),
word("X", 600, 0, 40, 20),
word("Alphabetical", 100, 60, 300, 20),
word("Y", 600, 60, 40, 20),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Alpha".to_string(), "X".to_string()],
vec!["Alphabetical".to_string(), "Y".to_string()],
]
);
assert_eq!(column_positions, vec![100, 600]);
}
#[test]
fn issue_1886_value_columns_further_apart_than_the_threshold_are_not_folded() {
let words = vec![
word("1", 0, 0, 20, 20),
word("2", 60, 0, 20, 20),
word("3", 0, 60, 20, 20),
word("4", 60, 60, 20, 20),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["1".to_string(), "2".to_string()],
vec!["3".to_string(), "4".to_string()],
]
);
assert_eq!(column_positions, vec![0, 60]);
}
#[test]
fn issue_1952_a_junk_token_does_not_stop_a_value_column_folding() {
let words = vec![
word("Alpha", 0, 0, 100, 30),
word("5", 480, 0, 20, 30),
word("Beta", 0, 60, 100, 30),
word("73", 460, 60, 40, 30),
word("Gamma", 0, 120, 100, 30),
word("1,234,567", 340, 120, 160, 30),
word("Delta", 0, 180, 100, 30),
word("a", 485, 180, 15, 30),
word("Epsilon", 0, 240, 100, 30),
word("8", 480, 240, 20, 30),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Alpha".to_string(), "5".to_string()],
vec!["Beta".to_string(), "73".to_string()],
vec!["Gamma".to_string(), "1,234,567".to_string()],
vec!["Delta".to_string(), "a".to_string()],
vec!["Epsilon".to_string(), "8".to_string()],
]
);
assert_eq!(column_positions, vec![0, 340]);
}
#[test]
fn issue_1952_a_multi_row_header_band_does_not_stop_a_value_column_folding() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Total", 250, 0, 60, 20),
word("Year 1", 255, 60, 55, 20),
word("(net)", 265, 120, 45, 20),
word("A", 0, 180, 60, 20),
word("846", 265, 180, 45, 20),
word("B", 0, 240, 60, 20),
word("12,345,678", 140, 240, 170, 20),
word("C", 0, 300, 60, 20),
word("5", 295, 300, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Total".to_string()],
vec!["".to_string(), "Year 1".to_string()],
vec!["".to_string(), "(net)".to_string()],
vec!["A".to_string(), "846".to_string()],
vec!["B".to_string(), "12,345,678".to_string()],
vec!["C".to_string(), "5".to_string()],
]
);
}
#[test]
fn a_text_column_with_a_coinciding_right_edge_is_not_folded() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Total", 440, 0, 60, 20),
word("A", 0, 60, 60, 20),
word("5", 480, 60, 20, 20),
word("B", 0, 120, 60, 20),
word("note", 440, 120, 60, 20),
word("C", 0, 180, 60, 20),
word("abc", 455, 180, 45, 20),
word("D", 0, 240, 60, 20),
word("1,234,567", 340, 240, 160, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "".to_string(), "Total".to_string()],
vec!["A".to_string(), "".to_string(), "5".to_string()],
vec!["B".to_string(), "".to_string(), "note".to_string()],
vec!["C".to_string(), "".to_string(), "abc".to_string()],
vec!["D".to_string(), "1,234,567".to_string(), "".to_string()],
]
);
}
#[test]
fn a_track_with_as_many_junk_tokens_as_values_is_not_folded() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Total", 250, 0, 60, 20),
word("A", 0, 60, 60, 20),
word("5", 295, 60, 15, 20),
word("B", 0, 120, 60, 20),
word("x", 297, 120, 13, 20),
word("C", 0, 180, 60, 20),
word("12,345,678", 140, 180, 170, 20),
word("D", 0, 240, 60, 20),
word("7", 295, 240, 15, 20),
word("E", 0, 300, 60, 20),
word("b", 297, 300, 13, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "".to_string(), "Total".to_string()],
vec!["A".to_string(), "".to_string(), "5".to_string()],
vec!["B".to_string(), "".to_string(), "x".to_string()],
vec!["C".to_string(), "12,345,678".to_string(), "".to_string()],
vec!["D".to_string(), "".to_string(), "7".to_string()],
vec!["E".to_string(), "".to_string(), "b".to_string()],
]
);
}
#[test]
fn a_number_in_row_zero_keeps_row_zero_as_the_header() {
let words = vec![
word("2024", 0, 0, 60, 20),
word("Total", 250, 0, 60, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 140, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("5", 295, 120, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["2024".to_string(), "Total".to_string()],
vec!["A".to_string(), "12,345,678".to_string()],
vec!["B".to_string(), "5".to_string()],
]
);
}
#[test]
fn two_close_text_columns_split_when_the_first_number_sits_far_down() {
let words = vec![
word("N", 0, 0, 15, 20),
word("Code", 45, 0, 60, 20),
word("Qty", 400, 0, 40, 20),
word("Al", 0, 60, 15, 20),
word("ABCDEFG", 45, 60, 105, 20),
word("Bo", 0, 120, 15, 20),
word("HIJKLMN", 45, 120, 105, 20),
word("Cy", 0, 180, 15, 20),
word("OPQRSTU", 45, 180, 105, 20),
word("VWXYZAB", 45, 240, 105, 20),
word("7", 425, 240, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["N".to_string(), "Code".to_string(), "Qty".to_string()],
vec!["Al".to_string(), "ABCDEFG".to_string(), "".to_string()],
vec!["Bo".to_string(), "HIJKLMN".to_string(), "".to_string()],
vec!["Cy".to_string(), "OPQRSTU".to_string(), "".to_string()],
vec!["".to_string(), "VWXYZAB".to_string(), "7".to_string()],
]
);
}
#[test]
fn a_quantity_column_does_not_fold_into_an_amount_column_folded_from_two_tracks() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Total", 250, 0, 60, 20),
word("A", 0, 60, 60, 20),
word("7", 295, 60, 15, 20),
word("5", 350, 60, 8, 20),
word("B", 0, 120, 60, 20),
word("12,345,678", 140, 120, 170, 20),
word("C", 0, 180, 60, 20),
word("9", 295, 180, 15, 20),
word("3", 350, 180, 8, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Total".to_string(), "".to_string()],
vec!["A".to_string(), "7".to_string(), "5".to_string()],
vec!["B".to_string(), "12,345,678".to_string(), "".to_string()],
vec!["C".to_string(), "9".to_string(), "3".to_string()],
]
);
}
#[test]
fn a_code_column_does_not_fold_into_the_quantity_column_beside_it() {
for third_code in ["1030", "10A5"] {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Code", 300, 0, 40, 20),
word("Qty", 360, 0, 25, 20),
word("A", 0, 60, 60, 20),
word("1010", 300, 60, 40, 20),
word("5", 370, 60, 15, 20),
word("B", 0, 120, 60, 20),
word("1020", 300, 120, 40, 20),
word("7", 370, 120, 15, 20),
word("C", 0, 180, 60, 20),
word(third_code, 300, 180, 40, 20),
word("3", 370, 180, 15, 20),
word("D", 0, 240, 60, 20),
word("1040", 300, 240, 40, 20),
word("9", 370, 240, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Code".to_string(), "Qty".to_string()],
vec!["A".to_string(), "1010".to_string(), "5".to_string()],
vec!["B".to_string(), "1020".to_string(), "7".to_string()],
vec!["C".to_string(), third_code.to_string(), "3".to_string()],
vec!["D".to_string(), "1040".to_string(), "9".to_string()],
],
"third code {third_code}"
);
}
}
#[test]
fn issue_1909_amount_columns_whose_left_edges_interleave_stay_apart() {
let words = vec![
word("A", 0, 0, 60, 20),
word("7", 285, 0, 15, 20),
word("40,218,965", 330, 0, 170, 20),
word("B", 0, 60, 60, 20),
word("12,345,678", 130, 60, 170, 20),
word("4", 485, 60, 15, 20),
word("C", 0, 120, 60, 20),
word("5", 285, 120, 15, 20),
word("17,382,649", 332, 120, 168, 20),
];
let (table, column_positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["A".to_string(), "7".to_string(), "40,218,965".to_string()],
vec!["B".to_string(), "12,345,678".to_string(), "4".to_string()],
vec!["C".to_string(), "5".to_string(), "17,382,649".to_string()],
]
);
assert_eq!(column_positions, vec![0, 130, 332]);
}
#[test]
fn issue_1769_footer_fragments_do_not_split_a_six_column_table() {
let columns = [44, 87, 129, 171, 211, 252];
let mut words = Vec::new();
for (column, text) in ["Number", "DP", "SP-C", "SP-D", "DP vs SP-C", "DP vs SP-D"]
.into_iter()
.enumerate()
{
words.push(word(text, columns[column], 0, 24, 6));
}
for row in 1..29 {
let top = row * 12;
let cells = if row == 1 {
["Number", "166/647", "157/647", "324/647", "0.114", "<0.001"]
} else {
["Measure", "10", "20", "30", "0.1", "0.2"]
};
for (column, text) in cells.into_iter().enumerate() {
words.push(word(text, columns[column], top, 24, 6));
}
}
let footer_top = 29 * 12;
words.push(word("Fisher", 38, footer_top, 10, 6));
words.push(word("exact test (2x2), and quantitative", 66, footer_top, 107, 6));
for (column, text) in ["10", "20", "30", "0.1", "0.2"].into_iter().enumerate() {
words.push(word(text, columns[column + 1], footer_top, 24, 6));
}
let (table, column_positions) = reconstruct_table_with_columns(&words, 30, 0.5);
assert_eq!(table.len(), 30);
assert_eq!(column_positions.len(), 6);
assert_eq!(table[1], ["Number", "166/647", "157/647", "324/647", "0.114", "<0.001"]);
}
#[test]
fn issue_1909_a_header_label_does_not_stop_its_value_column_folding() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Total", 250, 0, 60, 20),
word("A", 0, 60, 60, 20),
word("846", 255, 60, 45, 20),
word("B", 0, 120, 60, 20),
word("12,345,678", 130, 120, 170, 20),
word("C", 0, 180, 60, 20),
word("5", 285, 180, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Total".to_string()],
vec!["A".to_string(), "846".to_string()],
vec!["B".to_string(), "12,345,678".to_string()],
vec!["C".to_string(), "5".to_string()],
]
);
}
#[test]
fn issue_1909_a_split_that_leaves_two_cells_of_one_row_together_is_not_taken() {
let words = vec![
word("1", 100, 0, 10, 20),
word("2", 135, 0, 10, 20),
word("3", 120, 60, 90, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(table, vec![vec!["1 2".to_string()], vec!["3".to_string()]]);
}
#[test]
fn issue_1909_two_close_text_columns_split_like_two_close_amount_columns() {
let words = vec![
word("N", 0, 0, 15, 20),
word("Code", 45, 0, 60, 20),
word("Al", 0, 60, 15, 20),
word("ABCDEFG", 45, 60, 105, 20),
word("Bo", 0, 120, 15, 20),
word("HIJKLMN", 45, 120, 105, 20),
word("Cy", 0, 180, 15, 20),
word("OPQRSTU", 45, 180, 105, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["N".to_string(), "Code".to_string()],
vec!["Al".to_string(), "ABCDEFG".to_string()],
vec!["Bo".to_string(), "HIJKLMN".to_string()],
vec!["Cy".to_string(), "OPQRSTU".to_string()],
]
);
}
#[test]
fn issue_1909_a_header_label_does_not_stop_a_row_sharing_value_group_splitting() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("One", 130, 0, 50, 20),
word("Two", 330, 0, 50, 20),
word("A", 0, 60, 60, 20),
word("7", 285, 60, 15, 20),
word("40,218,965", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("12,345,678", 130, 120, 170, 20),
word("4", 485, 120, 15, 20),
word("C", 0, 180, 60, 20),
word("5", 285, 180, 15, 20),
word("17,382,649", 332, 180, 168, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "One".to_string(), "Two".to_string()],
vec!["A".to_string(), "7".to_string(), "40,218,965".to_string()],
vec!["B".to_string(), "12,345,678".to_string(), "4".to_string()],
vec!["C".to_string(), "5".to_string(), "17,382,649".to_string()],
]
);
}
#[test]
fn a_text_header_label_on_its_own_track_folds_with_its_amount_column() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Period 1", 400, 0, 100, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("5", 485, 120, 15, 20),
word("C", 0, 180, 60, 20),
word("40,218,965", 330, 180, 170, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Period 1".to_string()],
vec!["A".to_string(), "12,345,678".to_string()],
vec!["B".to_string(), "5".to_string()],
vec!["C".to_string(), "40,218,965".to_string()],
]
);
}
#[test]
fn a_header_label_with_its_own_right_edge_keeps_its_column() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Note", 250, 0, 60, 20),
word("Amt", 455, 0, 45, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("5", 485, 120, 15, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Note".to_string(), "Amt".to_string()],
vec!["A".to_string(), String::new(), "12,345,678".to_string()],
vec!["B".to_string(), String::new(), "5".to_string()],
]
);
}
#[test]
fn a_header_only_label_beside_a_labelled_value_column_keeps_its_column() {
let notes_left_of_quantities = vec![
word("Item", 0, 0, 60, 20),
word("Notes", 370, 0, 80, 20),
word("Qty", 470, 0, 30, 20),
word("A", 0, 60, 60, 20),
word("5", 485, 60, 15, 20),
word("B", 0, 120, 60, 20),
word("7", 485, 120, 15, 20),
word("C", 0, 180, 60, 20),
word("12", 470, 180, 30, 20),
];
let note_right_of_amounts = vec![
word("Item", 0, 0, 60, 20),
word("Amount", 380, 0, 120, 20),
word("Note", 520, 0, 30, 20),
word("A", 0, 60, 60, 20),
word("12,345", 400, 60, 100, 20),
word("B", 0, 120, 60, 20),
word("40,218", 400, 120, 100, 20),
];
assert_eq!(
reconstruct_table(¬es_left_of_quantities, 50, 0.5)[0],
vec!["Item", "Notes", "Qty"]
);
assert_eq!(
reconstruct_table(¬e_right_of_amounts, 50, 0.5)[0],
vec!["Item", "Amount", "Note"]
);
}
#[test]
fn a_folded_label_keeps_a_neighbouring_label_in_its_own_column() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Period 1", 400, 0, 100, 20),
word("Note", 520, 0, 30, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("1,234", 425, 120, 75, 20),
word("C", 0, 180, 60, 20),
word("40,218,965", 330, 180, 170, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "Period 1".to_string(), "Note".to_string()],
vec!["A".to_string(), "12,345,678".to_string(), String::new()],
vec!["B".to_string(), "1,234".to_string(), String::new()],
vec!["C".to_string(), "40,218,965".to_string(), String::new()],
]
);
}
#[test]
fn a_folded_label_column_keeps_the_position_of_its_long_amounts() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("Period 1", 270, 0, 230, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("5", 485, 120, 15, 20),
word("C", 0, 180, 60, 20),
word("40,218,965", 330, 180, 170, 20),
];
let (table, positions) = reconstruct_table_with_columns(&words, 50, 0.5);
assert_eq!(table[0], vec!["Item".to_string(), "Period 1".to_string()]);
assert_eq!(positions, vec![0, 330]);
}
#[test]
fn issue_1909_a_value_header_only_track_folds_with_its_amount_column() {
let words = vec![
word("Item", 0, 0, 60, 20),
word("2023", 420, 0, 80, 20),
word("A", 0, 60, 60, 20),
word("12,345,678", 330, 60, 170, 20),
word("B", 0, 120, 60, 20),
word("5", 485, 120, 15, 20),
word("C", 0, 180, 60, 20),
word("40,218,965", 330, 180, 170, 20),
];
let table = reconstruct_table(&words, 50, 0.5);
assert_eq!(
table,
vec![
vec!["Item".to_string(), "2023".to_string()],
vec!["A".to_string(), "12,345,678".to_string()],
vec!["B".to_string(), "5".to_string()],
vec!["C".to_string(), "40,218,965".to_string()],
]
);
}
}