Skip to main content

docling_pdf/
tf_core.rs

1//! ONNX-free half of the TableFormer pipeline: the 448 encoder-input
2//! preprocessing, the autoregressive loop's structure corrections and bbox
3//! bookkeeping, span merging, and the OTSL→grid layout. Everything here is pure
4//! Rust (no `ort`), so the browser build (#157 stage 3) runs the *same*
5//! conformance-critical logic the native pipeline does and delegates only the
6//! three ONNX graphs (encoder / decoder / bbox) to ONNX Runtime Web. Keeping
7//! one implementation is what keeps the wasm table structure identical to the
8//! native CPU path; drift can only come from the runtime kernels.
9//!
10//! `tableformer.rs` (native, `ml` feature) owns the `ort` sessions and the
11//! owned-value KV-cache fast path; it calls into here for the parts that don't
12//! touch the runtime.
13
14pub use crate::assemble::TableGrid;
15use image::RgbImage;
16
17/// The encoder's fixed square input side.
18pub const SIDE: u32 = 448;
19// Verbatim from docling's tm_config.json image_normalization (more digits than
20// f32 holds; kept exact for provenance).
21#[allow(clippy::excessive_precision)]
22pub const MEAN: [f32; 3] = [0.94247851, 0.94254675, 0.94292611];
23#[allow(clippy::excessive_precision)]
24pub const STD: [f32; 3] = [0.17910956, 0.17940403, 0.17931663];
25/// Cap on decode steps (docling's generation limit).
26pub const MAX_STEPS: usize = 1024;
27/// The decoder hidden width, and the bbox decoder's per-cell `tag_h` stride.
28pub const EMBED_DIM: usize = 512;
29
30/// OTSL structure tokens (TableModel04_rs wordmap indices).
31pub const START: i64 = 2;
32pub const END: i64 = 3;
33pub const ECEL: i64 = 4; // empty cell
34pub const FCEL: i64 = 5; // full (content) cell
35pub const LCEL: i64 = 6; // left-looking: extends the cell to its left (colspan)
36pub const UCEL: i64 = 7; // up-looking: extends the cell above (rowspan)
37pub const XCEL: i64 = 8; // cross: spans both ways
38pub const NL: i64 = 9; // new row
39pub const CHED: i64 = 10; // column header
40pub const RHED: i64 = 11; // row header
41pub const SROW: i64 = 12; // section row
42
43const CELL_TAGS: [i64; 6] = [FCEL, ECEL, XCEL, CHED, RHED, SROW];
44
45/// A predicted table cell: an OTSL grid position (with spans) + its box in the
46/// 448 image normalized cxcywh, the OTSL tag, and the bbox decoder's cell
47/// class (docling's `cell_class`; 2 = full, ≤1 = predicted empty).
48#[derive(Debug, Clone)]
49pub struct TableCell {
50    pub row: usize,
51    pub col: usize,
52    pub colspan: usize,
53    pub rowspan: usize,
54    pub tag: i64,
55    pub class: i64,
56    pub cx: f32,
57    pub cy: f32,
58    pub w: f32,
59    pub h: f32,
60}
61
62/// Resize `img` to `SIDE×SIDE` (bilinear, aligned to docling's half-pixel
63/// centers) and normalize, laid out `(C, W, H)` as the exported encoder expects
64/// — the raw `[1,3,SIDE,SIDE]` float buffer. The native path wraps this in an
65/// `ort` tensor; the browser path hands it to ONNX Runtime Web directly.
66pub fn preprocess_input(img: &RgbImage) -> Vec<f32> {
67    let nn = (SIDE * SIDE) as usize;
68    let side = SIDE as usize;
69    let (sw, sh) = (img.width() as i32, img.height() as i32);
70    let sxr = sw as f32 / SIDE as f32;
71    let syr = sh as f32 / SIDE as f32;
72    let mut data = vec![0f32; 3 * nn];
73    for h in 0..side {
74        let fy = (h as f32 + 0.5) * syr - 0.5;
75        let wy = fy - fy.floor();
76        let y0c = (fy.floor() as i32).clamp(0, sh - 1) as u32;
77        let y1c = (fy.floor() as i32 + 1).clamp(0, sh - 1) as u32;
78        for w in 0..side {
79            let fx = (w as f32 + 0.5) * sxr - 0.5;
80            let wx = fx - fx.floor();
81            let x0c = (fx.floor() as i32).clamp(0, sw - 1) as u32;
82            let x1c = (fx.floor() as i32 + 1).clamp(0, sw - 1) as u32;
83            let p00 = img.get_pixel(x0c, y0c);
84            let p01 = img.get_pixel(x1c, y0c);
85            let p10 = img.get_pixel(x0c, y1c);
86            let p11 = img.get_pixel(x1c, y1c);
87            let idx = w * side + h; // (C, W, H): c*n + w*H + h
88            for c in 0..3 {
89                let top = p00[c] as f32 * (1.0 - wx) + p01[c] as f32 * wx;
90                let bot = p10[c] as f32 * (1.0 - wx) + p11[c] as f32 * wx;
91                let v = top * (1.0 - wy) + bot * wy;
92                data[c * nn + idx] = (v / 255.0 - MEAN[c]) / STD[c];
93            }
94        }
95    }
96    data
97}
98
99/// docling's two structure corrections, applied to a raw argmax tag: `xcel`
100/// collapses to `lcel` (its `line_num` is never incremented, so this fires on
101/// every row), and an `lcel` right after a `ucel` becomes a full cell.
102pub fn correct(raw: i64, prev_ucel: bool) -> i64 {
103    let mut tag = raw;
104    if tag == XCEL {
105        tag = LCEL;
106    }
107    if prev_ucel && tag == LCEL {
108        tag = FCEL;
109    }
110    tag
111}
112
113/// The autoregressive loop's per-step state, mirroring docling's `predict`
114/// bookkeeping (`tag_H_buf` / `bboxes_to_merge`): which decoder hidden states
115/// feed the bbox decoder and how horizontal spans merge. Both the native and
116/// browser loops step the decoder themselves (sync vs async `ort`) and feed
117/// each result through [`step`](Self::step); everything else stays here so the
118/// two paths can't drift.
119#[derive(Default)]
120pub struct BboxBook {
121    /// The decoder input prefix (`[START]`, then every emitted tag).
122    pub tags: Vec<i64>,
123    /// The emitted OTSL structure tokens (no `START`/`END`).
124    pub otsl: Vec<i64>,
125    /// Per-bbox-cell decoder hidden states, flattened `[n, EMBED_DIM]`.
126    pub hiddens: Vec<f32>,
127    /// Number of hidden states collected (`hiddens.len() / EMBED_DIM`).
128    pub n: usize,
129    /// Span merges: `cur_bbox_ind → partner` (`-1` → the last box).
130    pub merge: std::collections::HashMap<usize, i64>,
131    prev_ucel: bool,
132    skip: bool,
133    first_lcel: bool,
134    bbox_ind: usize,
135    cur_bbox_ind: usize,
136}
137
138impl BboxBook {
139    pub fn new() -> Self {
140        Self {
141            tags: vec![START],
142            skip: true, // first tag after <start> is skipped
143            first_lcel: true,
144            ..Default::default()
145        }
146    }
147
148    /// Feed one raw decoded tag and its hidden state. Returns `false` when the
149    /// corrected tag is `END` (stop decoding) — the tag is not recorded then.
150    pub fn step(&mut self, raw: i64, hidden: &[f32]) -> bool {
151        let tag = correct(raw, self.prev_ucel);
152        if tag == END {
153            return false;
154        }
155        // docling's tag_H_buf / bboxes_to_merge bookkeeping.
156        if !self.skip && matches!(tag, FCEL | ECEL | CHED | RHED | SROW | NL | UCEL) {
157            self.hiddens.extend_from_slice(hidden);
158            self.n += 1;
159            if !self.first_lcel {
160                self.merge.insert(self.cur_bbox_ind, self.bbox_ind as i64);
161            }
162            self.bbox_ind += 1;
163        }
164        if tag != LCEL {
165            self.first_lcel = true;
166        } else if self.first_lcel {
167            self.hiddens.extend_from_slice(hidden);
168            self.n += 1;
169            self.first_lcel = false;
170            self.cur_bbox_ind = self.bbox_ind;
171            self.merge.insert(self.cur_bbox_ind, -1);
172            self.bbox_ind += 1;
173        }
174        self.skip = matches!(tag, NL | UCEL | XCEL);
175        self.prev_ucel = tag == UCEL;
176        self.otsl.push(tag);
177        self.tags.push(tag);
178        true
179    }
180}
181
182/// docling's `mergebboxes` (cxcywh): the union box of a horizontal span's first
183/// and last cell.
184fn mergebboxes(b1: [f32; 4], b2: [f32; 4]) -> [f32; 4] {
185    let new_w = (b2[0] + b2[2] / 2.0) - (b1[0] - b1[2] / 2.0);
186    let new_h = (b2[1] + b2[3] / 2.0) - (b1[1] - b1[3] / 2.0);
187    let new_left = b1[0] - b1[2] / 2.0;
188    let new_top = (b2[1] - b2[3] / 2.0).min(b1[1] - b1[3] / 2.0);
189    [new_left + new_w / 2.0, new_top + new_h / 2.0, new_w, new_h]
190}
191
192/// Apply docling's span merges: each merge key combines its box with the partner
193/// (`-1` → the last box); partners are dropped. The merged cell keeps the
194/// *first* box's class, matching docling's `outputs_class1.append(cls1)`.
195pub fn merge_spans(
196    boxes: &[[f32; 4]],
197    classes: &[i64],
198    merge: &std::collections::HashMap<usize, i64>,
199) -> (Vec<[f32; 4]>, Vec<i64>) {
200    let skip: std::collections::HashSet<usize> = merge
201        .values()
202        .filter(|&&v| v >= 0)
203        .map(|&v| v as usize)
204        .collect();
205    let mut out = Vec::new();
206    let mut out_classes = Vec::new();
207    for (i, &b) in boxes.iter().enumerate() {
208        let class = classes.get(i).copied().unwrap_or(2);
209        if let Some(&j) = merge.get(&i) {
210            let partner = if j < 0 { boxes.len() - 1 } else { j as usize };
211            out.push(mergebboxes(b, boxes[partner.min(boxes.len() - 1)]));
212            out_classes.push(class);
213        } else if !skip.contains(&i) {
214            out.push(b);
215            out_classes.push(class);
216        }
217    }
218    (out, out_classes)
219}
220
221/// Lay the OTSL tag stream onto a grid (docling's `_build_table_cells`, OTSL
222/// mode): cell tags create cells at (row, col); `lcel`/`ucel`/`xcel` are spans
223/// (counted toward the column index but not cells). Colspan/rowspan are read off
224/// the grid (consecutive `lcel`/`ucel` to the right/below). `boxes` are indexed
225/// by cell order and aligned with the cells.
226pub fn build_table_cells(otsl: &[i64], boxes: &[[f32; 4]], classes: &[i64]) -> Vec<TableCell> {
227    // 2D grid of tags (rows split on NL) for span lookups.
228    let mut grid: Vec<Vec<i64>> = vec![Vec::new()];
229    for &t in otsl {
230        if t == NL {
231            grid.push(Vec::new());
232        } else {
233            grid.last_mut().unwrap().push(t);
234        }
235    }
236    let mut cells = Vec::new();
237    let mut cell_id = 0usize;
238    for (r, row) in grid.iter().enumerate() {
239        for (c, &tag) in row.iter().enumerate() {
240            if !CELL_TAGS.contains(&tag) {
241                continue;
242            }
243            let mut colspan = 1;
244            while c + colspan < row.len() && matches!(row[c + colspan], LCEL | XCEL) {
245                colspan += 1;
246            }
247            let mut rowspan = 1;
248            while r + rowspan < grid.len()
249                && grid[r + rowspan]
250                    .get(c)
251                    .is_some_and(|&t| matches!(t, UCEL | XCEL))
252            {
253                rowspan += 1;
254            }
255            let b = boxes.get(cell_id).copied().unwrap_or([0.0; 4]);
256            // docling defaults a class-less cell to 2 (full).
257            let class = classes.get(cell_id).copied().unwrap_or(2);
258            cells.push(TableCell {
259                row: r,
260                col: c,
261                colspan,
262                rowspan,
263                tag,
264                class,
265                cx: b[0],
266                cy: b[1],
267                w: b[2],
268                h: b[3],
269            });
270            cell_id += 1;
271        }
272    }
273    cells
274}
275
276/// Index of the maximum. Uses Rust's `max_by` (ties resolve to the *last*
277/// index; the decoder/bbox float logits don't produce exact ties in practice).
278/// Kept verbatim from the native path so the two stay bit-identical.
279pub fn argmax(v: &[f32]) -> usize {
280    v.iter()
281        .enumerate()
282        .max_by(|a, b| a.1.total_cmp(b.1))
283        .map(|(i, _)| i)
284        .unwrap_or(0)
285}
286
287use crate::pdfium_backend::TextCell;
288use crate::tf_match::{PdfWord, TfCell};
289
290/// The ONNX-free tail of TableFormer row prediction: match the page's word
291/// cells into the predicted structure `cells` and expand spans into a dense
292/// `rows × cols` text grid. `region` is `(l, t, r, b)` in page points; `cells`
293/// are in the 448 image (normalized cxcywh). Shared by the native pipeline
294/// (after `predict_table_structure`) and the browser path (after the ort-web
295/// decode loop) — identical from here on. `None` when nothing matched.
296pub fn table_rows(cells: &[TableCell], region: [f32; 4], words: &[TextCell]) -> Option<TableGrid> {
297    // Words that belong to the table: non-empty text, ≥80 % of the word's area
298    // inside the table region (docling's `get_cells_in_bbox` ios test). Ids stay
299    // the page-level word indices so text joins in stream order.
300    let table_words: Vec<PdfWord> = words
301        .iter()
302        .enumerate()
303        .filter(|(_, w)| !w.text.trim().is_empty())
304        .filter_map(|(wi, w)| {
305            let (l, t, r, b) = (w.l as f64, w.t as f64, w.r as f64, w.b as f64);
306            let area = (r - l) * (b - t);
307            let iw = (r.min(region[2] as f64) - l.max(region[0] as f64)).max(0.0);
308            let ih = (b.min(region[3] as f64) - t.max(region[1] as f64)).max(0.0);
309            if area > 0.0 && iw * ih / area > 0.8 {
310                Some(PdfWord {
311                    id: wi,
312                    bbox: [l, t, r, b],
313                    text: w.text.trim().to_string(),
314                })
315            } else {
316                None
317            }
318        })
319        .collect();
320
321    if !table_words.is_empty() && !simple_match() {
322        return docling_match_rows(cells, region, &table_words, words);
323    }
324
325    let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
326
327    // Cell boxes in page points (top-left), aligned with `cells`.
328    let boxes: Vec<[f32; 4]> = cells
329        .iter()
330        .map(|c| {
331            [
332                region[0] + (c.cx - c.w / 2.0) * rw,
333                region[1] + (c.cy - c.h / 2.0) * rh,
334                region[0] + (c.cx + c.w / 2.0) * rw,
335                region[1] + (c.cy + c.h / 2.0) * rh,
336            ]
337        })
338        .collect();
339
340    // Assign each word to the cell it overlaps most (intersection / word area).
341    let mut cell_words: Vec<Vec<usize>> = vec![Vec::new(); cells.len()];
342    for (wi, w) in words.iter().enumerate() {
343        let wa = ((w.r - w.l) * (w.b - w.t)).max(1.0);
344        let mut best: Option<(f32, usize)> = None;
345        for (ci, b) in boxes.iter().enumerate() {
346            let ix = (w.r.min(b[2]) - w.l.max(b[0])).max(0.0);
347            let iy = (w.b.min(b[3]) - w.t.max(b[1])).max(0.0);
348            let io = ix * iy / wa;
349            if io > 0.0 && best.is_none_or(|(bo, _)| io > bo) {
350                best = Some((io, ci));
351            }
352        }
353        if let Some((_, ci)) = best {
354            cell_words[ci].push(wi);
355        }
356    }
357
358    let num_rows = cells.iter().map(|c| c.row + c.rowspan).max().unwrap_or(0);
359    let num_cols = cells.iter().map(|c| c.col + c.colspan).max().unwrap_or(0);
360    if num_rows == 0 || num_cols == 0 {
361        return None;
362    }
363    let mut grid = vec![vec![String::new(); num_cols]; num_rows];
364    let mut out_cells = Vec::with_capacity(cells.len());
365    for (ci, c) in cells.iter().enumerate() {
366        // Keep words in text-stream order (their word index), matching docling's
367        // cell text assembly — geometric re-sorting scrambles wrapped cells.
368        let wis = std::mem::take(&mut cell_words[ci]);
369        let text = wis
370            .iter()
371            .map(|&i| words[i].text.trim())
372            .collect::<Vec<_>>()
373            .join(" ");
374        let text = normalize_cell_text(text);
375        // Spanned cells repeat their text across the covered grid positions.
376        for row in grid.iter_mut().skip(c.row).take(c.rowspan) {
377            for cell in row.iter_mut().skip(c.col).take(c.colspan) {
378                *cell = text.clone();
379            }
380        }
381        out_cells.push(first_class_cell(text, Some(boxes[ci]), c, c.tag));
382    }
383    Some(TableGrid {
384        rows: grid,
385        cells: out_cells,
386    })
387}
388
389/// A public first-class cell (#240) from a predicted OTSL cell: the span
390/// rectangle comes from the grid layout, the header roles from the OTSL tag.
391fn first_class_cell(
392    text: String,
393    bbox: Option<[f32; 4]>,
394    c: &TableCell,
395    tag: i64,
396) -> docling_core::TableCell {
397    docling_core::TableCell {
398        text,
399        bbox,
400        start_row: c.row,
401        start_col: c.col,
402        row_span: c.rowspan.max(1),
403        col_span: c.colspan.max(1),
404        column_header: tag == CHED,
405        row_header: tag == RHED,
406        row_section: tag == SROW,
407    }
408}
409
410/// `DOCLING_RS_TF_SIMPLE_MATCH=1` reverts to the pre-#60 best-overlap word
411/// assignment (A/B escape hatch for the docling matching post-processor).
412fn simple_match() -> bool {
413    docling_core::env::flag("DOCLING_RS_TF_SIMPLE_MATCH")
414}
415
416/// docling glues `@` to whatever follows it (`mAP @0.5`, an email): the PDF's
417/// word cells split `@` from the next token, and joining with a space would
418/// widen the cell and — via the column pad — shift every row. The groundtruth
419/// never contains "@ ", so this is always the right normalization.
420fn normalize_cell_text(text: String) -> String {
421    text.replace("@ ", "@")
422}
423
424/// docling's matched-cell grid assembly (`tf_predictor.predict` with
425/// `do_cell_matching=True`): run the ported matching post-processor, group the
426/// word→cell assignments per grid position, compress the surviving row/column
427/// ids to sequential indexes, and expand spans into a dense `rows × cols` text
428/// grid. Matching runs in docling's coordinate space — the table bbox rounded
429/// to integers, everything ×2 (its page scale) — so the post-processor's
430/// absolute rounding agrees.
431fn docling_match_rows(
432    cells: &[TableCell],
433    region: [f32; 4],
434    table_words: &[PdfWord],
435    words: &[TextCell],
436) -> Option<TableGrid> {
437    const SCALE: f64 = 2.0; // docling's table-structure page scale
438    let sl = (region[0] as f64).round_ties_even() * SCALE;
439    let st = (region[1] as f64).round_ties_even() * SCALE;
440    let sr = (region[2] as f64).round_ties_even() * SCALE;
441    let sb = (region[3] as f64).round_ties_even() * SCALE;
442    let (w2, h2) = (sr - sl, sb - st);
443
444    let tf_cells: Vec<TfCell> = cells
445        .iter()
446        .enumerate()
447        .map(|(i, c)| {
448            let (cx, cy) = (c.cx as f64, c.cy as f64);
449            let (w, h) = (c.w as f64, c.h as f64);
450            TfCell {
451                bbox: [
452                    sl + (cx - w / 2.0) * w2,
453                    st + (cy - h / 2.0) * h2,
454                    sl + (cx + w / 2.0) * w2,
455                    st + (cy + h / 2.0) * h2,
456                ],
457                cell_id: i,
458                row_id: c.row,
459                column_id: c.col,
460                cell_class: c.class,
461                colspan_val: if c.colspan > 1 { c.colspan } else { 0 },
462                rowspan_val: if c.rowspan > 1 { c.rowspan } else { 0 },
463            }
464        })
465        .collect();
466
467    let scaled_words: Vec<PdfWord> = table_words
468        .iter()
469        .map(|w| PdfWord {
470            id: w.id,
471            bbox: [
472                w.bbox[0] * SCALE,
473                w.bbox[1] * SCALE,
474                w.bbox[2] * SCALE,
475                w.bbox[3] * SCALE,
476            ],
477            text: w.text.clone(),
478        })
479        .collect();
480
481    // Debug (native only): dump the matcher inputs as JSON lines for a
482    // side-by-side run against docling's Python post-processor.
483    #[cfg(feature = "ml")]
484    if let Some(dir) = docling_core::env::nonempty("DOCLING_RS_TF_MATCH_DUMP") {
485        dump_match_inputs(&dir, &tf_cells, &scaled_words);
486    }
487
488    let (cells_wo, final_matches) =
489        crate::tf_match::match_and_post_process(tf_cells, &scaled_words);
490
491    // `_merge_tf_output`: group per (column, row) in ascending-pdf-id order; the
492    // first word's table cell fixes the group's offsets and spans.
493    struct Merged {
494        start_row: usize,
495        start_col: usize,
496        row_span: usize,
497        col_span: usize,
498        word_ids: Vec<usize>,
499        /// The matched table cell's bbox, back in page points (the matcher
500        /// runs in docling's ×2 space).
501        bbox: [f32; 4],
502        /// The predicted cell's OTSL tag (header roles for #240).
503        tag: i64,
504    }
505    let mut merged: Vec<Merged> = Vec::new();
506    let mut key_ix: std::collections::HashMap<(usize, usize), usize> =
507        std::collections::HashMap::new();
508    for (&pdf_id, list) in &final_matches {
509        let tm = list[0].table_cell_id;
510        let Some(cell) = cells_wo.iter().find(|c| c.cell_id == tm) else {
511            continue;
512        };
513        match key_ix.entry((cell.column_id, cell.row_id)) {
514            std::collections::hash_map::Entry::Occupied(e) => {
515                merged[*e.get()].word_ids.push(pdf_id);
516            }
517            std::collections::hash_map::Entry::Vacant(e) => {
518                e.insert(merged.len());
519                merged.push(Merged {
520                    start_row: cell.row_id,
521                    start_col: cell.column_id,
522                    row_span: cell.rowspan_val.max(1),
523                    col_span: cell.colspan_val.max(1),
524                    word_ids: vec![pdf_id],
525                    bbox: [
526                        (cell.bbox[0] / 2.0) as f32,
527                        (cell.bbox[1] / 2.0) as f32,
528                        (cell.bbox[2] / 2.0) as f32,
529                        (cell.bbox[3] / 2.0) as f32,
530                    ],
531                    tag: cells.get(cell.cell_id).map_or(FCEL, |c| c.tag),
532                });
533            }
534        }
535    }
536    if merged.is_empty() {
537        return None;
538    }
539
540    // `multi_table_predict`'s sort_row_col_indexes: compress the surviving
541    // row/column ids to gap-free indexes.
542    let mut start_cols: Vec<usize> = merged.iter().map(|m| m.start_col).collect();
543    start_cols.sort_unstable();
544    start_cols.dedup();
545    let mut start_rows: Vec<usize> = merged.iter().map(|m| m.start_row).collect();
546    start_rows.sort_unstable();
547    start_rows.dedup();
548    let mut num_rows = 0;
549    let mut num_cols = 0;
550    for m in &mut merged {
551        m.start_col = start_cols.binary_search(&m.start_col).expect("own value");
552        m.start_row = start_rows.binary_search(&m.start_row).expect("own value");
553        num_cols = num_cols.max(m.start_col + m.col_span);
554        num_rows = num_rows.max(m.start_row + m.row_span);
555    }
556    if num_rows == 0 || num_cols == 0 {
557        return None;
558    }
559
560    let mut grid = vec![vec![String::new(); num_cols]; num_rows];
561    let mut out_cells = Vec::with_capacity(merged.len());
562    for m in &merged {
563        let text = m
564            .word_ids
565            .iter()
566            .map(|&i| words[i].text.trim())
567            .collect::<Vec<_>>()
568            .join(" ");
569        let text = normalize_cell_text(text);
570        for row in grid.iter_mut().skip(m.start_row).take(m.row_span) {
571            for cell in row.iter_mut().skip(m.start_col).take(m.col_span) {
572                *cell = text.clone();
573            }
574        }
575        out_cells.push(docling_core::TableCell {
576            text,
577            bbox: Some(m.bbox),
578            start_row: m.start_row,
579            start_col: m.start_col,
580            row_span: m.row_span,
581            col_span: m.col_span,
582            column_header: m.tag == CHED,
583            row_header: m.tag == RHED,
584            row_section: m.tag == SROW,
585        });
586    }
587    Some(TableGrid {
588        rows: grid,
589        cells: out_cells,
590    })
591}
592
593/// Append one JSON line per table into `<dir>/tf_match_dump.jsonl` with the
594/// exact matcher inputs (hand-rolled JSON to avoid a serde dependency).
595#[cfg(feature = "ml")]
596fn dump_match_inputs(dir: &str, tf_cells: &[TfCell], words: &[PdfWord]) {
597    use std::io::Write;
598    let cells: Vec<String> = tf_cells
599        .iter()
600        .map(|c| {
601            format!(
602                r#"{{"bbox":[{},{},{},{}],"cell_id":{},"row_id":{},"column_id":{},"cell_class":{},"colspan_val":{},"rowspan_val":{}}}"#,
603                c.bbox[0], c.bbox[1], c.bbox[2], c.bbox[3],
604                c.cell_id, c.row_id, c.column_id, c.cell_class,
605                c.colspan_val, c.rowspan_val
606            )
607        })
608        .collect();
609    let ws: Vec<String> = words
610        .iter()
611        .map(|w| {
612            format!(
613                r#"{{"id":{},"bbox":[{},{},{},{}],"text":{}}}"#,
614                w.id,
615                w.bbox[0],
616                w.bbox[1],
617                w.bbox[2],
618                w.bbox[3],
619                serde_json_escape(&w.text)
620            )
621        })
622        .collect();
623    let line = format!(
624        r#"{{"table_cells":[{}],"pdf_cells":[{}]}}"#,
625        cells.join(","),
626        ws.join(",")
627    );
628    if let Ok(mut f) = std::fs::OpenOptions::new()
629        .create(true)
630        .append(true)
631        .open(format!("{dir}/tf_match_dump.jsonl"))
632    {
633        let _ = writeln!(f, "{line}");
634    }
635}
636
637/// Minimal JSON string escaping for the parity dump.
638#[cfg(feature = "ml")]
639fn serde_json_escape(s: &str) -> String {
640    let mut out = String::with_capacity(s.len() + 2);
641    out.push('"');
642    for ch in s.chars() {
643        match ch {
644            '"' => out.push_str("\\\""),
645            '\\' => out.push_str("\\\\"),
646            '\n' => out.push_str("\\n"),
647            '\r' => out.push_str("\\r"),
648            '\t' => out.push_str("\\t"),
649            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
650            c => out.push(c),
651        }
652    }
653    out.push('"');
654    out
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn corrections() {
663        assert_eq!(correct(XCEL, false), LCEL); // xcel → lcel
664        assert_eq!(correct(LCEL, true), FCEL); // lcel after ucel → fcel
665        assert_eq!(correct(XCEL, true), FCEL); // xcel → lcel → fcel
666        assert_eq!(correct(FCEL, false), FCEL);
667        assert_eq!(correct(LCEL, false), LCEL);
668    }
669
670    #[test]
671    fn argmax_behaviour() {
672        assert_eq!(argmax(&[0.1, 0.9, 0.3]), 1);
673        assert_eq!(argmax(&[0.5, 0.5]), 1); // Rust max_by ties to the last index
674        assert_eq!(argmax(&[]), 0);
675    }
676
677    #[test]
678    fn book_skips_first_and_collects_hiddens() {
679        // <start> then a 2x1 row: FCEL FCEL NL, END. The first FCEL after start
680        // is NOT skipped (skip only guards the tag right after start-consumed
681        // rows); verify hidden collection count and merge stays empty.
682        let mut b = BboxBook::new();
683        let h = [1.0f32; EMBED_DIM];
684        assert!(b.step(FCEL, &h)); // skip=true initially → not collected
685        assert!(b.step(FCEL, &h));
686        assert!(b.step(NL, &h));
687        assert!(!b.step(END, &h)); // stop
688        assert_eq!(b.otsl, vec![FCEL, FCEL, NL]);
689        // first FCEL skipped (skip=true), second FCEL + NL collected → n=2
690        assert_eq!(b.n, 2);
691        assert_eq!(b.hiddens.len(), 2 * EMBED_DIM);
692        assert!(b.merge.is_empty());
693    }
694
695    #[test]
696    fn book_merges_horizontal_span() {
697        // FCEL LCEL: the LCEL is the first-lcel of a horizontal span → records a
698        // merge partner (-1 placeholder) for the span's leading cell.
699        let mut b = BboxBook::new();
700        let h = [0.0f32; EMBED_DIM];
701        b.step(FCEL, &h); // skipped (skip=true)
702        b.step(FCEL, &h); // collected, bbox_ind 0→1
703        b.step(LCEL, &h); // first-lcel: cur=1, merge{1:-1}, bbox_ind 1→2
704        assert_eq!(b.merge.get(&1), Some(&-1));
705    }
706
707    #[test]
708    fn build_cells_spans() {
709        // Row 0: FCEL LCEL  (a 1x2 colspan)
710        // Row 1: FCEL ECEL
711        let otsl = vec![FCEL, LCEL, NL, FCEL, ECEL];
712        let boxes = vec![[0.0; 4]; 3];
713        let classes = vec![2, 2, 2];
714        let cells = build_table_cells(&otsl, &boxes, &classes);
715        assert_eq!(cells.len(), 3);
716        assert_eq!((cells[0].colspan, cells[0].rowspan), (2, 1));
717        assert_eq!((cells[0].row, cells[0].col), (0, 0));
718        assert_eq!((cells[1].row, cells[1].col), (1, 0));
719    }
720}