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
26pub struct TableFormer {
27    encoder: Session,
28    decoder: Session,
29    bbox: Session,
30    /// Which decoder graph flavour is loaded, detected from the session's
31    /// input names (so an explicit `DOCLING_TABLEFORMER_DECODER` override
32    /// works with any of them).
33    style: DecoderStyle,
34}
35
36/// The three decoder-graph generations the loop supports.
37#[derive(Clone, Copy, PartialEq, Eq)]
38enum DecoderStyle {
39    /// `decoder.onnx`: layer-output cache; feeds the full `tags` prefix and a
40    /// single `cache` every step.
41    Legacy,
42    /// The pre-#97 `decoder_kv.onnx`: one tag per step, `cache_k`/`cache_v`,
43    /// with the stacked `cross_k`/`cross_v` re-split inside every step.
44    KvStacked,
45    /// The #97 `decoder_kv.onnx`: one tag per step, and the constant cross
46    /// tensors arrive as 2×`N_LAYERS` per-layer inputs (`cross_kt_i` already
47    /// transposed for q·Kᵀ, `cross_v_i`), computed once per table by the
48    /// encoder — the step graph does no work proportional to their size.
49    KvHoisted,
50}
51
52/// KV-cache geometry fixed by the `decoder_kv.onnx` export
53/// (`[N_LAYERS, 1, KV_HEADS, past, KV_HEAD_DIM]`, `KV_HEADS × KV_HEAD_DIM = EMBED_DIM`).
54const KV_HEADS: usize = 8;
55const KV_HEAD_DIM: usize = 64;
56
57/// The autoregressive decode state: `a` is the legacy layer-output cache, or
58/// `cache_k` for the KV graph; `b` is `cache_v` (KV graph only). `None` = first
59/// step (the zero-`past` empties are allocated per table by [`TableFormer::empty_cache`]).
60#[derive(Default)]
61struct DecodeCache {
62    a: Option<DynValue>,
63    b: Option<DynValue>,
64}
65
66/// Zero-`past` first-step cache tensors: `(cache, None)` for the legacy graph,
67/// `(cache_k, Some(cache_v))` for the KV graph.
68type EmptyCache = (Tensor<f32>, Option<Tensor<f32>>);
69
70/// Encoder outputs that drive the cached decode loop: the per-layer cross-attention
71/// K/V (projected from the image memory once, constant across decode steps) and
72/// `enc_out` for the bbox decoder. Kept as owned `ort` values so each decode step
73/// (and the bbox run) borrows them directly — no per-step extract/copy/re-wrap.
74struct EncodeOut {
75    ck: DynValue,
76    cv: DynValue,
77    eo: DynValue,
78    /// `KvHoisted` only: per-layer `[cross_kt_0..N, cross_v_0..N]`, index-aligned
79    /// with the decoder's input names, borrowed by every decode step.
80    per_layer: Vec<(String, DynValue)>,
81}
82
83impl TableFormer {
84    /// Load the exported encoder/decoder/bbox ONNX graphs (env overrides, else
85    /// `models/tableformer/{encoder,decoder,bbox}.onnx`). Returns `None` if any is
86    /// absent, so the pipeline falls back to geometric reconstruction.
87    pub fn load() -> Option<Self> {
88        Self::load_with(crate::intra_threads())
89    }
90
91    /// Like [`load`](Self::load) but with an explicit intra-op thread count, so a
92    /// parallel page-worker pool can run each table model on fewer threads (the
93    /// throughput comes from running pages concurrently, not from one fat model).
94    pub fn load_with(intra: usize) -> Option<Self> {
95        let enc = std::env::var("DOCLING_TABLEFORMER_ENCODER")
96            .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/encoder.onnx"));
97        // Decoder preference (explicit override wins): INT8 variants first
98        // unless DOCLING_RS_FP32 opts out; within a precision the true-KV-cache
99        // export (`decoder_kv*`, one token per step, O(past) step cost) ranks
100        // ahead of the legacy layer-output-cache graph it matches byte-for-byte
101        // (91/91 snapshot corpus exact with either). Re-measured warm on the
102        // corpus fixtures: the KV graph is ~13% faster on ordinary tables
103        // (2206.01062) and ~17% on the huge-table page (2305.03393v1-pg9),
104        // for +36 MB on disk — table-heavy single-page PDFs are exactly where
105        // the pipeline is tightest against Python docling, so speed wins the
106        // default and the legacy file stays as the smaller fallback.
107        let dec = std::env::var("DOCLING_TABLEFORMER_DECODER").unwrap_or_else(|_| {
108            let candidates: &[&str] = if crate::prefer_fp32() {
109                &[
110                    "models/tableformer/decoder_kv.onnx",
111                    "models/tableformer/decoder.onnx",
112                ]
113            } else {
114                // decoder_kv ranks ABOVE decoder_int8: the #97 hoisted fp32 KV
115                // graph is faster than the quantized legacy graph on every
116                // machine measured, and it is byte-exact (its own int8 variant
117                // is not produced — see quantize_models.py).
118                &[
119                    "models/tableformer/decoder_kv_int8.onnx",
120                    "models/tableformer/decoder_kv.onnx",
121                    "models/tableformer/decoder_int8.onnx",
122                    "models/tableformer/decoder.onnx",
123                ]
124            };
125            candidates
126                .iter()
127                .map(|p| crate::resolve_asset(p))
128                .find(|p| std::path::Path::new(p).exists())
129                .unwrap_or_else(|| "models/tableformer/decoder.onnx".to_string())
130        });
131        let bbx = std::env::var("DOCLING_TABLEFORMER_BBOX")
132            .unwrap_or_else(|_| crate::resolve_asset("models/tableformer/bbox.onnx"));
133        if crate::timing::enabled() {
134            eprintln!("docling-pdf: tableformer decoder: {dec}");
135        }
136        if [&enc, &dec, &bbx]
137            .iter()
138            .any(|p| !std::path::Path::new(p).exists())
139        {
140            // The geometric fallback is a supported, intentional configuration
141            // (docling has no ML table-structure equivalent baked in either), so
142            // this stays a single quiet stderr note rather than an error — but it
143            // fires every process (not per-worker) so a CWD-relative default that
144            // silently misses its files (a very easy mistake for anything not run
145            // from the repo root, e.g. an embedding app) is at least visible once.
146            warn_missing_once(&enc, &dec, &bbx);
147            return None;
148        }
149        // The decoder's KV-cache grows by one entry every autoregressive step, so
150        // its input shapes differ on every `run()` call. ONNX Runtime's memory
151        // pattern optimizer assumes stable shapes to plan buffer reuse; disabling
152        // it for this session avoids repeatedly re-validating/re-touching that
153        // plan (and the external-weights file) on each step.
154        let build = |path: &str, mem_pattern: bool| -> Result<Session, String> {
155            let builder = Session::builder()
156                .map_err(|e| e.to_string())?
157                .with_intra_threads(intra)
158                .map_err(|e| e.to_string())?
159                .with_memory_pattern(mem_pattern)
160                .map_err(|e| e.to_string())?;
161            crate::ep::apply(builder)?
162                .commit_from_file(path)
163                .map_err(|e| format!("tableformer load {path}: {e}"))
164        };
165        match (build(&enc, true), build(&dec, false), build(&bbx, true)) {
166            (Ok(encoder), Ok(decoder), Ok(bbox)) => {
167                let has = |n: &str| decoder.inputs().iter().any(|i| i.name() == n);
168                let style = if has("cross_kt_0") {
169                    DecoderStyle::KvHoisted
170                } else if has("cache_k") {
171                    DecoderStyle::KvStacked
172                } else {
173                    DecoderStyle::Legacy
174                };
175                if style == DecoderStyle::KvHoisted
176                    && !encoder.outputs().iter().any(|o| o.name() == "cross_kt_0")
177                {
178                    eprintln!(
179                        "docling-pdf: tableformer decoder needs per-layer cross tensors \
180                         (cross_kt_*) the encoder doesn't emit — re-download or re-export \
181                         the model set (scripts/install/export_tableformer.py); \
182                         falling back to geometric tables"
183                    );
184                    return None;
185                }
186                Some(Self {
187                    encoder,
188                    decoder,
189                    bbox,
190                    style,
191                })
192            }
193            _ => None,
194        }
195    }
196
197    /// Run the image encoder and capture what the cached decoder loop needs: each
198    /// decoder layer's cross-attention K/V (projected from the image memory once,
199    /// shape `[N_LAYERS,1,H,S,head_dim]`) and `enc_out` for the bbox decoder.
200    fn encode(&mut self, img: &RgbImage) -> Result<EncodeOut, String> {
201        let input = preprocess(img)?;
202        let mut enc_out = self
203            .encoder
204            .run(ort::inputs!["image" => input])
205            .map_err(|e| format!("tableformer: encode: {e}"))?;
206        let mut per_layer = Vec::new();
207        if self.style == DecoderStyle::KvHoisted {
208            for prefix in ["cross_kt_", "cross_v_"] {
209                for i in 0.. {
210                    let name = format!("{prefix}{i}");
211                    match enc_out.remove(&name) {
212                        Some(v) => per_layer.push((name, v)),
213                        None => break,
214                    }
215                }
216            }
217            if per_layer.is_empty() {
218                return Err("tableformer: encoder emitted no cross_kt_* outputs".into());
219            }
220        }
221        let mut grab = |name: &str| -> Result<DynValue, String> {
222            enc_out
223                .remove(name)
224                .ok_or_else(|| format!("tableformer: encoder output {name} missing"))
225        };
226        Ok(EncodeOut {
227            ck: grab("cross_k")?,
228            cv: grab("cross_v")?,
229            eo: grab("enc_out")?,
230            per_layer,
231        })
232    }
233
234    /// One doubly-cached decode step: feed the current `tags`, the constant cross
235    /// K/V, and the growing self-attention `cache`; return the raw argmax tag and
236    /// the last token's hidden state, advancing the cache. The cache stays an owned
237    /// `ort` value — the previous step's `out_cache` output is fed back directly,
238    /// never extracted or copied (it grows every step, so per-step copies were
239    /// O(steps²) float traffic). `empty_cache` is the zero-`past` value used on the
240    /// first step (ort's array constructors reject a 0-length dim, so it is
241    /// allocated through the session allocator by the caller).
242    fn decode_step(
243        &mut self,
244        tags: &[i64],
245        enc: &EncodeOut,
246        cache: &mut DecodeCache,
247        empty: &EmptyCache,
248    ) -> Result<(i64, Vec<f32>), String> {
249        crate::timing::timed("tf.decode_step", || {
250            self.decode_step_inner(tags, enc, cache, empty)
251        })
252    }
253
254    fn decode_step_inner(
255        &mut self,
256        tags: &[i64],
257        enc: &EncodeOut,
258        cache: &mut DecodeCache,
259        empty: &EmptyCache,
260    ) -> Result<(i64, Vec<f32>), String> {
261        let mut dout = match self.style {
262            DecoderStyle::KvHoisted => {
263                // #97 graph: one tag; the constant per-layer cross tensors are
264                // borrowed views — the step pays nothing proportional to them.
265                let last = *tags.last().expect("decode starts from <start>");
266                let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
267                    .map_err(|e| format!("tableformer: tag: {e}"))?;
268                let mut inputs: Vec<(
269                    std::borrow::Cow<'_, str>,
270                    ort::session::SessionInputValue<'_>,
271                )> = Vec::with_capacity(3 + enc.per_layer.len());
272                inputs.push(("tag".into(), tag_t.into()));
273                match (cache.a.as_ref(), cache.b.as_ref()) {
274                    (Some(k), Some(v)) => {
275                        inputs.push(("cache_k".into(), k.into()));
276                        inputs.push(("cache_v".into(), v.into()));
277                    }
278                    _ => {
279                        inputs.push(("cache_k".into(), (&empty.0).into()));
280                        inputs.push((
281                            "cache_v".into(),
282                            empty
283                                .1
284                                .as_ref()
285                                .expect("kv empty cache has both halves")
286                                .into(),
287                        ));
288                    }
289                }
290                for (name, v) in &enc.per_layer {
291                    inputs.push((name.as_str().into(), v.into()));
292                }
293                self.decoder.run(inputs)
294            }
295            DecoderStyle::KvStacked => {
296                // Pre-#97 KV graph: feed only the newly emitted tag; the projected
297                // K/V for the whole prefix live in cache_k/cache_v and are fed
298                // back as-is.
299                let last = *tags.last().expect("decode starts from <start>");
300                let tag_t = Tensor::from_array(([1usize, 1usize], vec![last]))
301                    .map_err(|e| format!("tableformer: tag: {e}"))?;
302                match (cache.a.as_ref(), cache.b.as_ref()) {
303                    (Some(k), Some(v)) => self.decoder.run(ort::inputs![
304                        "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
305                        "cache_k" => k, "cache_v" => v]),
306                    _ => self.decoder.run(ort::inputs![
307                        "tag" => tag_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
308                        "cache_k" => &empty.0,
309                        "cache_v" => empty.1.as_ref().expect("kv empty cache has both halves")]),
310                }
311            }
312            DecoderStyle::Legacy => {
313                let tags_t = Tensor::from_array(([tags.len(), 1usize], tags.to_vec()))
314                    .map_err(|e| format!("tableformer: tags: {e}"))?;
315                match cache.a.as_ref() {
316                    None => self.decoder.run(ort::inputs![
317                        "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
318                        "cache" => &empty.0]),
319                    Some(c) => self.decoder.run(ort::inputs![
320                        "tags" => tags_t, "cross_k" => &enc.ck, "cross_v" => &enc.cv,
321                        "cache" => c]),
322                }
323            }
324        }
325        .map_err(|e| format!("tableformer: decode: {e}"))?;
326        let (_, logits) = dout["logits"]
327            .try_extract_tensor::<f32>()
328            .map_err(|e| format!("tableformer: logits: {e}"))?;
329        let raw = argmax(logits) as i64;
330        let (_, hidden) = dout["hidden"]
331            .try_extract_tensor::<f32>()
332            .map_err(|e| format!("tableformer: hidden: {e}"))?;
333        let hidden = hidden.to_vec();
334        if self.style != DecoderStyle::Legacy {
335            cache.a = Some(
336                dout.remove("out_cache_k")
337                    .ok_or_else(|| "tableformer: out_cache_k missing".to_string())?,
338            );
339            cache.b = Some(
340                dout.remove("out_cache_v")
341                    .ok_or_else(|| "tableformer: out_cache_v missing".to_string())?,
342            );
343        } else {
344            cache.a = Some(
345                dout.remove("out_cache")
346                    .ok_or_else(|| "tableformer: decoder output out_cache missing".to_string())?,
347            );
348        }
349        Ok((raw, hidden))
350    }
351
352    /// The zero-`past` first-step cache(s), allocated through the session
353    /// allocator (ort's array constructors reject a 0-length dim; the C API does
354    /// allow it).
355    fn empty_cache(&self) -> Result<EmptyCache, String> {
356        let alloc = self.decoder.allocator();
357        if self.style != DecoderStyle::Legacy {
358            let mk = || {
359                Tensor::<f32>::new(alloc, [N_LAYERS, 1, KV_HEADS, 0usize, KV_HEAD_DIM])
360                    .map_err(|e| format!("tableformer: empty kv cache: {e}"))
361            };
362            Ok((mk()?, Some(mk()?)))
363        } else {
364            let c = Tensor::<f32>::new(alloc, [N_LAYERS, 0usize, 1, EMBED_DIM])
365                .map_err(|e| format!("tableformer: empty cache: {e}"))?;
366            Ok((c, None))
367        }
368    }
369
370    /// Predict the OTSL structure-token sequence for a table-region image.
371    pub fn predict_otsl(&mut self, img: &RgbImage) -> Result<Vec<i64>, String> {
372        let enc = self.encode(img)?;
373        // Structure corrections live in tf_core::correct (shared with the wasm
374        // path); docling's line_num is never incremented, so xcel→lcel fires on
375        // every row.
376        let mut tags: Vec<i64> = vec![START];
377        let mut out: Vec<i64> = Vec::new();
378        let mut prev_ucel = false;
379        let mut cache = DecodeCache::default();
380        let empty = self.empty_cache()?;
381        while out.len() < MAX_STEPS {
382            let (raw, _hidden) = self.decode_step(&tags, &enc, &mut cache, &empty)?;
383            let tag = correct(raw, prev_ucel);
384            if tag == END {
385                break;
386            }
387            out.push(tag);
388            tags.push(tag);
389            prev_ucel = tag == UCEL;
390        }
391        Ok(out)
392    }
393
394    /// Full structure prediction: OTSL grid cells with per-cell boxes (in the 448
395    /// image, normalized cxcywh). Collects per-cell decoder hidden states using
396    /// docling's exact bbox bookkeeping (skip-after-row-break, first-lcel of a
397    /// horizontal span), runs the bbox decoder, merges span boxes, then lays the
398    /// cells onto the OTSL grid with row/col spans.
399    pub fn predict_table_structure(&mut self, img: &RgbImage) -> Result<Vec<TableCell>, String> {
400        let enc = self.encode(img)?;
401
402        // The autoregressive loop's bbox bookkeeping lives in tf_core::BboxBook
403        // (shared with the wasm path); this loop only steps the decoder.
404        let mut book = BboxBook::new();
405        let mut cache = DecodeCache::default();
406        let empty = self.empty_cache()?;
407        while book.otsl.len() < MAX_STEPS {
408            let (raw, hidden) = self.decode_step(&book.tags, &enc, &mut cache, &empty)?;
409            if !book.step(raw, &hidden) {
410                break;
411            }
412        }
413        if book.n == 0 {
414            return Ok(Vec::new());
415        }
416        let tag_h = Tensor::from_array(([book.n, EMBED_DIM], std::mem::take(&mut book.hiddens)))
417            .map_err(|e| format!("tableformer: tag_h: {e}"))?;
418        let bout = self
419            .bbox
420            .run(ort::inputs!["enc_out" => &enc.eo, "tag_h" => tag_h])
421            .map_err(|e| format!("tableformer: bbox: {e}"))?;
422        let (_, raw) = bout["boxes"]
423            .try_extract_tensor::<f32>()
424            .map_err(|e| format!("tableformer: boxes: {e}"))?;
425        let boxes: Vec<[f32; 4]> = raw
426            .chunks_exact(4)
427            .map(|c| [c[0], c[1], c[2], c[3]])
428            .collect();
429        // Per-cell class logits [n, 3] → argmax (docling's `outputs_class`).
430        let (_, craw) = bout["classes"]
431            .try_extract_tensor::<f32>()
432            .map_err(|e| format!("tableformer: classes: {e}"))?;
433        let classes: Vec<i64> = craw.chunks_exact(3).map(|c| argmax(c) as i64).collect();
434        let (merged, merged_classes) = merge_spans(&boxes, &classes, &book.merge);
435        Ok(build_table_cells(&book.otsl, &merged, &merged_classes))
436    }
437
438    /// Predict a table region's Markdown grid: crop the region (docling's
439    /// page→1024px box-average then bbox crop), run the structure model, then
440    /// match the page's word cells into the predicted cells with docling's
441    /// matching post-processor ([`crate::tf_match`]) and expand spans into a
442    /// dense `rows × cols` grid. `region` is `(l, t, r, b)` in page points
443    /// (top-left). Returns `None` if no structure is predicted.
444    pub fn predict_table_rows(
445        &mut self,
446        page_image: &RgbImage,
447        region: [f32; 4],
448        words: &[TextCell],
449    ) -> Option<Vec<Vec<String>>> {
450        // page → 1024px height (cv2.INTER_AREA), then crop the table bbox.
451        // docling's coordinate chain, rounding included: the cluster bbox is
452        // rounded to integer page points *first* (`round(cluster.bbox.l) *
453        // scale`, banker's rounding), scaled by 2 (its table-structure page
454        // scale), then by `1024 / <2x page-image height>`, and the crop indices
455        // round again. Rounding after scaling instead shifts some crops by a
456        // pixel — enough to change TableFormer's cell boxes on tall tables
457        // (redp5110's TOC).
458        let sf = 1024.0 / page_image.height() as f32;
459        let pw = (page_image.width() as f32 * sf) as u32;
460        let page1024 = crate::timing::timed("tableformer.inter_area", || {
461            crate::resample::inter_area(page_image, pw, 1024)
462        });
463        let k = 2.0 * 1024.0 / page_image.height() as f64;
464        let px = |v: f32| (v as f64).round_ties_even() * k;
465        let x = (px(region[0]).round_ties_even()).max(0.0) as u32;
466        let y = (px(region[1]).round_ties_even()).max(0.0) as u32;
467        let x2 = (px(region[2]).round_ties_even() as u32).min(page1024.width());
468        let y2 = (px(region[3]).round_ties_even() as u32).min(page1024.height());
469        if x2 <= x || y2 <= y {
470            return None;
471        }
472        let crop = image::imageops::crop_imm(&page1024, x, y, x2 - x, y2 - y).to_image();
473        let cells = crate::timing::timed("tableformer.structure", || {
474            self.predict_table_structure(&crop)
475        })
476        .ok()?;
477        if cells.is_empty() {
478            return None;
479        }
480        // The ort-free tail (word matching + grid assembly) is shared with the
481        // browser path in tf_core.
482        crate::tf_core::table_rows(&cells, region, words)
483    }
484}
485
486/// Note once per process that TableFormer's ONNX graphs weren't found, so tables
487/// fall back to geometric reconstruction. The default paths are relative
488/// (`models/tableformer/*.onnx`), which only resolves when the process's current
489/// directory happens to be the repo root — a very easy miss for anything else
490/// (an embedding app, a binding invoked from a different working directory, …),
491/// and previously failed with no signal at all.
492fn warn_missing_once(enc: &str, dec: &str, bbx: &str) {
493    static WARNED: std::sync::Once = std::sync::Once::new();
494    WARNED.call_once(|| {
495        eprintln!(
496            "docling.rs: TableFormer models not found (checked {enc}, {dec}, {bbx}); \
497             tables will use geometric reconstruction instead of ML table-structure \
498             recognition. Set DOCLING_TABLEFORMER_ENCODER / DOCLING_TABLEFORMER_DECODER \
499             / DOCLING_TABLEFORMER_BBOX to enable it (see README.md)."
500        );
501    });
502}
503
504/// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448²,
505/// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes
506/// (2,1,0), so width is the major spatial axis. The page→1024px box-average
507/// (cv2.INTER_AREA) is the caller's job.
508fn preprocess(img: &RgbImage) -> Result<Tensor<f32>, String> {
509    Tensor::from_array(([1usize, 3, SIDE, SIDE], preprocess_input(img)))
510        .map_err(|e| format!("tableformer: input: {e}"))
511}