Skip to content

Commit 46c174d

Browse files
RoyLinRoyLin
authored andcommitted
feat(doc): add position-aware PDF extraction with table detection
Phase 2: Improve PDF text extraction with lopdf for position-aware text ordering and table detection. Changes: - Enhance parse_pdf_with_lopdf to preserve row structure and detect potential table rows based on consistent column alignment - Add [_TABLE_ROW_] markers for downstream table detection - Add process_table_row_markers to parse marked table rows into structured table blocks with extraction method "lopdf-position-aware" - Add detect_table_row for position-based table row detection - Add PositionedTextItem struct for tracking text positions - Improve row grouping with y-coordinate scaling for tolerance
1 parent 6cc847f commit 46c174d

10 files changed

Lines changed: 296 additions & 15 deletions

File tree

core/src/composite_document_parser/extract/text.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,28 @@ pub(super) fn fallback_text_blocks(text: &str) -> Vec<DocumentBlock> {
9090
}
9191

9292
pub(super) fn paged_text_blocks(text: &str, default_kind: DocumentBlockKind) -> Vec<DocumentBlock> {
93+
// First, handle page breaks from lopdf extraction
94+
let pages = text
95+
.split("[_PAGE_BREAK_]")
96+
.map(str::trim)
97+
.filter(|s| !s.is_empty())
98+
.collect::<Vec<_>>();
99+
100+
if pages.len() > 1 {
101+
// Multi-page document: process each page separately
102+
return pages
103+
.into_iter()
104+
.flat_map(|page| paged_text_blocks(page, default_kind.clone()))
105+
.collect();
106+
}
107+
108+
// Single page or after page split: handle table row markers
109+
let blocks = process_table_row_markers(text, default_kind.clone());
110+
if !blocks.is_empty() {
111+
return blocks;
112+
}
113+
114+
// Fall back to regular chunk processing
93115
split_paged_chunks(text)
94116
.into_iter()
95117
.flat_map(|chunk| {
@@ -106,6 +128,74 @@ pub(super) fn paged_text_blocks(text: &str, default_kind: DocumentBlockKind) ->
106128
.collect()
107129
}
108130

131+
/// Process text with [_TABLE_ROW_] markers from lopdf position-aware extraction.
132+
///
133+
/// Groups consecutive table rows into a single table block.
134+
/// Returns empty Vec if no [_TABLE_ROW_] markers were found, allowing
135+
/// normal multi-column layout detection to proceed.
136+
fn process_table_row_markers(text: &str, default_kind: DocumentBlockKind) -> Vec<DocumentBlock> {
137+
// Fast path: if no table row markers exist, return empty to use normal processing
138+
if !text.contains("[_TABLE_ROW_]") {
139+
return Vec::new();
140+
}
141+
142+
let mut result = Vec::new();
143+
let mut current_text = String::new();
144+
let mut table_rows: Vec<String> = Vec::new();
145+
146+
for line in text.lines() {
147+
let trimmed = line.trim();
148+
if trimmed.starts_with("[_TABLE_ROW_]") && trimmed.ends_with("[_TABLE_ROW_]") {
149+
// Extract the actual row content
150+
let row_content = trimmed
151+
.strip_prefix("[_TABLE_ROW_]")
152+
.and_then(|s| s.strip_suffix("[_TABLE_ROW_]"))
153+
.unwrap_or(trimmed)
154+
.trim();
155+
156+
if !row_content.is_empty() {
157+
table_rows.push(row_content.to_string());
158+
}
159+
} else if !table_rows.is_empty() {
160+
// End of table rows - flush the accumulated table
161+
if let Some(table_block) = build_table_from_rows(&table_rows) {
162+
result.push(table_block);
163+
}
164+
table_rows.clear();
165+
// Also flush any pending text
166+
if !current_text.trim().is_empty() {
167+
result.extend(chunk_to_blocks(&current_text.trim(), default_kind.clone()));
168+
current_text.clear();
169+
}
170+
// Process this non-table line
171+
if !trimmed.is_empty() {
172+
current_text.push_str(trimmed);
173+
current_text.push('\n');
174+
}
175+
} else {
176+
// Normal text, accumulate
177+
if !trimmed.is_empty() {
178+
current_text.push_str(trimmed);
179+
current_text.push('\n');
180+
}
181+
}
182+
}
183+
184+
// Flush remaining table rows
185+
if !table_rows.is_empty() {
186+
if let Some(table_block) = build_table_from_rows(&table_rows) {
187+
result.push(table_block);
188+
}
189+
}
190+
191+
// Flush remaining text
192+
if !current_text.trim().is_empty() {
193+
result.extend(chunk_to_blocks(&current_text.trim(), default_kind));
194+
}
195+
196+
result
197+
}
198+
109199
pub(super) fn text_blocks(text: &str, default_kind: DocumentBlockKind) -> Vec<DocumentBlock> {
110200
let normalized = super::normalize_text(text);
111201
normalized
@@ -783,3 +873,56 @@ pub(super) fn split_aligned_columns_with_gaps(line: &str) -> AlignedTextRow {
783873
}
784874
}
785875
}
876+
877+
/// Build a table DocumentBlock from parsed table rows.
878+
fn build_table_from_rows(rows: &[String]) -> Option<DocumentBlock> {
879+
if rows.len() < 2 {
880+
return None;
881+
}
882+
883+
// Try to parse each row as tab or comma-separated
884+
let mut table_rows: Vec<Vec<String>> = Vec::new();
885+
for row in rows {
886+
let cells: Vec<String> = if row.contains('\t') {
887+
row.split('\t')
888+
.map(str::trim)
889+
.filter(|s| !s.is_empty())
890+
.map(String::from)
891+
.collect()
892+
} else {
893+
// Try comma separation
894+
row.split(',')
895+
.map(str::trim)
896+
.filter(|s| !s.is_empty())
897+
.map(String::from)
898+
.collect()
899+
};
900+
901+
if cells.len() >= 2 {
902+
table_rows.push(cells);
903+
}
904+
}
905+
906+
if table_rows.len() < 2 {
907+
return None;
908+
}
909+
910+
// Verify consistent column count
911+
let col_count = table_rows[0].len();
912+
if !table_rows.iter().all(|r| r.len() == col_count) {
913+
return None;
914+
}
915+
916+
let row_count = table_rows.len();
917+
let column_count = col_count;
918+
let content = super::table_text_from_cells(&table_rows);
919+
let payload = super::table_structured_payload(&table_rows)?;
920+
921+
Some(
922+
DocumentBlock::new(DocumentBlockKind::Table, Some("pdf-table"), content)
923+
.with_attribute("row_count", row_count.to_string())
924+
.with_attribute("column_count", column_count.to_string())
925+
.with_attribute("extraction", "lopdf-position-aware")
926+
.with_structured_payload(payload),
927+
)
928+
}

core/src/composite_document_parser/ocr/mod.rs

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,17 @@ fn parse_pdf(path: &Path) -> Result<String> {
374374
.with_context(|| format!("failed to extract text from PDF {}", path.display()))
375375
}
376376

377+
/// Text item with position for table detection.
378+
#[derive(Debug, Clone)]
379+
struct PositionedTextItem {
380+
page: usize,
381+
y: f32,
382+
x: f32,
383+
text: String,
384+
/// Y coordinate scaled to integer for grouping
385+
y_scaled: i32,
386+
}
387+
377388
/// Extract text from PDF using lopdf for better position-aware extraction.
378389
///
379390
/// This provides improved text ordering (top-to-bottom, left-to-right) compared to
@@ -390,10 +401,8 @@ fn parse_pdf_with_lopdf(path: &Path) -> Result<String> {
390401
anyhow::bail!("PDF has no pages: {}", path.display());
391402
}
392403

393-
// Track text with positions: (page, y_coord_inverted, x_coord, text)
394-
// Using BTreeMap for automatic sorting
395-
// Scale coordinates to integers to work around f32 not implementing Ord
396-
let mut all_text: BTreeMap<(usize, i32, i32), String> = BTreeMap::new();
404+
// Collect all text items with positions
405+
let mut all_items: Vec<PositionedTextItem> = Vec::new();
397406

398407
// Collect all pages and sort by page number
399408
let mut page_list: Vec<(u32, lopdf::ObjectId)> = pages.iter().map(|(&k, &v)| (k, v)).collect();
@@ -406,38 +415,135 @@ fn parse_pdf_with_lopdf(path: &Path) -> Result<String> {
406415
let text_items = extract_text_from_content_stream(&contents, page_num as usize);
407416
for (y, x, text) in text_items {
408417
if !text.trim().is_empty() {
409-
// Invert y so higher values come first (top of page)
410-
// Scale by 1000 to preserve decimal precision as integers
411-
let y_key = -(y * 1000.0) as i32;
412-
let x_key = (x * 100.0) as i32;
413-
all_text.insert((page_num as usize, y_key, x_key), text);
418+
// Scale y by 1000 to group rows (tolerance of ~1 pixel for 72dpi)
419+
let y_scaled = (y * 1000.0) as i32;
420+
all_items.push(PositionedTextItem {
421+
page: page_num as usize,
422+
y,
423+
x,
424+
text,
425+
y_scaled,
426+
});
414427
}
415428
}
416429
}
417430
}
418431

