Skip to content

Commit cb7ed00

Browse files
artizclaude
andauthored
Add --no-ocr for the fastest possible PDF path (embedded text only) (#21)
* feat(cli): add --no-ocr to skip layout, OCR, and TableFormer entirely Adds the fastest possible PDF path: no model load, no inference at all. Regions come purely from the PDF's embedded text cells via the existing orphan-region mechanism (normally used to rescue text the layout detector missed; here it rescues all of it), grouped by line and emitted as flat paragraphs in reading order. No headings, lists, tables, code blocks, or pictures, since that structure requires the layout model. Implies --no-table-former. Pages with no embedded text layer (scanned/image-only PDFs) come back empty rather than erroring, so callers can detect that and re-convert without the flag. * perf(pdf): skip page rasterization and redundant FFI text pass --no-ocr's per-page time was ~90% wasted: extract_page always rasterized the full page bitmap (render + CatmullRom downsample) for layout/OCR/TableFormer, none of which no_ocr ever runs. Thread render_image through for_each_page/extract_page, driven by Pipeline::no_ocr, so the fast path skips rendering outright. Separately, extract_page always ran pdfium's FFI text extraction and then discarded it whenever the Rust parser's prose/word/code cells were non-empty (the common case, in every mode) — that pdfium call is now lazy, only running when the parser's per-channel output is actually missing. Net effect on a representative single-page PDF: --no-ocr's per-stage time drops from ~336ms to ~21ms (textparse + assemble only). Verified byte-identical output for both --no-ocr and the default pipeline, and scripts/pdf_conformance.sh: 91/91 exact, 0 drift. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 883754b commit cb7ed00

7 files changed

Lines changed: 227 additions & 80 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ The CLI streams Markdown by default (`--no-stream` opts back into buffering;
162162
loading/running the TableFormer table-structure model, falling back to simple
163163
geometric table reconstruction from cell positions — no model load, no
164164
per-table inference, which can noticeably speed up parsing (especially in
165-
streaming mode) at the cost of table fidelity.
165+
streaming mode) at the cost of table fidelity. `--no-ocr` goes further and
166+
skips layout detection, OCR, and TableFormer entirely — no ML inference at
167+
all, only the PDF's embedded text cells grouped into flat paragraphs by
168+
reading order (no headings/lists/tables/pictures). It's the fastest PDF path
169+
by a wide margin, but a scanned/image-only PDF (no embedded text layer) comes
170+
back empty rather than erroring, so a caller can detect that and re-convert
171+
without the flag.
166172

167173
## Node.js / Bun bindings
168174

crates/fleischwolf-cli/src/main.rs

Lines changed: 17 additions & 5 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] [--no-table-former] <input-file>
6+
//! Usage: fleischwolf [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] <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
@@ -24,6 +24,13 @@
2424
//! geometric reconstruction from cell positions. Faster
2525
//! (no model load, no per-table inference) at the cost of
2626
//! table fidelity — helps most in streaming mode.
27+
//! --no-ocr skip layout detection, OCR, and TableFormer entirely for
28+
//! PDF/image input — no model load or inference at all.
29+
//! Emits the embedded text layer as flat paragraphs in
30+
//! reading order (no headings/lists/tables/pictures). The
31+
//! fastest option, but a scanned/image-only PDF (no
32+
//! embedded text layer) yields no text — convert those
33+
//! without this flag.
2734
2835
use std::io::{self, Write};
2936
use std::path::Path;
@@ -38,6 +45,7 @@ fn main() -> ExitCode {
3845
let mut fetch_images = false;
3946
let mut no_stream = false;
4047
let mut no_table_former = false;
48+
let mut no_ocr = false;
4149
let mut bench_warm: Option<usize> = None;
4250
let mut path: Option<String> = None;
4351
let mut args = std::env::args().skip(1);
@@ -47,6 +55,7 @@ fn main() -> ExitCode {
4755
"--fetch-images" => fetch_images = true,
4856
"--no-stream" => no_stream = true,
4957
"--no-table-former" => no_table_former = true,
58+
"--no-ocr" => no_ocr = true,
5059
"--to" => to = args.next().unwrap_or_default(),
5160
"--images" => images = args.next().unwrap_or_default(),
5261
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
@@ -81,7 +90,7 @@ fn main() -> ExitCode {
8190
};
8291

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

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

96105
if let Some(runs) = bench_warm {
97-
return match bench_warm_conversion(&source, runs, no_table_former) {
106+
return match bench_warm_conversion(&source, runs, no_table_former, no_ocr) {
98107
Ok(avg) => {
99108
// Bare seconds on stdout for the benchmark harness; a human line on stderr.
100109
println!("{avg:.6}");
@@ -114,7 +123,8 @@ fn main() -> ExitCode {
114123
let converter = DocumentConverter::new()
115124
.strict(strict)
116125
.fetch_images(fetch_images)
117-
.no_table_former(no_table_former);
126+
.no_table_former(no_table_former)
127+
.no_ocr(no_ocr);
118128

119129
// Stream Markdown by default: print each chunk as the converter produces it
120130
// (page by page for PDF). JSON needs the whole tree, and the referenced image
@@ -201,10 +211,12 @@ fn bench_warm_conversion(
201211
source: &SourceDocument,
202212
runs: usize,
203213
no_table_former: bool,
214+
no_ocr: bool,
204215
) -> Result<f64, String> {
205216
let mut pipeline = Pipeline::new()
206217
.map_err(|e| e.to_string())?
207-
.no_table_former(no_table_former);
218+
.no_table_former(no_table_former)
219+
.no_ocr(no_ocr);
208220
let once = |p: &mut Pipeline| -> Result<(), String> {
209221
match source.format {
210222
InputFormat::Pdf => p

crates/fleischwolf-pdf/src/lib.rs

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,32 +82,59 @@ type PageOut = (Vec<Node>, Vec<(String, String)>);
8282
/// parallel page-worker owns its own `Worker` so inference runs concurrently
8383
/// without sharing an ONNX session (`ort`'s `Session::run` is `&mut self`).
8484
struct Worker {
85-
layout: layout::LayoutModel,
85+
/// `None` when `no_ocr` skips layout entirely — no model load, no inference.
86+
layout: Option<layout::LayoutModel>,
8687
ocr: Option<ocr::OcrModel>,
8788
/// TableFormer structure model; `None` when its ONNX graphs aren't present
88-
/// (the assembler then falls back to geometric table reconstruction).
89+
/// (the assembler then falls back to geometric table reconstruction) or
90+
/// when `no_table_former`/`no_ocr` skip it.
8991
tables: Option<tableformer::TableFormer>,
92+
/// Skip layout, OCR, and TableFormer; reconstruct text purely from the PDF's
93+
/// embedded text layer. See [`Pipeline::no_ocr`].
94+
no_ocr: bool,
9095
}
9196

9297
impl Worker {
93-
fn load(intra: usize, no_table_former: bool) -> Result<Self, PdfError> {
98+
fn load(intra: usize, no_table_former: bool, no_ocr: bool) -> Result<Self, PdfError> {
9499
Ok(Self {
95-
layout: layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?,
100+
layout: if no_ocr {
101+
None
102+
} else {
103+
Some(layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?)
104+
},
96105
ocr: None,
97-
tables: if no_table_former {
106+
tables: if no_table_former || no_ocr {
98107
None
99108
} else {
100109
tableformer::TableFormer::load_with(intra)
101110
},
111+
no_ocr,
102112
})
103113
}
104114

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

220249
impl Pipeline {
@@ -228,6 +257,7 @@ impl Pipeline {
228257
target_workers: pdf_worker_count(),
229258
parallel_min: pdf_parallel_min(),
230259
no_table_former: false,
260+
no_ocr: false,
231261
})
232262
}
233263

@@ -242,10 +272,27 @@ impl Pipeline {
242272
self
243273
}
244274

275+
/// Skip layout detection, OCR, and TableFormer entirely — no model load, no
276+
/// inference of any kind. The PDF's embedded text cells are grouped by line
277+
/// and emitted as plain paragraphs in reading order: no headings, lists,
278+
/// tables, code blocks, or pictures, since that structure comes from the
279+
/// layout model. The fastest possible PDF path, but pages with no embedded
280+
/// text layer (scanned/image-only PDFs) yield no text at all — convert those
281+
/// without this flag. Implies `no_table_former`. No effect if a worker is
282+
/// already loaded; set this before the first conversion.
283+
pub fn no_ocr(mut self, disable: bool) -> Self {
284+
self.no_ocr = disable;
285+
self
286+
}
287+
245288
/// The full-intra serial worker, loaded on first use.
246289
fn primary(&mut self) -> Result<&mut Worker, PdfError> {
247290
if self.primary.is_none() {
248-
self.primary = Some(Worker::load(intra_threads(), self.no_table_former)?);
291+
self.primary = Some(Worker::load(
292+
intra_threads(),
293+
self.no_table_former,
294+
self.no_ocr,
295+
)?);
249296
}
250297
Ok(self.primary.as_mut().unwrap())
251298
}
@@ -280,8 +327,9 @@ impl Pipeline {
280327
name: &str,
281328
) -> Result<DoclingDocument, PdfError> {
282329
let mut doc = DoclingDocument::new(name);
330+
let render_image = !self.no_ocr;
283331
let worker = self.primary()?;
284-
pdfium_backend::for_each_page(bytes, password, |n, _total, mut page| {
332+
pdfium_backend::for_each_page(bytes, password, render_image, |n, _total, mut page| {
285333
let (nodes, links) = worker.process(n, &mut page)?;
286334
doc.nodes.extend(nodes);
287335
doc.links.extend(links);
@@ -304,6 +352,7 @@ impl Pipeline {
304352
) -> Result<DoclingDocument, PdfError> {
305353
self.ensure_pool()?;
306354
let n_workers = self.pool.len();
355+
let render_image = !self.no_ocr;
307356
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
308357
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
309358
let results: Arc<Mutex<Vec<(usize, PageOut)>>> = Arc::new(Mutex::new(Vec::new()));
@@ -335,11 +384,12 @@ impl Pipeline {
335384
// Render on this thread and feed the workers; backpressure blocks here
336385
// when the channel is full. Dropping `work_tx` afterwards signals the
337386
// workers (recv → Err) to finish.
338-
let render = pdfium_backend::for_each_page(bytes, password, |i, _total, page| {
339-
work_tx
340-
.send((i, page))
341-
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
342-
});
387+
let render =
388+
pdfium_backend::for_each_page(bytes, password, render_image, |i, _total, page| {
389+
work_tx
390+
.send((i, page))
391+
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
392+
});
343393
drop(work_tx);
344394
if let Err(e) = render {
345395
let mut slot = first_err.lock().unwrap();
@@ -412,8 +462,9 @@ impl Pipeline {
412462
F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
413463
{
414464
let mut asm = assemble::StreamAssembler::new();
465+
let render_image = !self.no_ocr;
415466
let worker = self.primary()?;
416-
pdfium_backend::for_each_page(bytes, password, |n, _total, mut page| {
467+
pdfium_backend::for_each_page(bytes, password, render_image, |n, _total, mut page| {
417468
let (nodes, links) = worker.process(n, &mut page)?;
418469
emit(asm.push(nodes), links)
419470
})?;
@@ -437,6 +488,7 @@ impl Pipeline {
437488
{
438489
self.ensure_pool()?;
439490
let n_workers = self.pool.len();
491+
let render_image = !self.no_ocr;
440492
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
441493
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
442494
// Workers and the renderer report here; the calling thread drains it in
@@ -467,12 +519,16 @@ impl Pipeline {
467519
{
468520
let res_tx = res_tx.clone();
469521
s.spawn(move || {
470-
let render =
471-
pdfium_backend::for_each_page(bytes, password, |i, _total, page| {
522+
let render = pdfium_backend::for_each_page(
523+
bytes,
524+
password,
525+
render_image,
526+
|i, _total, page| {
472527
work_tx
473528
.send((i, page))
474529
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
475-
});
530+
},
531+
);
476532
drop(work_tx); // signal workers to finish
477533
if let Err(e) = render {
478534
let _ = res_tx.send(Err(e));
@@ -527,9 +583,10 @@ impl Pipeline {
527583
}
528584
let intra = pdf_intra();
529585
let no_table_former = self.no_table_former;
586+
let no_ocr = self.no_ocr;
530587
let loaded: Vec<Result<Worker, PdfError>> = std::thread::scope(|s| {
531588
let handles: Vec<_> = (0..need)
532-
.map(|_| s.spawn(move || Worker::load(intra, no_table_former)))
589+
.map(|_| s.spawn(move || Worker::load(intra, no_table_former, no_ocr)))
533590
.collect();
534591
handles.into_iter().map(|h| h.join().unwrap()).collect()
535592
});
@@ -587,53 +644,62 @@ pub fn convert(
587644
password: Option<&str>,
588645
name: &str,
589646
) -> Result<DoclingDocument, PdfError> {
590-
convert_with_options(bytes, password, name, false)
647+
convert_with_options(bytes, password, name, false, false)
591648
}
592649

593650
/// Like [`convert`], but optionally skips loading/running TableFormer (see
594-
/// [`Pipeline::no_table_former`]).
651+
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
652+
/// [`Pipeline::no_ocr`]).
595653
pub fn convert_with_options(
596654
bytes: &[u8],
597655
password: Option<&str>,
598656
name: &str,
599657
no_table_former: bool,
658+
no_ocr: bool,
600659
) -> Result<DoclingDocument, PdfError> {
601660
Pipeline::new()?
602661
.no_table_former(no_table_former)
662+
.no_ocr(no_ocr)
603663
.convert(bytes, password, name)
604664
}
605665

606666
/// Convenience one-shot image conversion (loads the pipeline per call).
607667
pub fn convert_image(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
608-
convert_image_with_options(bytes, name, false)
668+
convert_image_with_options(bytes, name, false, false)
609669
}
610670

611671
/// Like [`convert_image`], but optionally skips loading/running TableFormer (see
612-
/// [`Pipeline::no_table_former`]).
672+
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
673+
/// [`Pipeline::no_ocr`]).
613674
pub fn convert_image_with_options(
614675
bytes: &[u8],
615676
name: &str,
616677
no_table_former: bool,
678+
no_ocr: bool,
617679
) -> Result<DoclingDocument, PdfError> {
618680
Pipeline::new()?
619681
.no_table_former(no_table_former)
682+
.no_ocr(no_ocr)
620683
.convert_image(bytes, name)
621684
}
622685

623686
/// Convert pre-segmented pages (image + already-known text cells, e.g. METS/hOCR
624687
/// scans) through the shared layout + assembly pipeline.
625688
pub fn convert_pages(pages: Vec<PdfPage>, name: &str) -> Result<DoclingDocument, PdfError> {
626-
convert_pages_with_options(pages, name, false)
689+
convert_pages_with_options(pages, name, false, false)
627690
}
628691

629692
/// Like [`convert_pages`], but optionally skips loading/running TableFormer (see
630-
/// [`Pipeline::no_table_former`]).
693+
/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
694+
/// [`Pipeline::no_ocr`]).
631695
pub fn convert_pages_with_options(
632696
pages: Vec<PdfPage>,
633697
name: &str,
634698
no_table_former: bool,
699+
no_ocr: bool,
635700
) -> Result<DoclingDocument, PdfError> {
636701
Pipeline::new()?
637702
.no_table_former(no_table_former)
703+
.no_ocr(no_ocr)
638704
.process_pages(pages, name)
639705
}

crates/fleischwolf-pdf/src/mets.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@ use crate::pdfium_backend::{PdfPage, TextCell};
2020
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)
23+
convert_mets_gbs_with_options(bytes, name, false, false)
2424
}
2525

2626
/// Like [`convert_mets_gbs`], but optionally skips loading/running TableFormer
27-
/// (see [`crate::Pipeline::no_table_former`]).
27+
/// (see [`crate::Pipeline::no_table_former`]) and/or layout+OCR+TableFormer
28+
/// entirely (see [`crate::Pipeline::no_ocr`]).
2829
pub fn convert_mets_gbs_with_options(
2930
bytes: &[u8],
3031
name: &str,
3132
no_table_former: bool,
33+
no_ocr: bool,
3234
) -> Result<DoclingDocument, PdfError> {
3335
let mut html: BTreeMap<String, String> = BTreeMap::new();
3436
let mut tiff: BTreeMap<String, Vec<u8>> = BTreeMap::new();
@@ -90,7 +92,7 @@ pub fn convert_mets_gbs_with_options(
9092
"mets: no hOCR/TIFF page pairs found in archive".into(),
9193
));
9294
}
93-
convert_pages_with_options(pages, name, no_table_former)
95+
convert_pages_with_options(pages, name, no_table_former, no_ocr)
9496
}
9597

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

0 commit comments

Comments
 (0)