Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 20 additions & 6 deletions crates/fleischwolf-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] <input-file>
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] <input-file>
//! --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
Expand All @@ -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;
Expand All @@ -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<usize> = None;
let mut path: Option<String> = None;
let mut args = std::env::args().skip(1);
Expand All @@ -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
Expand Down Expand Up @@ -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] <input-file>");
eprintln!("usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] <input-file>");
return ExitCode::from(2);
};

Expand All @@ -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}");
Expand All @@ -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
Expand Down Expand Up @@ -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<f64, String> {
let mut pipeline = Pipeline::new().map_err(|e| e.to_string())?;
fn bench_warm_conversion(
source: &SourceDocument,
runs: usize,
no_table_former: bool,
) -> Result<f64, String> {
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
Expand Down
73 changes: 65 additions & 8 deletions crates/fleischwolf-pdf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -90,11 +90,15 @@ struct Worker {
}

impl Worker {
fn load(intra: usize) -> Result<Self, PdfError> {
fn load(intra: usize, no_table_former: bool) -> Result<Self, PdfError> {
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)
},
})
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
Expand Down Expand Up @@ -507,9 +526,10 @@ impl Pipeline {
return Ok(());
}
let intra = pdf_intra();
let no_table_former = self.no_table_former;
let loaded: Vec<Result<Worker, PdfError>> = 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()
});
Expand Down Expand Up @@ -567,16 +587,53 @@ pub fn convert(
password: Option<&str>,
name: &str,
) -> Result<DoclingDocument, PdfError> {
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<DoclingDocument, PdfError> {
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<DoclingDocument, PdfError> {
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<DoclingDocument, PdfError> {
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<PdfPage>, name: &str) -> Result<DoclingDocument, PdfError> {
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<PdfPage>,
name: &str,
no_table_former: bool,
) -> Result<DoclingDocument, PdfError> {
Pipeline::new()?
.no_table_former(no_table_former)
.process_pages(pages, name)
}
14 changes: 12 additions & 2 deletions crates/fleischwolf-pdf/src/mets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DoclingDocument, PdfError> {
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<DoclingDocument, PdfError> {
let mut html: BTreeMap<String, String> = BTreeMap::new();
let mut tiff: BTreeMap<String, Vec<u8>> = BTreeMap::new();

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

/// Parse an hOCR page: the `ocr_page` bbox gives the page geometry (cells are in
Expand Down
42 changes: 36 additions & 6 deletions crates/fleischwolf/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub struct DocumentConverter {
allowed_formats: Option<HashSet<InputFormat>>,
strict: bool,
fetch_images: bool,
no_table_former: bool,
}

impl DocumentConverter {
Expand All @@ -59,6 +60,7 @@ impl DocumentConverter {
allowed_formats: Some(formats.into_iter().collect()),
strict: false,
fetch_images: false,
no_table_former: false,
}
}

Expand Down Expand Up @@ -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).
///
Expand Down Expand Up @@ -140,6 +156,7 @@ impl DocumentConverter {
source,
image_mode,
self.strict,
self.no_table_former,
))
}

Expand Down Expand Up @@ -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.
Expand Down
24 changes: 15 additions & 9 deletions crates/fleischwolf/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Result<String, ConversionError>>(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),
Expand All @@ -92,12 +95,13 @@ fn run(
source: SourceDocument,
image_mode: ImageMode,
strict: bool,
no_table_former: bool,
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
) {
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).
Expand All @@ -109,19 +113,21 @@ fn run_pdf(
source: &SourceDocument,
image_mode: ImageMode,
strict: bool,
no_table_former: bool,
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
) {
// 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);
Expand Down