Skip to content

Commit abceed8

Browse files
artizclaude
andauthored
feat(cli): add --no-table-former to skip TableFormer for faster table parsing (#20)
Threads a no_table_former flag from the CLI through DocumentConverter, the streaming path, and the PDF Pipeline down to the per-worker model load, so tables fall back to geometric reconstruction instead of running the TableFormer ONNX model. Speeds up parsing (most notably in streaming mode) when exact table structure isn't needed. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d18af6 commit abceed8

6 files changed

Lines changed: 153 additions & 32 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ buffered `export_to_markdown_with_images` path. Use
158158
`convert_streaming_images(source, ImageMode::Embedded)` to pick the image mode.
159159

160160
The CLI streams Markdown by default (`--no-stream` opts back into buffering;
161-
`--to json` and `--images referenced` always buffer).
161+
`--to json` and `--images referenced` always buffer). `--no-table-former` skips
162+
loading/running the TableFormer table-structure model, falling back to simple
163+
geometric table reconstruction from cell positions — no model load, no
164+
per-table inference, which can noticeably speed up parsing (especially in
165+
streaming mode) at the cost of table fidelity.
162166

163167
## Node.js / Bun bindings
164168

crates/fleischwolf-cli/src/main.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! A stand-in for `docling.cli.main`; the full Typer-style CLI (batch mode,
44
//! pipeline options) is a later phase.
55
//!
6-
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] <input-file>
6+
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] <input-file>
77
//! --to md|json output format (default: md). `json` emits docling-core's
88
//! native DoclingDocument JSON (export_to_dict).
99
//! --images MODE picture handling for Markdown (mirrors docling's
@@ -19,6 +19,11 @@
1919
//! of streaming it page by page. Streaming is the default for
2020
//! Markdown (placeholder/embedded images); JSON and referenced
2121
//! images always use the buffered path.
22+
//! --no-table-former skip loading/running the TableFormer table-structure
23+
//! model for PDF/image input; tables fall back to simple
24+
//! geometric reconstruction from cell positions. Faster
25+
//! (no model load, no per-table inference) at the cost of
26+
//! table fidelity — helps most in streaming mode.
2227
2328
use std::io::{self, Write};
2429
use std::path::Path;
@@ -32,6 +37,7 @@ fn main() -> ExitCode {
3237
let mut images = "placeholder".to_string();
3338
let mut fetch_images = false;
3439
let mut no_stream = false;
40+
let mut no_table_former = false;
3541
let mut bench_warm: Option<usize> = None;
3642
let mut path: Option<String> = None;
3743
let mut args = std::env::args().skip(1);
@@ -40,6 +46,7 @@ fn main() -> ExitCode {
4046
"--strict" => strict = true,
4147
"--fetch-images" => fetch_images = true,
4248
"--no-stream" => no_stream = true,
49+
"--no-table-former" => no_table_former = true,
4350
"--to" => to = args.next().unwrap_or_default(),
4451
"--images" => images = args.next().unwrap_or_default(),
4552
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
@@ -74,7 +81,7 @@ fn main() -> ExitCode {
7481
};
7582

7683
let Some(path) = path else {
77-
eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] <input-file>");
84+
eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] <input-file>");
7885
return ExitCode::from(2);
7986
};
8087

@@ -87,7 +94,7 @@ fn main() -> ExitCode {
8794
};
8895

