1use std::collections::BTreeMap;
18
19#[derive(Debug, Clone)]
23pub struct TfCell {
24 pub bbox: [f64; 4],
25 pub cell_id: usize,
26 pub row_id: usize,
27 pub column_id: usize,
28 pub cell_class: i64,
29 pub colspan_val: usize,
30 pub rowspan_val: usize,
31}
32
33#[derive(Debug, Clone)]
35pub struct PdfWord {
36 pub id: usize,
37 pub bbox: [f64; 4],
38 pub text: String,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct MatchEntry {
44 pub table_cell_id: usize,
45 pub score: f64,
46}
47
48pub type Matches = BTreeMap<usize, Vec<MatchEntry>>;
52
53fn find_intersection(b1: &[f64; 4], b2: &[f64; 4]) -> Option<[f64; 4]> {
56 if b1[2] < b2[0] || b2[2] < b1[0] || b1[1] > b2[3] {
57 return None;
58 }
59 Some([
60 b1[0].max(b2[0]),
61 b1[1].max(b2[1]),
62 b1[2].min(b2[2]),
63 b1[3].min(b2[3]),
64 ])
65}
66
67fn intersection_over_pdf_match(table_cells: &[TfCell], pdf_cells: &[PdfWord]) -> Matches {
72 let mut matches: Matches = BTreeMap::new();
73 for cell in table_cells {
74 for word in pdf_cells {
75 let Some(ib) = find_intersection(&cell.bbox, &word.bbox) else {
76 continue;
77 };
78 let warea = (word.bbox[2] - word.bbox[0]) * (word.bbox[3] - word.bbox[1]);
79 let iarea = (ib[2] - ib[0]) * (ib[3] - ib[1]);
80 let iopdf = if warea > 0.0 { iarea / warea } else { 0.0 };
81 if iopdf > 0.0 {
82 let entry = MatchEntry {
83 table_cell_id: cell.cell_id,
84 score: iopdf,
85 };
86 let list = matches.entry(word.id).or_default();
87 if !list.contains(&entry) {
88 list.push(entry);
89 }
90 }
91 }
92 }
93 matches
94}
95
96fn get_table_dimension(table_cells: &[TfCell]) -> (usize, usize, usize) {
98 let mut columns = 1;
99 let mut rows = 1;
100 let mut max_cell_id = 0;
101 for cell in table_cells {
102 columns = columns.max(cell.column_id);
103 rows = rows.max(cell.row_id);
104 max_cell_id = max_cell_id.max(cell.cell_id);
105 }
106 (columns + 1, rows + 1, max_cell_id)
107}
108
109fn good_bad_cells_in_column(
114 table_cells: &[TfCell],
115 column: usize,
116 matches: &Matches,
117) -> (Vec<TfCell>, Vec<TfCell>) {
118 let mut good = Vec::new();
119 let mut bad = Vec::new();
120 for cell in table_cells {
121 if cell.column_id != column {
122 continue;
123 }
124 let mut bad_match = true;
125 if cell.cell_class > 1 {
126 for list in matches.values() {
127 for m in list {
128 if m.table_cell_id == cell.cell_id {
129 good.push(cell.clone());
130 bad_match = false;
131 }
132 }
133 }
134 }
135 if bad_match {
136 bad.push(cell.clone());
137 }
138 }
139 (good, bad)
140}
141
142#[derive(Clone, Copy, PartialEq)]
143enum Alignment {
144 Left,
145 Middle,
146 Right,
147}
148
149fn find_alignment_in_column(cells: &[TfCell]) -> Alignment {
152 if cells.is_empty() {
153 return Alignment::Left;
154 }
155 let (mut lmin, mut lmax) = (f64::INFINITY, f64::NEG_INFINITY);
156 let (mut mmin, mut mmax) = (f64::INFINITY, f64::NEG_INFINITY);
157 let (mut rmin, mut rmax) = (f64::INFINITY, f64::NEG_INFINITY);
158 for cell in cells {
159 let l = cell.bbox[0];
160 let r = cell.bbox[2];
161 let m = (l + r) / 2.0;
162 lmin = lmin.min(l);
163 lmax = lmax.max(l);
164 mmin = mmin.min(m);
165 mmax = mmax.max(m);
166 rmin = rmin.min(r);
167 rmax = rmax.max(r);
168 }
169 let deltas = [lmax - lmin, mmax - mmin, rmax - rmin];
170 let mut best = 0;
172 for (i, d) in deltas.iter().enumerate() {
173 if *d < deltas[best] {
174 best = i;
175 }
176 }
177 match best {
178 0 => Alignment::Left,
179 1 => Alignment::Middle,
180 _ => Alignment::Right,
181 }
182}
183
184#[cfg(test)]
186pub(crate) fn median_for_test(values: &mut [f64]) -> f64 {
187 median(values)
188}
189
190fn median(values: &mut [f64]) -> f64 {
195 values.sort_by(|a, b| a.total_cmp(b));
196 let n = values.len();
197 if n == 0 {
198 0.0
199 } else if n % 2 == 1 {
200 values[n / 2]
201 } else {
202 (values[n / 2 - 1] + values[n / 2]) / 2.0
203 }
204}
205
206fn get_median_pos_size(cells: &[TfCell], alignment: Alignment) -> (f64, f64, f64) {
210 let mut xs = Vec::new();
211 let mut ws = Vec::new();
212 let mut hs = Vec::new();
213 for cell in cells {
214 if cell.rowspan_val > 0 || cell.colspan_val > 0 || cell.cell_class <= 1 {
215 continue;
216 }
217 let x = match alignment {
218 Alignment::Left => cell.bbox[0],
219 Alignment::Middle => (cell.bbox[2] + cell.bbox[0]) / 2.0,
220 Alignment::Right => cell.bbox[2],
221 };
222 xs.push(x);
223 ws.push(cell.bbox[2] - cell.bbox[0]);
224 hs.push(cell.bbox[3] - cell.bbox[1]);
225 }
226 let median_x = if xs.is_empty() { 0.0 } else { median(&mut xs) };
227 let median_w = if ws.is_empty() { 1.0 } else { median(&mut ws) };
228 let median_h = if hs.is_empty() { 1.0 } else { median(&mut hs) };
229 (median_x, median_w, median_h)
230}
231
232fn move_cells_to_left_pos(cells: &[TfCell], median_x: f64, alignment: Alignment) -> Vec<TfCell> {
236 cells
237 .iter()
238 .map(|cell| {
239 let width = cell.bbox[2] - cell.bbox[0];
240 let (new_x1, new_x2) = match alignment {
241 Alignment::Left => (median_x, median_x + width),
242 Alignment::Middle => {
244 let x1 = median_x - width / 2.0;
245 (x1, x1 + width)
246 }
247 Alignment::Right => (median_x - width, median_x),
248 };
249 TfCell {
250 bbox: [new_x1, cell.bbox[1], new_x2, cell.bbox[3]],
251 ..cell.clone()
252 }
253 })
254 .collect()
255}
256
257fn deduplicate_cells(
261 tab_columns: usize,
262 table_cells: &[TfCell],
263 iou_matches: &Matches,
264 ioc_matches: &Matches,
265) -> (Vec<TfCell>, Matches) {
266 use std::collections::BTreeSet;
267 let mut pdf_cells_in_columns: Vec<BTreeSet<usize>> = Vec::with_capacity(tab_columns);
268 let mut total_score_in_columns: Vec<f64> = Vec::with_capacity(tab_columns);
269 for col in 0..tab_columns {
270 let column_cell_ids: BTreeSet<usize> = table_cells
271 .iter()
272 .filter(|c| c.column_id == col)
273 .map(|c| c.cell_id)
274 .collect();
275 let mut ids = BTreeSet::new();
276 let mut score = 0.0;
277 for matches in [iou_matches, ioc_matches] {
278 for (&pdf_id, list) in matches {
279 for m in list {
280 if column_cell_ids.contains(&m.table_cell_id) {
281 score += m.score;
282 ids.insert(pdf_id);
283 }
284 }
285 }
286 }
287 pdf_cells_in_columns.push(ids);
288 total_score_in_columns.push(score);
289 }
290
291 let mut cols_to_eliminate: Vec<usize> = Vec::new();
292 for cl in 0..tab_columns.saturating_sub(1) {
293 let col_a = &pdf_cells_in_columns[cl];
294 let col_b = &pdf_cells_in_columns[cl + 1];
295 let int_prc = if col_a.is_empty() {
296 0.0
297 } else {
298 col_a.intersection(col_b).count() as f64 / col_a.len() as f64
299 };
300 if int_prc > 0.6 {
301 if total_score_in_columns[cl] >= total_score_in_columns[cl + 1] {
302 cols_to_eliminate.push(cl + 1);
303 } else {
304 cols_to_eliminate.push(cl);
305 }
306 }
307 }
308
309 let mut removed_ids: BTreeSet<usize> = BTreeSet::new();
310 let mut new_table_cells = Vec::new();
311 for cell in table_cells {
312 if cols_to_eliminate.contains(&cell.column_id) {
313 removed_ids.insert(cell.cell_id);
314 } else {
315 new_table_cells.push(cell.clone());
316 }
317 }
318 let mut new_matches: Matches = BTreeMap::new();
319 for (&pdf_id, list) in ioc_matches {
320 let kept: Vec<MatchEntry> = list
321 .iter()
322 .filter(|m| !removed_ids.contains(&m.table_cell_id))
323 .cloned()
324 .collect();
325 if !kept.is_empty() {
326 new_matches.insert(pdf_id, kept);
327 }
328 }
329 (new_table_cells, new_matches)
330}
331
332fn do_final_assignment(ioc_matches: &Matches) -> Matches {
335 let mut new_matches: Matches = BTreeMap::new();
336 for (&pdf_id, list) in ioc_matches {
337 let mut best = &list[0];
338 for m in &list[1..] {
339 if m.score > best.score {
340 best = m;
341 }
342 }
343 new_matches.insert(pdf_id, vec![best.clone()]);
344 }
345 new_matches
346}
347
348fn align_table_cells_to_pdf(
351 table_cells: &[TfCell],
352 pdf_cells: &[PdfWord],
353 matches: &Matches,
354) -> Vec<TfCell> {
355 use std::collections::HashMap;
356 let word_boxes: HashMap<usize, [f64; 4]> = pdf_cells.iter().map(|w| (w.id, w.bbox)).collect();
357 let mut boxes_per_cell: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
358 let mut order: Vec<usize> = Vec::new();
359 for (pdf_id, list) in matches {
360 let Some(&wb) = word_boxes.get(pdf_id) else {
361 continue;
362 };
363 let mut seen = std::collections::BTreeSet::new();
364 for m in list {
365 if !seen.insert(m.table_cell_id) {
366 continue;
367 }
368 match boxes_per_cell.entry(m.table_cell_id) {
369 std::collections::btree_map::Entry::Vacant(e) => {
370 e.insert(wb);
371 order.push(m.table_cell_id);
372 }
373 std::collections::btree_map::Entry::Occupied(mut e) => {
374 let b = e.get_mut();
375 b[0] = b[0].min(wb[0]);
376 b[1] = b[1].min(wb[1]);
377 b[2] = b[2].max(wb[2]);
378 b[3] = b[3].max(wb[3]);
379 }
380 }
381 }
382 }
383 let by_id: HashMap<usize, &TfCell> = table_cells.iter().map(|c| (c.cell_id, c)).collect();
384 let mut out = Vec::new();
385 for cell_id in order {
386 if let Some(&cell) = by_id.get(&cell_id) {
387 let mut cell = cell.clone();
388 cell.bbox = boxes_per_cell[&cell_id];
389 out.push(cell);
390 }
391 }
392 out
393}
394
395fn merge_two_bboxes(a: &[f64; 4], b: &[f64; 4]) -> [f64; 4] {
396 [
397 a[0].min(b[0]),
398 a[1].min(b[1]),
399 a[2].max(b[2]),
400 a[3].max(b[3]),
401 ]
402}
403
404fn py_round(v: f64) -> i64 {
406 v.round_ties_even() as i64
407}
408
409type OrphanRecord = (usize, i64, [f64; 4]);
411
412fn band_orphans(
417 n_bands: usize,
418 cells_in_band: impl Fn(usize) -> Vec<[f64; 4]>,
419 pdf_cells: &[PdfWord],
420 matches: &Matches,
421 lo_ix: usize,
422 hi_ix: usize,
423) -> Vec<Vec<OrphanRecord>> {
424 let mut bands: Vec<Vec<OrphanRecord>> = Vec::with_capacity(n_bands);
425 let mut used: BTreeMap<usize, usize> = BTreeMap::new();
427 for band_ix in 0..n_bands {
428 let boxes = cells_in_band(band_ix);
429 let mut lo = -1.0f64;
430 let mut hi = -1.0f64;
431 if !boxes.is_empty() {
432 lo = boxes.iter().map(|b| b[lo_ix]).fold(f64::INFINITY, f64::min);
433 hi = boxes
434 .iter()
435 .map(|b| b[hi_ix])
436 .fold(f64::NEG_INFINITY, f64::max);
437 }
438 let mut in_band: Vec<OrphanRecord> = Vec::new();
439 for word in pdf_cells {
440 if matches.contains_key(&word.id) {
441 continue;
442 }
443 let centroid_band = (hi + lo) / 2.0;
444 let centroid_cell = (word.bbox[hi_ix] + word.bbox[lo_ix]) / 2.0;
445 let within = (word.bbox[lo_ix] >= lo && word.bbox[lo_ix] <= hi)
446 || (word.bbox[hi_ix] >= lo && word.bbox[hi_ix] <= hi)
447 || (word.bbox[lo_ix] <= lo && word.bbox[hi_ix] >= hi);
448 if !within {
449 continue;
450 }
451 let depth = py_round((centroid_band - centroid_cell).abs());
452 match used.get(&word.id) {
453 None => {
454 used.insert(word.id, band_ix);
455 in_band.push((word.id, depth, word.bbox));
456 }
457 Some(&old_band) => {
458 let Some(old_pos) = bands[old_band].iter().position(|r| r.0 == word.id) else {
459 continue;
460 };
461 if depth < bands[old_band][old_pos].1 {
462 bands[old_band].remove(old_pos);
463 used.insert(word.id, band_ix);
464 in_band.push((word.id, depth, word.bbox));
465 }
466 }
467 }
468 }
469 bands.push(in_band);
470 }
471 bands
472}
473
474fn pick_orphan_cells(
479 tab_rows: usize,
480 tab_cols: usize,
481 mut max_cell_id: usize,
482 mut table_cells: Vec<TfCell>,
483 pdf_cells: &[PdfWord],
484 mut matches: Matches,
485) -> (Matches, Vec<TfCell>, usize) {
486 let orphan_rows = band_orphans(
491 tab_rows,
492 |row| {
493 table_cells
494 .iter()
495 .filter(|c| c.row_id == row && c.rowspan_val == 0 && c.cell_class > 1)
496 .map(|c| c.bbox)
497 .collect()
498 },
499 pdf_cells,
500 &matches,
501 1,
502 3,
503 );
504 let orphan_columns = band_orphans(
505 tab_cols,
506 |col| {
507 table_cells
508 .iter()
509 .filter(|c| c.column_id == col && c.colspan_val == 0 && c.cell_class > 1)
510 .map(|c| c.bbox)
511 .collect()
512 },
513 pdf_cells,
514 &matches,
515 0,
516 2,
517 );
518
519 let mut row_of: BTreeMap<usize, usize> = BTreeMap::new();
521 for (row_id, records) in orphan_rows.iter().enumerate() {
522 for r in records {
523 row_of.insert(r.0, row_id);
524 }
525 }
526 let mut col_of: BTreeMap<usize, (usize, i64, [f64; 4])> = BTreeMap::new();
527 for (col_id, records) in orphan_columns.iter().enumerate() {
528 for r in records {
529 col_of.insert(r.0, (col_id, r.1, r.2));
530 }
531 }
532
533 for (&pdf_id, &new_row_id) in row_of.iter() {
535 let Some(&(new_column_id, confidence, pdf_bbox)) = col_of.get(&pdf_id) else {
536 continue;
537 };
538 let existing = table_cells
539 .iter()
540 .position(|c| c.row_id == new_row_id && c.column_id == new_column_id);
541 let table_cell_id = match existing {
542 Some(ix) => {
543 let merged = merge_two_bboxes(&table_cells[ix].bbox, &pdf_bbox);
544 table_cells[ix].bbox = merged;
545 table_cells[ix].cell_id
546 }
547 None => {
548 max_cell_id += 1;
549 table_cells.push(TfCell {
550 bbox: pdf_bbox,
551 cell_id: max_cell_id,
552 column_id: new_column_id,
553 row_id: new_row_id,
554 cell_class: 2,
555 colspan_val: 0,
556 rowspan_val: 0,
557 });
558 max_cell_id
559 }
560 };
561 matches.insert(
562 pdf_id,
563 vec![MatchEntry {
564 table_cell_id,
565 score: confidence as f64,
566 }],
567 );
568 }
569 (matches, table_cells, max_cell_id)
570}
571
572pub fn match_and_post_process(
576 table_cells: Vec<TfCell>,
577 pdf_cells: &[PdfWord],
578) -> (Vec<TfCell>, Matches) {
579 let matches = intersection_over_pdf_match(&table_cells, pdf_cells);
580
581 let (tab_columns, tab_rows, max_cell_id) = get_table_dimension(&table_cells);
582
583 let mut fixed: Vec<TfCell> = Vec::new();
586 for col in 0..tab_columns {
587 let (good, bad) = good_bad_cells_in_column(&table_cells, col, &matches);
588 let alignment = find_alignment_in_column(&good);
589 let (median_x, median_w, median_h) = get_median_pos_size(&good, alignment);
590 let _ = (median_w, median_h); let moved = move_cells_to_left_pos(&bad, median_x, alignment);
592 fixed.extend(good);
593 fixed.extend(moved);
594 }
595 fixed.sort_by_key(|c| c.cell_id);
596
597 let ioc = intersection_over_pdf_match(&fixed, pdf_cells);
599
600 let (dedupl_cells, dedupl_matches) = deduplicate_cells(tab_columns, &fixed, &matches, &ioc);
602
603 let final_matches = do_final_assignment(&dedupl_matches);
605
606 let mut dedupl_sorted = dedupl_cells;
609 dedupl_sorted.sort_by_key(|c| c.cell_id);
610 let aligned = if pdf_cells.len() > 300 {
611 dedupl_sorted
612 } else {
613 align_table_cells_to_pdf(&dedupl_sorted, pdf_cells, &final_matches)
614 };
615
616 let (final_matches, cells_wo, _) = pick_orphan_cells(
619 tab_rows,
620 tab_columns,
621 max_cell_id,
622 aligned,
623 pdf_cells,
624 final_matches,
625 );
626 (cells_wo, final_matches)
627}