fix(tables): Expand consolidated financial value items for table detection
Dense financial tables (balance sheets, income statements) emit each data row as 2 TextItems — a label and a single wide item containing all column values. The table detector requires 3+ X-position clusters per row, so these 2-item rows were missed. Pre-expand qualifying wide numeric items into individual sub-items before detection runs, then map indices back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a38f26abbf
commit
4796ccd634
+142
@@ -26,12 +26,146 @@ pub struct Table {
|
||||
pub item_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Check if a whitespace-separated token looks like a financial number.
|
||||
/// Must contain at least one digit; all chars must be `0-9 , . ( ) - + %`.
|
||||
fn is_numeric_token(tok: &str) -> bool {
|
||||
if tok.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut has_digit = false;
|
||||
for c in tok.chars() {
|
||||
match c {
|
||||
'0'..='9' => has_digit = true,
|
||||
',' | '.' | '(' | ')' | '-' | '+' | '%' => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
has_digit
|
||||
}
|
||||
|
||||
/// Check for em-dash, en-dash, or minus used as nil marker in financial tables.
|
||||
fn is_dash_token(tok: &str) -> bool {
|
||||
matches!(tok, "\u{2014}" | "\u{2013}" | "-" | "\u{2012}")
|
||||
}
|
||||
|
||||
/// Returns true if text contains 2+ consecutive alphabetic characters.
|
||||
/// Fast early-exit to reject items like `"Land $ 778,177"`.
|
||||
fn has_alphabetic_words(text: &str) -> bool {
|
||||
let mut consecutive = 0u32;
|
||||
for c in text.chars() {
|
||||
if c.is_alphabetic() {
|
||||
consecutive += 1;
|
||||
if consecutive >= 2 {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Splits text by whitespace, then groups tokens into financial values.
|
||||
/// - `$` + numeric token → one value (`"$ 5,147,649"`)
|
||||
/// - standalone numeric token → one value (`"114,167"`)
|
||||
/// - dash token → one value (`"—"`)
|
||||
/// - any unrecognized token → return `None` (not a pure-value item)
|
||||
fn tokenize_financial_values(text: &str) -> Option<Vec<String>> {
|
||||
let tokens: Vec<&str> = text.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut values = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let tok = tokens[i];
|
||||
if tok == "$" {
|
||||
// Dollar sign followed by a numeric token → one value
|
||||
if i + 1 < tokens.len() && is_numeric_token(tokens[i + 1]) {
|
||||
values.push(format!("{} {}", tok, tokens[i + 1]));
|
||||
i += 2;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else if is_numeric_token(tok) || is_dash_token(tok) {
|
||||
values.push(tok.to_string());
|
||||
i += 1;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to split a consolidated financial item into individual sub-items.
|
||||
/// Criteria: width > font_size × 20, no alphabetic words, tokenization yields 3+ values.
|
||||
/// Creates sub-items with evenly-distributed X positions across the original item's span.
|
||||
fn try_split_financial_item(item: &TextItem) -> Option<Vec<TextItem>> {
|
||||
if item.width <= item.font_size * 20.0 {
|
||||
return None;
|
||||
}
|
||||
let text = &item.text;
|
||||
if has_alphabetic_words(text) {
|
||||
return None;
|
||||
}
|
||||
let values = tokenize_financial_values(text)?;
|
||||
if values.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let n = values.len() as f32;
|
||||
let spacing = item.width / n;
|
||||
let sub_width = spacing * 0.9;
|
||||
let mut sub_items = Vec::with_capacity(values.len());
|
||||
for (i, val) in values.iter().enumerate() {
|
||||
sub_items.push(TextItem {
|
||||
text: val.clone(),
|
||||
x: item.x + spacing * i as f32 + spacing * 0.5,
|
||||
y: item.y,
|
||||
width: sub_width,
|
||||
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,
|
||||
item_type: item.item_type.clone(),
|
||||
});
|
||||
}
|
||||
Some(sub_items)
|
||||
}
|
||||
|
||||
/// Iterates all items, expanding qualifying consolidated financial items.
|
||||
/// Returns `(expanded_items, index_map)` where `index_map[expanded_idx] = original_idx`.
|
||||
fn expand_consolidated_items(items: &[TextItem]) -> (Vec<TextItem>, Vec<usize>) {
|
||||
let mut expanded = Vec::with_capacity(items.len());
|
||||
let mut index_map = Vec::with_capacity(items.len());
|
||||
for (orig_idx, item) in items.iter().enumerate() {
|
||||
if let Some(sub_items) = try_split_financial_item(item) {
|
||||
for sub in sub_items {
|
||||
expanded.push(sub);
|
||||
index_map.push(orig_idx);
|
||||
}
|
||||
} else {
|
||||
expanded.push(item.clone());
|
||||
index_map.push(orig_idx);
|
||||
}
|
||||
}
|
||||
(expanded, index_map)
|
||||
}
|
||||
|
||||
/// Detect tables in a set of text items from a single page
|
||||
pub fn detect_tables(items: &[TextItem], base_font_size: f32) -> Vec<Table> {
|
||||
if items.len() < 6 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let (expanded_items, index_map) = expand_consolidated_items(items);
|
||||
let items = &expanded_items[..]; // shadow parameter — all detection uses expanded items
|
||||
|
||||
let mut tables = Vec::new();
|
||||
let mut claimed_indices = std::collections::HashSet::new();
|
||||
|
||||
@@ -105,6 +239,14 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32) -> Vec<Table> {
|
||||
}
|
||||
}
|
||||
|
||||
// Map expanded indices back to original item indices
|
||||
for table in &mut tables {
|
||||
let original_indices: std::collections::HashSet<usize> =
|
||||
table.item_indices.iter().map(|&i| index_map[i]).collect();
|
||||
table.item_indices = original_indices.into_iter().collect();
|
||||
table.item_indices.sort_unstable();
|
||||
}
|
||||
|
||||
tables
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user