419-
// Build output with page markers
432+
if all_items.is_empty() {
433+
anyhow::bail!("PDF has no extractable text: {}", path.display());
434+
}
435+
436+
// Build output with page markers and row structure preserved
420437
let mut output = String::new();
421438
let mut current_page = 0usize;
422439

423-
for ((page_num, _, _), text) in all_text {
440+
// Group items by page and y position (row grouping)
441+
let mut page_groups: BTreeMap<usize, BTreeMap<i32, Vec<PositionedTextItem>>> = BTreeMap::new();
442+
for item in all_items {
443+
page_groups
444+
.entry(item.page)
445+
.or_default()
446+
.entry(item.y_scaled)
447+
.or_default()
448+
.push(item);
449+
}
450+
451+
for (page_num, y_groups) in page_groups {
424452
// Add page break marker when page changes
425453
if page_num != current_page {
426454
if current_page > 0 {
427455
output.push_str("\n\n[_PAGE_BREAK_]\n\n");
428456
}
429457
current_page = page_num;
430458
}
431-
// Preserve position hints for alignment detection
432-
if !output.is_empty() && !output.ends_with('\n') {
433-
output.push(' ');
459+
460+
// Process rows in order (BTreeMap is sorted by y_scaled)
461+
for (_y_key, mut items) in y_groups {
462+
// Sort items within row by x position
463+
items.sort_by(|a, b| {
464+
let a_x = (a.x * 100.0) as i32;
465+
let b_x = (b.x * 100.0) as i32;
466+
let x_cmp = a_x.cmp(&b_x);
467+
x_cmp.then_with(|| a.text.cmp(&b.text))
468+
});
469+
470+
// Join items on same row
471+
let row_text = items
472+
.iter()
473+
.map(|item| item.text.as_str())
474+
.collect::<Vec<_>>()
475+
.join(" ");
476+
477+
// Detect potential table rows by checking:
478+
// 1. Row has multiple cell-like fragments (separated by tabs or consistent spacing)
479+
// 2. Row looks like it could be a table row (multiple aligned segments)
480+
let is_potential_table_row = detect_table_row(&items);
481+
482+
if is_potential_table_row {
483+
// Mark as potential table row for downstream table detection
484+
output.push_str(&format!("[_TABLE_ROW_]{}[_TABLE_ROW_]", row_text));
485+
} else {
486+
output.push_str(&row_text);
487+
}
488+
output.push('\n');
434489
}
435-
output.push_str(&text);
436490
}
437491

438492
Ok(output)
439493
}
440494

495+
/// Detect if a row of text items might be part of a table.
496+
///
497+
/// Looks for patterns like:
498+
/// - Multiple tab-separated cells
499+
/// - Multiple fragments with consistent horizontal spacing (column alignment)
500+
/// - Fragments that look like table cells (short text, consistent width)
501+
fn detect_table_row(items: &[PositionedTextItem]) -> bool {
502+
if items.len() < 2 {
503+
return false;
504+
}
505+
506+
// Check for explicit tab separators
507+
let tab_count = items.iter().filter(|i| i.text.contains('\t')).count();
508+
if tab_count > 0 {
509+
return true;
510+
}
511+
512+
// Check for consistent spacing between items (column alignment indicator)
513+
if items.len() >= 2 {
514+
let mut gaps: Vec<i32> = Vec::new();
515+
for i in 1..items.len() {
516+
let gap = ((items[i].x - items[i - 1].x) * 100.0) as i32;
517+
gaps.push(gap);
518+
}
519+
520+
// If we have 3+ items and gaps are somewhat consistent, might be a table
521+
if items.len() >= 3 {
522+
let avg_gap: i32 = gaps.iter().sum::<i32>() / gaps.len() as i32;
523+
let variance: i32 =
524+
gaps.iter().map(|g| (g - avg_gap).abs()).sum::<i32>() / gaps.len() as i32;
525+
// Low variance indicates column alignment
526+
if variance < avg_gap / 4 && avg_gap > 50 {
527+
return true;
528+
}
529+
}
530+
}
531+
532+
// Check if items look like table cells (short, similar length)
533+
let all_short = items
534+
.iter()
535+
.all(|i| i.text.len() <= 30 && !i.text.contains(' '));
536+
let similar_length = items.len() >= 2 && {
537+
let avg_len = items.iter().map(|i| i.text.len() as i32).sum::<i32>() / items.len() as i32;
538+
items.iter().all(|i| {
539+
let len = i.text.len() as i32;
540+
(len - avg_len).abs() < avg_len / 2
541+
})
542+
};
543+
544+
all_short && similar_length && items.len() >= 3
545+
}
546+
441547
/// Extract text items from PDF content stream with position information.
442548
///
443549
/// Returns vector of (y_position, x_position, text) tuples.

core/src/composite_document_parser/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2863,6 +2863,7 @@ fn extract_document_runtime_metadata_parses_ocr_block() {
28632863
),
28642864
],
28652865
metadata: None,
2866+
..Default::default()
28662867
};
28672868

