Skip to main content

docling_pdf/
tf_match.rs

1//! docling's TableFormer cell matching, ported from docling-ibm-models
2//! (`tf_cell_matcher.py` + `matching_post_processor.py`, and the response
3//! assembly in `tf_predictor.py`). The predicted table cells and the page's
4//! word cells are matched by intersection-over-word-area, then the
5//! post-processor snaps unmatched cells to their column's median position,
6//! de-duplicates columns, assigns each word to its best cell, and finally
7//! picks up "orphan" words (no overlap with any cell — dot leaders in a TOC,
8//! stray page numbers) through row/column banding. Everything is kept
9//! bug-for-bug faithful, including duplicate `good` cells weighting the
10//! column medians and Python's banker's rounding on orphan depths.
11//!
12//! Coordinates: docling matches in its 2x-scaled page space with the table
13//! bbox rounded to integers *before* scaling (`round(cluster.bbox.l) * scale`);
14//! the caller reproduces that space so absolute constants (orphan-depth
15//! rounding) agree.
16
17use std::collections::BTreeMap;
18
19/// docling's `table_cell` dict: an OTSL grid cell with its predicted box in
20/// the matching coordinate space. `colspan_val`/`rowspan_val` are 0 when the
21/// cell has no span (the dict key is absent in docling).
22#[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/// A page word cell in the matching space (docling's `pdf_cells` token).
34#[derive(Debug, Clone)]
35pub struct PdfWord {
36    pub id: usize,
37    pub bbox: [f64; 4],
38    pub text: String,
39}
40
41/// One `{table_cell_id, iopdf|post}` match entry.
42#[derive(Debug, Clone, PartialEq)]
43pub struct MatchEntry {
44    pub table_cell_id: usize,
45    pub score: f64,
46}
47
48/// pdf-cell-id → its match list. BTreeMap keeps deterministic ascending-id
49/// iteration; docling's insertion-ordered dicts never depend on their order
50/// for the final result (the response is re-sorted by pdf cell id).
51pub type Matches = BTreeMap<usize, Vec<MatchEntry>>;
52
53/// docling's `find_intersection`, including the source's harmless
54/// `b2[1] > b2[3]` self-comparison typo (never true for a valid box).
55fn 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
67/// `CellMatcher._intersection_over_pdf_match`: every (table cell, word) pair
68/// with a positive intersection-over-word-area becomes a match entry; exact
69/// duplicates (same cell id *and* score — duplicated `good` cells) are
70/// suppressed like Python's `match not in matches[id]`.
71fn 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
96/// Step 0: `_get_table_dimension` — (columns, rows, max_cell_id).
97fn 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
109/// Step 1: `_get_good_bad_cells_in_column`. A cell is `good` once per match
110/// entry pointing at it (docling appends without breaking, so a cell matched
111/// by N words appears N times and weights the medians N-fold); a cell whose
112/// predicted class is empty (`cell_class <= 1`) is always `bad`.
113fn 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
149/// Step 2: `_find_alignment_in_column` — smallest min-max spread of the
150/// left / middle / right cell edges decides the column alignment.
151fn 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    // Python's list.index(min(...)) keeps the first minimum.
171    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/// Test hook for the crate-internal [`median`] (empty-slice guard).
185#[cfg(test)]
186pub(crate) fn median_for_test(values: &mut [f64]) -> f64 {
187    median(values)
188}
189
190/// `statistics.median`: mean of the two middle values for an even count.
191/// Returns 0.0 for an empty slice — a crafted table can leave a row/column
192/// with zero matched cells, and `values[n / 2 - 1]` would otherwise underflow
193/// (`0usize - 1`) and panic, which (via docling-serve) is a remote crash.
194fn 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
206/// Step 3: `_get_median_pos_size` — median alignment-edge X / width / height
207/// over the good cells, skipping spans and predicted-empty cells. Defaults
208/// (0, 1, 1) when nothing qualifies, exactly like the source.
209fn 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
232/// Step 4: `_move_cells_to_left_pos` with `rescale=False` (docling's call):
233/// slide each bad cell to the column's median alignment edge, keeping its
234/// original width.
235fn 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                // Bit-faithful to the source: `new_x2 = new_x1 + original_width`.
243                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
257/// Step 7: `_deduplicate_cells` — when two *adjacent* structural columns share
258/// more than 60 % of their matched words, drop the lower-scoring column (its
259/// cells and their match entries).
260fn 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
332/// Step 8: `_do_final_asignment` — each word keeps only its highest-scoring
333/// match (Python's `max` keeps the first of equals).
334fn 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
348/// Step 8.a: `_align_table_cells_to_pdf` — matched cells take the union box
349/// of their matched words; cells with no match are dropped.
350fn 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
404/// Python 3 `round()` — banker's rounding to the nearest integer.
405fn py_round(v: f64) -> i64 {
406    v.round_ties_even() as i64
407}
408
409/// A per-band orphan record: (pdf id, rounded depth, word bbox).
410type OrphanRecord = (usize, i64, [f64; 4]);
411
412/// Shared row/column banding scan of step 9: for each band (min/max of the
413/// non-span, non-empty member cells' `lo`/`hi` edges), collect the unmatched
414/// words whose edge or centroid falls inside, resolving a word seen in an
415/// earlier band by the smaller rounded centroid distance.
416fn 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    // pdf id → (band index, index within band) of its current record.
426    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
474/// Step 9: `_pick_orphan_cells` — words with no match are placed by the row
475/// band containing them vertically and the column band containing them
476/// horizontally; the (row, column) either reuses an existing structural cell
477/// (growing its box) or creates a new one.
478fn 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    // NOTE: docling's row-band pass exposes an aliasing quirk — the band's
487    // orphan test reads `matches` as it was on entry (orphans found later
488    // never re-enter it), which we reproduce by collecting all bands before
489    // assigning anything.
490    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    // pdf id → row / column of its accepted band record.
520    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    // Ascending pdf id, matching the sorted C++-parity loop in the source.
534    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
572/// `MatchingPostProcessor.process` with docling's defaults
573/// (`correct_overlapping_cells=False`): returns the post-processed table
574/// cells and the final one-cell-per-word matches.
575pub 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    // Steps 1-4 per column: snap the unmatched cells to the matched cells'
584    // median alignment edge.
585    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); // rescale=False: medians only steer X.
591        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    // Step 5: re-match against the fixed cells.
598    let ioc = intersection_over_pdf_match(&fixed, pdf_cells);
599
600    // Step 7: drop duplicated columns.
601    let (dedupl_cells, dedupl_matches) = deduplicate_cells(tab_columns, &fixed, &matches, &ioc);
602
603    // Step 8: one table cell per word.
604    let final_matches = do_final_assignment(&dedupl_matches);
605
606    // Step 8.a: align cell boxes to their matched words (dropping unmatched
607    // cells) — skipped for large pages, as in the source.
608    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    // Step 9: place the leftover words by row/column banding. docling passes
617    // the *pre-dedup* column count into the orphan scan.
618    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}