From 08607ea1a326fa8328f53ae9ef8764aa55c0d852 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 16:16:18 +0000 Subject: [PATCH] feat(cli): add --no-table-former to skip TableFormer for faster table parsing 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. --- README.md | 6 ++- crates/fleischwolf-cli/src/main.rs | 26 +++++++--- crates/fleischwolf-pdf/src/lib.rs | 73 +++++++++++++++++++++++++---- crates/fleischwolf-pdf/src/mets.rs | 14 +++++- crates/fleischwolf/src/converter.rs | 42 ++++++++++++++--- crates/fleischwolf/src/stream.rs | 24 ++++++---- 6 files changed, 153 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 754dc1ec..2430d08f 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,11 @@ buffered `export_to_markdown_with_images` path. Use `convert_streaming_images(source, ImageMode::Embedded)` to pick the image mode. The CLI streams Markdown by default (`--no-stream` opts back into buffering; -`--to json` and `--images referenced` always buffer). +`--to json` and `--images referenced` always buffer). `--no-table-former` skips +loading/running the TableFormer table-structure model, falling back to simple +geometric table reconstruction from cell positions — no model load, no +per-table inference, which can noticeably speed up parsing (especially in +streaming mode) at the cost of table fidelity. ## Node.js / Bun bindings diff --git a/crates/fleischwolf-cli/src/main.rs b/crates/fleischwolf-cli/src/main.rs index 264524a1..adf76e95 100644 --- a/crates/fleischwolf-cli/src/main.rs +++ b/crates/fleischwolf-cli/src/main.rs @@ -3,7 +3,7 @@ //! A stand-in for `docling.cli.main`; the full Typer-style CLI (batch mode, //! pipeline options) is a later phase. //! -//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] +//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] //! --to md|json output format (default: md). `json` emits docling-core's //! native DoclingDocument JSON (export_to_dict). //! --images MODE picture handling for Markdown (mirrors docling's @@ -19,6 +19,11 @@ //! of streaming it page by page. Streaming is the default for //! Markdown (placeholder/embedded images); JSON and referenced //! images always use the buffered path. +//! --no-table-former skip loading/running the TableFormer table-structure +//! model for PDF/image input; tables fall back to simple +//! geometric reconstruction from cell positions. Faster +//! (no model load, no per-table inference) at the cost of +//! table fidelity — helps most in streaming mode. use std::io::{self, Write}; use std::path::Path; @@ -32,6 +37,7 @@ fn main() -> ExitCode { let mut images = "placeholder".to_string(); let mut fetch_images = false; let mut no_stream = false; + let mut no_table_former = false; let mut bench_warm: Option = None; let mut path: Option = None; let mut args = std::env::args().skip(1); @@ -40,6 +46,7 @@ fn main() -> ExitCode { "--strict" => strict = true, "--fetch-images" => fetch_images = true, "--no-stream" => no_stream = true, + "--no-table-former" => no_table_former = true, "--to" => to = args.next().unwrap_or_default(), "--images" => images = args.next().unwrap_or_default(), // Hidden benchmarking aid: load the PDF/image pipeline once, then time @@ -74,7 +81,7 @@ fn main() -> ExitCode { }; let Some(path) = path else { - eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] "); + eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] "); return ExitCode::from(2); }; @@ -87,7 +94,7 @@ fn main() -> ExitCode { }; if let Some(runs) = bench_warm { - return match bench_warm_conversion(&source, runs) { + return match bench_warm_conversion(&source, runs, no_table_former) { Ok(avg) => { // Bare seconds on stdout for the benchmark harness; a human line on stderr. println!("{avg:.6}"); @@ -106,7 +113,8 @@ fn main() -> ExitCode { let converter = DocumentConverter::new() .strict(strict) - .fetch_images(fetch_images); + .fetch_images(fetch_images) + .no_table_former(no_table_former); // Stream Markdown by default: print each chunk as the converter produces it // (page by page for PDF). JSON needs the whole tree, and the referenced image @@ -189,8 +197,14 @@ fn main() -> ExitCode { /// conversion is a discarded warm-up that triggers the lazy model loads, so the /// timed runs reuse them — the startup-excluded figure comparable to docling's /// in-process warm number. -fn bench_warm_conversion(source: &SourceDocument, runs: usize) -> Result { - let mut pipeline = Pipeline::new().map_err(|e| e.to_string())?; +fn bench_warm_conversion( + source: &SourceDocument, + runs: usize, + no_table_former: bool, +) -> Result { + let mut pipeline = Pipeline::new() + .map_err(|e| e.to_string())? + .no_table_former(no_table_former); let once = |p: &mut Pipeline| -> Result<(), String> { match source.format { InputFormat::Pdf => p diff --git a/crates/fleischwolf-pdf/src/lib.rs b/crates/fleischwolf-pdf/src/lib.rs index 54f64870..2eb8dc8f 100644 --- a/crates/fleischwolf-pdf/src/lib.rs +++ b/crates/fleischwolf-pdf/src/lib.rs @@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex}; use fleischwolf_core::{DoclingDocument, Node}; -pub use mets::convert_mets_gbs; +pub use mets::{convert_mets_gbs, convert_mets_gbs_with_options}; pub use pdfium_backend::{PdfDocument, PdfPage, TextCell}; /// Errors from the PDF backend. Detailed and surfaced (never silently skipped). @@ -90,11 +90,15 @@ struct Worker { } impl Worker { - fn load(intra: usize) -> Result { + fn load(intra: usize, no_table_former: bool) -> Result { Ok(Self { layout: layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?, ocr: None, - tables: tableformer::TableFormer::load_with(intra), + tables: if no_table_former { + None + } else { + tableformer::TableFormer::load_with(intra) + }, }) } @@ -208,6 +212,9 @@ pub struct Pipeline { target_workers: usize, /// Page count at/above which the parallel pool is worth its load cost. parallel_min: usize, + /// Skip loading/running TableFormer; table regions fall back to geometric + /// reconstruction. See [`Pipeline::no_table_former`]. + no_table_former: bool, } impl Pipeline { @@ -220,13 +227,25 @@ impl Pipeline { pool: Vec::new(), target_workers: pdf_worker_count(), parallel_min: pdf_parallel_min(), + no_table_former: false, }) } + /// Skip loading and running the TableFormer table-structure model. Table + /// regions still get emitted, but reconstructed geometrically from cell + /// positions instead of via the ONNX model's predicted structure — faster + /// (no model load, no per-table inference) at the cost of table fidelity. + /// No effect if a worker is already loaded; set this before the first + /// conversion. + pub fn no_table_former(mut self, disable: bool) -> Self { + self.no_table_former = disable; + self + } + /// The full-intra serial worker, loaded on first use. fn primary(&mut self) -> Result<&mut Worker, PdfError> { if self.primary.is_none() { - self.primary = Some(Worker::load(intra_threads())?); + self.primary = Some(Worker::load(intra_threads(), self.no_table_former)?); } Ok(self.primary.as_mut().unwrap()) } @@ -507,9 +526,10 @@ impl Pipeline { return Ok(()); } let intra = pdf_intra(); + let no_table_former = self.no_table_former; let loaded: Vec> = std::thread::scope(|s| { let handles: Vec<_> = (0..need) - .map(|_| s.spawn(move || Worker::load(intra))) + .map(|_| s.spawn(move || Worker::load(intra, no_table_former))) .collect(); handles.into_iter().map(|h| h.join().unwrap()).collect() }); @@ -567,16 +587,53 @@ pub fn convert( password: Option<&str>, name: &str, ) -> Result { - Pipeline::new()?.convert(bytes, password, name) + convert_with_options(bytes, password, name, false) +} + +/// Like [`convert`], but optionally skips loading/running TableFormer (see +/// [`Pipeline::no_table_former`]). +pub fn convert_with_options( + bytes: &[u8], + password: Option<&str>, + name: &str, + no_table_former: bool, +) -> Result { + Pipeline::new()? + .no_table_former(no_table_former) + .convert(bytes, password, name) } /// Convenience one-shot image conversion (loads the pipeline per call). pub fn convert_image(bytes: &[u8], name: &str) -> Result { - Pipeline::new()?.convert_image(bytes, name) + convert_image_with_options(bytes, name, false) +} + +/// Like [`convert_image`], but optionally skips loading/running TableFormer (see +/// [`Pipeline::no_table_former`]). +pub fn convert_image_with_options( + bytes: &[u8], + name: &str, + no_table_former: bool, +) -> Result { + Pipeline::new()? + .no_table_former(no_table_former) + .convert_image(bytes, name) } /// Convert pre-segmented pages (image + already-known text cells, e.g. METS/hOCR /// scans) through the shared layout + assembly pipeline. pub fn convert_pages(pages: Vec, name: &str) -> Result { - Pipeline::new()?.process_pages(pages, name) + convert_pages_with_options(pages, name, false) +} + +/// Like [`convert_pages`], but optionally skips loading/running TableFormer (see +/// [`Pipeline::no_table_former`]). +pub fn convert_pages_with_options( + pages: Vec, + name: &str, + no_table_former: bool, +) -> Result { + Pipeline::new()? + .no_table_former(no_table_former) + .process_pages(pages, name) } diff --git a/crates/fleischwolf-pdf/src/mets.rs b/crates/fleischwolf-pdf/src/mets.rs index f2e32f96..de6b139e 100644 --- a/crates/fleischwolf-pdf/src/mets.rs +++ b/crates/fleischwolf-pdf/src/mets.rs @@ -17,9 +17,19 @@ use tar::Archive; use fleischwolf_core::DoclingDocument; use crate::pdfium_backend::{PdfPage, TextCell}; -use crate::{convert_pages, PdfError}; +use crate::{convert_pages_with_options, PdfError}; pub fn convert_mets_gbs(bytes: &[u8], name: &str) -> Result { + convert_mets_gbs_with_options(bytes, name, false) +} + +/// Like [`convert_mets_gbs`], but optionally skips loading/running TableFormer +/// (see [`crate::Pipeline::no_table_former`]). +pub fn convert_mets_gbs_with_options( + bytes: &[u8], + name: &str, + no_table_former: bool, +) -> Result { let mut html: BTreeMap = BTreeMap::new(); let mut tiff: BTreeMap> = BTreeMap::new(); @@ -80,7 +90,7 @@ pub fn convert_mets_gbs(bytes: &[u8], name: &str) -> Result>, strict: bool, fetch_images: bool, + no_table_former: bool, } impl DocumentConverter { @@ -59,6 +60,7 @@ impl DocumentConverter { allowed_formats: Some(formats.into_iter().collect()), strict: false, fetch_images: false, + no_table_former: false, } } @@ -90,6 +92,20 @@ impl DocumentConverter { self } + /// Skip loading and running the TableFormer table-structure model for + /// PDF/image/METS sources. + /// + /// Off by default. When enabled, table regions are still detected and + /// emitted, but their structure is reconstructed geometrically from cell + /// positions instead of the ONNX model's predicted structure — no model + /// load and no per-table inference, at the cost of table fidelity. Useful + /// when parsing speed matters more than exact table structure, especially + /// with [`convert_streaming`](Self::convert_streaming). + pub fn no_table_former(mut self, disable: bool) -> Self { + self.no_table_former = disable; + self + } + /// Convert a source document to Markdown **incrementally**, returning an /// iterator of Markdown chunks (with picture placeholders). /// @@ -140,6 +156,7 @@ impl DocumentConverter { source, image_mode, self.strict, + self.no_table_former, )) } @@ -194,12 +211,25 @@ impl DocumentConverter { InputFormat::Odt | InputFormat::Ods | InputFormat::Odp => { OdfBackend.convert(&source)? } - InputFormat::Pdf => fleischwolf_pdf::convert(&source.bytes, None, &source.name) - .map_err(|e| ConversionError::Parse(e.to_string()))?, - InputFormat::Image => fleischwolf_pdf::convert_image(&source.bytes, &source.name) - .map_err(|e| ConversionError::Parse(e.to_string()))?, - InputFormat::MetsGbs => fleischwolf_pdf::convert_mets_gbs(&source.bytes, &source.name) - .map_err(|e| ConversionError::Parse(e.to_string()))?, + InputFormat::Pdf => fleischwolf_pdf::convert_with_options( + &source.bytes, + None, + &source.name, + self.no_table_former, + ) + .map_err(|e| ConversionError::Parse(e.to_string()))?, + InputFormat::Image => fleischwolf_pdf::convert_image_with_options( + &source.bytes, + &source.name, + self.no_table_former, + ) + .map_err(|e| ConversionError::Parse(e.to_string()))?, + InputFormat::MetsGbs => fleischwolf_pdf::convert_mets_gbs_with_options( + &source.bytes, + &source.name, + self.no_table_former, + ) + .map_err(|e| ConversionError::Parse(e.to_string()))?, other => return Err(ConversionError::UnsupportedFormat(other)), }; // Carry the mode so `result.document.export_to_markdown()` reflects it. diff --git a/crates/fleischwolf/src/stream.rs b/crates/fleischwolf/src/stream.rs index f40f7485..696a2966 100644 --- a/crates/fleischwolf/src/stream.rs +++ b/crates/fleischwolf/src/stream.rs @@ -75,9 +75,12 @@ pub(crate) fn spawn( source: SourceDocument, image_mode: ImageMode, strict: bool, + no_table_former: bool, ) -> MarkdownStream { let (tx, rx) = sync_channel::>(CHANNEL_DEPTH); - let handle = std::thread::spawn(move || run(converter, source, image_mode, strict, &tx)); + let handle = std::thread::spawn(move || { + run(converter, source, image_mode, strict, no_table_former, &tx) + }); MarkdownStream { rx: Some(rx), handle: Some(handle), @@ -92,12 +95,13 @@ fn run( source: SourceDocument, image_mode: ImageMode, strict: bool, + no_table_former: bool, tx: &std::sync::mpsc::SyncSender>, ) { match source.format { // PDF is the format with internal page-level parallelism, so it gets the // true streaming path: emit each page's Markdown in order as it completes. - InputFormat::Pdf => run_pdf(&source, image_mode, strict, tx), + InputFormat::Pdf => run_pdf(&source, image_mode, strict, no_table_former, tx), // Every other backend builds the whole `DoclingDocument` synchronously, so // there is no latency to stream away; serialize it through the same chunk // API for a uniform interface (one chunk plus the trailing newline). @@ -109,19 +113,21 @@ fn run_pdf( source: &SourceDocument, image_mode: ImageMode, strict: bool, + no_table_former: bool, tx: &std::sync::mpsc::SyncSender>, ) { // The PDF pipeline builds its document from `DoclingDocument::new` defaults, so // tables use the padded GitHub serializer (compact_tables = false), matching the // buffered PDF path. let mut streamer = MarkdownStreamer::new(strict, image_mode, false); - let mut pipeline = match fleischwolf_pdf::Pipeline::new() { - Ok(p) => p, - Err(e) => { - let _ = tx.send(Err(ConversionError::Parse(e.to_string()))); - return; - } - }; + let mut pipeline = + match fleischwolf_pdf::Pipeline::new().map(|p| p.no_table_former(no_table_former)) { + Ok(p) => p, + Err(e) => { + let _ = tx.send(Err(ConversionError::Parse(e.to_string()))); + return; + } + }; let result = pipeline.convert_streaming(&source.bytes, None, &source.name, |nodes, links| { let chunk = streamer.push(&nodes, &links);