* feat(extractor): geometric underline detection on TextItem (ENG-5015) PDFs carry no underline font flag — underlines are stroked horizontal lines or thin filled rects drawn under the baseline. Correlate those graphics (already parsed from the content stream) with text items in a post-pass: a rule within ~0.35em below the baseline covering >=60% of an item's width marks is_underline. Exposed through the napi and python bindings. Verified on real docs: 4/4 underlined sentences flagged on a Japanese report, links/headings flagged on 8 of 10 underline-bearing eval docs, zero flags on docs without underlines. Known FP source (table cell borders) documented — downstream applies inline styling only to plain-text regions. napi 1.9.8 -> 1.9.9. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(extractor): underline rules only from painted rects, normalized extents (review) Two review fixes: (1) normalize rect extents before the thickness/width checks — `re` operands pass through the CTM so width/height can be negative, which missed negative-width rules and let negative-height bands pass as thin; (2) only feed painted rects to underline detection — `re` rects now wait in a pending list until a paint operator (S/s, f/F/ f*, B/B*/b/b*) confirms them, and `re W n` clip-only paths are discarded at `n`, so invisible clip boundaries no longer underline nearby text. Marking moved into content_stream where paint state lives (pre-rotation, consistent device space). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(extractor): harden underline detection * feat(cli): export positioned text item json --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2471 lines
81 KiB
Rust
2471 lines
81 KiB
Rust
//! Table detection and formatting.
|
||
//!
|
||
//! Detects tabular data in PDF text items and converts to markdown tables.
|
||
|
||
mod detect_heuristic;
|
||
mod detect_lines;
|
||
mod detect_rects;
|
||
mod detect_struct;
|
||
mod financial;
|
||
mod format;
|
||
mod grid;
|
||
pub mod structured;
|
||
|
||
pub use detect_heuristic::detect_tables;
|
||
pub(crate) use detect_heuristic::is_table_of_contents;
|
||
pub use detect_lines::detect_tables_from_lines;
|
||
pub(crate) use detect_rects::cluster_rects;
|
||
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
||
pub use detect_struct::detect_tables_from_struct_tree;
|
||
pub use format::table_to_markdown;
|
||
pub use structured::{cells_to_markdown, StructuredCell};
|
||
|
||
use crate::types::TextItem;
|
||
|
||
/// Try to build a table from items + cluster rects (calendar-style layouts).
|
||
///
|
||
/// Uses rect X positions as column boundaries to directly construct a `Table`,
|
||
/// bypassing heuristic detection. Splits merged multi-number items first.
|
||
pub(crate) fn try_build_rect_guided_table(
|
||
items: &[TextItem],
|
||
cluster_rects: &[(f32, f32, f32, f32)],
|
||
) -> Option<Table> {
|
||
if items.is_empty() || cluster_rects.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// 1. Derive column boundaries from rect X positions (snapped to 2pt tolerance)
|
||
let mut x_lefts: Vec<f32> = cluster_rects.iter().map(|&(x, _, _, _)| x).collect();
|
||
x_lefts.sort_by(|a, b| a.total_cmp(b));
|
||
// Snap: deduplicate within 2pt tolerance
|
||
let mut col_boundaries: Vec<f32> = Vec::new();
|
||
for x in &x_lefts {
|
||
if col_boundaries
|
||
.last()
|
||
.is_none_or(|last| (*x - *last).abs() > 2.0)
|
||
{
|
||
col_boundaries.push(*x);
|
||
}
|
||
}
|
||
|
||
if col_boundaries.len() < 5 {
|
||
return None;
|
||
}
|
||
|
||
// 1b. Interpolate missing boundaries: holidays/non-work days may not have
|
||
// rects, creating gaps. Fill gaps > 1.5× median spacing with evenly spaced
|
||
// boundaries so every day gets a column.
|
||
if col_boundaries.len() >= 2 {
|
||
let mut spacings: Vec<f32> = col_boundaries.windows(2).map(|w| w[1] - w[0]).collect();
|
||
spacings.sort_by(|a, b| a.total_cmp(b));
|
||
let median_spacing = spacings[spacings.len() / 2];
|
||
let threshold = median_spacing * 1.5;
|
||
|
||
let mut filled: Vec<f32> = vec![col_boundaries[0]];
|
||
for i in 1..col_boundaries.len() {
|
||
let gap = col_boundaries[i] - col_boundaries[i - 1];
|
||
if gap > threshold {
|
||
// Insert interpolated boundaries
|
||
let n = (gap / median_spacing).round() as usize;
|
||
if n >= 2 {
|
||
let step = gap / n as f32;
|
||
for j in 1..n {
|
||
filled.push(col_boundaries[i - 1] + j as f32 * step);
|
||
}
|
||
}
|
||
}
|
||
filled.push(col_boundaries[i]);
|
||
}
|
||
col_boundaries = filled;
|
||
}
|
||
|
||
// 2. Split merged multi-number items
|
||
let mut expanded_items: Vec<(TextItem, usize)> = Vec::new();
|
||
for (idx, item) in items.iter().enumerate() {
|
||
let splits = split_merged_numbers(item, &col_boundaries);
|
||
for split_item in splits {
|
||
expanded_items.push((split_item, idx));
|
||
}
|
||
}
|
||
|
||
// 3. Derive row boundaries from item Y positions (5pt tolerance)
|
||
let mut y_values: Vec<f32> = expanded_items.iter().map(|(item, _)| item.y).collect();
|
||
y_values.sort_by(|a, b| b.total_cmp(a)); // descending
|
||
let mut row_boundaries: Vec<f32> = Vec::new();
|
||
for y in &y_values {
|
||
if row_boundaries
|
||
.last()
|
||
.is_none_or(|last| (*last - *y).abs() > 5.0)
|
||
{
|
||
row_boundaries.push(*y);
|
||
}
|
||
}
|
||
|
||
if row_boundaries.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// 4. Assign items to cells
|
||
let n_rows = row_boundaries.len();
|
||
let n_cols = col_boundaries.len();
|
||
let mut cells: Vec<Vec<String>> = vec![vec![String::new(); n_cols]; n_rows];
|
||
let mut used_indices: Vec<usize> = Vec::new();
|
||
|
||
// Compute max X to exclude legend text beyond the table area
|
||
let col_spacing = if col_boundaries.len() >= 2 {
|
||
(col_boundaries.last().unwrap() - col_boundaries.first().unwrap())
|
||
/ (col_boundaries.len() - 1) as f32
|
||
} else {
|
||
20.0
|
||
};
|
||
let max_x = col_boundaries.last().unwrap() + col_spacing * 1.5;
|
||
|
||
for (item, orig_idx) in &expanded_items {
|
||
// Skip items beyond the table's rightmost column (legend text)
|
||
if item.x > max_x {
|
||
continue;
|
||
}
|
||
// Find row (nearest Y within tolerance)
|
||
let row = row_boundaries
|
||
.iter()
|
||
.position(|&ry| (ry - item.y).abs() <= 5.0);
|
||
// Find column: rightmost boundary ≤ item.x + tolerance.
|
||
// 4pt tolerance catches annotation items (e.g. "Memorial Day") that sit
|
||
// slightly before the next column boundary.
|
||
let col = col_boundaries.iter().rposition(|&cx| item.x >= cx - 4.0);
|
||
|
||
if let (Some(r), Some(c)) = (row, col) {
|
||
let cell = &mut cells[r][c];
|
||
if !cell.is_empty() {
|
||
cell.push(' ');
|
||
}
|
||
cell.push_str(item.text.trim());
|
||
used_indices.push(*orig_idx);
|
||
}
|
||
}
|
||
|
||
// 5. Clean up: strip tilde-leader noise from cells (legend text bleeding
|
||
// into the last column from the right side of the page)
|
||
for row in &mut cells {
|
||
for cell in row.iter_mut() {
|
||
if let Some(pos) = cell.find("~~~") {
|
||
cell.truncate(pos);
|
||
*cell = cell.trim_end().to_string();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. Validate: at least one row should have ≥ 5 non-empty cells
|
||
let best_row_fill = cells
|
||
.iter()
|
||
.map(|row| row.iter().filter(|c| !c.is_empty()).count())
|
||
.max()
|
||
.unwrap_or(0);
|
||
if best_row_fill < 5 {
|
||
return None;
|
||
}
|
||
|
||
// Deduplicate used indices
|
||
used_indices.sort_unstable();
|
||
used_indices.dedup();
|
||
|
||
Some(Table::new(
|
||
col_boundaries,
|
||
row_boundaries,
|
||
cells,
|
||
used_indices,
|
||
))
|
||
}
|
||
|
||
/// Split a TextItem whose text contains multiple whitespace-separated tokens
|
||
/// (like "10 11 12 ... 31") into individual TextItems, each assigned to the
|
||
/// nearest column boundary.
|
||
fn split_merged_numbers(item: &TextItem, col_boundaries: &[f32]) -> Vec<TextItem> {
|
||
let tokens: Vec<&str> = item.text.split_whitespace().collect();
|
||
if tokens.len() <= 1 {
|
||
return vec![item.clone()];
|
||
}
|
||
|
||
// Count consecutive leading numeric tokens (day numbers like "10 11 12")
|
||
let leading_numeric = tokens
|
||
.iter()
|
||
.take_while(|t| t.chars().all(|c| c.is_ascii_digit()))
|
||
.count();
|
||
|
||
// Need at least one leading number to split
|
||
if leading_numeric == 0 {
|
||
return vec![item.clone()];
|
||
}
|
||
|
||
let token_width = item.width / tokens.len() as f32;
|
||
let mut result = Vec::with_capacity(leading_numeric + 1);
|
||
|
||
// Find the enclosing column boundary (rightmost boundary ≤ item.x + 2pt),
|
||
// then advance through successive boundaries for each leading number.
|
||
// Using rposition avoids overshooting when item.x sits between boundaries.
|
||
let start_col = col_boundaries
|
||
.iter()
|
||
.rposition(|&cx| cx <= item.x + 2.0)
|
||
.unwrap_or(0);
|
||
|
||
// Split each leading numeric token into its own item at successive columns
|
||
for (i, token) in tokens.iter().enumerate().take(leading_numeric) {
|
||
let col_idx = start_col + i;
|
||
let snapped_x = if col_idx < col_boundaries.len() {
|
||
col_boundaries[col_idx]
|
||
} else {
|
||
// Fallback: distribute evenly if we run out of boundaries
|
||
let raw_x = item.x + i as f32 * token_width + token_width / 2.0;
|
||
col_boundaries
|
||
.iter()
|
||
.rev()
|
||
.find(|&&cx| cx <= raw_x + 2.0)
|
||
.copied()
|
||
.unwrap_or(raw_x)
|
||
};
|
||
|
||
result.push(TextItem {
|
||
text: token.to_string(),
|
||
x: snapped_x,
|
||
width: token_width,
|
||
y: item.y,
|
||
height: item.height,
|
||
font: item.font.clone(),
|
||
font_size: item.font_size,
|
||
page: item.page,
|
||
is_bold: item.is_bold,
|
||
is_italic: item.is_italic,
|
||
is_underline: item.is_underline,
|
||
item_type: item.item_type.clone(),
|
||
mcid: item.mcid,
|
||
});
|
||
}
|
||
|
||
// Trailing non-numeric tokens become annotation placed at last numeric column
|
||
if leading_numeric < tokens.len() {
|
||
let annotation = tokens[leading_numeric..].join(" ");
|
||
let last_x = result.last().map(|i| i.x).unwrap_or(item.x);
|
||
result.push(TextItem {
|
||
text: annotation,
|
||
x: last_x,
|
||
width: token_width,
|
||
y: item.y,
|
||
height: item.height,
|
||
font: item.font.clone(),
|
||
font_size: item.font_size,
|
||
page: item.page,
|
||
is_bold: item.is_bold,
|
||
is_italic: item.is_italic,
|
||
is_underline: item.is_underline,
|
||
item_type: item.item_type.clone(),
|
||
mcid: item.mcid,
|
||
});
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Detection mode controls thresholds for table validation.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub(crate) enum TableDetectionMode {
|
||
/// Existing behavior: items with font size smaller than body text
|
||
SmallFont,
|
||
/// New: body-font items with stricter structural criteria
|
||
BodyFont,
|
||
}
|
||
|
||
/// Build a table from layout-detected column boundaries.
|
||
///
|
||
/// When the layout engine detects multiple tabular columns (not newspaper),
|
||
/// this function uses those boundaries to construct a Table directly. This
|
||
/// handles borderless tables (no rects/lines) where columns are defined
|
||
/// purely by text alignment — common in exam/reference tables.
|
||
///
|
||
/// Requires ≥3 columns, ≥3 rows, and ≥40% cell fill rate.
|
||
pub(crate) fn try_build_table_from_columns(items: &[TextItem], page: u32) -> Option<Table> {
|
||
use crate::extractor::{
|
||
detect_columns, group_into_lines_with_thresholds, is_newspaper_layout, ColumnRegion,
|
||
};
|
||
use std::collections::HashMap;
|
||
|
||
let mut columns = detect_columns(items, page, false);
|
||
if columns.len() < 4 {
|
||
return None;
|
||
}
|
||
|
||
// Refine columns: look for header-like rows where multiple items share
|
||
// the same Y and are evenly spaced. If a wide column contains two header
|
||
// items, split it at the gap between them.
|
||
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
|
||
let y_tol = 3.0;
|
||
|
||
// Find the top-most row with items in multiple columns (likely the header)
|
||
let mut ys: Vec<f32> = page_items.iter().map(|i| i.y).collect();
|
||
ys.sort_by(|a, b| b.total_cmp(a));
|
||
ys.dedup_by(|a, b| (*a - *b).abs() < y_tol);
|
||
|
||
for &header_y in ys.iter().take(5) {
|
||
let row_items: Vec<&&TextItem> = page_items
|
||
.iter()
|
||
.filter(|i| (i.y - header_y).abs() < y_tol)
|
||
.collect();
|
||
if row_items.len() < columns.len() {
|
||
continue;
|
||
}
|
||
// Check if any column contains 2+ items at this Y — needs splitting
|
||
let mut new_columns = Vec::new();
|
||
let mut did_split = false;
|
||
for col in &columns {
|
||
let col_items: Vec<&&&TextItem> = row_items
|
||
.iter()
|
||
.filter(|i| i.x >= col.x_min && i.x < col.x_max)
|
||
.collect();
|
||
if col_items.len() >= 2 {
|
||
// Sort by X and find the split point
|
||
let mut sorted: Vec<f32> = col_items.iter().map(|i| i.x).collect();
|
||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||
// Split at the midpoint between the two items
|
||
let split_x = (sorted[0]
|
||
+ col_items.iter().find(|i| i.x == sorted[0]).unwrap().width
|
||
+ sorted[1])
|
||
/ 2.0;
|
||
new_columns.push(ColumnRegion {
|
||
x_min: col.x_min,
|
||
x_max: split_x,
|
||
});
|
||
new_columns.push(ColumnRegion {
|
||
x_min: split_x,
|
||
x_max: col.x_max,
|
||
});
|
||
did_split = true;
|
||
} else {
|
||
new_columns.push(col.clone());
|
||
}
|
||
}
|
||
if did_split {
|
||
log::debug!(
|
||
"column refinement: {} -> {} columns from header row at y={:.1}",
|
||
columns.len(),
|
||
new_columns.len(),
|
||
header_y
|
||
);
|
||
columns = new_columns;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Group items into per-column lines to check newspaper vs tabular
|
||
let mut col_buckets: Vec<Vec<TextItem>> = vec![Vec::new(); columns.len()];
|
||
let mut spanning_items: Vec<TextItem> = Vec::new();
|
||
for item in items {
|
||
if item.page != page {
|
||
continue;
|
||
}
|
||
// Check if item spans multiple columns
|
||
let item_left = item.x;
|
||
let item_right = item.x + item.width;
|
||
let mut spans = 0;
|
||
for col in &columns {
|
||
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
|
||
if overlap > 0.0 {
|
||
spans += 1;
|
||
}
|
||
}
|
||
if spans > 1 {
|
||
spanning_items.push(item.clone());
|
||
continue;
|
||
}
|
||
// Assign to best-overlap column
|
||
let mut best_col = 0;
|
||
let mut best_overlap = f32::NEG_INFINITY;
|
||
for (ci, col) in columns.iter().enumerate() {
|
||
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
|
||
if overlap > best_overlap {
|
||
best_overlap = overlap;
|
||
best_col = ci;
|
||
}
|
||
}
|
||
col_buckets[best_col].push(item.clone());
|
||
}
|
||
|
||
let thresholds = HashMap::new();
|
||
let per_column_lines: Vec<Vec<crate::types::TextLine>> = col_buckets
|
||
.iter()
|
||
.map(|bucket| {
|
||
group_into_lines_with_thresholds(
|
||
bucket.clone(),
|
||
&thresholds,
|
||
&std::collections::HashSet::new(),
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
// Must be tabular (not newspaper) layout
|
||
if is_newspaper_layout(&per_column_lines, &columns) {
|
||
return None;
|
||
}
|
||
|
||
// Collect all unique Y positions across all columns (row boundaries)
|
||
let y_tol = 5.0;
|
||
let mut row_ys: Vec<f32> = Vec::new();
|
||
for col_lines in &per_column_lines {
|
||
for line in col_lines {
|
||
let y = line.y;
|
||
if !row_ys.iter().any(|&ry| (ry - y).abs() < y_tol) {
|
||
row_ys.push(y);
|
||
}
|
||
}
|
||
}
|
||
row_ys.sort_by(|a, b| b.total_cmp(a));
|
||
|
||
if row_ys.len() < 3 || row_ys.len() > 40 {
|
||
return None;
|
||
}
|
||
|
||
// Build cell grid
|
||
let col_xs: Vec<f32> = columns.iter().map(|c| c.x_min).collect();
|
||
let mut cells: Vec<Vec<String>> = vec![vec![String::new(); columns.len()]; row_ys.len()];
|
||
let mut item_indices: Vec<usize> = Vec::new();
|
||
|
||
for (item_idx, item) in items.iter().enumerate() {
|
||
if item.page != page {
|
||
continue;
|
||
}
|
||
// Find column
|
||
let item_left = item.x;
|
||
let item_right = item.x + item.width;
|
||
let mut best_col = None;
|
||
let mut best_overlap = 0.0f32;
|
||
let mut span_count = 0;
|
||
for (ci, col) in columns.iter().enumerate() {
|
||
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
|
||
if overlap > 0.0 {
|
||
span_count += 1;
|
||
}
|
||
if overlap > best_overlap {
|
||
best_overlap = overlap;
|
||
best_col = Some(ci);
|
||
}
|
||
}
|
||
if span_count > 1 || best_col.is_none() {
|
||
continue; // spanning item, skip
|
||
}
|
||
let col = best_col.unwrap();
|
||
|
||
// Find row
|
||
let row = row_ys.iter().position(|&ry| (ry - item.y).abs() < y_tol);
|
||
if let Some(row) = row {
|
||
if !cells[row][col].is_empty() {
|
||
cells[row][col].push(' ');
|
||
}
|
||
cells[row][col].push_str(&item.text);
|
||
item_indices.push(item_idx);
|
||
}
|
||
}
|
||
merge_superscript_marker_rows(&mut row_ys, &mut cells);
|
||
|
||
// Validate: need reasonable fill rate
|
||
let total_cells = row_ys.len() * columns.len();
|
||
let filled_cells = cells
|
||
.iter()
|
||
.flat_map(|r| r.iter())
|
||
.filter(|c| !c.trim().is_empty())
|
||
.count();
|
||
let fill_rate = filled_cells as f32 / total_cells as f32;
|
||
|
||
if fill_rate < 0.15 {
|
||
return None;
|
||
}
|
||
|
||
// Need at least 40% of rows to have content in 2+ columns
|
||
let multi_col_rows = cells
|
||
.iter()
|
||
.filter(|row| row.iter().filter(|c| !c.trim().is_empty()).count() >= 2)
|
||
.count();
|
||
// Need majority (>50%) of rows with content in 2+ columns
|
||
if multi_col_rows * 2 < row_ys.len() {
|
||
return None;
|
||
}
|
||
|
||
// Reject prose-like content: if cells are too long on average, this is
|
||
// a multi-column text layout, not a data table. Real table cells are
|
||
// typically short (≤ 40 chars). Prose paragraphs are much longer.
|
||
let cell_lengths: Vec<usize> = cells
|
||
.iter()
|
||
.flat_map(|r| r.iter())
|
||
.filter(|c| !c.trim().is_empty())
|
||
.map(|c| c.trim().len())
|
||
.collect();
|
||
if !cell_lengths.is_empty() {
|
||
let avg_cell_len = cell_lengths.iter().sum::<usize>() as f32 / cell_lengths.len() as f32;
|
||
if avg_cell_len > 40.0 {
|
||
return None;
|
||
}
|
||
// Reject if any significant number of cells are long prose (> 80 chars)
|
||
let long_cells = cell_lengths.iter().filter(|&&len| len > 80).count();
|
||
if long_cells as f32 / cell_lengths.len() as f32 > 0.10 {
|
||
return None;
|
||
}
|
||
}
|
||
|
||
// Reject when cells look like prose sentences: if too many cells contain
|
||
// sentence-ending punctuation (.!?:) it's prose text, not table data.
|
||
let prose_cells = cells
|
||
.iter()
|
||
.flat_map(|r| r.iter())
|
||
.filter(|c| {
|
||
let t = c.trim();
|
||
t.len() > 20
|
||
&& (t.ends_with('.') || t.ends_with('!') || t.ends_with('?') || t.ends_with(':'))
|
||
})
|
||
.count();
|
||
if filled_cells > 0 && prose_cells as f32 / filled_cells as f32 > 0.15 {
|
||
return None;
|
||
}
|
||
|
||
// Reject when most content is in one column (newspaper-like asymmetry).
|
||
// Count items per column; if any column has >60% of items, it's likely
|
||
// a body text column with side annotations, not a data table.
|
||
let mut items_per_col: Vec<usize> = vec![0; columns.len()];
|
||
for row in &cells {
|
||
for (ci, cell) in row.iter().enumerate() {
|
||
if !cell.trim().is_empty() {
|
||
items_per_col[ci] += 1;
|
||
}
|
||
}
|
||
}
|
||
let max_col_items = *items_per_col.iter().max().unwrap_or(&0);
|
||
if filled_cells > 0 && max_col_items as f32 / filled_cells as f32 > 0.60 {
|
||
return None;
|
||
}
|
||
|
||
log::debug!(
|
||
"column-based table: {} cols x {} rows, fill={:.0}%, multi_col_rows={}",
|
||
columns.len(),
|
||
row_ys.len(),
|
||
fill_rate * 100.0,
|
||
multi_col_rows
|
||
);
|
||
|
||
Some(Table::new(col_xs, row_ys, cells, item_indices))
|
||
}
|
||
|
||
/// Build a region-scoped two-column key/value table from text baselines.
|
||
///
|
||
/// This intentionally lives outside the full-page heuristic detector. Layout
|
||
/// callers already supplied a table-shaped bbox, and some real table regions
|
||
/// are plain product/spec forms with only two visual columns. The main column
|
||
/// fallback starts at four columns to avoid newspaper/prose false positives;
|
||
/// this path keeps tighter key/value-specific guards instead.
|
||
pub(crate) fn try_build_key_value_table_from_rows(items: &[TextItem], page: u32) -> Option<Table> {
|
||
let page_items: Vec<RowItem> = items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, item)| item.page == page && !item.text.trim().is_empty())
|
||
.map(|(idx, item)| RowItem {
|
||
index: idx,
|
||
item: item.clone(),
|
||
})
|
||
.collect();
|
||
|
||
if page_items.len() < 2 {
|
||
return None;
|
||
}
|
||
|
||
let median_font_size = median_f32(page_items.iter().map(|ri| ri.item.font_size).collect())
|
||
.unwrap_or(10.0)
|
||
.max(1.0);
|
||
let y_tol = (median_font_size * 0.75).clamp(4.0, 9.0);
|
||
let rows = group_key_value_visual_rows(page_items, y_tol);
|
||
if rows.is_empty() || rows.len() > 80 {
|
||
return None;
|
||
}
|
||
|
||
let split_x = infer_key_value_split_x(&rows, median_font_size)?;
|
||
let mut kv_rows: Vec<KeyValueRow> = Vec::new();
|
||
let mut left_starts = Vec::new();
|
||
let mut right_starts = Vec::new();
|
||
|
||
for row in &rows {
|
||
let mut left_items = Vec::new();
|
||
let mut right_items = Vec::new();
|
||
for item in &row.items {
|
||
if item.item.x < split_x {
|
||
left_items.push(item);
|
||
} else {
|
||
right_items.push(item);
|
||
}
|
||
}
|
||
|
||
let left = join_row_item_text(&left_items);
|
||
let right = join_row_item_text(&right_items);
|
||
if left.is_empty() && right.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let mut item_indices: Vec<usize> = row.items.iter().map(|ri| ri.index).collect();
|
||
item_indices.sort_unstable();
|
||
item_indices.dedup();
|
||
|
||
if !left.is_empty() && !right.is_empty() {
|
||
if let Some(x) = left_items.first().map(|ri| ri.item.x) {
|
||
left_starts.push(x);
|
||
}
|
||
if let Some(x) = right_items.first().map(|ri| ri.item.x) {
|
||
right_starts.push(x);
|
||
}
|
||
}
|
||
|
||
kv_rows.push(KeyValueRow {
|
||
y: row.y,
|
||
left,
|
||
right,
|
||
item_indices,
|
||
});
|
||
}
|
||
|
||
if kv_rows.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let raw_left_only_rows = kv_rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && row.right.is_empty())
|
||
.count();
|
||
let raw_right_only_rows = kv_rows
|
||
.iter()
|
||
.filter(|row| row.left.is_empty() && !row.right.is_empty())
|
||
.count();
|
||
let edgar_tag_rows = key_value_rows_look_like_edgar_tags(&kv_rows);
|
||
if edgar_tag_rows {
|
||
kv_rows.retain(|row| !row.right.is_empty() || !is_edgar_table_boundary_cell(&row.left));
|
||
}
|
||
let header_inferred = !edgar_tag_rows && key_value_first_pair_is_header(&kv_rows);
|
||
kv_rows = normalize_key_value_rows(kv_rows, header_inferred);
|
||
|
||
let paired_rows = kv_rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
.count();
|
||
let section_rows = kv_rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && row.right.is_empty())
|
||
.count();
|
||
let dangling_right_rows = kv_rows
|
||
.iter()
|
||
.filter(|row| row.left.is_empty() && !row.right.is_empty())
|
||
.count();
|
||
let left_label_like = kv_rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
.filter(|row| looks_like_key_value_label(&row.left))
|
||
.count();
|
||
if paired_rows < 1 {
|
||
return None;
|
||
}
|
||
if dangling_right_rows > 0 {
|
||
return None;
|
||
}
|
||
|
||
let left_x = median_f32(left_starts).unwrap_or_else(|| {
|
||
rows.iter()
|
||
.flat_map(|row| row.items.iter().map(|ri| ri.item.x))
|
||
.fold(f32::INFINITY, f32::min)
|
||
});
|
||
let right_x = median_f32(right_starts).unwrap_or(split_x);
|
||
if !left_x.is_finite() || !right_x.is_finite() || right_x - left_x < 40.0 {
|
||
return None;
|
||
}
|
||
|
||
let single_pair_allowed = key_value_single_pair_allowed(
|
||
KeyValueSinglePairStats {
|
||
paired_rows,
|
||
section_rows,
|
||
raw_left_only_rows,
|
||
raw_right_only_rows,
|
||
},
|
||
&kv_rows,
|
||
header_inferred,
|
||
left_x,
|
||
right_x,
|
||
);
|
||
if (kv_rows.len() < 2 || paired_rows < 2) && !single_pair_allowed {
|
||
return None;
|
||
}
|
||
|
||
let data_pairs = if header_inferred {
|
||
paired_rows.saturating_sub(1)
|
||
} else {
|
||
paired_rows
|
||
};
|
||
if data_pairs < 1 {
|
||
return None;
|
||
}
|
||
|
||
if section_rows > paired_rows * 2 + 2 && !single_pair_allowed {
|
||
return None;
|
||
}
|
||
|
||
let label_rows_for_score = if header_inferred {
|
||
paired_rows.saturating_sub(1)
|
||
} else {
|
||
paired_rows
|
||
};
|
||
let label_like_for_score = if header_inferred && !kv_rows.is_empty() {
|
||
left_label_like.saturating_sub(1)
|
||
} else {
|
||
left_label_like
|
||
};
|
||
if !header_inferred
|
||
&& !edgar_tag_rows
|
||
&& label_rows_for_score >= 2
|
||
&& label_like_for_score * 2 < label_rows_for_score
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let right_cluster_count = significant_side_x_clusters(&rows, split_x, false);
|
||
let marker_rows = marker_matrix_value_rows(&kv_rows);
|
||
if !single_pair_allowed
|
||
&& !edgar_tag_rows
|
||
&& ((right_cluster_count >= 5 && paired_rows >= 3)
|
||
|| (right_cluster_count >= 3 && marker_rows >= 3 && marker_rows * 2 >= paired_rows))
|
||
{
|
||
return None;
|
||
}
|
||
|
||
if !edgar_tag_rows && key_value_rows_look_like_prose(&kv_rows, header_inferred) {
|
||
return None;
|
||
}
|
||
|
||
let mut table_rows = Vec::new();
|
||
let mut cells = Vec::new();
|
||
let mut item_indices = Vec::new();
|
||
|
||
let mut start_idx = 0usize;
|
||
if header_inferred {
|
||
let header = &kv_rows[0];
|
||
table_rows.push(header.y);
|
||
cells.push(vec![header.left.clone(), header.right.clone()]);
|
||
item_indices.extend(header.item_indices.iter().copied());
|
||
start_idx = 1;
|
||
} else {
|
||
table_rows.push(kv_rows.first().map(|row| row.y + y_tol).unwrap_or(0.0));
|
||
cells.push(vec!["Field".to_string(), "Value".to_string()]);
|
||
}
|
||
|
||
for row in kv_rows.iter().skip(start_idx) {
|
||
if !row.left.is_empty() && !row.right.is_empty() {
|
||
table_rows.push(row.y);
|
||
cells.push(vec![row.left.clone(), row.right.clone()]);
|
||
item_indices.extend(row.item_indices.iter().copied());
|
||
} else if !row.left.is_empty() {
|
||
table_rows.push(row.y);
|
||
cells.push(vec!["Section".to_string(), row.left.clone()]);
|
||
item_indices.extend(row.item_indices.iter().copied());
|
||
} else if !row.right.is_empty() {
|
||
if let Some(last) = cells.last_mut() {
|
||
if let Some(value) = last.get_mut(1) {
|
||
if !value.trim().is_empty() {
|
||
value.push(' ');
|
||
}
|
||
value.push_str(&row.right);
|
||
item_indices.extend(row.item_indices.iter().copied());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if cells.len() < 2 {
|
||
return None;
|
||
}
|
||
|
||
item_indices.sort_unstable();
|
||
item_indices.dedup();
|
||
|
||
log::debug!(
|
||
"key-value table: {} rows, pairs={}, sections={}, split_x={:.1}",
|
||
cells.len(),
|
||
paired_rows,
|
||
section_rows,
|
||
split_x
|
||
);
|
||
|
||
Some(Table::new(
|
||
vec![left_x, right_x],
|
||
table_rows,
|
||
cells,
|
||
item_indices,
|
||
))
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct RowItem {
|
||
index: usize,
|
||
item: TextItem,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct VisualRow {
|
||
y: f32,
|
||
items: Vec<RowItem>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct KeyValueRow {
|
||
y: f32,
|
||
left: String,
|
||
right: String,
|
||
item_indices: Vec<usize>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct KeyValueSinglePairStats {
|
||
paired_rows: usize,
|
||
section_rows: usize,
|
||
raw_left_only_rows: usize,
|
||
raw_right_only_rows: usize,
|
||
}
|
||
|
||
fn normalize_key_value_rows(rows: Vec<KeyValueRow>, header_inferred: bool) -> Vec<KeyValueRow> {
|
||
let mut normalized: Vec<KeyValueRow> = Vec::with_capacity(rows.len());
|
||
|
||
for row in rows {
|
||
if row.left.is_empty() && row.right.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
if row.left.is_empty() && !row.right.is_empty() {
|
||
if let Some(last) = normalized.last_mut() {
|
||
if !last.right.is_empty() {
|
||
append_key_value_text(&mut last.right, &row.right);
|
||
last.item_indices.extend(row.item_indices);
|
||
continue;
|
||
}
|
||
}
|
||
normalized.push(row);
|
||
continue;
|
||
}
|
||
|
||
if !row.left.is_empty() && row.right.is_empty() {
|
||
let normalized_len = normalized.len();
|
||
if let Some(last) = normalized.last_mut() {
|
||
let last_is_header = header_inferred && normalized_len == 1;
|
||
if !last_is_header
|
||
&& !last.left.is_empty()
|
||
&& !last.right.is_empty()
|
||
&& key_value_left_continuation_allowed(&last.left, &row.left)
|
||
{
|
||
append_key_value_text(&mut last.left, &row.left);
|
||
last.item_indices.extend(row.item_indices);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
normalized.push(row);
|
||
}
|
||
|
||
normalized
|
||
}
|
||
|
||
fn append_key_value_text(target: &mut String, addition: &str) {
|
||
let addition = addition.trim();
|
||
if addition.is_empty() {
|
||
return;
|
||
}
|
||
if !target.trim().is_empty() {
|
||
target.push(' ');
|
||
}
|
||
target.push_str(addition);
|
||
}
|
||
|
||
fn key_value_left_continuation_allowed(previous_left: &str, continuation: &str) -> bool {
|
||
let trimmed = continuation.trim();
|
||
if trimmed.is_empty() || looks_like_key_value_section_label(trimmed) {
|
||
return false;
|
||
}
|
||
|
||
let previous = previous_left.trim_end();
|
||
let continuation_chars = trimmed.chars().count();
|
||
let continuation_words = word_count_simple(trimmed);
|
||
previous.ends_with(['-', '/', ',', ';', ':'])
|
||
|| first_alpha_is_lowercase(trimmed)
|
||
|| continuation_chars > 28
|
||
|| continuation_words > 4
|
||
}
|
||
|
||
fn group_key_value_visual_rows(mut items: Vec<RowItem>, y_tol: f32) -> Vec<VisualRow> {
|
||
items.sort_by(|a, b| {
|
||
b.item
|
||
.y
|
||
.total_cmp(&a.item.y)
|
||
.then_with(|| a.item.x.total_cmp(&b.item.x))
|
||
});
|
||
|
||
let mut rows: Vec<VisualRow> = Vec::new();
|
||
for row_item in items {
|
||
if let Some(row) = rows
|
||
.iter_mut()
|
||
.find(|row| (row.y - row_item.item.y).abs() <= y_tol)
|
||
{
|
||
let len = row.items.len() as f32;
|
||
row.y = (row.y * len + row_item.item.y) / (len + 1.0);
|
||
row.items.push(row_item);
|
||
continue;
|
||
}
|
||
|
||
rows.push(VisualRow {
|
||
y: row_item.item.y,
|
||
items: vec![row_item],
|
||
});
|
||
}
|
||
|
||
for row in &mut rows {
|
||
row.items.sort_by(|a, b| a.item.x.total_cmp(&b.item.x));
|
||
}
|
||
rows.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||
rows
|
||
}
|
||
|
||
fn infer_key_value_split_x(rows: &[VisualRow], median_font_size: f32) -> Option<f32> {
|
||
let min_gap = (median_font_size * 2.0).max(24.0);
|
||
let mut splits = Vec::new();
|
||
|
||
for row in rows {
|
||
if row.items.len() < 2 {
|
||
continue;
|
||
}
|
||
|
||
let mut best_gap = 0.0f32;
|
||
let mut best_split = None;
|
||
for pair in row.items.windows(2) {
|
||
let left = &pair[0].item;
|
||
let right = &pair[1].item;
|
||
let left_right = left.x + left.width.max(0.0);
|
||
let gap = right.x - left_right;
|
||
if gap > best_gap {
|
||
best_gap = gap;
|
||
best_split = Some(left_right + gap / 2.0);
|
||
}
|
||
}
|
||
|
||
if best_gap >= min_gap {
|
||
if let Some(split) = best_split {
|
||
splits.push(split);
|
||
}
|
||
}
|
||
}
|
||
|
||
if splits.len() < 2 {
|
||
let paired_visual_rows = rows.iter().filter(|row| row.items.len() >= 2).count();
|
||
if splits.len() == 1
|
||
&& paired_visual_rows == 1
|
||
&& (rows.len() == 1 || rows.iter().all(|row| row.items.len() <= 2))
|
||
{
|
||
return splits.into_iter().next();
|
||
}
|
||
return None;
|
||
}
|
||
|
||
median_f32(splits)
|
||
}
|
||
|
||
fn join_row_item_text(items: &[&RowItem]) -> String {
|
||
let mut parts = Vec::new();
|
||
for item in items {
|
||
let trimmed = item.item.text.trim();
|
||
if !trimmed.is_empty() {
|
||
parts.push(trimmed);
|
||
}
|
||
}
|
||
normalize_cell_text(&parts.join(" "))
|
||
}
|
||
|
||
fn normalize_cell_text(text: &str) -> String {
|
||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||
}
|
||
|
||
fn key_value_first_pair_is_header(rows: &[KeyValueRow]) -> bool {
|
||
let Some(first) = rows.first() else {
|
||
return false;
|
||
};
|
||
if first.left.is_empty() || first.right.is_empty() {
|
||
return false;
|
||
}
|
||
if !looks_like_key_value_header_cell(&first.left)
|
||
|| !looks_like_key_value_header_cell(&first.right)
|
||
{
|
||
return false;
|
||
}
|
||
rows.iter()
|
||
.skip(1)
|
||
.any(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
}
|
||
|
||
fn looks_like_key_value_header_cell(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
if trimmed.len() < 2 || trimmed.len() > 40 {
|
||
return false;
|
||
}
|
||
let words = word_count_simple(trimmed);
|
||
if !(1..=4).contains(&words) {
|
||
return false;
|
||
}
|
||
let lower = trimmed.to_ascii_lowercase();
|
||
if matches!(
|
||
lower.as_str(),
|
||
"yes" | "no" | "true" | "false" | "none" | "n/a" | "na"
|
||
) {
|
||
return false;
|
||
}
|
||
trimmed.chars().any(|c| c.is_alphabetic())
|
||
&& !trimmed.chars().any(|c| c.is_ascii_digit())
|
||
&& !trimmed.ends_with(['.', ',', ';', ':'])
|
||
}
|
||
|
||
fn looks_like_key_value_label(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
if trimmed.len() < 2 || trimmed.len() > 90 {
|
||
return false;
|
||
}
|
||
let words = word_count_simple(trimmed);
|
||
if words == 0 || words > 10 {
|
||
return false;
|
||
}
|
||
if trimmed.ends_with(['.', ',', ';']) {
|
||
return false;
|
||
}
|
||
trimmed.chars().any(|c| c.is_alphabetic())
|
||
}
|
||
|
||
fn key_value_rows_look_like_edgar_tags(rows: &[KeyValueRow]) -> bool {
|
||
let paired_rows = rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
.count();
|
||
if paired_rows < 2 {
|
||
return false;
|
||
}
|
||
|
||
let tag_pairs = rows
|
||
.iter()
|
||
.filter(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
.filter(|row| is_edgar_tag_cell(&row.left))
|
||
.count();
|
||
let first_marker = rows.first().is_some_and(|row| {
|
||
row.left.eq_ignore_ascii_case("<S>") && row.right.eq_ignore_ascii_case("<C>")
|
||
});
|
||
|
||
tag_pairs >= 3 || (first_marker && tag_pairs >= 2)
|
||
}
|
||
|
||
fn is_edgar_tag_cell(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
let Some(inner) = trimmed.strip_prefix('<').and_then(|s| s.strip_suffix('>')) else {
|
||
return false;
|
||
};
|
||
!inner.is_empty()
|
||
&& inner.len() <= 48
|
||
&& inner
|
||
.chars()
|
||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_'))
|
||
}
|
||
|
||
fn is_edgar_table_boundary_cell(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
trimmed.eq_ignore_ascii_case("<TABLE>") || trimmed.eq_ignore_ascii_case("</TABLE>")
|
||
}
|
||
|
||
fn key_value_single_pair_allowed(
|
||
stats: KeyValueSinglePairStats,
|
||
rows: &[KeyValueRow],
|
||
header_inferred: bool,
|
||
left_x: f32,
|
||
right_x: f32,
|
||
) -> bool {
|
||
if header_inferred || stats.paired_rows != 1 || stats.section_rows != 0 || rows.len() != 1 {
|
||
return false;
|
||
}
|
||
if right_x - left_x < 60.0 {
|
||
return false;
|
||
}
|
||
|
||
let Some(row) = rows
|
||
.iter()
|
||
.find(|row| !row.left.is_empty() && !row.right.is_empty())
|
||
else {
|
||
return false;
|
||
};
|
||
let left_chars = row.left.chars().count();
|
||
let right_chars = row.right.chars().count();
|
||
if !(2..=120).contains(&left_chars) || right_chars == 0 {
|
||
return false;
|
||
}
|
||
if key_value_cell_looks_like_sentence(&row.left) {
|
||
return false;
|
||
}
|
||
if stats.raw_left_only_rows == 0
|
||
&& stats.raw_right_only_rows >= 2
|
||
&& left_chars <= 70
|
||
&& right_chars <= 1_500
|
||
&& looks_like_key_value_label(&row.left)
|
||
{
|
||
return true;
|
||
}
|
||
if right_chars > 80 {
|
||
return false;
|
||
}
|
||
if key_value_cell_looks_like_sentence(&row.right) && !compact_key_value_scalar(&row.right) {
|
||
return false;
|
||
}
|
||
|
||
(looks_like_key_value_label(&row.left) || left_chars <= 90)
|
||
&& compact_key_value_scalar(&row.right)
|
||
}
|
||
|
||
fn compact_key_value_scalar(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
let chars = trimmed.chars().count();
|
||
let words = word_count_simple(trimmed);
|
||
if trimmed.is_empty() || chars > 60 || words > 6 || trimmed.ends_with(['.', '!', '?']) {
|
||
return false;
|
||
}
|
||
|
||
let lower = trimmed.to_ascii_lowercase();
|
||
trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||
|| matches!(
|
||
lower.as_str(),
|
||
"yes" | "no" | "true" | "false" | "none" | "n/a" | "na"
|
||
)
|
||
|| words <= 4
|
||
}
|
||
|
||
fn looks_like_key_value_section_label(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
let chars = trimmed.chars().count();
|
||
let words = word_count_simple(trimmed);
|
||
if !(1..=5).contains(&words) || !(2..=48).contains(&chars) {
|
||
return false;
|
||
}
|
||
if trimmed.ends_with(['.', ',', ';', ':']) || first_alpha_is_lowercase(trimmed) {
|
||
return false;
|
||
}
|
||
if trimmed
|
||
.chars()
|
||
.any(|ch| matches!(ch, '.' | ',' | ';' | '(' | ')' | '[' | ']'))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
trimmed.chars().any(|ch| ch.is_alphabetic())
|
||
}
|
||
|
||
fn first_alpha_is_lowercase(cell: &str) -> bool {
|
||
cell.chars()
|
||
.find(|ch| ch.is_alphabetic())
|
||
.is_some_and(|ch| ch.is_lowercase())
|
||
}
|
||
|
||
fn key_value_cell_looks_like_sentence(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
let chars = trimmed.chars().count();
|
||
chars > 90
|
||
|| word_count_simple(trimmed) > 12
|
||
|| (chars > 42 && trimmed.ends_with(['.', '!', '?']))
|
||
}
|
||
|
||
fn key_value_rows_look_like_prose(rows: &[KeyValueRow], header_inferred: bool) -> bool {
|
||
let mut left_cells = 0usize;
|
||
let mut left_prose_cells = 0usize;
|
||
let mut left_label_like = 0usize;
|
||
let mut total_left_chars = 0usize;
|
||
let mut paired_rows = 0usize;
|
||
let mut paired_sentence_rows = 0usize;
|
||
let mut solo_prose_rows = 0usize;
|
||
|
||
for row in rows.iter().skip(usize::from(header_inferred)) {
|
||
if !row.left.is_empty() && !row.right.is_empty() {
|
||
paired_rows += 1;
|
||
let left = row.left.trim();
|
||
let right = row.right.trim();
|
||
let left_prose = key_value_cell_looks_like_sentence(left);
|
||
let right_prose = key_value_cell_looks_like_sentence(right);
|
||
left_cells += 1;
|
||
total_left_chars += left.chars().count();
|
||
if looks_like_key_value_label(left) {
|
||
left_label_like += 1;
|
||
}
|
||
if left_prose {
|
||
left_prose_cells += 1;
|
||
}
|
||
if left_prose && right_prose {
|
||
paired_sentence_rows += 1;
|
||
}
|
||
} else {
|
||
let solo = if row.left.is_empty() {
|
||
row.right.trim()
|
||
} else {
|
||
row.left.trim()
|
||
};
|
||
if solo.chars().count() > 70
|
||
|| word_count_simple(solo) > 9
|
||
|| (solo.chars().count() > 35 && solo.ends_with(['.', '!', '?']))
|
||
{
|
||
solo_prose_rows += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if paired_rows < 1 || left_cells == 0 {
|
||
return true;
|
||
}
|
||
if solo_prose_rows >= 3 {
|
||
return true;
|
||
}
|
||
if paired_rows >= 2 && paired_sentence_rows * 2 >= paired_rows {
|
||
return true;
|
||
}
|
||
if !header_inferred && left_prose_cells * 2 >= left_cells {
|
||
return true;
|
||
}
|
||
|
||
let avg_left_chars = total_left_chars as f32 / left_cells as f32;
|
||
!header_inferred && avg_left_chars > 70.0 && left_label_like * 2 < left_cells
|
||
}
|
||
|
||
fn marker_matrix_value_rows(rows: &[KeyValueRow]) -> usize {
|
||
rows.iter()
|
||
.filter(|row| !row.left.is_empty() && compact_marker_value(&row.right))
|
||
.count()
|
||
}
|
||
|
||
fn compact_marker_value(cell: &str) -> bool {
|
||
let trimmed = cell.trim();
|
||
if trimmed.is_empty() || trimmed.chars().count() > 80 {
|
||
return false;
|
||
}
|
||
if trimmed.chars().any(|ch| ch.is_alphabetic()) {
|
||
return false;
|
||
}
|
||
trimmed
|
||
.chars()
|
||
.any(|ch| ch.is_ascii_digit() || matches!(ch, '•' | '●' | '·'))
|
||
}
|
||
|
||
fn significant_side_x_clusters(rows: &[VisualRow], split_x: f32, left_side: bool) -> usize {
|
||
let mut xs = Vec::new();
|
||
for row in rows {
|
||
for item in &row.items {
|
||
let is_left = item.item.x < split_x;
|
||
if is_left == left_side {
|
||
xs.push(item.item.x);
|
||
}
|
||
}
|
||
}
|
||
xs.sort_by(|a, b| a.total_cmp(b));
|
||
|
||
let mut counts = Vec::new();
|
||
let mut center = None::<f32>;
|
||
let mut count = 0usize;
|
||
for x in xs {
|
||
match center {
|
||
Some(current) if (x - current).abs() <= 8.0 => {
|
||
center = Some((current * count as f32 + x) / (count as f32 + 1.0));
|
||
count += 1;
|
||
}
|
||
Some(_) => {
|
||
counts.push(count);
|
||
center = Some(x);
|
||
count = 1;
|
||
}
|
||
None => {
|
||
center = Some(x);
|
||
count = 1;
|
||
}
|
||
}
|
||
}
|
||
if count > 0 {
|
||
counts.push(count);
|
||
}
|
||
|
||
counts.into_iter().filter(|&count| count >= 2).count()
|
||
}
|
||
|
||
fn word_count_simple(cell: &str) -> usize {
|
||
cell.split_whitespace()
|
||
.filter(|word| word.chars().any(|c| c.is_alphanumeric()))
|
||
.count()
|
||
}
|
||
|
||
fn median_f32(mut values: Vec<f32>) -> Option<f32> {
|
||
values.retain(|value| value.is_finite());
|
||
if values.is_empty() {
|
||
return None;
|
||
}
|
||
values.sort_by(|a, b| a.total_cmp(b));
|
||
Some(values[values.len() / 2])
|
||
}
|
||
|
||
fn merge_superscript_marker_rows(row_ys: &mut Vec<f32>, cells: &mut Vec<Vec<String>>) {
|
||
let mut row_idx = 0;
|
||
while row_idx < cells.len() {
|
||
let non_empty: Vec<(usize, String)> = cells[row_idx]
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(col_idx, cell)| {
|
||
let trimmed = cell.trim();
|
||
(!trimmed.is_empty()).then_some((col_idx, trimmed.to_string()))
|
||
})
|
||
.collect();
|
||
|
||
if non_empty.len() != 1 || !is_superscript_marker_cell(&non_empty[0].1) {
|
||
row_idx += 1;
|
||
continue;
|
||
}
|
||
|
||
let (marker_col, marker) = &non_empty[0];
|
||
let prev =
|
||
(row_idx > 0).then(|| (row_idx - 1, (row_ys[row_idx - 1] - row_ys[row_idx]).abs()));
|
||
let next = (row_idx + 1 < cells.len())
|
||
.then(|| (row_idx + 1, (row_ys[row_idx] - row_ys[row_idx + 1]).abs()));
|
||
let target = [prev, next]
|
||
.into_iter()
|
||
.flatten()
|
||
.filter(|(_, gap)| *gap <= 10.0)
|
||
.min_by(|(_, gap_a), (_, gap_b)| gap_a.total_cmp(gap_b))
|
||
.map(|(idx, _)| idx);
|
||
|
||
let Some(target_idx) = target else {
|
||
row_idx += 1;
|
||
continue;
|
||
};
|
||
|
||
let target_cell = &mut cells[target_idx][*marker_col];
|
||
if target_cell.trim().is_empty() {
|
||
*target_cell = marker.to_string();
|
||
} else {
|
||
target_cell.push_str(marker);
|
||
}
|
||
cells.remove(row_idx);
|
||
row_ys.remove(row_idx);
|
||
}
|
||
}
|
||
|
||
fn is_superscript_marker_cell(value: &str) -> bool {
|
||
let trimmed = value.trim();
|
||
!trimmed.is_empty()
|
||
&& trimmed.chars().count() <= 2
|
||
&& trimmed
|
||
.chars()
|
||
.all(|ch| matches!(ch, '*' | '#' | 'o' | 'O' | '°' | 'º' | '†' | '‡'))
|
||
}
|
||
|
||
/// What kind of structure a detected `Table` represents. Classification is
|
||
/// computed once at construction so consumers don't have to re-analyze the
|
||
/// cells (and stay consistent across detection backends).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum TableKind {
|
||
/// A real data table — renders as markdown table syntax.
|
||
#[default]
|
||
Data,
|
||
/// A table of contents — renders as a flat list with tab-aligned page
|
||
/// numbers via `format_toc_as_list`. Detected through the table pipeline
|
||
/// because TOCs share row/column structure with tables, but they are not
|
||
/// data tables and shouldn't appear in `pages_with_tables` etc.
|
||
Toc,
|
||
}
|
||
|
||
/// A detected table.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Table {
|
||
/// Column boundaries (x positions)
|
||
pub columns: Vec<f32>,
|
||
/// Row boundaries (y positions, descending order)
|
||
pub rows: Vec<f32>,
|
||
/// Cell contents indexed by (row, col)
|
||
pub cells: Vec<Vec<String>>,
|
||
/// Items that belong to this table
|
||
pub item_indices: Vec<usize>,
|
||
/// Data table vs TOC. Set by `Table::new` from `cells`.
|
||
pub kind: TableKind,
|
||
}
|
||
|
||
impl Table {
|
||
/// Build a table and classify it (data vs TOC) from its cells.
|
||
pub fn new(
|
||
columns: Vec<f32>,
|
||
rows: Vec<f32>,
|
||
cells: Vec<Vec<String>>,
|
||
item_indices: Vec<usize>,
|
||
) -> Self {
|
||
let kind = if is_table_of_contents(&cells) {
|
||
TableKind::Toc
|
||
} else {
|
||
TableKind::Data
|
||
};
|
||
Self {
|
||
columns,
|
||
rows,
|
||
cells,
|
||
item_indices,
|
||
kind,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::types::{ItemType, TextItem};
|
||
|
||
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
|
||
TextItem {
|
||
text: text.into(),
|
||
x,
|
||
y,
|
||
width: 10.0,
|
||
height: font_size,
|
||
font: "F1".into(),
|
||
font_size,
|
||
page: 1,
|
||
is_bold: false,
|
||
is_italic: false,
|
||
is_underline: false,
|
||
item_type: ItemType::Text,
|
||
mcid: None,
|
||
}
|
||
}
|
||
|
||
fn make_char(text: &str, x: f32, y: f32, font_size: f32, width: f32) -> TextItem {
|
||
TextItem {
|
||
text: text.into(),
|
||
x,
|
||
y,
|
||
width,
|
||
height: font_size,
|
||
font: "F1".into(),
|
||
font_size,
|
||
page: 1,
|
||
is_bold: false,
|
||
is_italic: false,
|
||
is_underline: false,
|
||
item_type: ItemType::Text,
|
||
mcid: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_table_detection() {
|
||
let items = vec![
|
||
// Header row
|
||
make_item("Subject", 100.0, 500.0, 8.0),
|
||
make_item("Q1", 200.0, 500.0, 8.0),
|
||
make_item("Q2", 280.0, 500.0, 8.0),
|
||
make_item("Q3", 360.0, 500.0, 8.0),
|
||
// Data row 1
|
||
make_item("Math", 100.0, 480.0, 8.0),
|
||
make_item("9.0", 200.0, 480.0, 8.0),
|
||
make_item("8.5", 280.0, 480.0, 8.0),
|
||
make_item("9.5", 360.0, 480.0, 8.0),
|
||
// Data row 2
|
||
make_item("Science", 100.0, 460.0, 8.0),
|
||
make_item("8.0", 200.0, 460.0, 8.0),
|
||
make_item("9.0", 280.0, 460.0, 8.0),
|
||
make_item("8.5", 360.0, 460.0, 8.0),
|
||
// Data row 3
|
||
make_item("English", 100.0, 440.0, 8.0),
|
||
make_item("9.5", 200.0, 440.0, 8.0),
|
||
make_item("9.0", 280.0, 440.0, 8.0),
|
||
make_item("9.5", 360.0, 440.0, 8.0),
|
||
];
|
||
|
||
let tables = detect_tables(&items, 10.0, false);
|
||
assert_eq!(tables.len(), 1);
|
||
assert_eq!(tables[0].columns.len(), 4);
|
||
assert_eq!(tables[0].rows.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn test_table_to_markdown() {
|
||
let table = Table {
|
||
columns: vec![100.0, 200.0],
|
||
rows: vec![500.0, 480.0],
|
||
cells: vec![
|
||
vec!["Header 1".into(), "Header 2".into()],
|
||
vec!["Cell 1".into(), "Cell 2".into()],
|
||
],
|
||
item_indices: vec![],
|
||
kind: TableKind::Data,
|
||
};
|
||
|
||
let md = table_to_markdown(&table);
|
||
assert!(md.contains("|Header 1|"));
|
||
assert!(md.contains("|---|"));
|
||
assert!(md.contains("|Cell 1|"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_merge_superscript_marker_rows() {
|
||
let mut rows = vec![506.0, 500.0, 480.0];
|
||
let mut cells = vec![
|
||
vec!["".into(), "".into(), "*".into()],
|
||
vec!["Name".into(), "Method".into(), "Typical values".into()],
|
||
vec!["Flow".into(), "ASTM D1238".into(), "3.0".into()],
|
||
];
|
||
|
||
merge_superscript_marker_rows(&mut rows, &mut cells);
|
||
|
||
assert_eq!(rows, vec![500.0, 480.0]);
|
||
assert_eq!(cells[0][2], "Typical values*");
|
||
}
|
||
|
||
#[test]
|
||
fn test_column_builder_handles_borderless_specs_table() {
|
||
let items = vec![
|
||
make_char("*", 458.1, 544.2, 8.0, 4.4),
|
||
make_char("Properties", 36.0, 538.6, 12.0, 53.1),
|
||
make_char("Conditions", 195.8, 538.6, 12.0, 55.0),
|
||
make_char("Method", 297.2, 538.6, 12.0, 39.4),
|
||
make_char("Typical values", 384.1, 538.6, 12.0, 74.0),
|
||
make_char("Units", 510.6, 538.6, 8.0, 17.9),
|
||
make_char("Rheology", 36.0, 508.3, 10.0, 40.6),
|
||
make_char("o", 209.8, 492.5, 6.5, 3.5),
|
||
make_char("Melt Flow Rate", 36.0, 488.0, 10.0, 65.2),
|
||
make_char("230 ", 190.4, 488.0, 10.0, 19.4),
|
||
make_char("C/2.16 kg", 213.3, 488.0, 10.0, 42.8),
|
||
make_char("ASTM D1238", 288.4, 488.0, 10.0, 56.8),
|
||
make_char("3.0 ", 416.4, 488.0, 10.0, 16.9),
|
||
make_char("g/10 min", 504.1, 488.0, 10.0, 39.5),
|
||
make_char("Mechanical", 36.0, 451.5, 10.0, 48.3),
|
||
make_char("Tensile Stress at Yield", 36.0, 431.3, 10.0, 96.7),
|
||
make_char("50 mm/min", 197.9, 431.3, 10.0, 50.8),
|
||
make_char("ASTM D638", 291.2, 431.3, 10.0, 51.3),
|
||
make_char("31 ", 417.9, 431.3, 10.0, 13.9),
|
||
make_char("MPa", 514.7, 431.3, 10.0, 18.4),
|
||
make_char("Elongation at Yield", 36.0, 403.0, 10.0, 82.2),
|
||
make_char("50 mm/min", 197.9, 403.0, 10.0, 50.8),
|
||
make_char("ASTM D638", 291.2, 403.0, 10.0, 51.3),
|
||
make_char("8 ", 420.6, 403.0, 10.0, 8.5),
|
||
make_char("%", 519.1, 403.0, 10.0, 9.7),
|
||
make_char("Flexural Modulus", 36.0, 374.6, 10.0, 74.0),
|
||
make_char("ASTM D790", 291.2, 374.6, 10.0, 51.3),
|
||
make_char("1400", 412.4, 374.6, 10.0, 21.8),
|
||
make_char("MPa", 514.7, 374.6, 10.0, 18.4),
|
||
];
|
||
|
||
let table = try_build_table_from_columns(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(
|
||
md.contains("|Properties|Conditions|Method|Typical values*|Units|"),
|
||
"{md}"
|
||
);
|
||
assert!(md.contains("|Mechanical|||||"), "{md}");
|
||
assert!(
|
||
md.contains("|Flexural Modulus||ASTM D790|1400|MPa|"),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_recovers_sectioned_specs_table() {
|
||
let items = vec![
|
||
make_char("Ordering Information", 69.0, 700.0, 9.0, 96.0),
|
||
make_char("Package Contents", 69.0, 680.0, 9.0, 82.0),
|
||
make_char(
|
||
"CCH Adapter Panel with 3 m pigtail; installation guide",
|
||
200.0,
|
||
680.0,
|
||
9.0,
|
||
245.0,
|
||
),
|
||
make_char("Units per Delivery", 69.0, 660.0, 9.0, 78.0),
|
||
make_char("1/1", 200.0, 660.0, 9.0, 18.0),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.contains("|Field|Value|"), "{md}");
|
||
assert!(md.contains("|Section|Ordering Information|"), "{md}");
|
||
assert!(
|
||
md.contains(
|
||
"|Package Contents|CCH Adapter Panel with 3 m pigtail; installation guide|"
|
||
),
|
||
"{md}"
|
||
);
|
||
assert!(md.contains("|Units per Delivery|1/1|"), "{md}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_preserves_two_column_header() {
|
||
let items = vec![
|
||
make_char("Media", 86.0, 700.0, 10.0, 36.0),
|
||
make_char("Options", 311.0, 700.0, 10.0, 44.0),
|
||
make_char("BACnet/IP (Annex J)", 86.0, 680.0, 10.0, 115.0),
|
||
make_char("Register as Foreign Device", 311.0, 680.0, 10.0, 138.0),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.starts_with("|Media|Options|"), "{md}");
|
||
assert!(
|
||
md.contains("|BACnet/IP (Annex J)|Register as Foreign Device|"),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_keeps_repeated_spec_sections() {
|
||
let items = vec![
|
||
make_char("1.33 DUAL VVT-i", 90.0, 700.0, 9.0, 82.0),
|
||
make_char("Engine Code", 90.0, 682.0, 9.0, 62.0),
|
||
make_char("1NR-FE", 406.0, 682.0, 9.0, 42.0),
|
||
make_char("Type", 90.0, 664.0, 9.0, 24.0),
|
||
make_char("Four cylinders in-line", 376.0, 664.0, 9.0, 104.0),
|
||
make_char("1.6 VALVEMATIC", 90.0, 636.0, 9.0, 78.0),
|
||
make_char("Engine Code", 90.0, 618.0, 9.0, 62.0),
|
||
make_char("1ZR-FAE", 404.0, 618.0, 9.0, 44.0),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.contains("|Section|1.33 DUAL VVT-i|"), "{md}");
|
||
assert!(md.contains("|Engine Code|1NR-FE|"), "{md}");
|
||
assert!(md.contains("|Section|1.6 VALVEMATIC|"), "{md}");
|
||
assert!(md.contains("|Engine Code|1ZR-FAE|"), "{md}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_merges_wrapped_value_continuations() {
|
||
let items = vec![
|
||
make_char("Storage", 80.0, 700.0, 9.0, 42.0),
|
||
make_char(
|
||
"Store under normal conditions in dry rooms.",
|
||
250.0,
|
||
700.0,
|
||
9.0,
|
||
210.0,
|
||
),
|
||
make_char(
|
||
"Protect from heat and humidity in the original packaging material.",
|
||
250.0,
|
||
686.0,
|
||
9.0,
|
||
315.0,
|
||
),
|
||
make_char("Shelf Life", 80.0, 668.0, 9.0, 48.0),
|
||
make_char(
|
||
"To obtain best performance use within 24 months.",
|
||
250.0,
|
||
668.0,
|
||
9.0,
|
||
255.0,
|
||
),
|
||
make_char("Technical Information", 80.0, 650.0, 9.0, 104.0),
|
||
make_char(
|
||
"The product is designed for repeated industrial use and long service life.",
|
||
250.0,
|
||
650.0,
|
||
9.0,
|
||
340.0,
|
||
),
|
||
make_char(
|
||
"Additional details are provided for compatibility and installation planning.",
|
||
250.0,
|
||
636.0,
|
||
9.0,
|
||
350.0,
|
||
),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.contains("|Field|Value|"), "{md}");
|
||
assert!(
|
||
md.contains(
|
||
"|Storage|Store under normal conditions in dry rooms. Protect from heat and humidity in the original packaging material.|"
|
||
),
|
||
"{md}"
|
||
);
|
||
assert!(
|
||
md.contains(
|
||
"|Technical Information|The product is designed for repeated industrial use and long service life. Additional details are provided for compatibility and installation planning.|"
|
||
),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_merges_wrapped_left_labels() {
|
||
let items = vec![
|
||
make_char("Title/Description", 76.0, 700.0, 9.0, 86.0),
|
||
make_char("Instances", 350.0, 700.0, 9.0, 48.0),
|
||
make_char("RE: Homes Gerald Ford lived in.", 76.0, 682.0, 9.0, 150.0),
|
||
make_char("Box 7", 350.0, 682.0, 9.0, 28.0),
|
||
make_char(
|
||
"Grand Rapids Remembers Gerald R. Ford issue. Grand",
|
||
76.0,
|
||
664.0,
|
||
9.0,
|
||
245.0,
|
||
),
|
||
make_char("Box 7", 350.0, 664.0, 9.0, 28.0),
|
||
make_char(
|
||
"Rapids Magazine, September 1987, p. 65.",
|
||
76.0,
|
||
650.0,
|
||
9.0,
|
||
196.0,
|
||
),
|
||
make_char(
|
||
"A Workhorse not a show horse: Gerald Ford remembered as humble.",
|
||
76.0,
|
||
632.0,
|
||
9.0,
|
||
290.0,
|
||
),
|
||
make_char("Box 7", 350.0, 632.0, 9.0, 28.0),
|
||
make_char(
|
||
"not flashy during his public life.",
|
||
76.0,
|
||
618.0,
|
||
9.0,
|
||
150.0,
|
||
),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.starts_with("|Title/Description|Instances|"), "{md}");
|
||
assert!(
|
||
md.contains(
|
||
"|Grand Rapids Remembers Gerald R. Ford issue. Grand Rapids Magazine, September 1987, p. 65.|Box 7|"
|
||
),
|
||
"{md}"
|
||
);
|
||
assert!(
|
||
md.contains(
|
||
"|A Workhorse not a show horse: Gerald Ford remembered as humble. not flashy during his public life.|Box 7|"
|
||
),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_allows_tiny_two_cell_region() {
|
||
let items = vec![
|
||
make_char(
|
||
"3M E-A-R Classic Small Earplug Uncorded",
|
||
80.0,
|
||
700.0,
|
||
9.0,
|
||
210.0,
|
||
),
|
||
make_char("02/05/24", 360.0, 700.0, 9.0, 42.0),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.contains("|Field|Value|"), "{md}");
|
||
assert!(
|
||
md.contains("|3M E-A-R Classic Small Earplug Uncorded|02/05/24|"),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_allows_single_wrapped_value_region() {
|
||
let items = vec![
|
||
make_char("Intrinsic Safety", 42.0, 174.0, 9.0, 60.0),
|
||
make_char(
|
||
"The powered air purifying respirator has been tested and classified",
|
||
311.0,
|
||
174.0,
|
||
9.0,
|
||
260.0,
|
||
),
|
||
make_char(
|
||
"for intrinsic safety in hazardous locations by Underwriters Laboratory",
|
||
311.0,
|
||
160.0,
|
||
9.0,
|
||
270.0,
|
||
),
|
||
make_char(
|
||
"for the following classes, divisions, groups, and temperature ratings.",
|
||
311.0,
|
||
146.0,
|
||
9.0,
|
||
275.0,
|
||
),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.contains("|Field|Value|"), "{md}");
|
||
assert!(
|
||
md.contains(
|
||
"|Intrinsic Safety|The powered air purifying respirator has been tested and classified for intrinsic safety in hazardous locations by Underwriters Laboratory for the following classes, divisions, groups, and temperature ratings.|"
|
||
),
|
||
"{md}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_rejects_leading_value_only_prose() {
|
||
let items = vec![
|
||
make_char(
|
||
"3rd Party Authorization documenting the reason for the hardship.",
|
||
260.0,
|
||
714.0,
|
||
9.0,
|
||
310.0,
|
||
),
|
||
make_char("Borrower", 80.0, 696.0, 9.0, 44.0),
|
||
make_char(
|
||
"Homeowner has adequate income to support modified payments.",
|
||
260.0,
|
||
696.0,
|
||
9.0,
|
||
300.0,
|
||
),
|
||
make_char("Servicer", 80.0, 678.0, 9.0, 42.0),
|
||
make_char(
|
||
"Collects documentation and reviews hardship status.",
|
||
260.0,
|
||
678.0,
|
||
9.0,
|
||
260.0,
|
||
),
|
||
];
|
||
|
||
assert!(try_build_key_value_table_from_rows(&items, 1).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_recovers_edgar_tag_value_rows() {
|
||
let items = vec![
|
||
make_char("<S>", 70.0, 700.0, 9.0, 18.0),
|
||
make_char("<C>", 240.0, 700.0, 9.0, 18.0),
|
||
make_char("<PERIOD-TYPE>", 70.0, 684.0, 9.0, 78.0),
|
||
make_char("3-MOS", 240.0, 684.0, 9.0, 30.0),
|
||
make_char("<FISCAL-YEAR-END>", 70.0, 668.0, 9.0, 104.0),
|
||
make_char("DEC-31-2000", 240.0, 668.0, 9.0, 66.0),
|
||
make_char("<PERIOD-END>", 70.0, 652.0, 9.0, 76.0),
|
||
make_char("MAR-31-2000", 240.0, 652.0, 9.0, 66.0),
|
||
make_char("<CASH>", 70.0, 636.0, 9.0, 38.0),
|
||
make_char("214", 240.0, 636.0, 9.0, 18.0),
|
||
make_char("</TABLE>", 70.0, 620.0, 9.0, 46.0),
|
||
];
|
||
|
||
let table = try_build_key_value_table_from_rows(&items, 1).unwrap();
|
||
let md = table_to_markdown(&table);
|
||
|
||
assert!(md.starts_with("|Field|Value|"), "{md}");
|
||
assert!(md.contains("|<S>|<C>|"), "{md}");
|
||
assert!(md.contains("|<FISCAL-YEAR-END>|DEC-31-2000|"), "{md}");
|
||
assert!(md.contains("|<CASH>|214|"), "{md}");
|
||
assert!(!md.contains("</TABLE>"), "{md}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_key_value_builder_rejects_split_prose() {
|
||
let items = vec![
|
||
make_char(
|
||
"This paragraph describes an operational process and continues without a field label.",
|
||
70.0,
|
||
700.0,
|
||
10.0,
|
||
350.0,
|
||
),
|
||
make_char(
|
||
"It was split only because the text wrapped across a wide line.",
|
||
455.0,
|
||
700.0,
|
||
10.0,
|
||
300.0,
|
||
),
|
||
make_char(
|
||
"Another sentence explains background context rather than a measurable property.",
|
||
70.0,
|
||
680.0,
|
||
10.0,
|
||
350.0,
|
||
),
|
||
make_char(
|
||
"The neighboring phrase is not a value and should not form a table.",
|
||
455.0,
|
||
680.0,
|
||
10.0,
|
||
300.0,
|
||
),
|
||
make_char(
|
||
"Finally, this narrative line keeps flowing with normal prose content.",
|
||
70.0,
|
||
660.0,
|
||
10.0,
|
||
350.0,
|
||
),
|
||
make_char(
|
||
"It has punctuation and complete sentences on both sides of the gap.",
|
||
455.0,
|
||
660.0,
|
||
10.0,
|
||
300.0,
|
||
),
|
||
];
|
||
|
||
assert!(try_build_key_value_table_from_rows(&items, 1).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_body_font_table_detected() {
|
||
let items = vec![
|
||
// Header row
|
||
make_item("Name", 100.0, 500.0, 10.0),
|
||
make_item("Price", 200.0, 500.0, 10.0),
|
||
make_item("Qty", 300.0, 500.0, 10.0),
|
||
make_item("Total", 400.0, 500.0, 10.0),
|
||
// Data row 1
|
||
make_item("Widget", 100.0, 480.0, 10.0),
|
||
make_item("5.00", 200.0, 480.0, 10.0),
|
||
make_item("10", 300.0, 480.0, 10.0),
|
||
make_item("50.00", 400.0, 480.0, 10.0),
|
||
// Data row 2
|
||
make_item("Gadget", 100.0, 460.0, 10.0),
|
||
make_item("12.50", 200.0, 460.0, 10.0),
|
||
make_item("4", 300.0, 460.0, 10.0),
|
||
make_item("50.00", 400.0, 460.0, 10.0),
|
||
// Data row 3
|
||
make_item("Gizmo", 100.0, 440.0, 10.0),
|
||
make_item("3.25", 200.0, 440.0, 10.0),
|
||
make_item("20", 300.0, 440.0, 10.0),
|
||
make_item("65.00", 400.0, 440.0, 10.0),
|
||
];
|
||
|
||
let tables = detect_tables(&items, 10.0, false);
|
||
assert_eq!(
|
||
tables.len(),
|
||
1,
|
||
"Body-font table should be detected by Pass 2"
|
||
);
|
||
assert_eq!(tables[0].columns.len(), 4);
|
||
assert!(tables[0].rows.len() >= 3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_paragraph_not_falsely_detected() {
|
||
let items = vec![
|
||
make_item(
|
||
"This is a paragraph of text that spans the full width",
|
||
72.0,
|
||
500.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"of the page and should not be detected as a table.",
|
||
72.0,
|
||
485.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"It continues for several lines with normal body text",
|
||
72.0,
|
||
470.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"that is left-aligned and has no columnar structure.",
|
||
72.0,
|
||
455.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"The paragraph keeps going with more content here.",
|
||
72.0,
|
||
440.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"And it has even more text on this line as well.",
|
||
72.0,
|
||
425.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"Finally the paragraph concludes with this last line.",
|
||
72.0,
|
||
410.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"One more line to have enough items for detection.",
|
||
72.0,
|
||
395.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"And another line of plain paragraph text content.",
|
||
72.0,
|
||
380.0,
|
||
10.0,
|
||
),
|
||
make_item(
|
||
"Last line of the paragraph ends here for the test.",
|
||
72.0,
|
||
365.0,
|
||
10.0,
|
||
),
|
||
];
|
||
|
||
let tables = detect_tables(&items, 10.0, false);
|
||
assert_eq!(
|
||
tables.len(),
|
||
0,
|
||
"Single-column paragraph must not be detected as table"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_word_level_paragraph_not_detected_as_table() {
|
||
let items = vec![
|
||
// Line 1
|
||
make_item("We", 72.0, 500.0, 10.0),
|
||
make_item("would", 95.0, 500.0, 10.0),
|
||
make_item("like", 145.0, 500.0, 10.0),
|
||
make_item("to", 180.0, 500.0, 10.0),
|
||
make_item("thank", 200.0, 500.0, 10.0),
|
||
make_item("all", 250.0, 500.0, 10.0),
|
||
make_item("the", 278.0, 500.0, 10.0),
|
||
make_item("practitioners", 305.0, 500.0, 10.0),
|
||
// Line 2
|
||
make_item("and", 72.0, 485.0, 10.0),
|
||
make_item("researchers", 105.0, 485.0, 10.0),
|
||
make_item("across", 185.0, 485.0, 10.0),
|
||
make_item("the", 232.0, 485.0, 10.0),
|
||
make_item("University", 260.0, 485.0, 10.0),
|
||
make_item("of", 335.0, 485.0, 10.0),
|
||
make_item("Leeds", 355.0, 485.0, 10.0),
|
||
// Line 3
|
||
make_item("Libraries", 72.0, 470.0, 10.0),
|
||
make_item("whose", 142.0, 470.0, 10.0),
|
||
make_item("contributions", 190.0, 470.0, 10.0),
|
||
make_item("made", 290.0, 470.0, 10.0),
|
||
make_item("this", 328.0, 470.0, 10.0),
|
||
make_item("report", 360.0, 470.0, 10.0),
|
||
// Line 4
|
||
make_item("possible", 72.0, 455.0, 10.0),
|
||
make_item("Both", 140.0, 455.0, 10.0),
|
||
make_item("constituent", 178.0, 455.0, 10.0),
|
||
make_item("studies", 262.0, 455.0, 10.0),
|
||
make_item("were", 315.0, 455.0, 10.0),
|
||
make_item("approved", 350.0, 455.0, 10.0),
|
||
];
|
||
|
||
let tables = detect_tables(&items, 10.0, false);
|
||
assert_eq!(
|
||
tables.len(),
|
||
0,
|
||
"Word-level paragraph text must not be detected as table"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_large_data_table_not_rejected() {
|
||
let mut items = Vec::new();
|
||
// Header row
|
||
items.push(make_item("Temp", 100.0, 800.0, 8.0));
|
||
items.push(make_item("Pressure", 200.0, 800.0, 8.0));
|
||
items.push(make_item("Volume", 300.0, 800.0, 8.0));
|
||
items.push(make_item("Enthalpy", 400.0, 800.0, 8.0));
|
||
|
||
// 49 data rows
|
||
for i in 1..50 {
|
||
let y = 800.0 - (i as f32 * 12.0);
|
||
items.push(make_item(&format!("{}", -40 + i * 2), 100.0, y, 8.0));
|
||
items.push(make_item(
|
||
&format!("{:.1}", 100.0 + i as f32 * 5.0),
|
||
200.0,
|
||
y,
|
||
8.0,
|
||
));
|
||
items.push(make_item(
|
||
&format!("{:.3}", 0.05 + i as f32 * 0.01),
|
||
300.0,
|
||
y,
|
||
8.0,
|
||
));
|
||
items.push(make_item(
|
||
&format!("{:.1}", 150.0 + i as f32 * 2.5),
|
||
400.0,
|
||
y,
|
||
8.0,
|
||
));
|
||
}
|
||
|
||
let tables = detect_tables(&items, 10.0, false);
|
||
assert_eq!(tables.len(), 1, "Large data table should not be rejected");
|
||
assert!(
|
||
tables[0].rows.len() >= 40,
|
||
"Large table should preserve most rows, got {}",
|
||
tables[0].rows.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_uniform_spacing_rows_not_merged() {
|
||
let companies = [
|
||
"SC Priority LLC",
|
||
"Craft Roofing Co",
|
||
"Alpha Roofing Inc",
|
||
"Beta Construction",
|
||
"Gamma Builders",
|
||
"Delta Roofing",
|
||
"Epsilon Contractors",
|
||
];
|
||
|
||
let mut items = Vec::new();
|
||
|
||
// Header row at y=800
|
||
items.push(make_item("No.", 50.0, 800.0, 8.0));
|
||
items.push(make_item("Company", 120.0, 800.0, 8.0));
|
||
items.push(make_item("Bid Amount", 350.0, 800.0, 8.0));
|
||
|
||
// 7 data rows, each 10pt apart (exactly the old threshold)
|
||
for (i, company) in companies.iter().enumerate() {
|
||
let y = 790.0 - (i as f32 * 10.0);
|
||
items.push(make_item(&format!("{}", i + 1), 50.0, y, 8.0));
|
||
items.push(make_item(company, 120.0, y, 8.0));
|
||
items.push(make_item(&format!("${},000", 100 + i * 10), 350.0, y, 8.0));
|
||
}
|
||
|
||
let tables = detect_tables(&items, 12.0, false);
|
||
assert_eq!(tables.len(), 1, "Should detect one table");
|
||
assert_eq!(
|
||
tables[0].rows.len(),
|
||
8,
|
||
"Each company must be on its own row, got {} rows instead of 8",
|
||
tables[0].rows.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_merge_adjacent_items() {
|
||
let items = vec![
|
||
make_char("J", 310.0, 532.0, 13.3, 4.0),
|
||
make_char("u", 314.0, 532.0, 13.3, 4.4),
|
||
make_char("n", 318.4, 532.0, 13.3, 4.4),
|
||
make_char("e", 322.8, 532.0, 13.3, 3.5),
|
||
// word gap (2pt)
|
||
make_char("3", 328.3, 532.0, 13.3, 4.0),
|
||
make_char("0", 332.3, 532.0, 13.3, 4.0),
|
||
make_char(",", 336.3, 532.0, 13.3, 2.0),
|
||
// large column gap (40pt)
|
||
make_char("M", 378.3, 532.0, 13.3, 7.5),
|
||
make_char("a", 385.8, 532.0, 13.3, 4.0),
|
||
make_char("r", 389.8, 532.0, 13.3, 3.5),
|
||
];
|
||
|
||
let (merged, map) = detect_heuristic::merge_adjacent_items(&items);
|
||
|
||
assert_eq!(
|
||
merged.len(),
|
||
2,
|
||
"Should produce 2 merged items, got {}",
|
||
merged.len()
|
||
);
|
||
assert!(
|
||
merged[0].text.contains("June") && merged[0].text.contains("30"),
|
||
"First merged item should be 'June 30,' but got {:?}",
|
||
merged[0].text
|
||
);
|
||
assert_eq!(merged[1].text, "Mar");
|
||
|
||
assert_eq!(
|
||
map[0].len(),
|
||
7,
|
||
"First merged item should map to 7 original chars"
|
||
);
|
||
assert_eq!(
|
||
map[1].len(),
|
||
3,
|
||
"Second merged item should map to 3 original chars"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_per_char_financial_table_detected() {
|
||
let mut items = Vec::new();
|
||
|
||
// Per-character header row
|
||
for (i, c) in "Col1".chars().enumerate() {
|
||
items.push(make_char(
|
||
&c.to_string(),
|
||
300.0 + i as f32 * 5.0,
|
||
540.0,
|
||
13.0,
|
||
5.0,
|
||
));
|
||
}
|
||
for (i, c) in "Col2".chars().enumerate() {
|
||
items.push(make_char(
|
||
&c.to_string(),
|
||
400.0 + i as f32 * 5.0,
|
||
540.0,
|
||
13.0,
|
||
5.0,
|
||
));
|
||
}
|
||
for (i, c) in "Col3".chars().enumerate() {
|
||
items.push(make_char(
|
||
&c.to_string(),
|
||
500.0 + i as f32 * 5.0,
|
||
540.0,
|
||
13.0,
|
||
5.0,
|
||
));
|
||
}
|
||
|
||
// Data rows with multi-word items
|
||
let data = [
|
||
("Revenue", 520.0, "1,000", "2,000", "3,000"),
|
||
("Expenses", 505.0, "500", "800", "1,200"),
|
||
("Net Income", 490.0, "500", "1,200", "1,800"),
|
||
("Taxes", 475.0, "100", "200", "300"),
|
||
];
|
||
|
||
for (label, y, v1, v2, v3) in &data {
|
||
items.push(make_item(label, 50.0, *y, 12.0));
|
||
items.push(make_item(v1, 310.0, *y, 12.0));
|
||
items.push(make_item(v2, 410.0, *y, 12.0));
|
||
items.push(make_item(v3, 510.0, *y, 12.0));
|
||
}
|
||
|
||
let tables = detect_tables(&items, 13.0, false);
|
||
assert!(
|
||
!tables.is_empty(),
|
||
"Per-character financial table should be detected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_short_subheader_not_merged_as_continuation() {
|
||
// Simulate a table with section sub-headers (like month names) that have
|
||
// an empty first column and short text in a single other column.
|
||
// These should NOT be merged into the previous row as continuation text.
|
||
let table = Table {
|
||
columns: vec![50.0, 150.0, 300.0, 450.0],
|
||
rows: vec![500.0, 480.0, 460.0, 440.0, 420.0, 400.0],
|
||
cells: vec![
|
||
// Header row
|
||
vec!["No.".into(), "Date".into(), "Title".into(), "Amount".into()],
|
||
// Sub-header: month name in 1 column, rest empty
|
||
vec!["".into(), "JAN".into(), "".into(), "".into()],
|
||
// Data row
|
||
vec!["1".into(), "8/1".into(), "Item A".into(), "100".into()],
|
||
vec!["2".into(), "15/1".into(), "Item B".into(), "200".into()],
|
||
// Another sub-header
|
||
vec!["".into(), "FEB".into(), "".into(), "".into()],
|
||
// Data row
|
||
vec!["3".into(), "5/2".into(), "Item C".into(), "300".into()],
|
||
],
|
||
item_indices: vec![],
|
||
kind: TableKind::Data,
|
||
};
|
||
|
||
let md = table_to_markdown(&table);
|
||
// JAN and FEB should be on their own rows, not merged into adjacent rows
|
||
assert!(
|
||
md.contains("|JAN|"),
|
||
"JAN should be on its own row, got:\n{}",
|
||
md
|
||
);
|
||
assert!(
|
||
md.contains("|FEB|"),
|
||
"FEB should be on its own row, got:\n{}",
|
||
md
|
||
);
|
||
// Verify they're NOT merged into data rows
|
||
assert!(
|
||
!md.contains("15/1 FEB"),
|
||
"FEB should not be merged into data row, got:\n{}",
|
||
md
|
||
);
|
||
assert!(
|
||
!md.contains("8/1 JAN"),
|
||
"JAN should not be merged into data row, got:\n{}",
|
||
md
|
||
);
|
||
}
|
||
|
||
// ── Rect-guided table builder tests ─────────────────────────────
|
||
|
||
#[test]
|
||
fn rect_guided_basic() {
|
||
// 7 column boundaries (like days of week), items "1"-"7" at matching X
|
||
let col_xs: Vec<f32> = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
|
||
let cluster_rects: Vec<(f32, f32, f32, f32)> =
|
||
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
|
||
let items: Vec<TextItem> = (1..=7)
|
||
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 110.0, 7.0))
|
||
.collect();
|
||
|
||
let table = try_build_rect_guided_table(&items, &cluster_rects);
|
||
assert!(table.is_some(), "Should produce a table from 7 columns");
|
||
let table = table.unwrap();
|
||
assert_eq!(table.columns.len(), 7);
|
||
assert_eq!(table.rows.len(), 1);
|
||
for (i, cell) in table.cells[0].iter().enumerate() {
|
||
assert_eq!(cell, &(i + 1).to_string());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rect_guided_split_merged() {
|
||
// One merged item "10 11 12" spanning 3 column boundaries
|
||
let col_xs: Vec<f32> = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
|
||
let cluster_rects: Vec<(f32, f32, f32, f32)> =
|
||
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
|
||
// Single items for cols 0-3, merged "4 5 6" spanning cols 4-6
|
||
let mut items = vec![
|
||
make_item("1", col_xs[0] + 2.0, 110.0, 7.0),
|
||
make_item("2", col_xs[1] + 2.0, 110.0, 7.0),
|
||
make_item("3", col_xs[2] + 2.0, 110.0, 7.0),
|
||
];
|
||
// Merged item spanning from col 3 to col 5 (width covers 3 columns)
|
||
let mut merged = make_item("4 5 6", col_xs[3], 110.0, 7.0);
|
||
merged.width = 3.0 * 30.0; // spans 3 column widths
|
||
items.push(merged);
|
||
|
||
let table = try_build_rect_guided_table(&items, &cluster_rects);
|
||
assert!(table.is_some(), "Should handle merged number items");
|
||
let table = table.unwrap();
|
||
// Check that "4", "5", "6" ended up in separate columns
|
||
let row = &table.cells[0];
|
||
assert!(
|
||
row.contains(&"4".to_string()),
|
||
"Should have '4' in a cell: {:?}",
|
||
row
|
||
);
|
||
assert!(
|
||
row.contains(&"5".to_string()),
|
||
"Should have '5' in a cell: {:?}",
|
||
row
|
||
);
|
||
assert!(
|
||
row.contains(&"6".to_string()),
|
||
"Should have '6' in a cell: {:?}",
|
||
row
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rect_guided_with_annotations() {
|
||
// Day numbers on one row, annotations on a second row
|
||
let col_xs: Vec<f32> = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
|
||
let cluster_rects: Vec<(f32, f32, f32, f32)> =
|
||
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
|
||
let mut items: Vec<TextItem> = (1..=7)
|
||
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 115.0, 7.0))
|
||
.collect();
|
||
// Add annotation "Holiday" under day 4
|
||
items.push(make_item("Holiday", col_xs[3] + 2.0, 105.0, 6.0));
|
||
|
||
let table = try_build_rect_guided_table(&items, &cluster_rects);
|
||
assert!(table.is_some());
|
||
let table = table.unwrap();
|
||
assert_eq!(
|
||
table.rows.len(),
|
||
2,
|
||
"Should have 2 rows (days + annotations)"
|
||
);
|
||
// The annotation row should have "Holiday" in column 3
|
||
assert_eq!(table.cells[1][3], "Holiday");
|
||
}
|
||
|
||
#[test]
|
||
fn rect_guided_too_few_columns() {
|
||
// Only 3 column boundaries → should return None (need ≥ 5)
|
||
let cluster_rects = vec![
|
||
(50.0, 100.0, 28.0, 15.0),
|
||
(80.0, 100.0, 28.0, 15.0),
|
||
(110.0, 100.0, 28.0, 15.0),
|
||
];
|
||
let items = vec![
|
||
make_item("A", 52.0, 110.0, 7.0),
|
||
make_item("B", 82.0, 110.0, 7.0),
|
||
make_item("C", 112.0, 110.0, 7.0),
|
||
];
|
||
let table = try_build_rect_guided_table(&items, &cluster_rects);
|
||
assert!(table.is_none(), "Should reject fewer than 5 columns");
|
||
}
|
||
|
||
#[test]
|
||
fn split_merged_numbers_single_token() {
|
||
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
|
||
let item = make_item("Holiday", 52.0, 110.0, 7.0);
|
||
let result = split_merged_numbers(&item, &col_boundaries);
|
||
assert_eq!(result.len(), 1, "Single-token item should not be split");
|
||
assert_eq!(result[0].text, "Holiday");
|
||
}
|
||
|
||
#[test]
|
||
fn split_leading_numbers_with_annotation() {
|
||
// "11 Veterans Day" → "11" split off, "Veterans Day" as annotation
|
||
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
|
||
let mut item = make_item("11 Veterans Day", 110.0, 110.0, 7.0);
|
||
item.width = 90.0; // spans 3 tokens
|
||
let result = split_merged_numbers(&item, &col_boundaries);
|
||
assert_eq!(result.len(), 2, "Should split into number + annotation");
|
||
assert_eq!(result[0].text, "11");
|
||
assert_eq!(result[1].text, "Veterans Day");
|
||
}
|
||
|
||
#[test]
|
||
fn split_multiple_leading_numbers_with_annotation() {
|
||
// "24 25 Memorial Day" → "24", "25" split, "Memorial Day" trails
|
||
let col_xs: Vec<f32> = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
|
||
let mut item = make_item("24 25 Memorial Day", col_xs[3], 110.0, 7.0);
|
||
item.width = 4.0 * 30.0; // spans 4 tokens
|
||
let result = split_merged_numbers(&item, &col_xs);
|
||
assert_eq!(result.len(), 3, "Should split into 2 numbers + annotation");
|
||
assert_eq!(result[0].text, "24");
|
||
assert_eq!(result[1].text, "25");
|
||
assert_eq!(result[2].text, "Memorial Day");
|
||
}
|
||
|
||
#[test]
|
||
fn split_no_leading_numbers() {
|
||
// "Memorial Day" → no leading numeric, returned as-is
|
||
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
|
||
let item = make_item("Memorial Day", 52.0, 110.0, 7.0);
|
||
let result = split_merged_numbers(&item, &col_boundaries);
|
||
assert_eq!(result.len(), 1);
|
||
assert_eq!(result[0].text, "Memorial Day");
|
||
}
|
||
|
||
#[test]
|
||
fn rect_guided_tilde_cleanup() {
|
||
// Items with tilde noise should have it stripped
|
||
let col_xs: Vec<f32> = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
|
||
let cluster_rects: Vec<(f32, f32, f32, f32)> =
|
||
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
|
||
let mut items: Vec<TextItem> = (1..=7)
|
||
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 110.0, 7.0))
|
||
.collect();
|
||
// Day 7 has tilde-leader legend text bleeding in
|
||
items[6] = make_item("7 ~~~~~~~ Legend text here", col_xs[6] + 2.0, 110.0, 7.0);
|
||
|
||
let table = try_build_rect_guided_table(&items, &cluster_rects).unwrap();
|
||
assert_eq!(table.cells[0][6], "7", "Tilde noise should be stripped");
|
||
}
|
||
}
|