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