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