Skip to content

Commit 32890e6

Browse files
committed
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.
1 parent 7ffd460 commit 32890e6

2 files changed

Lines changed: 85 additions & 47 deletions

File tree

crates/fleischwolf-pdf/src/lib.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,9 @@ impl Pipeline {
327327
name: &str,
328328
) -> Result<DoclingDocument, PdfError> {
329329
let mut doc = DoclingDocument::new(name);
330+
let render_image = !self.no_ocr;
330331
let worker = self.primary()?;
331-
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| {
332333
let (nodes, links) = worker.process(n, &mut page)?;
333334
doc.nodes.extend(nodes);
334335
doc.links.extend(links);
@@ -351,6 +352,7 @@ impl Pipeline {
351352
) -> Result<DoclingDocument, PdfError> {
352353
self.ensure_pool()?;
353354
let n_workers = self.pool.len();
355+
let render_image = !self.no_ocr;
354356
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
355357
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
356358
let results: Arc<Mutex<Vec<(usize, PageOut)>>> = Arc::new(Mutex::new(Vec::new()));
@@ -382,11 +384,12 @@ impl Pipeline {
382384
// Render on this thread and feed the workers; backpressure blocks here
383385
// when the channel is full. Dropping `work_tx` afterwards signals the
384386
// workers (recv → Err) to finish.
385-
let render = pdfium_backend::for_each_page(bytes, password, |i, _total, page| {
386-
work_tx
387-
.send((i, page))
388-
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
389-
});
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+
});
390393
drop(work_tx);
391394
if let Err(e) = render {
392395
let mut slot = first_err.lock().unwrap();
@@ -459,8 +462,9 @@ impl Pipeline {
459462
F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
460463
{
461464
let mut asm = assemble::StreamAssembler::new();
465+
let render_image = !self.no_ocr;
462466
let worker = self.primary()?;
463-
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| {
464468
let (nodes, links) = worker.process(n, &mut page)?;
465469
emit(asm.push(nodes), links)
466470
})?;
@@ -484,6 +488,7 @@ impl Pipeline {
484488
{
485489
self.ensure_pool()?;
486490
let n_workers = self.pool.len();
491+
let render_image = !self.no_ocr;
487492
let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * 2);
488493
let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
489494
// Workers and the renderer report here; the calling thread drains it in
@@ -514,12 +519,16 @@ impl Pipeline {
514519
{
515520
let res_tx = res_tx.clone();
516521
s.spawn(move || {
517-
let render =
518-
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| {
519527
work_tx
520528
.send((i, page))
521529
.map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
522-
});
530+
},
531+
);
523532
drop(work_tx); // signal workers to finish
524533
if let Err(e) = render {
525534
let _ = res_tx.send(Err(e));

crates/fleischwolf-pdf/src/pdfium_backend.rs

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ impl PdfDocument {
138138
let mut pages = Vec::new();
139139
for (i, page) in doc.pages().iter().enumerate() {
140140
let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
141-
pages.push(extract_page(&page, &ffi, i as i32, rc)?);
141+
pages.push(extract_page(&page, &ffi, i as i32, rc, true)?);
142142
}
143143
Ok(PdfDocument { pages })
144144
}
@@ -173,8 +173,20 @@ pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfiumE
173173
/// large PDF would otherwise hold gigabytes of bitmaps at once. `f` receives the
174174
/// zero-based page index and the total page count.
175175
///
176+
/// `render_image` controls whether the page bitmap is rasterized at all: layout,
177+
/// OCR, TableFormer, and picture cropping all need it, but a caller that skips
178+
/// every one of those (the `no_ocr` fast path) doesn't, and rasterizing +
179+
/// downsampling a page is by far the most expensive step per page — skipping it
180+
/// is most of `no_ocr`'s speedup. `PdfPage::image` is a 1×1 placeholder when
181+
/// `false`; do not read it.
182+
///
176183
/// `E` is the caller's error type; pdfium errors convert into it via `From`.
177-
pub fn for_each_page<E, F>(bytes: &[u8], password: Option<&str>, mut f: F) -> Result<(), E>
184+
pub fn for_each_page<E, F>(
185+
bytes: &[u8],
186+
password: Option<&str>,
187+
render_image: bool,
188+
mut f: F,
189+
) -> Result<(), E>
178190
where
179191
E: From<PdfiumError>,
180192
F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
@@ -187,7 +199,7 @@ where
187199
let total = pages.len() as usize;
188200
for (i, page) in pages.iter().enumerate() {
189201
let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
190-
let extracted = extract_page(&page, &ffi, i as i32, rc)?;
202+
let extracted = extract_page(&page, &ffi, i as i32, rc, render_image)?;
191203
f(i, total, extracted)?;
192204
}
193205
Ok(())
@@ -198,52 +210,69 @@ fn extract_page(
198210
ffi: &FfiText<'_>,
199211
index: i32,
200212
rust_cells: Option<crate::textparse::PageParserCells>,
213+
render_image: bool,
201214
) -> Result<PdfPage, PdfiumError> {
202215
let width = page.width().value;
203216
let height = page.height().value;
204217

205-
let (mut cells, mut code_cells, mut word_cells) =
206-
crate::timing::timed("ffi.page_cells", || ffi.page_cells(index, height));
207-
if cells.is_empty() {
208-
cells = segment_cells(&page.text()?, height);
209-
}
210218
// Default: use the pure-Rust text parser instead of pdfium's text layer
211219
// (override with `DOCLING_PDFIUM_TEXT`). Prose line cells always come from the
212220
// parser; word and code cells do too unless `DOCLING_PDFIUM_WORDS` keeps them
213221
// on pdfium (the parser's word grouping reproduces docling-parse's, which
214222
// TableFormer matches against — roadmap item 6). A page the parser couldn't
215223
// read (no text layer) keeps pdfium's cells.
216-
if let Some(rc) = rust_cells {
217-
if !rc.prose.is_empty() {
218-
cells = rc.prose;
219-
}
220-
if use_parser_words() && !rc.words.is_empty() {
221-
word_cells = rc.words;
222-
}
223-
if use_parser_code() && !rc.code.is_empty() {
224-
code_cells = rc.code;
225-
}
224+
let rc = rust_cells.unwrap_or_default();
225+
let need_pdfium_prose = rc.prose.is_empty();
226+
let need_pdfium_words = !use_parser_words() || rc.words.is_empty();
227+
let need_pdfium_code = !use_parser_code() || rc.code.is_empty();
228+
229+
// The parser covers prose/words/code from one shared glyph pass, so on the
230+
// common (parser-succeeded) page all three are already satisfied and this
231+
// pdfium FFI call — otherwise fully discarded below — is skipped outright.
232+
let (mut cells, mut code_cells, mut word_cells) =
233+
if need_pdfium_prose || need_pdfium_words || need_pdfium_code {
234+
let (mut cells, code_cells, word_cells) =
235+
crate::timing::timed("ffi.page_cells", || ffi.page_cells(index, height));
236+
if cells.is_empty() {
237+
cells = segment_cells(&page.text()?, height);
238+
}
239+
(cells, code_cells, word_cells)
240+
} else {
241+
(Vec::new(), Vec::new(), Vec::new())
242+
};
243+
if !rc.prose.is_empty() {
244+
cells = rc.prose;
245+
}
246+
if use_parser_words() && !rc.words.is_empty() {
247+
word_cells = rc.words;
248+
}
249+
if use_parser_code() && !rc.code.is_empty() {
250+
code_cells = rc.code;
226251
}
227252

228-
// docling renders at 1.5× the target scale and downsamples "to make it
229-
// sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
230-
// model is pixel-sensitive, so the page bitmap must match byte-for-byte.
231-
// `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
232-
const SUPERSAMPLE: f32 = 1.5;
233-
let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
234-
let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
235-
let cfg = PdfRenderConfig::new()
236-
.set_target_width(tw)
237-
.set_target_height(th);
238-
let big = crate::timing::timed("pdfium.render", || {
239-
page.render_with_config(&cfg)
240-
.map(|b| b.as_image().into_rgb8())
241-
})?;
242-
let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
243-
let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
244-
let image = crate::timing::timed("image.resize", || {
245-
image::imageops::resize(&big, dw, dh, image::imageops::FilterType::CatmullRom)
246-
});
253+
let image = if render_image {
254+
// docling renders at 1.5× the target scale and downsamples "to make it
255+
// sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
256+
// model is pixel-sensitive, so the page bitmap must match byte-for-byte.
257+
// `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
258+
const SUPERSAMPLE: f32 = 1.5;
259+
let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
260+
let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
261+
let cfg = PdfRenderConfig::new()
262+
.set_target_width(tw)
263+
.set_target_height(th);
264+
let big = crate::timing::timed("pdfium.render", || {
265+
page.render_with_config(&cfg)
266+
.map(|b| b.as_image().into_rgb8())
267+
})?;
268+
let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
269+
let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
270+
crate::timing::timed("image.resize", || {
271+
image::imageops::resize(&big, dw, dh, image::imageops::FilterType::CatmullRom)
272+
})
273+
} else {
274+
RgbImage::new(1, 1)
275+
};
247276

248277
Ok(PdfPage {
249278
width,

0 commit comments

Comments
 (0)