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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,13 @@ The CLI streams Markdown by default (`--no-stream` opts back into buffering;
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.
streaming mode) at the cost of table fidelity. `--no-ocr` goes further and
skips layout detection, OCR, and TableFormer entirely — no ML inference at
all, only the PDF's embedded text cells grouped into flat paragraphs by
reading order (no headings/lists/tables/pictures). It's the fastest PDF path
by a wide margin, but a scanned/image-only PDF (no embedded text layer) comes
back empty rather than erroring, so a caller can detect that and re-convert
without the flag.

## Node.js / Bun bindings

Expand Down
22 changes: 17 additions & 5 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] [--no-table-former] <input-file>
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] <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 @@ -24,6 +24,13 @@
//! geometric reconstruction from cell positions. Faster
//! (no model load, no per-table inference) at the cost of
//! table fidelity — helps most in streaming mode.
//! --no-ocr skip layout detection, OCR, and TableFormer entirely for
//! PDF/image input — no model load or inference at all.
//! Emits the embedded text layer as flat paragraphs in
//! reading order (no headings/lists/tables/pictures). The
//! fastest option, but a scanned/image-only PDF (no
//! embedded text layer) yields no text — convert those
//! without this flag.

use std::io::{self, Write};
use std::path::Path;
Expand All @@ -38,6 +45,7 @@ fn main() -> ExitCode {
let mut fetch_images = false;
let mut no_stream = false;
let mut no_table_former = false;
let mut no_ocr = false;
let mut bench_warm: Option<usize> = None;
let mut path: Option<String> = None;
let mut args = std::env::args().skip(1);
Expand All @@ -47,6 +55,7 @@ fn main() -> ExitCode {
"--fetch-images" => fetch_images = true,
"--no-stream" => no_stream = true,
"--no-table-former" => no_table_former = true,
"--no-ocr" => no_ocr = 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 @@ -81,7 +90,7 @@ fn main() -> ExitCode {
};

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

Expand All @@ -94,7 +103,7 @@ fn main() -> ExitCode {
};

if let Some(runs) = bench_warm {
return match bench_warm_conversion(&source, runs, no_table_former) {
return match bench_warm_conversion(&source, runs, no_table_former, no_ocr) {
Ok(avg) => {
// Bare seconds on stdout for the benchmark harness; a human line on stderr.
println!("{avg:.6}");
Expand All @@ -114,7 +123,8 @@ fn main() -> ExitCode {
let converter = DocumentConverter::new()
.strict(strict)
.fetch_images(fetch_images)
.no_table_former(no_table_former);
.no_table_former(no_table_former)
.no_ocr(no_ocr);

// 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 @@ -201,10 +211,12 @@ fn bench_warm_conversion(
source: &SourceDocument,
runs: usize,
no_table_former: bool,
no_ocr: bool,
) -> Result<f64, String> {
let mut pipeline = Pipeline::new()
.map_err(|e| e.to_string())?
.no_table_former(no_table_former);
.no_table_former(no_table_former)
.no_ocr(no_ocr);
let once = |p: &mut Pipeline| -> Result<(), String> {
match source.format {
InputFormat::Pdf => p
Expand Down
114 changes: 90 additions & 24 deletions crates/fleischwolf-pdf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,32 +82,59 @@ type PageOut = (Vec<Node>, Vec<(String, String)>);
/// parallel page-worker owns its own `Worker` so inference runs concurrently
/// without sharing an ONNX session (`ort`'s `Session::run` is `&mut self`).
struct Worker {
layout: layout::LayoutModel,
/// `None` when `no_ocr` skips layout entirely — no model load, no inference.
layout: Option<layout::LayoutModel>,
ocr: Option<ocr::OcrModel>,
/// TableFormer structure model; `None` when its ONNX graphs aren't present
/// (the assembler then falls back to geometric table reconstruction).
/// (the assembler then falls back to geometric table reconstruction) or
/// when `no_table_former`/`no_ocr` skip it.
tables: Option<tableformer::TableFormer>,
/// Skip layout, OCR, and TableFormer; reconstruct text purely from the PDF's
/// embedded text layer. See [`Pipeline::no_ocr`].
no_ocr: bool,
}

impl Worker {
fn load(intra: usize, no_table_former: bool) -> Result<Self, PdfError> {
fn load(intra: usize, no_table_former: bool, no_ocr: bool) -> Result<Self, PdfError> {
Ok(Self {
layout: layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?,
layout: if no_ocr {
None
} else {
Some(layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?)
},
ocr: None,
tables: if no_table_former {
tables: if no_table_former || no_ocr {
None
} else {
tableformer::TableFormer::load_with(intra)
},
no_ocr,
})
}

/// Run layout (+ OCR for cell-less pages) + TableFormer and assemble page `n`
/// into its nodes and links. Pure given the page (mutates only the worker's
/// lazily-loaded OCR model), so it is safe to run concurrently across pages.
fn process(&mut self, n: usize, page: &mut PdfPage) -> Result<PageOut, PdfError> {
if self.no_ocr {
// Fastest path: no layout/OCR/TableFormer inference at all. The PDF's
// embedded text cells (if any) become flat, line-grouped paragraphs in
// reading order via the same orphan-region machinery that normally
// rescues text the detector missed — here it rescues *all* of it.
// Pages with no embedded text layer (scanned/image-only) yield nothing;
// convert those without `no_ocr`.
let mut regions = Vec::new();
assemble::add_orphan_regions(&mut regions, &page.cells);
let table_rows = vec![None; regions.len()];
return Ok(timing::timed("assemble_page", || {
assemble::assemble_page(page, regions, &table_rows)
}));
}
let regions = timing::timed("layout.predict", || {
self.layout.predict(&page.image, page.width, page.height)
self.layout
.as_mut()
.expect("layout model loaded unless no_ocr")
.predict(&page.image, page.width, page.height)
})
.map_err(|e| PdfError::Layout(format!("page {}: {e}", n + 1)))?;
// Resolve overlapping detections once, before OCR.
Expand Down Expand Up @@ -215,6 +242,8 @@ pub struct Pipeline {
/// Skip loading/running TableFormer; table regions fall back to geometric
/// reconstruction. See [`Pipeline::no_table_former`].
no_table_former: bool,
/// Skip layout, OCR, and TableFormer entirely. See [`Pipeline::no_ocr`].
no_ocr: bool,
}

impl Pipeline {
Expand All @@ -228,6 +257,7 @@ impl Pipeline {
target_workers: pdf_worker_count(),
parallel_min: pdf_parallel_min(),
no_table_former: false,
no_ocr: false,
})
}

Expand All @@ -242,10 +272,27 @@ impl Pipeline {
self
}

/// Skip layout detection, OCR, and TableFormer entirely — no model load, no
/// inference of any kind. The PDF's embedded text cells are grouped by line
/// and emitted as plain paragraphs in reading order: no headings, lists,
/// tables, code blocks, or pictures, since that structure comes from the
/// layout model. The fastest possible PDF path, but pages with no embedded
/// text layer (scanned/image-only PDFs) yield no text at all — convert those
/// without this flag. Implies `no_table_former`. No effect if a worker is
/// already loaded; set this before the first conversion.
pub fn no_ocr(mut self, disable: bool) -> Self {
self.no_ocr = 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.no_table_former)?);
self.primary = Some(Worker::load(
intra_threads(),
self.no_table_former,
self.no_ocr,
)?);
}
Ok(self.primary.as_mut().unwrap())
}
Expand Down Expand Up @@ -280,8 +327,9 @@ impl Pipeline {
name: &str,
) -> Result<DoclingDocument, PdfError> {
let mut doc = DoclingDocument::new(name);
let render_image = !self.no_ocr;
let worker = self.primary()?;
pdfium_backend::for_each_page(bytes, password, |n, _total, mut page| {
pdfium_backend::for_each_page(bytes, password, render_image, |n, _total, mut page| {
let (nodes, links) = worker.process(n, &mut page)?;
doc.nodes.extend(nodes);
doc.links.extend(links);
Expand All @@ -304,6 +352,7 @@ impl Pipeline {
) -> Result<DoclingDocument, PdfError> {
self.ensure_pool()?;
let n_workers = self.pool.len();
let render_image = !self.no_ocr;
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
let results: Arc<Mutex<Vec<(usize, PageOut)>>> = Arc::new(Mutex::new(Vec::new()));
Expand Down Expand Up @@ -335,11 +384,12 @@ impl Pipeline {
// Render on this thread and feed the workers; backpressure blocks here
// when the channel is full. Dropping `work_tx` afterwards signals the
// workers (recv → Err) to finish.
let render = pdfium_backend::for_each_page(bytes, password, |i, _total, page| {
work_tx
.send((i, page))
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
});
let render =
pdfium_backend::for_each_page(bytes, password, render_image, |i, _total, page| {
work_tx
.send((i, page))
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
});
drop(work_tx);
if let Err(e) = render {
let mut slot = first_err.lock().unwrap();
Expand Down Expand Up @@ -412,8 +462,9 @@ impl Pipeline {
F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
{
let mut asm = assemble::StreamAssembler::new();
let render_image = !self.no_ocr;
let worker = self.primary()?;
pdfium_backend::for_each_page(bytes, password, |n, _total, mut page| {
pdfium_backend::for_each_page(bytes, password, render_image, |n, _total, mut page| {
let (nodes, links) = worker.process(n, &mut page)?;
emit(asm.push(nodes), links)
})?;
Expand All @@ -437,6 +488,7 @@ impl Pipeline {
{
self.ensure_pool()?;
let n_workers = self.pool.len();
let render_image = !self.no_ocr;
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
// Workers and the renderer report here; the calling thread drains it in
Expand Down Expand Up @@ -467,12 +519,16 @@ impl Pipeline {
{
let res_tx = res_tx.clone();
s.spawn(move || {
let render =
pdfium_backend::for_each_page(bytes, password, |i, _total, page| {
let render = pdfium_backend::for_each_page(
bytes,
password,
render_image,
|i, _total, page| {
work_tx
.send((i, page))
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
});
},
);
drop(work_tx); // signal workers to finish
if let Err(e) = render {
let _ = res_tx.send(Err(e));
Expand Down Expand Up @@ -527,9 +583,10 @@ impl Pipeline {
}
let intra = pdf_intra();
let no_table_former = self.no_table_former;
let no_ocr = self.no_ocr;
let loaded: Vec<Result<Worker, PdfError>> = std::thread::scope(|s| {
let handles: Vec<_> = (0..need)
.map(|_| s.spawn(move || Worker::load(intra, no_table_former)))
.map(|_| s.spawn(move || Worker::load(intra, no_table_former, no_ocr)))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
Expand Down Expand Up @@ -587,53 +644,62 @@ pub fn convert(
password: Option<&str>,
name: &str,
) -> Result<DoclingDocument, PdfError> {
convert_with_options(bytes, password, name, false)
convert_with_options(bytes, password, name, false, false)
}

/// Like [`convert`], but optionally skips loading/running TableFormer (see
/// [`Pipeline::no_table_former`]).
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
/// [`Pipeline::no_ocr`]).
pub fn convert_with_options(
bytes: &[u8],
password: Option<&str>,
name: &str,
no_table_former: bool,
no_ocr: bool,
) -> Result<DoclingDocument, PdfError> {
Pipeline::new()?
.no_table_former(no_table_former)
.no_ocr(no_ocr)
.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> {
convert_image_with_options(bytes, name, false)
convert_image_with_options(bytes, name, false, false)
}

/// Like [`convert_image`], but optionally skips loading/running TableFormer (see
/// [`Pipeline::no_table_former`]).
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
/// [`Pipeline::no_ocr`]).
pub fn convert_image_with_options(
bytes: &[u8],
name: &str,
no_table_former: bool,
no_ocr: bool,
) -> Result<DoclingDocument, PdfError> {
Pipeline::new()?
.no_table_former(no_table_former)
.no_ocr(no_ocr)
.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> {
convert_pages_with_options(pages, name, false)
convert_pages_with_options(pages, name, false, false)
}

/// Like [`convert_pages`], but optionally skips loading/running TableFormer (see
/// [`Pipeline::no_table_former`]).
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
/// [`Pipeline::no_ocr`]).
pub fn convert_pages_with_options(
pages: Vec<PdfPage>,
name: &str,
no_table_former: bool,
no_ocr: bool,
) -> Result<DoclingDocument, PdfError> {
Pipeline::new()?
.no_table_former(no_table_former)
.no_ocr(no_ocr)
.process_pages(pages, name)
}
8 changes: 5 additions & 3 deletions crates/fleischwolf-pdf/src/mets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ use crate::pdfium_backend::{PdfPage, TextCell};
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)
convert_mets_gbs_with_options(bytes, name, false, false)
}

/// Like [`convert_mets_gbs`], but optionally skips loading/running TableFormer
/// (see [`crate::Pipeline::no_table_former`]).
/// (see [`crate::Pipeline::no_table_former`]) and/or layout+OCR+TableFormer
/// entirely (see [`crate::Pipeline::no_ocr`]).
pub fn convert_mets_gbs_with_options(
bytes: &[u8],
name: &str,
no_table_former: bool,
no_ocr: 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 @@ -90,7 +92,7 @@ pub fn convert_mets_gbs_with_options(
"mets: no hOCR/TIFF page pairs found in archive".into(),
));
}
convert_pages_with_options(pages, name, no_table_former)
convert_pages_with_options(pages, name, no_table_former, no_ocr)
}

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