Skip to main content

docling_pdf/
tableformer.rs

1//! TableFormer: table-structure recovery via docling-ibm-models, exported to
2//! ONNX by `scripts/install/export_tableformer.py`. The image encoder + tag-transformer
3//! encoder run once to a memory tensor; the decoder is then stepped
4//! autoregressively to emit an OTSL structure-token sequence (the same model
5//! docling runs). See docs/PDF_CONFORMANCE.md.
6
7use crate::pdfium_backend::TextCell;
8use image::RgbImage;
9use ort::session::Session;
10use ort::value::{DynValue, Tensor};
11
12const SIDE: u32 = 448;
13// Verbatim from docling's tm_config.json image_normalization (more digits than
14// f32 holds; kept exact for provenance).
15#[allow(clippy::excessive_precision)]
16const MEAN: [f32; 3] = [0.94247851, 0.94254675, 0.94292611];
17#[allow(clippy::excessive_precision)]
18const STD: [f32; 3] = [0.17910956, 0.17940403, 0.17931663];
19const MAX_STEPS: usize = 1024;
20/// Decoder geometry, fixed by the exported TableModel04_rs graph: the cached
21/// decoder threads a `[N_LAYERS, past, 1, EMBED_DIM]` per-layer state cache.
22const N_LAYERS: usize = 6;
23const EMBED_DIM: usize = 512;
24
25/// OTSL structure tokens (TableModel04_rs wordmap indices).
26pub const START: i64 = 2;
27pub const END: i64 = 3;
28pub const ECEL: i64 = 4; // empty cell
29pub const FCEL: i64 = 5; // full (content) cell
30pub const LCEL: i64 = 6; // left-looking: extends the cell to its left (colspan)
31pub const UCEL: i64 = 7; // up-looking: extends the cell above (rowspan)
32pub const XCEL: i64 = 8; // cross: spans both ways
33pub const NL: i64 = 9; // new row
34pub const CHED: i64 = 10; // column header
35pub const RHED: i64 = 11; // row header
36pub const SROW: i64 = 12; // section row
37
38/// A predicted table cell: an OTSL grid position (with spans) + its box in the
39/// 448 image normalized cxcywh, the OTSL tag, and the bbox decoder's cell
40/// class (docling's `cell_class`; 2 = full, ≤1 = predicted empty).
41#[derive(Debug, Clone)]
42pub struct TableCell {
43    pub row: usize,
44    pub col: usize,
45    pub colspan: usize,
46    pub rowspan: usize,
47    pub tag: i64,
48    pub class: i64,
49    pub cx: f32,
50    pub cy: f32,
51    pub w: f32,
52    pub h: f32,
53}
54
55pub struct TableFormer {
56    encoder: Session,
57    decoder: Session,
58    bbox: Session,
59    /// Which decoder graph flavour is loaded, detected from the session's
60    /// input names (so an explicit `DOCLING_TABLEFORMER_DECODER` override
61    /// works with any of them).
62    style: DecoderStyle,
63}
64
65/// The three decoder-graph generations the loop supports.
66#[derive(Clone, Copy, PartialEq, Eq)]
67enum DecoderStyle {
68    /// `decoder.onnx`: layer-output cache; feeds the full `tags` prefix and a
69    /// single `cache` every step.
70    Legacy,
71    /// The pre-#97 `decoder_kv.onnx`: one tag per step, `cache_k`/`cache_v`,
72    /// with the stacked `cross_k`/`cross_v` re-split inside every step.
73    KvStacked,
74    /// The #97 `decoder_kv.onnx`: one tag per step, and the constant cross
75    /// tensors arrive as 2×`N_LAYERS` per-layer inputs (`cross_kt_i` already
76    /// transposed for q·Kᵀ, `cross_v_i`), computed once per table by the
77    /// encoder — the step graph does no work proportional to their size.
78    KvHoisted,
79}
80
81/// KV-cache geometry fixed by the `decoder_kv.onnx` export
82/// (`[N_LAYERS, 1, KV_HEADS, past, KV_HEAD_DIM]`, `KV_HEADS × KV_HEAD_DIM = EMBED_DIM`).
83const KV_HEADS: usize = 8;
84const KV_HEAD_DIM: usize = 64;
85
86/// The autoregressive decode state: `a` is the legacy layer-output cache, or
87/// `cache_k` for the KV graph; `b` is `cache_v` (KV graph only). `None` = first
88/// step (the zero-`past` empties are allocated per table by [`TableFormer::empty_cache`]).
89#[derive(Default)]
90struct DecodeCache {
91    a: Option<DynValue>,
92    b: Option<DynValue>,
93}
94
95/// Zero-`past` first-step cache tensors: `(cache, None)` for the legacy graph,
96/// `(cache_k, Some(cache_v))` for the KV graph.
97type EmptyCache = (Tensor<f32>, Option<Tensor<f32>>);
98
99/// Encoder outputs that drive the cached decode loop: the per-layer cross-attention
100/// K/V (projected from the image memory once, constant across decode steps) and
101/// `enc_out` for the bbox decoder. Kept as owned `ort` values so each decode step
102/// (and the bbox run) borrows them directly — no per-step extract/copy/re-wrap.
103struct EncodeOut {
104    ck: DynValue,
105    cv: DynValue,
106    eo: DynValue,
107    /// `KvHoisted` only: per-layer `[cross_kt_0..N, cross_v_0..N]`, index-aligned
108    /// with the decoder's input names, borrowed by every decode step.
109    per_layer: Vec<(String, DynValue)>,
110}
111
112impl TableFormer {
113    /// Load the exported encoder/decoder/bbox ONNX graphs (env overrides, else
114    /// `models/tableformer/{encoder,decoder,bbox}.onnx`). Returns `None` if any is
115    /// absent, so the pipeline falls back to geometric reconstruction.
116    pub fn load() -> Option<Self> {
117        Self::load_with(crate::intra_threads())
118    }
119
120    /// Like [`load`](Self::load) but with an explicit intra-op thread count, so a
121    /// parallel page-worker pool can run each table model on fewer threads (the
122    /// throughput comes from running pages concurrently, not from one fat model).
123    pub fn load_with(intra: usize) -> Option<Self> {
124        let enc = std::env::var("DOCLING_TABLEFORMER_ENCODER")
125            .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/encoder.onnx"));
126        // Decoder preference (explicit override wins): INT8 variants first
127        // unless DOCLING_RS_FP32 opts out; within a precision the true-KV-cache
128        // export (`decoder_kv*`, one token per step, O(past) step cost) ranks
129        // ahead of the legacy layer-output-cache graph it matches byte-for-byte
130        // (91/91 snapshot corpus exact with either). Re-measured warm on the
131        // corpus fixtures: the KV graph is ~13% faster on ordinary tables
132        // (2206.01062) and ~17% on the huge-table page (2305.03393v1-pg9),
133        // for +36 MB on disk — table-heavy single-page PDFs are exactly where
134        // the pipeline is tightest against Python docling, so speed wins the
135        // default and the legacy file stays as the smaller fallback.
136        let dec = std::env::var("DOCLING_TABLEFORMER_DECODER").unwrap_or_else(|_| {
137            let candidates: &[&str] = if crate::prefer_fp32() {
138                &[
139                    "models/tableformer/decoder_kv.onnx",
140                    "models/tableformer/decoder.onnx",
141                ]
142            } else {
143                // decoder_kv ranks ABOVE decoder_int8: the #97 hoisted fp32 KV
144                // graph is faster than the quantized legacy graph on every
145                // machine measured, and it is byte-exact (its own int8 variant
146                // is not produced — see quantize_models.py).
147                &[
148                    "models/tableformer/decoder_kv_int8.onnx",
149                    "models/tableformer/decoder_kv.onnx",
150                    "models/tableformer/decoder_int8.onnx",
151                    "models/tableformer/decoder.onnx",
152                ]
153            };
154            candidates
155                .iter()
156                .map(|p| crate::resolve_asset(p))
157                .find(|p| std::path::Path::new(p).exists())
158                .unwrap_or_else(|| "models/tableformer/decoder.onnx".to_string())
159        });
160        let bbx = std::env::var("DOCLING_TABLEFORMER_BBOX")
161            .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/bbox.onnx"));
162        if crate::timing::enabled() {
163            eprintln!("docling-pdf: tableformer decoder: {dec}");
164        }
165        if [&enc, &dec, &bbx]
166            .iter()
167            .any(|p| !std::path::Path::new(p).exists())
168        {
169            // The geometric fallback is a supported, intentional configuration
170            // (docling has no ML table-structure equivalent baked in either), so
171            // this stays a single quiet stderr note rather than an error — but it
172            // fires every process (not per-worker) so a CWD-relative default that
173            // silently misses its files (a very easy mistake for anything not run
174            // from the repo root, e.g. an embedding app) is at least visible once.
175            warn_missing_once(&enc, &dec, &bbx);
176            return None;
177        }
178        // The decoder's KV-cache grows by one entry every autoregressive step, so
179        // its input shapes differ on every `run()` call. ONNX Runtime's memory
180        // pattern optimizer assumes stable shapes to plan buffer reuse; disabling
181        // it for this session avoids repeatedly re-validating/re-touching that
182        // plan (and the external-weights file) on each step.
183        let build = |path: &str, mem_pattern: bool| -> Result<Session, String> {
184            let builder = Session::builder()
185                .map_err(|e| e.to_string())?
186                .with_intra_threads(intra)
187                .map_err(|e| e.to_string())?
188                .with_memory_pattern(mem_pattern)
189                .map_err(|e| e.to_string())?;
190            crate::ep::apply(builder)?
191                .commit_from_file(path)
192                .map_err(|e| format!("tableformer load {path}: {e}"))
193        };
194        match (build(&enc, true), build(&dec, false), build(&bbx, true)) {
195            (Ok(encoder), Ok(decoder), Ok(bbox)) => {
196                let has = |n: &str| decoder.inputs().iter().any(|i| i.name() == n);
197                let style = if has("cross_kt_0") {
198                    DecoderStyle::KvHoisted
199                } else if has("cache_k") {
200                    DecoderStyle::KvStacked
201                } else {
202                    DecoderStyle::Legacy
203                };
204                if style == DecoderStyle::KvHoisted
205                    && !encoder.outputs().iter().any(|o| o.name() == "cross_kt_0")
206                {
207                    eprintln!(
208                        "docling-pdf: tableformer decoder needs per-layer cross tensors \
209                         (cross_kt_*) the encoder doesn't emit — re-download or re-export \
210                         the model set (scripts/install/export_tableformer.py); \
211                         falling back to geometric tables"
212                    );
213                    return None;
214                }
215                Some(Self {
216                    encoder,
217                    decoder,
218                    bbox,
219                    style,
220                })
221            }
222            _ => None,
223        }
224    }
225
226    /// Run the image encoder and capture what the cached decoder loop needs: each
227    /// decoder layer's cross-attention K/V (projected from the image memory once,
228    /// shape `[N_LAYERS,1,H,S,head_dim]`) and `enc_out` for the bbox decoder.
229    fn encode(&mut self, img: &RgbImage) -> Result<EncodeOut, String> {
230        let input = preprocess(img)?;
231        let mut enc_out = self
232            .encoder
233            .run(ort::inputs!["image" => input])
234            .map_err(|e| format!("tableformer: encode: {e}"))?;
235        let mut per_layer = Vec::new();
236        if self.style == DecoderStyle::KvHoisted {
237            for prefix in ["cross_kt_", "cross_v_"] {
238                for i in 0.. {
239                    let name = format!("{prefix}{i}");
240                    match enc_out.remove(&name) {
241                        Some(v) => per_layer.push((name, v)),
242                        None => break,
243                    }
244                }
245            }
246            if per_layer.is_empty() {
247                return Err("tableformer: encoder emitted no cross_kt_* outputs".into());
248            }
249        }
250        let mut grab = |name: &str| -> Result<DynValue, String> {
251            enc_out
252                .remove(name)
253                .ok_or_else(|| format!("tableformer: encoder output {name} missing"))
254        };
255        Ok(EncodeOut {
256            ck: grab("cross_k")?,
257            cv: grab("cross_v")?,
258            eo: grab("enc_out")?,
259            per_layer,
260        })
261    }
262
263    /// One doubly-cached decode step: feed the current `tags`, the constant cross
264    /// K/V, and the growing self-attention `cache`; return the raw argmax tag and
265    /// the last token's hidden state, advancing the cache. The cache stays an owned
266    /// `ort` value — the previous step's `out_cache` output is fed back directly,
267    /// never extracted or copied (it grows every step, so per-step copies were
268    /// O(steps²) float traffic). `empty_cache` is the zero-`past` value used on the
269    /// first step (ort's array constructors reject a 0-length dim, so it is
270    /// allocated through the session allocator by the caller).
271    fn decode_step(
272        &mut self,
273        tags: &[i64],
274        enc: &EncodeOut,
275        cache: &mut DecodeCache,
276        empty: &EmptyCache,
277    ) -> Result<(i64, Vec<f32>), String> {
278        crate::timing::timed("tf.decode_step", || {
279            self.decode_step_inner(tags, enc, cache, empty)
280        })
281    }
282
283    fn decode_step_inner(
284        &mut self,
285        tags: &[i64],
286        enc: &EncodeOut,
287        cache: &mut DecodeCache,
288        empty: &EmptyCache,
289    ) -> Result<(i64, Vec<f32>), String> {
290        let mut dout = match self.style {
291            DecoderStyle::KvHoisted => {
292                // #97 graph: one tag; the constant per-layer cross tensors are
293                // borrowed views — the step pays nothing proportional to them.
294                let last = *tags.last().expect("decode starts from <start>");
295                let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
296                    .map_err(|e| format!("tableformer: tag: {e}"))?;
297                let mut inputs: Vec<(
298                    std::borrow::Cow<'_, str>,
299                    ort::session::SessionInputValue<'_>,
300                )> = Vec::with_capacity(3 + enc.per_layer.len());
301                inputs.push(("tag".into(), tag_t.into()));
302                match (cache.a.as_ref(), cache.b.as_ref()) {
303                    (Some(k), Some(v)) => {
304                        inputs.push(("cache_k".into(), k.into()));
305                        inputs.push(("cache_v".into(), v.into()));
306                    }
307                    _ => {
308                        inputs.push(("cache_k".into(), (&empty.0).into()));
309                        inputs.push((
310                            "cache_v".into(),
311                            empty
312                                .1
313                                .as_ref()
314                                .expect("kv empty cache has both halves")
315                                .into(),
316                        ));
317                    }
318                }
319                for (name, v) in &enc.per_layer {
320                    inputs.push((name.as_str().into(), v.into()));
321                }
322                self.decoder.run(inputs)
323            }
324            DecoderStyle::KvStacked => {
325                // Pre-#97 KV graph: feed only the newly emitted tag; the projected
326                // K/V for the whole prefix live in cache_k/cache_v and are fed
327                // back as-is.
328                let last = *tags.last().expect("decode starts from <start>");
329                let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
330                    .map_err(|e| format!("tableformer: tag: {e}"))?;
331                match (cache.a.as_ref(), cache.b.as_ref()) {
332                    (Some(k), Some(v)) => self.decoder.run(ort::inputs![
333                        "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
334                        "cache_k" => k, "cache_v" => v]),
335                    _ => self.decoder.run(ort::inputs![
336                        "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
337                        "cache_k" => &empty.0,
338                        "cache_v" => empty.1.as_ref().expect("kv empty cache has both halves")]),
339                }
340            }
341            DecoderStyle::Legacy => {
342                let tags_t = Tensor::from_array(([tags.len(), 1usize], tags.to_vec()))
343                    .map_err(|e| format!("tableformer: tags: {e}"))?;
344                match cache.a.as_ref() {
345                    None => self.decoder.run(ort::inputs![
346                        "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
347                        "cache" => &empty.0]),
348                    Some(c) => self.decoder.run(ort::inputs![
349                        "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
350                        "cache" => c]),
351                }
352            }
353        }
354        .map_err(|e| format!("tableformer: decode: {e}"))?;
355        let (_, logits) = dout["logits"]
356            .try_extract_tensor::<f32>()
357            .map_err(|e| format!("tableformer: logits: {e}"))?;
358        let raw = argmax(logits) as i64;
359        let (_, hidden) = dout["hidden"]
360            .try_extract_tensor::<f32>()
361            .map_err(|e| format!("tableformer: hidden: {e}"))?;
362        let hidden = hidden.to_vec();
363        if self.style != DecoderStyle::Legacy {
364            cache.a = Some(
365                dout.remove("out_cache_k")
366                    .ok_or_else(|| "tableformer: out_cache_k missing".to_string())?,
367            );
368            cache.b = Some(
369                dout.remove("out_cache_v")
370                    .ok_or_else(|| "tableformer: out_cache_v missing".to_string())?,
371            );
372        } else {
373            cache.a = Some(
374                dout.remove("out_cache")
375                    .ok_or_else(|| "tableformer: decoder output out_cache missing".to_string())?,
376            );
377        }
378        Ok((raw, hidden))
379    }
380
381    /// The zero-`past` first-step cache(s), allocated through the session
382    /// allocator (ort's array constructors reject a 0-length dim; the C API does
383    /// allow it).
384    fn empty_cache(&self) -> Result<EmptyCache, String> {
385        let alloc = self.decoder.allocator();
386        if self.style != DecoderStyle::Legacy {
387            let mk = || {
388                Tensor::<f32>::new(alloc, [N_LAYERS, 1, KV_HEADS, 0usize, KV_HEAD_DIM])
389                    .map_err(|e| format!("tableformer: empty kv cache: {e}"))
390            };
391            Ok((mk()?, Some(mk()?)))
392        } else {
393            let c = Tensor::<f32>::new(alloc, [N_LAYERS, 0usize, 1, EMBED_DIM])
394                .map_err(|e| format!("tableformer: empty cache: {e}"))?;
395            Ok((c, None))
396        }
397    }
398
399    /// Predict the OTSL structure-token sequence for a table-region image.
400    pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
401        let enc = self.encode(img)?;
402        // The two structure corrections mirror docling's `predict` exactly — note
403        // its `line_num` is never incremented, so `xcel→lcel` applies on every row.
404        let mut tags: Vec<i64> = vec![START];
405        let mut out: Vec<i64> = Vec::new();
406        let mut prev_ucel = false;
407        let mut cache = DecodeCache::default();
408        let empty = self.empty_cache()?;
409        while out.len() < MAX_STEPS {
410            let (raw, _hidden) = self.decode_step(&tags, &enc, &mut cache, &empty)?;
411            let mut tag = raw;
412            if tag == XCEL {
413                tag = LCEL;
414            }
415            if prev_ucel && tag == LCEL {
416                tag = FCEL;
417            }
418            if tag == END {
419                break;
420            }
421            out.push(tag);
422            tags.push(tag);
423            prev_ucel = tag == UCEL;
424        }
425        Ok(out)
426    }
427
428    /// Full structure prediction: OTSL grid cells with per-cell boxes (in the 448
429    /// image, normalized cxcywh). Collects per-cell decoder hidden states using
430    /// docling's exact bbox bookkeeping (skip-after-row-break, first-lcel of a
431    /// horizontal span), runs the bbox decoder, merges span boxes, then lays the
432    /// cells onto the OTSL grid with row/col spans.
433    pub fn predict_table_structure(&mut self, img: &RgbImage) -> Result<Vec<TableCell>, String> {
434        let enc = self.encode(img)?;
435
436        let mut tags: Vec<i64> = vec![START];
437        let mut otsl: Vec<i64> = Vec::new();
438        let mut hiddens: Vec<f32> = Vec::new(); // flattened [n, 512]
439        let mut n = 0usize;
440        let mut prev_ucel = false;
441        let mut skip = true; // first tag after <start> is skipped
442        let mut first_lcel = true;
443        let mut bbox_ind = 0usize;
444        let mut cur_bbox_ind = 0usize;
445        let mut merge: std::collections::HashMap<usize, i64> = std::collections::HashMap::new();
446        let mut cache = DecodeCache::default();
447        let empty = self.empty_cache()?;
448        while otsl.len() < MAX_STEPS {
449            let (raw, hidden) = self.decode_step(&tags, &enc, &mut cache, &empty)?;
450            let mut tag = raw;
451            if tag == XCEL {
452                tag = LCEL;
453            }
454            if prev_ucel && tag == LCEL {
455                tag = FCEL;
456            }
457            if tag == END {
458                break;
459            }
460            // docling's tag_H_buf / bboxes_to_merge bookkeeping.
461            if !skip && matches!(tag, FCEL | ECEL | CHED | RHED | SROW | NL | UCEL) {
462                hiddens.extend_from_slice(&hidden);
463                n += 1;
464                if !first_lcel {
465                    merge.insert(cur_bbox_ind, bbox_ind as i64);
466                }
467                bbox_ind += 1;
468            }
469            if tag != LCEL {
470                first_lcel = true;
471            } else if first_lcel {
472                hiddens.extend_from_slice(&hidden);
473                n += 1;
474                first_lcel = false;
475                cur_bbox_ind = bbox_ind;
476                merge.insert(cur_bbox_ind, -1);
477                bbox_ind += 1;
478            }
479            skip = matches!(tag, NL | UCEL | XCEL);
480            prev_ucel = tag == UCEL;
481            otsl.push(tag);
482            tags.push(tag);
483        }
484        if n == 0 {
485            return Ok(Vec::new());
486        }
487        let tag_h = Tensor::from_array(([n, 512usize], hiddens))
488            .map_err(|e| format!("tableformer: tag_h: {e}"))?;
489        let bout = self
490            .bbox
491            .run(ort::inputs!["enc_out" => &enc.eo, "tag_h" => tag_h])
492            .map_err(|e| format!("tableformer: bbox: {e}"))?;
493        let (_, raw) = bout["boxes"]
494            .try_extract_tensor::<f32>()
495            .map_err(|e| format!("tableformer: boxes: {e}"))?;
496        let boxes: Vec<[f32; 4]> = raw
497            .chunks_exact(4)
498            .map(|c| [c[0], c[1], c[2], c[3]])
499            .collect();
500        // Per-cell class logits [n, 3] → argmax (docling's `outputs_class`).
501        let (_, craw) = bout["classes"]
502            .try_extract_tensor::<f32>()
503            .map_err(|e| format!("tableformer: classes: {e}"))?;
504        let classes: Vec<i64> = craw.chunks_exact(3).map(|c| argmax(c) as i64).collect();
505        let (merged, merged_classes) = merge_spans(&boxes, &classes, &merge);
506        Ok(build_table_cells(&otsl, &merged, &merged_classes))
507    }
508
509    /// Predict a table region's Markdown grid: crop the region (docling's
510    /// page→1024px box-average then bbox crop), run the structure model, then
511    /// match the page's word cells into the predicted cells with docling's
512    /// matching post-processor ([`crate::tf_match`]) and expand spans into a
513    /// dense `rows × cols` grid. `region` is `(l, t, r, b)` in page points
514    /// (top-left). Returns `None` if no structure is predicted.
515    pub fn predict_table_rows(
516        &mut self,
517        page_image: &RgbImage,
518        region: [f32; 4],
519        words: &[TextCell],
520    ) -> Option<Vec<Vec<String>>> {
521        // page → 1024px height (cv2.INTER_AREA), then crop the table bbox.
522        // docling's coordinate chain, rounding included: the cluster bbox is
523        // rounded to integer page points *first* (`round(cluster.bbox.l) *
524        // scale`, banker's rounding), scaled by 2 (its table-structure page
525        // scale), then by `1024 / <2x page-image height>`, and the crop indices
526        // round again. Rounding after scaling instead shifts some crops by a
527        // pixel — enough to change TableFormer's cell boxes on tall tables
528        // (redp5110's TOC).
529        let sf = 1024.0 / page_image.height() as f32;
530        let pw = (page_image.width() as f32 * sf) as u32;
531        let page1024 = crate::timing::timed("tableformer.inter_area", || {
532            crate::resample::inter_area(page_image, pw, 1024)
533        });
534        let k = 2.0 * 1024.0 / page_image.height() as f64;
535        let px = |v: f32| (v as f64).round_ties_even() * k;
536        let x = (px(region[0]).round_ties_even()).max(0.0) as u32;
537        let y = (px(region[1]).round_ties_even()).max(0.0) as u32;
538        let x2 = (px(region[2]).round_ties_even() as u32).min(page1024.width());
539        let y2 = (px(region[3]).round_ties_even() as u32).min(page1024.height());
540        if x2 <= x || y2 <= y {
541            return None;
542        }
543        let crop = image::imageops::crop_imm(&page1024, x, y, x2 - x, y2 - y).to_image();
544        let cells = crate::timing::timed("tableformer.structure", || {
545            self.predict_table_structure(&crop)
546        })
547        .ok()?;
548        if cells.is_empty() {
549            return None;
550        }
551        // Words that belong to the table: non-empty text, ≥80 % of the word's
552        // area inside the table region (docling's `get_cells_in_bbox` ios test).
553        // Ids stay the page-level word indices so text joins in stream order.
554        let table_words: Vec<crate::tf_match::PdfWord> = words
555            .iter()
556            .enumerate()
557            .filter(|(_, w)| !w.text.trim().is_empty())
558            .filter_map(|(wi, w)| {
559                let (l, t, r, b) = (w.l as f64, w.t as f64, w.r as f64, w.b as f64);
560                let area = (r - l) * (b - t);
561                let iw = (r.min(region[2] as f64) - l.max(region[0] as f64)).max(0.0);
562                let ih = (b.min(region[3] as f64) - t.max(region[1] as f64)).max(0.0);
563                if area > 0.0 && iw * ih / area > 0.8 {
564                    Some(crate::tf_match::PdfWord {
565                        id: wi,
566                        bbox: [l, t, r, b],
567                        text: w.text.trim().to_string(),
568                    })
569                } else {
570                    None
571                }
572            })
573            .collect();
574
575        if !table_words.is_empty() && !simple_match() {
576            return docling_match_rows(&cells, region, &table_words, words);
577        }
578
579        let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
580
581        // Cell boxes in page points (top-left), aligned with `cells`.
582        let boxes: Vec<[f32; 4]> = cells
583            .iter()
584            .map(|c| {
585                [
586                    region[0] + (c.cx - c.w / 2.0) * rw,
587                    region[1] + (c.cy - c.h / 2.0) * rh,
588                    region[0] + (c.cx + c.w / 2.0) * rw,
589                    region[1] + (c.cy + c.h / 2.0) * rh,
590                ]
591            })
592            .collect();
593
594        // Assign each word to the cell it overlaps most (intersection / word area).
595        let mut cell_words: Vec<Vec<usize>> = vec![Vec::new(); cells.len()];
596        for (wi, w) in words.iter().enumerate() {
597            let wa = ((w.r - w.l) * (w.b - w.t)).max(1.0);
598            let mut best: Option<(f32, usize)> = None;
599            for (ci, b) in boxes.iter().enumerate() {
600                let ix = (w.r.min(b[2]) - w.l.max(b[0])).max(0.0);
601                let iy = (w.b.min(b[3]) - w.t.max(b[1])).max(0.0);
602                let io = ix * iy / wa;
603                if io > 0.0 && best.is_none_or(|(bo, _)| io > bo) {
604                    best = Some((io, ci));
605                }
606            }
607            if let Some((_, ci)) = best {
608                cell_words[ci].push(wi);
609            }
610        }
611
612        let num_rows = cells.iter().map(|c| c.row + c.rowspan).max().unwrap_or(0);
613        let num_cols = cells.iter().map(|c| c.col + c.colspan).max().unwrap_or(0);
614        if num_rows == 0 || num_cols == 0 {
615            return None;
616        }
617        let mut grid = vec![vec![String::new(); num_cols]; num_rows];
618        for (ci, c) in cells.iter().enumerate() {
619            // Keep words in text-stream order (the order they were collected =
620            // their word index), matching docling's cell text assembly — geometric
621            // re-sorting scrambles wrapped cells (`Inference time (secs)`).
622            let wis = std::mem::take(&mut cell_words[ci]);
623            let text = wis
624                .iter()
625                .map(|&i| words[i].text.trim())
626                .collect::<Vec<_>>()
627                .join(" ");
628            let text = normalize_cell_text(text);
629            // Spanned cells repeat their text across the covered grid positions.
630            for row in grid.iter_mut().skip(c.row).take(c.rowspan) {
631                for cell in row.iter_mut().skip(c.col).take(c.colspan) {
632                    *cell = text.clone();
633                }
634            }
635        }
636        Some(grid)
637    }
638}
639
640/// Append one JSON line per table into `<dir>/tf_match_dump.jsonl` with the
641/// exact matcher inputs (hand-rolled JSON to avoid a serde dependency).
642fn dump_match_inputs(
643    dir: &str,
644    tf_cells: &[crate::tf_match::TfCell],
645    words: &[crate::tf_match::PdfWord],
646) {
647    use std::io::Write;
648    let cells: Vec<String> = tf_cells
649        .iter()
650        .map(|c| {
651            format!(
652                r#"{{"bbox":[{},{},{},{}],"cell_id":{},"row_id":{},"column_id":{},"cell_class":{},"colspan_val":{},"rowspan_val":{}}}"#,
653                c.bbox[0], c.bbox[1], c.bbox[2], c.bbox[3],
654                c.cell_id, c.row_id, c.column_id, c.cell_class,
655                c.colspan_val, c.rowspan_val
656            )
657        })
658        .collect();
659    let ws: Vec<String> = words
660        .iter()
661        .map(|w| {
662            format!(
663                r#"{{"id":{},"bbox":[{},{},{},{}],"text":{}}}"#,
664                w.id,
665                w.bbox[0],
666                w.bbox[1],
667                w.bbox[2],
668                w.bbox[3],
669                serde_json_escape(&w.text)
670            )
671        })
672        .collect();
673    let line = format!(
674        r#"{{"table_cells":[{}],"pdf_cells":[{}]}}"#,
675        cells.join(","),
676        ws.join(",")
677    );
678    if let Ok(mut f) = std::fs::OpenOptions::new()
679        .create(true)
680        .append(true)
681        .open(format!("{dir}/tf_match_dump.jsonl"))
682    {
683        let _ = writeln!(f, "{line}");
684    }
685}
686
687/// Minimal JSON string escaping for the parity dump.
688fn serde_json_escape(s: &str) -> String {
689    let mut out = String::with_capacity(s.len() + 2);
690    out.push('"');
691    for ch in s.chars() {
692        match ch {
693            '"' => out.push_str("\\\""),
694            '\\' => out.push_str("\\\\"),
695            '\n' => out.push_str("\\n"),
696            '\r' => out.push_str("\\r"),
697            '\t' => out.push_str("\\t"),
698            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
699            c => out.push(c),
700        }
701    }
702    out.push('"');
703    out
704}
705
706/// `DOCLING_RS_TF_SIMPLE_MATCH=1` reverts to the pre-#60 best-overlap word
707/// assignment (A/B escape hatch for the docling matching post-processor).
708fn simple_match() -> bool {
709    std::env::var("DOCLING_RS_TF_SIMPLE_MATCH").is_ok_and(|v| !v.is_empty() && v != "0")
710}
711
712/// docling glues `@` to whatever follows it (`mAP @0.5`, an email):
713/// the PDF's word cells split `@` from the next token, and joining them
714/// with a space would widen the cell and — via the column pad — shift
715/// every row of the table. The groundtruth never contains "@ ", so this
716/// is always the right normalization.
717fn normalize_cell_text(text: String) -> String {
718    text.replace("@ ", "@")
719}
720
721/// docling's matched-cell grid assembly (`tf_predictor.predict` with
722/// `do_cell_matching=True`): run the ported matching post-processor, group the
723/// word→cell assignments per grid position, compress the surviving row/column
724/// ids to sequential indexes, and expand spans into a dense `rows × cols` text
725/// grid. Matching runs in docling's coordinate space — the table bbox rounded
726/// to integers, everything ×2 (its page scale) — so the post-processor's
727/// absolute rounding agrees.
728fn docling_match_rows(
729    cells: &[TableCell],
730    region: [f32; 4],
731    table_words: &[crate::tf_match::PdfWord],
732    words: &[TextCell],
733) -> Option<Vec<Vec<String>>> {
734    const SCALE: f64 = 2.0; // docling's table-structure page scale
735    let sl = (region[0] as f64).round_ties_even() * SCALE;
736    let st = (region[1] as f64).round_ties_even() * SCALE;
737    let sr = (region[2] as f64).round_ties_even() * SCALE;
738    let sb = (region[3] as f64).round_ties_even() * SCALE;
739    let (w2, h2) = (sr - sl, sb - st);
740
741    let tf_cells: Vec<crate::tf_match::TfCell> = cells
742        .iter()
743        .enumerate()
744        .map(|(i, c)| {
745            let (cx, cy) = (c.cx as f64, c.cy as f64);
746            let (w, h) = (c.w as f64, c.h as f64);
747            crate::tf_match::TfCell {
748                bbox: [
749                    sl + (cx - w / 2.0) * w2,
750                    st + (cy - h / 2.0) * h2,
751                    sl + (cx + w / 2.0) * w2,
752                    st + (cy + h / 2.0) * h2,
753                ],
754                cell_id: i,
755                row_id: c.row,
756                column_id: c.col,
757                cell_class: c.class,
758                colspan_val: if c.colspan > 1 { c.colspan } else { 0 },
759                rowspan_val: if c.rowspan > 1 { c.rowspan } else { 0 },
760            }
761        })
762        .collect();
763
764    let scaled_words: Vec<crate::tf_match::PdfWord> = table_words
765        .iter()
766        .map(|w| crate::tf_match::PdfWord {
767            id: w.id,
768            bbox: [
769                w.bbox[0] * SCALE,
770                w.bbox[1] * SCALE,
771                w.bbox[2] * SCALE,
772                w.bbox[3] * SCALE,
773            ],
774            text: w.text.clone(),
775        })
776        .collect();
777
778    // Debug: dump the matcher inputs as JSON lines for a side-by-side run
779    // against docling's Python post-processor (parity harness, not a feature).
780    if let Ok(dir) = std::env::var("DOCLING_RS_TF_MATCH_DUMP") {
781        if !dir.is_empty() {
782            dump_match_inputs(&dir, &tf_cells, &scaled_words);
783        }
784    }
785
786    let (cells_wo, final_matches) =
787        crate::tf_match::match_and_post_process(tf_cells, &scaled_words);
788
789    // `_merge_tf_output`: group per (column, row) in ascending-pdf-id order;
790    // the first word's table cell fixes the group's offsets and spans.
791    struct Merged {
792        start_row: usize,
793        start_col: usize,
794        row_span: usize,
795        col_span: usize,
796        word_ids: Vec<usize>,
797    }
798    let mut merged: Vec<Merged> = Vec::new();
799    let mut key_ix: std::collections::HashMap<(usize, usize), usize> =
800        std::collections::HashMap::new();
801    for (&pdf_id, list) in &final_matches {
802        let tm = list[0].table_cell_id;
803        let Some(cell) = cells_wo.iter().find(|c| c.cell_id == tm) else {
804            continue;
805        };
806        match key_ix.entry((cell.column_id, cell.row_id)) {
807            std::collections::hash_map::Entry::Occupied(e) => {
808                merged[*e.get()].word_ids.push(pdf_id);
809            }
810            std::collections::hash_map::Entry::Vacant(e) => {
811                e.insert(merged.len());
812                merged.push(Merged {
813                    start_row: cell.row_id,
814                    start_col: cell.column_id,
815                    row_span: cell.rowspan_val.max(1),
816                    col_span: cell.colspan_val.max(1),
817                    word_ids: vec![pdf_id],
818                });
819            }
820        }
821    }
822    if merged.is_empty() {
823        return None;
824    }
825
826    // `multi_table_predict`'s sort_row_col_indexes: compress the surviving
827    // row/column ids to gap-free indexes.
828    let mut start_cols: Vec<usize> = merged.iter().map(|m| m.start_col).collect();
829    start_cols.sort_unstable();
830    start_cols.dedup();
831    let mut start_rows: Vec<usize> = merged.iter().map(|m| m.start_row).collect();
832    start_rows.sort_unstable();
833    start_rows.dedup();
834    let mut num_rows = 0;
835    let mut num_cols = 0;
836    for m in &mut merged {
837        m.start_col = start_cols.binary_search(&m.start_col).expect("own value");
838        m.start_row = start_rows.binary_search(&m.start_row).expect("own value");
839        num_cols = num_cols.max(m.start_col + m.col_span);
840        num_rows = num_rows.max(m.start_row + m.row_span);
841    }
842    if num_rows == 0 || num_cols == 0 {
843        return None;
844    }
845
846    let mut grid = vec![vec![String::new(); num_cols]; num_rows];
847    for m in &merged {
848        let text = m
849            .word_ids
850            .iter()
851            .map(|&i| words[i].text.trim())
852            .collect::<Vec<_>>()
853            .join(" ");
854        let text = normalize_cell_text(text);
855        for row in grid.iter_mut().skip(m.start_row).take(m.row_span) {
856            for cell in row.iter_mut().skip(m.start_col).take(m.col_span) {
857                *cell = text.clone();
858            }
859        }
860    }
861    Some(grid)
862}
863
864/// Note once per process that TableFormer's ONNX graphs weren't found, so tables
865/// fall back to geometric reconstruction. The default paths are relative
866/// (`models/tableformer/*.onnx`), which only resolves when the process's current
867/// directory happens to be the repo root — a very easy miss for anything else
868/// (an embedding app, a binding invoked from a different working directory, …),
869/// and previously failed with no signal at all.
870fn warn_missing_once(enc: &str, dec: &str, bbx: &str) {
871    static WARNED: std::sync::Once = std::sync::Once::new();
872    WARNED.call_once(|| {
873        eprintln!(
874            "docling.rs: TableFormer models not found (checked {enc}, {dec}, {bbx}); \
875             tables will use geometric reconstruction instead of ML table-structure \
876             recognition. Set DOCLING_TABLEFORMER_ENCODER / DOCLING_TABLEFORMER_DECODER \
877             / DOCLING_TABLEFORMER_BBOX to enable it (see README.md)."
878        );
879    });
880}
881
882/// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448²,
883/// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes
884/// (2,1,0), so width is the major spatial axis. The page→1024px box-average
885/// (cv2.INTER_AREA) is the caller's job.
886fn preprocess(img: &RgbImage) -> Result<Tensor<f32>, String> {
887    let nn = (SIDE * SIDE) as usize;
888    let side = SIDE as usize;
889    let (sw, sh) = (img.width() as i32, img.height() as i32);
890    let sxr = sw as f32 / SIDE as f32;
891    let syr = sh as f32 / SIDE as f32;
892    let mut data = vec![0f32; 3 * nn];
893    for h in 0..side {
894        let fy = (h as f32 + 0.5) * syr - 0.5;
895        let wy = fy - fy.floor();
896        let y0c = (fy.floor() as i32).clamp(0, sh - 1) as u32;
897        let y1c = (fy.floor() as i32 + 1).clamp(0, sh - 1) as u32;
898        for w in 0..side {
899            let fx = (w as f32 + 0.5) * sxr - 0.5;
900            let wx = fx - fx.floor();
901            let x0c = (fx.floor() as i32).clamp(0, sw - 1) as u32;
902            let x1c = (fx.floor() as i32 + 1).clamp(0, sw - 1) as u32;
903            let p00 = img.get_pixel(x0c, y0c);
904            let p01 = img.get_pixel(x1c, y0c);
905            let p10 = img.get_pixel(x0c, y1c);
906            let p11 = img.get_pixel(x1c, y1c);
907            let idx = w * side + h; // (C, W, H): c*n + w*H + h
908            for c in 0..3 {
909                let top = p00[c] as f32 * (1.0 - wx) + p01[c] as f32 * wx;
910                let bot = p10[c] as f32 * (1.0 - wx) + p11[c] as f32 * wx;
911                let v = top * (1.0 - wy) + bot * wy;
912                data[c * nn + idx] = (v / 255.0 - MEAN[c]) / STD[c];
913            }
914        }
915    }
916    Tensor::from_array(([1usize, 3, side, side], data))
917        .map_err(|e| format!("tableformer: input: {e}"))
918}
919
920/// docling's `mergebboxes` (cxcywh): the union box of a horizontal span's first
921/// and last cell.
922fn mergebboxes(b1: [f32; 4], b2: [f32; 4]) -> [f32; 4] {
923    let new_w = (b2[0] + b2[2] / 2.0) - (b1[0] - b1[2] / 2.0);
924    let new_h = (b2[1] + b2[3] / 2.0) - (b1[1] - b1[3] / 2.0);
925    let new_left = b1[0] - b1[2] / 2.0;
926    let new_top = (b2[1] - b2[3] / 2.0).min(b1[1] - b1[3] / 2.0);
927    [new_left + new_w / 2.0, new_top + new_h / 2.0, new_w, new_h]
928}
929
930/// Apply docling's span merges: each merge key combines its box with the partner
931/// (`-1` → the last box); partners are dropped. The merged cell keeps the
932/// *first* box's class, matching docling's `outputs_class1.append(cls1)`.
933fn merge_spans(
934    boxes: &[[f32; 4]],
935    classes: &[i64],
936    merge: &std::collections::HashMap<usize, i64>,
937) -> (Vec<[f32; 4]>, Vec<i64>) {
938    let skip: std::collections::HashSet<usize> = merge
939        .values()
940        .filter(|&&v| v >= 0)
941        .map(|&v| v as usize)
942        .collect();
943    let mut out = Vec::new();
944    let mut out_classes = Vec::new();
945    for (i, &b) in boxes.iter().enumerate() {
946        let class = classes.get(i).copied().unwrap_or(2);
947        if let Some(&j) = merge.get(&i) {
948            let partner = if j < 0 { boxes.len() - 1 } else { j as usize };
949            out.push(mergebboxes(b, boxes[partner.min(boxes.len() - 1)]));
950            out_classes.push(class);
951        } else if !skip.contains(&i) {
952            out.push(b);
953            out_classes.push(class);
954        }
955    }
956    (out, out_classes)
957}
958
959const CELL_TAGS: [i64; 6] = [FCEL, ECEL, XCEL, CHED, RHED, SROW];
960
961/// Lay the OTSL tag stream onto a grid (docling's `_build_table_cells`, OTSL
962/// mode): cell tags create cells at (row, col); `lcel`/`ucel`/`xcel` are spans
963/// (counted toward the column index but not cells). Colspan/rowspan are read off
964/// the grid (consecutive `lcel`/`ucel` to the right/below). `boxes` are indexed
965/// by cell order and aligned with the cells.
966fn build_table_cells(otsl: &[i64], boxes: &[[f32; 4]], classes: &[i64]) -> Vec<TableCell> {
967    // 2D grid of tags (rows split on NL) for span lookups.
968    let mut grid: Vec<Vec<i64>> = vec![Vec::new()];
969    for &t in otsl {
970        if t == NL {
971            grid.push(Vec::new());
972        } else {
973            grid.last_mut().unwrap().push(t);
974        }
975    }
976    let mut cells = Vec::new();
977    let mut cell_id = 0usize;
978    for (r, row) in grid.iter().enumerate() {
979        for (c, &tag) in row.iter().enumerate() {
980            if !CELL_TAGS.contains(&tag) {
981                continue;
982            }
983            let mut colspan = 1;
984            while c + colspan < row.len() && matches!(row[c + colspan], LCEL | XCEL) {
985                colspan += 1;
986            }
987            let mut rowspan = 1;
988            while r + rowspan < grid.len()
989                && grid[r + rowspan]
990                    .get(c)
991                    .is_some_and(|&t| matches!(t, UCEL | XCEL))
992            {
993                rowspan += 1;
994            }
995            let b = boxes.get(cell_id).copied().unwrap_or([0.0; 4]);
996            // docling defaults a class-less cell to 2 (full).
997            let class = classes.get(cell_id).copied().unwrap_or(2);
998            cells.push(TableCell {
999                row: r,
1000                col: c,
1001                colspan,
1002                rowspan,
1003                tag,
1004                class,
1005                cx: b[0],
1006                cy: b[1],
1007                w: b[2],
1008                h: b[3],
1009            });
1010            cell_id += 1;
1011        }
1012    }
1013    cells
1014}
1015
1016fn argmax(v: &[f32]) -> usize {
1017    v.iter()
1018        .enumerate()
1019        .max_by(|a, b| a.1.total_cmp(b.1))
1020        .map(|(i, _)| i)
1021        .unwrap_or(0)
1022}