28682869
let metadata = extract_document_runtime_metadata(&doc).unwrap();

core/src/document/consume.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ mod tests {
491491
DocumentBlock::new(DocumentBlockKind::EmailHeader, Some("Subject"), "Hello"),
492492
],
493493
metadata: None,
494+
..Default::default()
494495
};
495496

496497
let lines = build_search_lines(&doc);
@@ -517,6 +518,7 @@ mod tests {
517518
.with_page(2)
518519
.with_ordinal(4)],
519520
metadata: None,
521+
..Default::default()
520522
};
521523

522524
let metadata = llm_block_metadata(&doc, &[0], block_kind_label);
@@ -655,6 +657,8 @@ mod tests {
655657
max_chars: 8000,
656658
structural_summary: "\n## Structural Summary\n\nOverview\n",
657659
llm_answer: Some("Key finding"),
660+
tables: &[],
661+
pages: &[],
658662
};
659663

660664
let built = build_parse_result(&input);
@@ -722,6 +726,8 @@ mod tests {
722726
max_chars: 8000,
723727
structural_summary: "\n## Structural Summary\n\nOverview\n",
724728
llm_answer: None,
729+
tables: &[],
730+
pages: &[],
725731
};
726732

727733
let output = build_parse_tool_output(&input);

core/src/document/consume_search.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,7 @@ mod tests {
10581058
DocumentBlock::new(DocumentBlockKind::EmailHeader, Some("Subject"), "Hello"),
10591059
],
10601060
metadata: None,
1061+
..Default::default()
10611062
};
10621063

10631064
let chunks = vec![

0 commit comments

Comments
 (0)