8996
if let Some(runs) = bench_warm {
90-
return match bench_warm_conversion(&source, runs) {
97+
return match bench_warm_conversion(&source, runs, no_table_former) {
9198
Ok(avg) => {
9299
// Bare seconds on stdout for the benchmark harness; a human line on stderr.
93100
println!("{avg:.6}");
@@ -106,7 +113,8 @@ fn main() -> ExitCode {
106113

107114
let converter = DocumentConverter::new()
108115
.strict(strict)
109-
.fetch_images(fetch_images);
116+
.fetch_images(fetch_images)
117+
.no_table_former(no_table_former);
110118

111119
// Stream Markdown by default: print each chunk as the converter produces it
112120
// (page by page for PDF). JSON needs the whole tree, and the referenced image
@@ -189,8 +197,14 @@ fn main() -> ExitCode {
189197
/// conversion is a discarded warm-up that triggers the lazy model loads, so the
190198
/// timed runs reuse them — the startup-excluded figure comparable to docling's
191199
/// in-process warm number.
192-
fn bench_warm_conversion(source: &SourceDocument, runs: usize) -> Result<f64, String> {
193-
let mut pipeline = Pipeline::new().map_err(|e| e.to_string())?;
200+
fn bench_warm_conversion(
201+
source: &SourceDocument,
202+
runs: usize,
203+
no_table_former: bool,
204+
) -> Result<f64, String> {
205+
let mut pipeline = Pipeline::new()
206+
.map_err(|e| e.to_string())?
207+
.no_table_former(no_table_former);
194208
let once = |p: &mut Pipeline| -> Result<(), String> {
195209
match source.format {
196210
InputFormat::Pdf => p

crates/fleischwolf-pdf/src/lib.rs

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex};
2727

2828
use fleischwolf_core::{DoclingDocument, Node};
2929

30-
pub use mets::convert_mets_gbs;
30+
pub use mets::{convert_mets_gbs, convert_mets_gbs_with_options};
3131
pub use pdfium_backend::{PdfDocument, PdfPage, TextCell};
3232

3333
/// Errors from the PDF backend. Detailed and surfaced (never silently skipped).
@@ -90,11 +90,15 @@ struct Worker {
9090
}
9191

9292
impl Worker {
93-
fn load(intra: usize) -> Result<Self, PdfError> {
93+
fn load(intra: usize, no_table_former: bool) -> Result<Self, PdfError> {
9494
Ok(Self {
9595
layout: layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?,
9696
ocr: None,
97-
tables: tableformer::TableFormer::load_with(intra),
97+
tables: if no_table_former {
98+
None
99+
} else {
100+
tableformer::TableFormer::load_with(intra)
101+
},
98102
})
99103
}
100104

@@ -208,6 +212,9 @@ pub struct Pipeline {
208212
target_workers: usize,
209213
/// Page count at/above which the parallel pool is worth its load cost.
210214
parallel_min: usize,
215+
/// Skip loading/running TableFormer; table regions fall back to geometric
216+
/// reconstruction. See [`Pipeline::no_table_former`].
217+
no_table_former: bool,
211218
}
212219

213220
impl Pipeline {
@@ -220,13 +227,25 @@ impl Pipeline {
220227
pool: Vec::new(),
221228
target_workers: pdf_worker_count(),
222229
parallel_min: pdf_parallel_min(),
230+
no_table_former: false,
223231
})
224232
}
225233

234+
/// Skip loading and running the TableFormer table-structure model. Table
235+
/// regions still get emitted, but reconstructed geometrically from cell
236+
/// positions instead of via the ONNX model's predicted structure — faster
237+
/// (no model load, no per-table inference) at the cost of table fidelity.
238+
/// No effect if a worker is already loaded; set this before the first
239+
/// conversion.
240+
pub fn no_table_former(mut self, disable: bool) -> Self {
241+
self.no_table_former = disable;
242+
self
243+
}
244+
226245
/// The full-intra serial worker, loaded on first use.
227246
fn primary(&mut self) -> Result<&mut Worker, PdfError> {
228247
if self.primary.is_none() {
229-
self.primary = Some(Worker::load(intra_threads())?);
248+
self.primary = Some(Worker::load(intra_threads(), self.no_table_former)?);
230249
}
231250
Ok(self.primary.as_mut().unwrap())
232251
}
@@ -507,9 +526,10 @@ impl Pipeline {
507526
return Ok(());
508527
}
509528
let intra = pdf_intra();
529+
let no_table_former = self.no_table_former;
510530
let loaded: Vec<Result<Worker, PdfError>> = std::thread::scope(|s| {
511531
let handles: Vec<_> = (0..need)
512-
.map(|_| s.spawn(move || Worker::load(intra)))
532+
.map(|_| s.spawn(move || Worker::load(intra, no_table_former)))
513533
.collect();
514534
handles.into_iter().map(|h| h.join().unwrap()).collect()
515535
});
@@ -567,16 +587,53 @@ pub fn convert(
567587
password: Option<&str>,
568588
name: &str,
569589
) -> Result<DoclingDocument, PdfError> {
570-
Pipeline::new()?.convert(bytes, password, name)
590+
convert_with_options(bytes, password, name, false)
591+
}
592+
593+
/// Like [`convert`], but optionally skips loading/running TableFormer (see
594+
/// [`Pipeline::no_table_former`]).
595+
pub fn convert_with_options(
596+
bytes: &[u8],
597+
password: Option<&str>,
598+
name: &str,
599+
no_table_former: bool,
600+
) -> Result<DoclingDocument, PdfError> {
601+
Pipeline::new()?
602+
.no_table_former(no_table_former)
603+
.convert(bytes, password, name)
571604
}
572605

573606
/// Convenience one-shot image conversion (loads the pipeline per call).
574607
pub fn convert_image(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
575-
Pipeline::new()?.convert_image(bytes, name)
608+
convert_image_with_options(bytes, name, false)
609+
}
610+
611+
/// Like [`convert_image`], but optionally skips loading/running TableFormer (see
612+
/// [`Pipeline::no_table_former`]).
613+
pub fn convert_image_with_options(
614+
bytes: &[u8],
615+
name: &str,
616+
no_table_former: bool,
617+
) -> Result<DoclingDocument, PdfError> {
618+
Pipeline::new()?
619+
.no_table_former(no_table_former)
620+
.convert_image(bytes, name)
576621
}
577622

578623
/// Convert pre-segmented pages (image + already-known text cells, e.g. METS/hOCR
579624
/// scans) through the shared layout + assembly pipeline.
580625
pub fn convert_pages(pages: Vec<PdfPage>, name: &str) -> Result<DoclingDocument, PdfError> {
581-
Pipeline::new()?.process_pages(pages, name)
626+
convert_pages_with_options(pages, name, false)
627+
}
628+
629+
/// Like [`convert_pages`], but optionally skips loading/running TableFormer (see
630+
/// [`Pipeline::no_table_former`]).
631+
pub fn convert_pages_with_options(
632+
pages: Vec<PdfPage>,
633+
name: &str,
634+
no_table_former: bool,
635+
) -> Result<DoclingDocument, PdfError> {
636+
Pipeline::new()?
637+
.no_table_former(no_table_former)
638+
.process_pages(pages, name)
582639
}

crates/fleischwolf-pdf/src/mets.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,19 @@ use tar::Archive;
1717
use fleischwolf_core::DoclingDocument;
1818

1919
use crate::pdfium_backend::{PdfPage, TextCell};
20-
use crate::{convert_pages, PdfError};
20+
use crate::{convert_pages_with_options, PdfError};
2121

2222
pub fn convert_mets_gbs(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
23+
convert_mets_gbs_with_options(bytes, name, false)
24+
}
25+
26+
/// Like [`convert_mets_gbs`], but optionally skips loading/running TableFormer
27+
/// (see [`crate::Pipeline::no_table_former`]).
28+
pub fn convert_mets_gbs_with_options(
29+
bytes: &[u8],
30+
name: &str,
31+
no_table_former: bool,
32+
) -> Result<DoclingDocument, PdfError> {
2333
let mut html: BTreeMap<String, String> = BTreeMap::new();
2434
let mut tiff: BTreeMap<String, Vec<u8>> = BTreeMap::new();
2535

@@ -80,7 +90,7 @@ pub fn convert_mets_gbs(bytes: &[u8], name: &str) -> Result<DoclingDocument, Pdf
8090
"mets: no hOCR/TIFF page pairs found in archive".into(),
8191
));
8292
}
83-
convert_pages(pages, name)
93+
convert_pages_with_options(pages, name, no_table_former)
8494
}
8595

8696
/// Parse an hOCR page: the `ocr_page` bbox gives the page geometry (cells are in

crates/fleischwolf/src/converter.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub struct DocumentConverter {
4444
allowed_formats: Option<HashSet<InputFormat>>,
4545
strict: bool,
4646
fetch_images: bool,
47+
no_table_former: bool,
4748
}
4849

4950
impl DocumentConverter {
@@ -59,6 +60,7 @@ impl DocumentConverter {
5960
allowed_formats: Some(formats.into_iter().collect()),
6061
strict: false,
6162
fetch_images: false,
63+
no_table_former: false,
6264
}
6365
}
6466

@@ -90,6 +92,20 @@ impl DocumentConverter {
9092
self
9193
}
9294

95+
/// Skip loading and running the TableFormer table-structure model for
96+
/// PDF/image/METS sources.
97+
///
98+
/// Off by default. When enabled, table regions are still detected and
99+
/// emitted, but their structure is reconstructed geometrically from cell
100+
/// positions instead of the ONNX model's predicted structure — no model
101+
/// load and no per-table inference, at the cost of table fidelity. Useful
102+
/// when parsing speed matters more than exact table structure, especially
103+
/// with [`convert_streaming`](Self::convert_streaming).
104+
pub fn no_table_former(mut self, disable: bool) -> Self {
105+
self.no_table_former = disable;
106+
self
107+
}
108+
93109
/// Convert a source document to Markdown **incrementally**, returning an
94110
/// iterator of Markdown chunks (with picture placeholders).
95111
///
@@ -140,6 +156,7 @@ impl DocumentConverter {
140156
source,
141157
image_mode,
142158
self.strict,
159+
self.no_table_former,
143160
))
144161
}
145162

@@ -194,12 +211,25 @@ impl DocumentConverter {
194211
InputFormat::Odt | InputFormat::Ods | InputFormat::Odp => {
195212
OdfBackend.convert(&source)?
196213
}
197-
InputFormat::Pdf => fleischwolf_pdf::convert(&source.bytes, None, &source.name)
198-
.map_err(|e| ConversionError::Parse(e.to_string()))?,
199-
InputFormat::Image => fleischwolf_pdf::convert_image(&source.bytes, &source.name)
200-
.map_err(|e| ConversionError::Parse(e.to_string()))?,
201-
InputFormat::MetsGbs => fleischwolf_pdf::convert_mets_gbs(&source.bytes, &source.name)
202-
.map_err(|e| ConversionError::Parse(e.to_string()))?,
214+
InputFormat::Pdf => fleischwolf_pdf::convert_with_options(
215+
&source.bytes,
216+
None,
217+
&source.name,
218+
self.no_table_former,
219+
)
220+
.map_err(|e| ConversionError::Parse(e.to_string()))?,
221+
InputFormat::Image => fleischwolf_pdf::convert_image_with_options(
222+
&source.bytes,
223+
&source.name,
224+
self.no_table_former,
225+
)
226+
.map_err(|e| ConversionError::Parse(e.to_string()))?,
227+
InputFormat::MetsGbs => fleischwolf_pdf::convert_mets_gbs_with_options(
228+
&source.bytes,
229+
&source.name,
230+
self.no_table_former,
231+
)
232+
.map_err(|e| ConversionError::Parse(e.to_string()))?,
203233
other => return Err(ConversionError::UnsupportedFormat(other)),
204234
};
205235
// Carry the mode so `result.document.export_to_markdown()` reflects it.

crates/fleischwolf/src/stream.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ pub(crate) fn spawn(
7575
source: SourceDocument,
7676
image_mode: ImageMode,
7777
strict: bool,
78+
no_table_former: bool,
7879
) -> MarkdownStream {
7980
let (tx, rx) = sync_channel::<Result<String, ConversionError>>(CHANNEL_DEPTH);
80-
let handle = std::thread::spawn(move || run(converter, source, image_mode, strict, &tx));
81+
let handle = std::thread::spawn(move || {
82+
run(converter, source, image_mode, strict, no_table_former, &tx)
83+
});
8184
MarkdownStream {
8285
rx: Some(rx),
8386
handle: Some(handle),
@@ -92,12 +95,13 @@ fn run(
9295
source: SourceDocument,
9396
image_mode: ImageMode,
9497
strict: bool,
98+
no_table_former: bool,
9599
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
96100
) {
97101
match source.format {
98102
// PDF is the format with internal page-level parallelism, so it gets the
99103
// true streaming path: emit each page's Markdown in order as it completes.
100-
InputFormat::Pdf => run_pdf(&source, image_mode, strict, tx),
104+
InputFormat::Pdf => run_pdf(&source, image_mode, strict, no_table_former, tx),
101105
// Every other backend builds the whole `DoclingDocument` synchronously, so
102106
// there is no latency to stream away; serialize it through the same chunk
103107
// API for a uniform interface (one chunk plus the trailing newline).
@@ -109,19 +113,21 @@ fn run_pdf(
109113
source: &SourceDocument,
110114
image_mode: ImageMode,
111115
strict: bool,
116+
no_table_former: bool,
112117
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
113118
) {
114119
// The PDF pipeline builds its document from `DoclingDocument::new` defaults, so
115120
// tables use the padded GitHub serializer (compact_tables = false), matching the
116121
// buffered PDF path.
117122
let mut streamer = MarkdownStreamer::new(strict, image_mode, false);
118-
let mut pipeline = match fleischwolf_pdf::Pipeline::new() {
119-
Ok(p) => p,
120-
Err(e) => {
121-
let _ = tx.send(Err(ConversionError::Parse(e.to_string())));
122-
return;
123-
}
124-
};
123+
let mut pipeline =
124+
match fleischwolf_pdf::Pipeline::new().map(|p| p.no_table_former(no_table_former)) {
125+
Ok(p) => p,
126+
Err(e) => {
127+
let _ = tx.send(Err(ConversionError::Parse(e.to_string())));
128+
return;
129+
}
130+
};
125131

126132
let result = pipeline.convert_streaming(&source.bytes, None, &source.name, |nodes, links| {
127133
let chunk = streamer.push(&nodes, &links);

0 commit comments

Comments
 (0)