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;
8// The ONNX-free half (preprocessing, structure corrections, bbox bookkeeping,
9// span merge, OTSL→grid) lives in tf_core so the browser build (#157 stage 3)
10// runs the same logic; this file owns the three `ort` sessions and the
11// owned-value KV-cache fast path.
12use crate::tf_core::{
13    argmax, build_table_cells, correct, merge_spans, preprocess_input, BboxBook, TableCell, END,
14    MAX_STEPS, START, UCEL,
15};
16use image::RgbImage;
17use ort::session::Session;
18use ort::value::{DynValue, Tensor};
19
20const SIDE: usize = crate::tf_core::SIDE as usize;
21const EMBED_DIM: usize = crate::tf_core::EMBED_DIM;
22/// Decoder geometry, fixed by the exported TableModel04_rs graph: the cached
23/// decoder threads a `[N_LAYERS, past, 1, EMBED_DIM]` per-layer state cache.
24const N_LAYERS: usize = 6;
25
26/// Resolve the encoder / decoder / bbox files exactly as [`TableFormer::load`]
27/// will (shared with `model_inventory`, so diagnostics can never drift from
28/// what actually loads). Explicit `DOCLING_TABLEFORMER_*` overrides win; the
29/// decoder otherwise picks by preference — INT8 variants first unless
30/// `DOCLING_RS_FP32` opts out, and within a precision the true-KV-cache
31/// export (`decoder_kv*`, one token per step, O(past) step cost) ranks ahead
32/// of the legacy layer-output-cache graph it matches byte-for-byte (91/91
33/// snapshot corpus exact with either; the KV graph re-measured ~13–17% faster
34/// warm, so speed wins the default and the legacy file stays as the smaller
35/// fallback). `decoder_kv` ranks ABOVE `decoder_int8`: the #97 hoisted fp32
36/// KV graph is faster than the quantized legacy graph on every machine
37/// measured, and it is byte-exact (its own int8 variant is not produced — see
38/// quantize_models.py).
39pub fn resolved_paths() -> (String, String, String) {
40    let enc = docling_core::env::nonempty("DOCLING_TABLEFORMER_ENCODER")
41        .unwrap_or_else(|| crate::resolve_asset(".models/tableformer/encoder.onnx"));
42    let dec = docling_core::env::nonempty("DOCLING_TABLEFORMER_DECODER").unwrap_or_else(|| {
43        let candidates: &[&str] = if crate::prefer_fp32() {
44            &[
45                ".models/tableformer/decoder_kv.onnx",
46                ".models/tableformer/decoder.onnx",
47            ]
48        } else {
49            &[
50                ".models/tableformer/decoder_kv_int8.onnx",
51                ".models/tableformer/decoder_kv.onnx",
52                ".models/tableformer/decoder_int8.onnx",
53                ".models/tableformer/decoder.onnx",
54            ]
55        };
56        candidates
57            .iter()
58            .map(|p| crate::resolve_asset(p))
59            .find(|p| std::path::Path::new(p).exists())
60            .unwrap_or_else(|| ".models/tableformer/decoder.onnx".to_string())
61    });
62    let bbx = docling_core::env::nonempty("DOCLING_TABLEFORMER_BBOX")
63        .unwrap_or_else(|| crate::resolve_asset(".models/tableformer/bbox.onnx"));
64    (enc, dec, bbx)
65}
66
67pub struct TableFormer {
68    encoder: Session,
69    decoder: Session,
70    bbox: Session,
71    /// Which decoder graph flavour is loaded, detected from the session's
72    /// input names (so an explicit `DOCLING_TABLEFORMER_DECODER` override
73    /// works with any of them).
74    style: DecoderStyle,
75    /// The `KvHoisted` decoder's `tag` input has a symbolic batch axis (the
76    /// dynamic-batch `decoder_kv.onnx` export): a page's tables decode
77    /// together, one step for all of them — see [`Self::predict_tables_on`].
78    /// The older fixed-`[1,1]` export decodes the tables one after another.
79    batched: bool,
80}
81
82/// The three decoder-graph generations the loop supports.
83#[derive(Clone, Copy, PartialEq, Eq)]
84enum DecoderStyle {
85    /// `decoder.onnx`: layer-output cache; feeds the full `tags` prefix and a
86    /// single `cache` every step.
87    Legacy,
88    /// The pre-#97 `decoder_kv.onnx`: one tag per step, `cache_k`/`cache_v`,
89    /// with the stacked `cross_k`/`cross_v` re-split inside every step.
90    KvStacked,
91    /// The #97 `decoder_kv.onnx`: one tag per step, and the constant cross
92    /// tensors arrive as 2×`N_LAYERS` per-layer inputs (`cross_kt_i` already
93    /// transposed for q·Kᵀ, `cross_v_i`), computed once per table by the
94    /// encoder — the step graph does no work proportional to their size.
95    KvHoisted,
96}
97
98/// KV-cache geometry fixed by the `decoder_kv.onnx` export
99/// (`[N_LAYERS, 1, KV_HEADS, past, KV_HEAD_DIM]`, `KV_HEADS × KV_HEAD_DIM = EMBED_DIM`).
100const KV_HEADS: usize = 8;
101const KV_HEAD_DIM: usize = 64;
102
103/// The autoregressive decode state: `a` is the legacy layer-output cache, or
104/// `cache_k` for the KV graph; `b` is `cache_v` (KV graph only). `None` = first
105/// step (the zero-`past` empties are allocated per table by [`TableFormer::empty_cache`]).
106#[derive(Default)]
107struct DecodeCache {
108    a: Option<DynValue>,
109    b: Option<DynValue>,
110}
111
112/// Zero-`past` first-step cache tensors: `(cache, None)` for the legacy graph,
113/// `(cache_k, Some(cache_v))` for the KV graph.
114type EmptyCache = (Tensor<f32>, Option<Tensor<f32>>);
115
116/// Encoder outputs that drive the cached decode loop: the per-layer cross-attention
117/// K/V (projected from the image memory once, constant across decode steps) and
118/// `enc_out` for the bbox decoder. Kept as owned `ort` values so each decode step
119/// (and the bbox run) borrows them directly — no per-step extract/copy/re-wrap.
120struct EncodeOut {
121    /// Stacked `[N_LAYERS,1,H,S,hd]` cross K/V — the `Legacy`/`KvStacked`
122    /// decoders' inputs. `None` for `KvHoisted`, which reads the per-layer
123    /// tensors instead: the stacked pair is 2×9.6 MB per table, and a page's
124    /// tables are now all held encoded at once for the batched loop.
125    ck: Option<DynValue>,
126    cv: Option<DynValue>,
127    eo: DynValue,
128    /// `KvHoisted` only: per-layer `[cross_kt_0..N, cross_v_0..N]`, index-aligned
129    /// with the decoder's input names, borrowed by every decode step.
130    per_layer: Vec<(String, DynValue)>,
131}
132
133impl TableFormer {
134    /// Load the exported encoder/decoder/bbox ONNX graphs (env overrides, else
135    /// `.models/tableformer/{encoder,decoder,bbox}.onnx`). Returns `None` if any is
136    /// absent, so the pipeline falls back to geometric reconstruction.
137    pub fn load() -> Option<Self> {
138        Self::load_with(crate::intra_threads())
139    }
140
141    /// Like [`load`](Self::load) but with an explicit intra-op thread count, so a
142    /// parallel page-worker pool can run each table model on fewer threads (the
143    /// throughput comes from running pages concurrently, not from one fat model).
144    ///
145    /// See [`resolved_paths`] for the encoder/decoder/bbox file selection.
146    pub fn load_with(intra: usize) -> Option<Self> {
147        // (resolution shared with the model inventory — see resolved_paths)
148        let (enc, dec, bbx) = resolved_paths();
149        if crate::timing::enabled() {
150            eprintln!("docling-pdf: tableformer decoder: {dec}");
151        }
152        if [&enc, &dec, &bbx]
153            .iter()
154            .any(|p| !std::path::Path::new(p).exists())
155        {
156            // The geometric fallback is a supported, intentional configuration
157            // (docling has no ML table-structure equivalent baked in either), so
158            // this stays a single quiet stderr note rather than an error — but it
159            // fires every process (not per-worker) so a CWD-relative default that
160            // silently misses its files (a very easy mistake for anything not run
161            // from the repo root, e.g. an embedding app) is at least visible once.
162            warn_missing_once(&enc, &dec, &bbx);
163            return None;
164        }
165        // The decoder's KV-cache grows by one entry every autoregressive step, so
166        // its input shapes differ on every `run()` call. ONNX Runtime's memory
167        // pattern optimizer assumes stable shapes to plan buffer reuse; disabling
168        // it for this session avoids repeatedly re-validating/re-touching that
169        // plan (and the external-weights file) on each step. The bbox head has
170        // the same problem one level up: its `tag_h` input is `[ncells, 512]`
171        // and every table has a different cell count, so with the pattern
172        // planner on each run re-plans — and on this graph the plan is *worse*
173        // than none: 290 ms vs 54 ms for a 100-cell table, 560 vs 94 ms for
174        // 200 cells (ORT 1.22, 4 threads). It was 0.26 s per table on the
175        // corpus, more than the encoder.
176        //
177        // The decoder runs on ONE intra-op thread. A step is 49 small GEMMs
178        // over a single token — it streams the layer weights, it does not
179        // compute — so extra threads only add synchronisation: measured 4.1 ms
180        // per step on 1 thread vs 5.5 on 4 (7.1 vs 4.9 once the cache is 100+
181        // long). In the pool it also stops a table decode from taking all the
182        // cores away from the other workers' layout inference. And a
183        // single-thread session has a fixed reduction order, so table
184        // structure no longer varies run-to-run on near-tie tokens the way
185        // multi-threaded float sums let it (the conformance scripts pin one
186        // thread for exactly that reason; the default now matches them). The
187        // encoder keeps the shared budget: one 448×448 CNN + transformer pass
188        // per table, 680 ms single-threaded vs 165 on four.
189        let build = |path: &str, mem_pattern: bool, threads: usize| -> Result<Session, String> {
190            let builder = Session::builder()
191                .map_err(|e| e.to_string())?
192                .with_intra_threads(threads)
193                .map_err(|e| e.to_string())?
194                .with_memory_pattern(mem_pattern)
195                .map_err(|e| e.to_string())?;
196            let variant = if mem_pattern {
197                "mem_pattern"
198            } else {
199                "no_mem_pattern"
200            };
201            docling_onnx::commit(docling_onnx::apply(builder)?, path, variant)
202                .map_err(|e| format!("tableformer load {path}: {e}"))
203        };
204        match (
205            build(&enc, true, intra),
206            build(&dec, false, 1),
207            build(&bbx, false, intra),
208        ) {
209            (Ok(encoder), Ok(decoder), Ok(bbox)) => {
210                let has = |n: &str| decoder.inputs().iter().any(|i| i.name() == n);
211                let style = if has("cross_kt_0") {
212                    DecoderStyle::KvHoisted
213                } else if has("cache_k") {
214                    DecoderStyle::KvStacked
215                } else {
216                    DecoderStyle::Legacy
217                };
218                if style == DecoderStyle::KvHoisted
219                    && !encoder.outputs().iter().any(|o| o.name() == "cross_kt_0")
220                {
221                    eprintln!(
222                        "docling-pdf: tableformer decoder needs per-layer cross tensors \
223                         (cross_kt_*) the encoder doesn't emit — re-download or re-export \
224                         the model set (scripts/install/export_tableformer.py); \
225                         falling back to geometric tables"
226                    );
227                    return None;
228                }
229                // Dynamic batch axis on `tag` ⇒ the export batches decode
230                // steps across tables (ort reports a symbolic dim as -1).
231                let batched = style == DecoderStyle::KvHoisted
232                    && decoder.inputs().iter().any(|i| {
233                        i.name() == "tag"
234                            && matches!(i.dtype(), ort::value::ValueType::Tensor { shape, .. }
235                                if shape.first().is_some_and(|d| *d < 0))
236                    });
237                if crate::timing::enabled() && batched {
238                    eprintln!("docling-pdf: tableformer decoder batches a page's tables per step");
239                }
240                Some(Self {
241                    encoder,
242                    decoder,
243                    bbox,
244                    style,
245                    batched,
246                })
247            }
248            _ => None,
249        }
250    }
251
252    /// Run the image encoder and capture what the cached decoder loop needs: each
253    /// decoder layer's cross-attention K/V (projected from the image memory once,
254    /// shape `[N_LAYERS,1,H,S,head_dim]`) and `enc_out` for the bbox decoder.
255    fn encode(&mut self, img: &RgbImage) -> Result<EncodeOut, String> {
256        let input = crate::timing::timed("tf.preprocess", || preprocess(img))?;
257        let mut enc_out = crate::timing::timed("tf.encoder", || {
258            self.encoder
259                .run(ort::inputs!["image" => input])
260                .map_err(|e| format!("tableformer: encode: {e}"))
261        })?;
262        let mut per_layer = Vec::new();
263        if self.style == DecoderStyle::KvHoisted {
264            for prefix in ["cross_kt_", "cross_v_"] {
265                for i in 0.. {
266                    let name = format!("{prefix}{i}");
267                    match enc_out.remove(&name) {
268                        Some(v) => per_layer.push((name, v)),
269                        None => break,
270                    }
271                }
272            }
273            if per_layer.is_empty() {
274                return Err("tableformer: encoder emitted no cross_kt_* outputs".into());
275            }
276        }
277        let mut grab = |name: &str| -> Result<DynValue, String> {
278            enc_out
279                .remove(name)
280                .ok_or_else(|| format!("tableformer: encoder output {name} missing"))
281        };
282        let hoisted = self.style == DecoderStyle::KvHoisted;
283        Ok(EncodeOut {
284            ck: if hoisted {
285                None
286            } else {
287                Some(grab("cross_k")?)
288            },
289            cv: if hoisted {
290                None
291            } else {
292                Some(grab("cross_v")?)
293            },
294            eo: grab("enc_out")?,
295            per_layer,
296        })
297    }
298
299    /// One doubly-cached decode step: feed the current `tags`, the constant cross
300    /// K/V, and the growing self-attention `cache`; return the raw argmax tag and
301    /// the last token's hidden state, advancing the cache. The cache stays an owned
302    /// `ort` value — the previous step's `out_cache` output is fed back directly,
303    /// never extracted or copied (it grows every step, so per-step copies were
304    /// O(steps²) float traffic). `empty_cache` is the zero-`past` value used on the
305    /// first step (ort's array constructors reject a 0-length dim, so it is
306    /// allocated through the session allocator by the caller).
307    fn decode_step(
308        &mut self,
309        tags: &[i64],
310        enc: &EncodeOut,
311        cache: &mut DecodeCache,
312        empty: &EmptyCache,
313    ) -> Result<(i64, Vec<f32>), String> {
314        crate::timing::timed("tf.decode_step", || {
315            self.decode_step_inner(tags, enc, cache, empty)
316        })
317    }
318
319    fn decode_step_inner(
320        &mut self,
321        tags: &[i64],
322        enc: &EncodeOut,
323        cache: &mut DecodeCache,
324        empty: &EmptyCache,
325    ) -> Result<(i64, Vec<f32>), String> {
326        if self.style == DecoderStyle::KvHoisted {
327            // #97 graph: one tag; the constant per-layer cross tensors are
328            // borrowed views — the step pays nothing proportional to them.
329            let last = *tags.last().expect("decode starts from <start>");
330            let (raws, hidden) = self.step_kv_hoisted(&[last], &enc.per_layer, cache, empty)?;
331            return Ok((raws[0], hidden));
332        }
333        let (ck, cv) = match (enc.ck.as_ref(), enc.cv.as_ref()) {
334            (Some(k), Some(v)) => (k, v),
335            _ => return Err("tableformer: stacked cross K/V missing".into()),
336        };
337        let mut dout = match self.style {
338            DecoderStyle::KvHoisted => unreachable!("handled above"),
339            DecoderStyle::KvStacked => {
340                // Pre-#97 KV graph: feed only the newly emitted tag; the projected
341                // K/V for the whole prefix live in cache_k/cache_v and are fed
342                // back as-is.
343                let last = *tags.last().expect("decode starts from <start>");
344                let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
345                    .map_err(|e| format!("tableformer: tag: {e}"))?;
346                match (cache.a.as_ref(), cache.b.as_ref()) {
347                    (Some(k), Some(v)) => self.decoder.run(ort::inputs![
348                        "tag" => tag_t, "cross_k" => ck, "cross_v" => cv,
349                        "cache_k" => k, "cache_v" => v]),
350                    _ => self.decoder.run(ort::inputs![
351                        "tag" => tag_t, "cross_k" => ck, "cross_v" => cv,
352                        "cache_k" => &empty.0,
353                        "cache_v" => empty.1.as_ref().expect("kv empty cache has both halves")]),
354                }
355            }
356            DecoderStyle::Legacy => {
357                let tags_t = Tensor::from_array(([tags.len(), 1usize], tags.to_vec()))
358                    .map_err(|e| format!("tableformer: tags: {e}"))?;
359                match cache.a.as_ref() {
360                    None => self.decoder.run(ort::inputs![
361                        "tags" => tags_t, "cross_k" => ck, "cross_v" => cv,
362                        "cache" => &empty.0]),
363                    Some(c) => self.decoder.run(ort::inputs![
364                        "tags" => tags_t, "cross_k" => ck, "cross_v" => cv,
365                        "cache" => c]),
366                }
367            }
368        }
369        .map_err(|e| format!("tableformer: decode: {e}"))?;
370        let (_, logits) = dout["logits"]
371            .try_extract_tensor::<f32>()
372            .map_err(|e| format!("tableformer: logits: {e}"))?;
373        let raw = argmax(logits) as i64;
374        let (_, hidden) = dout["hidden"]
375            .try_extract_tensor::<f32>()
376            .map_err(|e| format!("tableformer: hidden: {e}"))?;
377        let hidden = hidden.to_vec();
378        if self.style != DecoderStyle::Legacy {
379            cache.a = Some(
380                dout.remove("out_cache_k")
381                    .ok_or_else(|| "tableformer: out_cache_k missing".to_string())?,
382            );
383            cache.b = Some(
384                dout.remove("out_cache_v")
385                    .ok_or_else(|| "tableformer: out_cache_v missing".to_string())?,
386            );
387        } else {
388            cache.a = Some(
389                dout.remove("out_cache")
390                    .ok_or_else(|| "tableformer: decoder output out_cache missing".to_string())?,
391            );
392        }
393        Ok((raw, hidden))
394    }
395
396    /// One `KvHoisted` step over `tags.len()` rows — one table per row. `tags`
397    /// holds each row's last emitted tag, `per_layer` the cross tensors with a
398    /// matching leading batch axis (the encoder's own `[1,…]` outputs for a
399    /// single table, or [`Self::batch_cross`]'s concatenation), and the cache
400    /// grows `[N_LAYERS, rows, H, past, hd]` in lockstep. Returns each row's raw
401    /// argmax tag and the `[rows, EMBED_DIM]` hidden states, flattened.
402    fn step_kv_hoisted(
403        &mut self,
404        tags: &[i64],
405        per_layer: &[(String, DynValue)],
406        cache: &mut DecodeCache,
407        empty: &EmptyCache,
408    ) -> Result<(Vec<i64>, Vec<f32>), String> {
409        let rows = tags.len();
410        let tag_t = Tensor::from_array(([rows, 1usize], tags.to_vec()))
411            .map_err(|e| format!("tableformer: tag: {e}"))?;
412        let mut inputs: Vec<(
413            std::borrow::Cow<'_, str>,
414            ort::session::SessionInputValue<'_>,
415        )> = Vec::with_capacity(3 + per_layer.len());
416        inputs.push(("tag".into(), tag_t.into()));
417        match (cache.a.as_ref(), cache.b.as_ref()) {
418            (Some(k), Some(v)) => {
419                inputs.push(("cache_k".into(), k.into()));
420                inputs.push(("cache_v".into(), v.into()));
421            }
422            _ => {
423                inputs.push(("cache_k".into(), (&empty.0).into()));
424                inputs.push((
425                    "cache_v".into(),
426                    empty
427                        .1
428                        .as_ref()
429                        .expect("kv empty cache has both halves")
430                        .into(),
431                ));
432            }
433        }
434        for (name, v) in per_layer {
435            inputs.push((name.as_str().into(), v.into()));
436        }
437        let mut dout = self
438            .decoder
439            .run(inputs)
440            .map_err(|e| format!("tableformer: decode: {e}"))?;
441        let (_, logits) = dout["logits"]
442            .try_extract_tensor::<f32>()
443            .map_err(|e| format!("tableformer: logits: {e}"))?;
444        let vocab = logits.len() / rows;
445        let raws: Vec<i64> = logits
446            .chunks_exact(vocab)
447            .map(|row| argmax(row) as i64)
448            .collect();
449        let (_, hidden) = dout["hidden"]
450            .try_extract_tensor::<f32>()
451            .map_err(|e| format!("tableformer: hidden: {e}"))?;
452        let hidden = hidden.to_vec();
453        cache.a = Some(
454            dout.remove("out_cache_k")
455                .ok_or_else(|| "tableformer: out_cache_k missing".to_string())?,
456        );
457        cache.b = Some(
458            dout.remove("out_cache_v")
459                .ok_or_else(|| "tableformer: out_cache_v missing".to_string())?,
460        );
461        Ok((raws, hidden))
462    }
463
464    /// Stack the per-layer cross tensors of several encoded tables along the
465    /// batch axis (`[1,H,hd,S]` × B → `[B,H,hd,S]`, same for `cross_v`), index-
466    /// aligned with the decoder's input names. One copy per page — ~20 MB per
467    /// table, nothing next to the decode steps it lets the tables share.
468    fn batch_cross(encs: &[EncodeOut]) -> Result<Vec<(String, DynValue)>, String> {
469        let b = encs.len();
470        let mut out = Vec::with_capacity(encs[0].per_layer.len());
471        for j in 0..encs[0].per_layer.len() {
472            let name = encs[0].per_layer[j].0.clone();
473            let mut data: Vec<f32> = Vec::new();
474            let mut dims = [b, 0, 0, 0];
475            for enc in encs {
476                let (shape, v) = enc.per_layer[j]
477                    .1
478                    .try_extract_tensor::<f32>()
479                    .map_err(|e| format!("tableformer: {name}: {e}"))?;
480                if shape.len() != 4 || shape[0] != 1 {
481                    return Err(format!("tableformer: {name}: unexpected shape {shape:?}"));
482                }
483                dims[1..].copy_from_slice(&[
484                    shape[1] as usize,
485                    shape[2] as usize,
486                    shape[3] as usize,
487                ]);
488                data.reserve(v.len() * b);
489                data.extend_from_slice(v);
490            }
491            let t = Tensor::from_array((dims, data))
492                .map_err(|e| format!("tableformer: {name}: {e}"))?;
493            out.push((name, t.into_dyn()));
494        }
495        Ok(out)
496    }
497
498    /// Decode `encs.len()` tables in lockstep: every step runs the decoder once
499    /// over all of them (a step is 49 weight-streaming GEMMs over one token per
500    /// row — B rows cost about what one does). The caches start empty for
501    /// every row and grow together, so nothing is ever padded or masked; a
502    /// table that emits `<end>` simply keeps its row (fed `END`, output
503    /// ignored) until the last one finishes. Row b of every op is exactly the
504    /// single-table computation, so each table's tokens and hidden states are
505    /// bit-identical to decoding it alone (asserted by the export script's
506    /// batching gate; the corpus snapshots pin it end-to-end).
507    fn decode_batch(&mut self, encs: &[EncodeOut]) -> Result<Vec<BboxBook>, String> {
508        let b = encs.len();
509        let cross = Self::batch_cross(encs)?;
510        let mut books: Vec<BboxBook> = (0..b).map(|_| BboxBook::new()).collect();
511        let mut active = vec![true; b];
512        let mut last = vec![START; b];
513        let mut cache = DecodeCache::default();
514        let empty = self.empty_cache(b)?;
515        crate::timing::timed("tf.decode_loop", || -> Result<(), String> {
516            // Each active table's `otsl` grows by one per step, so a shared
517            // step counter is the per-table `otsl.len() < MAX_STEPS` bound.
518            for _ in 0..MAX_STEPS {
519                if !active.iter().any(|a| *a) {
520                    break;
521                }
522                let (raws, hidden) = crate::timing::timed("tf.decode_step", || {
523                    self.step_kv_hoisted(&last, &cross, &mut cache, &empty)
524                })?;
525                for t in 0..b {
526                    if !active[t] {
527                        continue;
528                    }
529                    let h = &hidden[t * EMBED_DIM..(t + 1) * EMBED_DIM];
530                    if books[t].step(raws[t], h) {
531                        last[t] = *books[t].tags.last().expect("step pushed a tag");
532                    } else {
533                        active[t] = false;
534                        last[t] = END;
535                    }
536                }
537            }
538            Ok(())
539        })?;
540        Ok(books)
541    }
542
543    /// The zero-`past` first-step cache(s) for `rows` tables, allocated through
544    /// the session allocator (ort's array constructors reject a 0-length dim;
545    /// the C API does allow it).
546    fn empty_cache(&self, rows: usize) -> Result<EmptyCache, String> {
547        let alloc = self.decoder.allocator();
548        if self.style != DecoderStyle::Legacy {
549            let mk = || {
550                Tensor::<f32>::new(alloc, [N_LAYERS, rows, KV_HEADS, 0usize, KV_HEAD_DIM])
551                    .map_err(|e| format!("tableformer: empty kv cache: {e}"))
552            };
553            Ok((mk()?, Some(mk()?)))
554        } else {
555            let c = Tensor::<f32>::new(alloc, [N_LAYERS, 0usize, 1, EMBED_DIM])
556                .map_err(|e| format!("tableformer: empty cache: {e}"))?;
557            Ok((c, None))
558        }
559    }
560
561    /// Predict the OTSL structure-token sequence for a table-region image.
562    pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
563        let enc = self.encode(img)?;
564        // Structure corrections live in tf_core::correct (shared with the wasm
565        // path); docling's line_num is never incremented, so xcel→lcel fires on
566        // every row.
567        let mut tags: Vec<i64> = vec![START];
568        let mut out: Vec<i64> = Vec::new();
569        let mut prev_ucel = false;
570        let mut cache = DecodeCache::default();
571        let empty = self.empty_cache(1)?;
572        while out.len() < MAX_STEPS {
573            let (raw, _hidden) = self.decode_step(&tags, &enc, &mut cache, &empty)?;
574            let tag = correct(raw, prev_ucel);
575            if tag == END {
576                break;
577            }
578            out.push(tag);
579            tags.push(tag);
580            prev_ucel = tag == UCEL;
581        }
582        Ok(out)
583    }
584
585    /// Full structure prediction: OTSL grid cells with per-cell boxes (in the 448
586    /// image, normalized cxcywh). Collects per-cell decoder hidden states using
587    /// docling's exact bbox bookkeeping (skip-after-row-break, first-lcel of a
588    /// horizontal span), runs the bbox decoder, merges span boxes, then lays the
589    /// cells onto the OTSL grid with row/col spans.
590    pub fn predict_table_structure(&mut self, img: &RgbImage) -> Result<Vec<TableCell>, String> {
591        let enc = self.encode(img)?;
592
593        // The autoregressive loop's bbox bookkeeping lives in tf_core::BboxBook
594        // (shared with the wasm path); this loop only steps the decoder.
595        let mut book = BboxBook::new();
596        let mut cache = DecodeCache::default();
597        let empty = self.empty_cache(1)?;
598        crate::timing::timed("tf.decode_loop", || -> Result<(), String> {
599            while book.otsl.len() < MAX_STEPS {
600                let (raw, hidden) = self.decode_step(&book.tags, &enc, &mut cache, &empty)?;
601                if !book.step(raw, &hidden) {
602                    break;
603                }
604            }
605            Ok(())
606        })?;
607        self.finish_table(book, &enc.eo)
608    }
609
610    /// The bbox stage after a table's decode loop: run the bbox decoder over
611    /// the collected per-cell hidden states, merge span boxes, lay the cells
612    /// onto the OTSL grid.
613    fn finish_table(
614        &mut self,
615        mut book: BboxBook,
616        eo: &DynValue,
617    ) -> Result<Vec<TableCell>, String> {
618        if book.n == 0 {
619            return Ok(Vec::new());
620        }
621        let tag_h = Tensor::from_array(([book.n, EMBED_DIM], std::mem::take(&mut book.hiddens)))
622            .map_err(|e| format!("tableformer: tag_h: {e}"))?;
623        let bout = crate::timing::timed("tf.bbox", || {
624            self.bbox
625                .run(ort::inputs!["enc_out" => eo, "tag_h" => tag_h])
626                .map_err(|e| format!("tableformer: bbox: {e}"))
627        })?;
628        let (_, raw) = bout["boxes"]
629            .try_extract_tensor::<f32>()
630            .map_err(|e| format!("tableformer: boxes: {e}"))?;
631        let boxes: Vec<[f32; 4]> = raw
632            .chunks_exact(4)
633            .map(|c| [c[0], c[1], c[2], c[3]])
634            .collect();
635        // Per-cell class logits [n, 3] → argmax (docling's `outputs_class`).
636        let (_, craw) = bout["classes"]
637            .try_extract_tensor::<f32>()
638            .map_err(|e| format!("tableformer: classes: {e}"))?;
639        let classes: Vec<i64> = craw.chunks_exact(3).map(|c| argmax(c) as i64).collect();
640        let (merged, merged_classes) = merge_spans(&boxes, &classes, &book.merge);
641        Ok(build_table_cells(&book.otsl, &merged, &merged_classes))
642    }
643
644    /// Predict a table region's Markdown grid: crop the region (docling's
645    /// page→1024px box-average then bbox crop), run the structure model, then
646    /// match the page's word cells into the predicted cells with docling's
647    /// matching post-processor ([`crate::tf_match`]) and expand spans into a
648    /// dense `rows × cols` grid. `region` is `(l, t, r, b)` in page points
649    /// (top-left). Returns `None` if no structure is predicted.
650    pub fn predict_table_rows(
651        &mut self,
652        page_image: &RgbImage,
653        region: [f32; 4],
654        words: &[TextCell],
655    ) -> Option<crate::tf_core::TableGrid> {
656        let page1024 = Self::page_1024(page_image);
657        self.predict_table_rows_on(page_image.height(), &page1024, region, words)
658    }
659
660    /// The page rendered at 1024 px height (cv2.INTER_AREA), the frame every
661    /// table crop of that page is cut from. Computed once per page by the
662    /// pipeline and shared across its tables — the resample is a full-page
663    /// f64 box filter, 110–170 ms on the corpus pages, and it used to run
664    /// again for every table on the page.
665    pub fn page_1024(page_image: &RgbImage) -> RgbImage {
666        let sf = 1024.0 / page_image.height() as f32;
667        let pw = (page_image.width() as f32 * sf) as u32;
668        crate::timing::timed("tableformer.inter_area", || {
669            crate::resample::inter_area(page_image, pw, 1024)
670        })
671    }
672
673    /// [`predict_table_rows`](Self::predict_table_rows) with the page's
674    /// 1024-px frame already built ([`page_1024`](Self::page_1024));
675    /// `page_h` is the source page image's pixel height.
676    pub fn predict_table_rows_on(
677        &mut self,
678        page_h: u32,
679        page1024: &RgbImage,
680        region: [f32; 4],
681        words: &[TextCell],
682    ) -> Option<crate::tf_core::TableGrid> {
683        let crop = Self::crop_region(page_h, page1024, region)?;
684        let cells = crate::timing::timed("tableformer.structure", || {
685            self.predict_table_structure(&crop)
686        })
687        .ok()?;
688        if cells.is_empty() {
689            return None;
690        }
691        // The ort-free tail (word matching + grid assembly) is shared with the
692        // browser path in tf_core.
693        crate::tf_core::table_rows(&cells, region, words)
694    }
695
696    /// Every table of a page at once: [`predict_table_rows_on`](Self::predict_table_rows_on)
697    /// per region, except that with the dynamic-batch decoder the tables'
698    /// decode steps are shared — each table is encoded on its own, then one
699    /// decode loop steps all of them together ([`Self::decode_batch`]), then
700    /// each runs its own bbox head. Per table the result is bit-identical to
701    /// the one-at-a-time path; a page with a single table takes exactly that
702    /// path. Should the batched run fail (an ort error), the tables are
703    /// retried one by one so a page never loses all of its tables to one
704    /// shared step.
705    pub fn predict_tables_on(
706        &mut self,
707        page_h: u32,
708        page1024: &RgbImage,
709        regions: &[[f32; 4]],
710        words: &[TextCell],
711    ) -> Vec<Option<crate::tf_core::TableGrid>> {
712        let mut out: Vec<Option<crate::tf_core::TableGrid>> = vec![None; regions.len()];
713        let crops: Vec<(usize, RgbImage)> = regions
714            .iter()
715            .enumerate()
716            .filter_map(|(i, r)| Self::crop_region(page_h, page1024, *r).map(|c| (i, c)))
717            .collect();
718        if self.batched && crops.len() > 1 {
719            let batched = crate::timing::timed("tableformer.structure", || {
720                self.predict_structures_batched(crops.iter().map(|(_, c)| c))
721            });
722            match batched {
723                Ok(cells) => {
724                    for ((i, _), cells) in crops.iter().zip(cells) {
725                        if !cells.is_empty() {
726                            out[*i] = crate::tf_core::table_rows(&cells, regions[*i], words);
727                        }
728                    }
729                    return out;
730                }
731                Err(e) => docling_core::debug_log!(
732                    "docling-pdf: tableformer batched decode failed ({e}); decoding tables one by one"
733                ),
734            }
735        }
736        for (i, crop) in &crops {
737            let cells = crate::timing::timed("tableformer.structure", || {
738                self.predict_table_structure(crop)
739            });
740            if let Ok(cells) = cells {
741                if !cells.is_empty() {
742                    out[*i] = crate::tf_core::table_rows(&cells, regions[*i], words);
743                }
744            }
745        }
746        out
747    }
748
749    /// [`predict_table_structure`](Self::predict_table_structure) for several
750    /// crops with the decode steps shared across them.
751    fn predict_structures_batched<'a>(
752        &mut self,
753        crops: impl Iterator<Item = &'a RgbImage>,
754    ) -> Result<Vec<Vec<TableCell>>, String> {
755        let mut encs = Vec::new();
756        for crop in crops {
757            encs.push(self.encode(crop)?);
758        }
759        let books = self.decode_batch(&encs)?;
760        books
761            .into_iter()
762            .zip(&encs)
763            .map(|(book, enc)| self.finish_table(book, &enc.eo))
764            .collect()
765    }
766
767    /// Crop the table bbox out of the 1024px frame. docling's coordinate
768    /// chain, rounding included: the cluster bbox is rounded to integer page
769    /// points *first* (`round(cluster.bbox.l) * scale`, banker's rounding),
770    /// scaled by 2 (its table-structure page scale), then by `1024 / <2x
771    /// page-image height>`, and the crop indices round again. Rounding after
772    /// scaling instead shifts some crops by a pixel — enough to change
773    /// TableFormer's cell boxes on tall tables (redp5110's TOC). `None` for a
774    /// region that collapses to an empty crop.
775    fn crop_region(page_h: u32, page1024: &RgbImage, region: [f32; 4]) -> Option<RgbImage> {
776        let k = 2.0 * 1024.0 / page_h as f64;
777        let px = |v: f32| (v as f64).round_ties_even() * k;
778        let x = (px(region[0]).round_ties_even()).max(0.0) as u32;
779        let y = (px(region[1]).round_ties_even()).max(0.0) as u32;
780        let x2 = (px(region[2]).round_ties_even() as u32).min(page1024.width());
781        let y2 = (px(region[3]).round_ties_even() as u32).min(page1024.height());
782        if x2 <= x || y2 <= y {
783            return None;
784        }
785        Some(image::imageops::crop_imm(page1024, x, y, x2 - x, y2 - y).to_image())
786    }
787}
788
789/// Note once per process that TableFormer's ONNX graphs weren't found, so tables
790/// fall back to geometric reconstruction. The default paths are relative
791/// (`.models/tableformer/*.onnx`), which only resolves when the process's current
792/// directory happens to be the repo root — a very easy miss for anything else
793/// (an embedding app, a binding invoked from a different working directory, …),
794/// and previously failed with no signal at all.
795fn warn_missing_once(enc: &str, dec: &str, bbx: &str) {
796    static WARNED: std::sync::Once = std::sync::Once::new();
797    WARNED.call_once(|| {
798        eprintln!(
799            "docling.rs: TableFormer models not found (checked {enc}, {dec}, {bbx}); \
800             tables will use geometric reconstruction instead of ML table-structure \
801             recognition. Set DOCLING_TABLEFORMER_ENCODER / DOCLING_TABLEFORMER_DECODER \
802             / DOCLING_TABLEFORMER_BBOX to enable it (see README.md)."
803        );
804    });
805}
806
807/// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448²,
808/// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes
809/// (2,1,0), so width is the major spatial axis. The page→1024px box-average
810/// (cv2.INTER_AREA) is the caller's job.
811fn preprocess(img: &RgbImage) -> Result<Tensor<f32>, String> {
812    Tensor::from_array(([1usize, 3, SIDE, SIDE], preprocess_input(img)))
813        .map_err(|e| format!("tableformer: input: {e}"))
814}