Skip to content

Commit 6a5294c

Browse files
authored
Merge pull request #9 from artiz/claude/pdf-2305-improvements
fix(pdf): recover text dropped by ff-ligatures and degenerate font metrics (2305)
2 parents 29e50bf + 89dd73b commit 6a5294c

20 files changed

Lines changed: 1062 additions & 277 deletions

File tree

PDF_CONFORMANCE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ to ONNX in `tableformer.rs`) on a cv2-exact preprocessed crop (`resample.rs`); t
4848
structure + matched cell text reproduce docling's padded GitHub tables (2305-pg9
4949
is cell-for-cell exact).
5050

51+
### Performance / parallelism
52+
53+
Profiling a 14-page document (`FLEISCHWOLF_TIMING=1` prints an env-gated per-stage
54+
wall-clock breakdown) shows ~80 % of the time is the two ONNX models (layout ~58 %,
55+
TableFormer ~22 %) and ~16 % the page-image downsample — all per-page work that is
56+
independent across pages. A multi-page PDF therefore renders on one thread (pdfium
57+
is not thread-safe) and fans the pages out across a **pool of page-workers**, each
58+
owning its own model set (`ort`'s `Session::run` is `&mut self`, so sessions can't
59+
be shared), reassembled in page order. A bounded channel keeps only a handful of
60+
page bitmaps resident, so the streaming memory profile is preserved; the output is
61+
byte-identical to the serial path (verified across all PDF snapshots). Single-page /
62+
image / METS inputs keep the serial path and load no helper models.
63+
64+
The layout model is **memory-bandwidth bound** (even one model at four intra-op
65+
threads only reaches ~2.1× core utilisation), so the pool defaults to two intra-op
66+
threads per worker with `workers ≈ cores / 2` (capped at 4): two threads sharing one
67+
in-cache copy of the weights beats both one fat model and many single-thread workers.
68+
The speed-up scales with cores and memory bandwidth. Tune per machine with
69+
`FLEISCHWOLF_PDF_WORKERS` (pool size) and `FLEISCHWOLF_PDF_INTRA` (intra-op threads
70+
per worker).
71+
5172
### Text reconstruction: a pure-Rust PDF text parser (default)
5273

5374
The byte-exact ceiling was the **text extractor** — pdfium's *rendered* glyph

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ The ML formats (PDF, images, METS) need pdfium + the ONNX models, so they are
143143
covered by a separate **deterministic snapshot** harness rather than `cargo test`:
144144

145145
```bash
146-
bash scripts/pdf_setup.sh # one-time: fetch pdfium + the ONNX models
146+
bash scripts/pdf_setup.sh # one-time: fetch pdfium + export the ONNX models
147+
# (layout + TableFormer; needs a torch/docling Python)
148+
# Updating an existing checkout after a model-format change (e.g. the cached
149+
# TableFormer decoder): `rm -rf models/tableformer && bash scripts/pdf_setup.sh`,
150+
# or re-run `python scripts/export_tableformer.py models/tableformer` directly.
147151

148152
export PDFIUM_DYNAMIC_LIB_PATH="$(pwd)/.pdfium/lib"
149153
export DOCLING_LAYOUT_ONNX="$(pwd)/models/layout_heron.onnx"

crates/fleischwolf-cli/src/main.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
use std::path::Path;
2020
use std::process::ExitCode;
2121

22-
use fleischwolf::{DocumentConverter, ImageMode, SourceDocument};
22+
use fleischwolf::{DocumentConverter, ImageMode, InputFormat, Pipeline, SourceDocument};
2323

2424
fn main() -> ExitCode {
2525
let mut strict = false;
2626
let mut to = "md".to_string();
2727
let mut images = "placeholder".to_string();
2828
let mut fetch_images = false;
29+
let mut bench_warm: Option<usize> = None;
2930
let mut path: Option<String> = None;
3031
let mut args = std::env::args().skip(1);
3132
while let Some(arg) = args.next() {
@@ -34,6 +35,17 @@ fn main() -> ExitCode {
3435
"--fetch-images" => fetch_images = true,
3536
"--to" => to = args.next().unwrap_or_default(),
3637
"--images" => images = args.next().unwrap_or_default(),
38+
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
39+
// N warm conversions (models already loaded), printing the avg seconds
40+
// per conversion to stdout. This is the startup-excluded counterpart to
41+
// Python docling's in-process "warm" measurement, for a fair head-to-head.
42+
"--bench-warm" => {
43+
bench_warm = args.next().and_then(|n| n.parse::<usize>().ok());
44+
if bench_warm.is_none() {
45+
eprintln!("error: --bench-warm needs a positive run count");
46+
return ExitCode::from(2);
47+
}
48+
}
3749
_ => path = Some(arg),
3850
}
3951
}
@@ -67,6 +79,24 @@ fn main() -> ExitCode {
6779
}
6880
};
6981

82+
if let Some(runs) = bench_warm {
83+
return match bench_warm_conversion(&source, runs) {
84+
Ok(avg) => {
85+
// Bare seconds on stdout for the benchmark harness; a human line on stderr.
86+
println!("{avg:.6}");
87+
eprintln!(
88+
"warm conversion: {:.4}s/doc over {runs} runs (startup excluded)",
89+
avg
90+
);
91+
ExitCode::SUCCESS
92+
}
93+
Err(e) => {
94+
eprintln!("error: {e}");
95+
ExitCode::FAILURE
96+
}
97+
};
98+
}
99+
70100
let document = match DocumentConverter::new()
71101
.strict(strict)
72102
.fetch_images(fetch_images)
@@ -109,3 +139,35 @@ fn main() -> ExitCode {
109139
print!("{md}");
110140
ExitCode::SUCCESS
111141
}
142+
143+
/// Build the PDF/image pipeline once (loading the ONNX models), then time `runs`
144+
/// warm conversions and return the average seconds per conversion. The first
145+
/// conversion is a discarded warm-up that triggers the lazy model loads, so the
146+
/// timed runs reuse them — the startup-excluded figure comparable to docling's
147+
/// in-process warm number.
148+
fn bench_warm_conversion(source: &SourceDocument, runs: usize) -> Result<f64, String> {
149+
let mut pipeline = Pipeline::new().map_err(|e| e.to_string())?;
150+
let once = |p: &mut Pipeline| -> Result<(), String> {
151+
match source.format {
152+
InputFormat::Pdf => p
153+
.convert(&source.bytes, None, &source.name)
154+
.map(|_| ())
155+
.map_err(|e| e.to_string()),
156+
InputFormat::Image => p
157+
.convert_image(&source.bytes, &source.name)
158+
.map(|_| ())
159+
.map_err(|e| e.to_string()),
160+
other => Err(format!(
161+
"--bench-warm supports PDF/image only, not {other:?}"
162+
)),
163+
}
164+
};
165+
once(&mut pipeline)?; // warm-up: load models, prime caches
166+
let mut total = 0.0f64;
167+
for _ in 0..runs {
168+
let t = std::time::Instant::now();
169+
once(&mut pipeline)?;
170+
total += t.elapsed().as_secs_f64();
171+
}
172+
Ok(total / runs as f64)
173+
}

crates/fleischwolf-pdf/src/assemble.rs

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! assigned to its best-containing region, regions are ordered in reading order
66
//! (two-column aware), and each becomes a typed node by its layout label.
77
8-
use fleischwolf_core::{DoclingDocument, Node, PictureImage, Table};
8+
use fleischwolf_core::{Node, PictureImage, Table};
99

1010
use crate::layout::Region;
1111
use crate::pdfium_backend::{PdfPage, TextCell};
@@ -455,20 +455,27 @@ fn region_text(region: &Region, cells: &[TextCell]) -> String {
455455
let h = (c.b - c.t).abs().max((p.b - p.t).abs()).max(1.0);
456456
let gap = if rtl { p.l - c.r } else { c.l - p.r };
457457
// Dehyphenate a wrapped word: a line ending in a hyphen/dash followed
458-
// by a lowercase continuation joins without the dash or a space
459-
// (`platforms—` + `reflects` → `platformsreflects`). The dash is still
460-
// raw here (clean_text normalizes em/en dashes later), so match them all.
458+
// by a continuation joins without the dash or a space (`platforms—` +
459+
// `reflects` → `platformsreflects`). The dash is still raw here
460+
// (clean_text normalizes em/en dashes later), so match them all.
461461
let ends_dash = matches!(
462462
joined.chars().last(),
463463
Some('-' | '\u{2010}' | '\u{2013}' | '\u{2014}')
464464
);
465+
let before = joined.chars().nth_back(1); // char before the dash
466+
let next = t.chars().next();
465467
let dehyph = dp
466468
&& ends_dash
467-
&& joined
468-
.chars()
469-
.nth_back(1)
470-
.is_some_and(|c| c.is_alphabetic())
471-
&& t.chars().next().is_some_and(|c| c.is_lowercase());
469+
&& before.is_some_and(|c| c.is_alphabetic())
470+
&& next.is_some_and(|n| {
471+
// Ordinary hyphenation (lowercase continuation), or a CamelCase
472+
// compound name wrapped at the hyphen — a lowercase letter before
473+
// the dash continuing with an uppercase one (`PubTab-Net` →
474+
// `PubTabNet`, `Table-Former` → `TableFormer`). Excludes runs like
475+
// `MS-COCO` (uppercase before the dash) and `PubTables-1M` (digit).
476+
n.is_lowercase()
477+
|| (n.is_uppercase() && before.is_some_and(|b| b.is_lowercase()))
478+
});
472479
if dehyph {
473480
joined.pop();
474481
} else if dp || !same_band || gap > h * 0.25 {
@@ -656,10 +663,10 @@ pub fn assemble_page(
656663
page: &PdfPage,
657664
regions: Vec<Region>,
658665
table_rows: &[Option<Vec<Vec<String>>>],
659-
doc: &mut DoclingDocument,
660-
) {
666+
) -> (Vec<Node>, Vec<(String, String)>) {
667+
let mut nodes: Vec<Node> = Vec::new();
661668
// Recover this page's hyperlinks (rendered only in strict Markdown).
662-
doc.links.extend(resolve_link_anchors(page));
669+
let links = resolve_link_anchors(page);
663670
// Pair each region with its precomputed TableFormer grid (indexed by original
664671
// order) and order by reading order together, so they stay aligned.
665672
let mut items: Vec<(Region, Option<Vec<Vec<String>>>)> = regions
@@ -697,9 +704,9 @@ pub fn assemble_page(
697704
let caption = caption_for[i]
698705
.map(|ci| region_text(&regions[ci], &page.cells))
699706
.filter(|t| !t.is_empty());
700-
doc.push(Node::Picture {
707+
nodes.push(Node::Picture {
701708
caption,
702-
image: crop_region(page, region),
709+
image: crate::timing::timed("crop_region", || crop_region(page, region)),
703710
});
704711
continue;
705712
}
@@ -710,7 +717,7 @@ pub fn assemble_page(
710717
match region.label {
711718
// docling renders both the document title and section headers as
712719
// `##` (it never emits a top-level `#` for PDFs), so match that.
713-
"title" | "section_header" => doc.push(Node::Heading {
720+
"title" | "section_header" => nodes.push(Node::Heading {
714721
level: 2,
715722
text: md_escape(&text),
716723
}),
@@ -723,15 +730,15 @@ pub fn assemble_page(
723730
.trim_start()
724731
.to_string();
725732
if let Some((number, rest)) = parse_ordered_marker(&stripped) {
726-
doc.push(Node::ListItem {
733+
nodes.push(Node::ListItem {
727734
ordered: true,
728735
number,
729736
first_in_list: false,
730737
text: md_escape(&rest),
731738
level: 0,
732739
});
733740
} else {
734-
doc.push(Node::ListItem {
741+
nodes.push(Node::ListItem {
735742
ordered: false,
736743
number: 0,
737744
first_in_list: false,
@@ -752,11 +759,11 @@ pub fn assemble_page(
752759
vec![vec![text.clone()]]
753760
}
754761
});
755-
doc.push(Node::Table(Table { rows }));
762+
nodes.push(Node::Table(Table { rows }));
756763
}
757764
// docling does not decode formulas in the standard pipeline; it emits
758765
// a placeholder comment rather than the (garbled) raw glyph text.
759-
"formula" => doc.push(Node::Paragraph {
766+
"formula" => nodes.push(Node::Paragraph {
760767
text: "<!-- formula-not-decoded -->".into(),
761768
}),
762769
// Code blocks: use the space-glyph-only grouping (monospace keeps its
@@ -772,24 +779,25 @@ pub fn assemble_page(
772779
.replace(" ;", ";")
773780
.replace(" )", ")")
774781
.replace(" (", "(");
775-
doc.push(Node::Code {
782+
nodes.push(Node::Code {
776783
language: None,
777784
text: code,
778785
});
779786
// docling emits the `Listing N:` caption after the code block.
780787
if let Some(ci) = code_caption_for[i] {
781788
let cap = region_text(&regions[ci], &page.cells);
782789
if !cap.is_empty() {
783-
doc.push(Node::Paragraph { text: cap });
790+
nodes.push(Node::Paragraph { text: cap });
784791
}
785792
}
786793
}
787794
// text, caption, footnote → paragraph
788-
_ => doc.push(Node::Paragraph {
795+
_ => nodes.push(Node::Paragraph {
789796
text: md_escape(&text),
790797
}),
791798
}
792799
}
800+
(nodes, links)
793801
}
794802

795803
/// Merge paragraph fragments split across a column or page break. docling joins a
@@ -800,13 +808,31 @@ pub fn assemble_page(
800808
/// past a figure resumes below it (`…The wing type that is` ⟶[figure]⟶ `the most
801809
/// common…`), and docling emits the whole paragraph before the figure. A heading,
802810
/// table, or list between them ends the paragraph (no merge).
811+
/// A paragraph that is really a figure/table caption (`Fig. 1. …`, `Table 2 …`).
812+
/// Used to skip an unpaired caption when stitching a paragraph that wraps around
813+
/// a figure.
814+
fn looks_like_caption(text: &str) -> bool {
815+
let head: String = text.trim_start().chars().take(14).collect();
816+
(head.starts_with("Fig") || head.starts_with("Table"))
817+
&& head.contains(|c: char| c.is_ascii_digit())
818+
}
819+
803820
pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
804821
let mut i = 0;
805822
while i + 1 < nodes.len() {
806823
let Node::Paragraph { text: a } = &nodes[i] else {
807824
i += 1;
808825
continue;
809826
};
827+
// A figure/table caption is a self-contained unit; body text resuming
828+
// after a figure is the continuation case, not the caption itself. Never
829+
// stitch *from* a caption — otherwise a caption that ends in a lone glyph
830+
// (`Fig. 5. … PubTabNet. μ`) would swallow a following stray figure label
831+
// (a standalone `μ`) into `… μ μ`.
832+
if looks_like_caption(a) {
833+
i += 1;
834+
continue;
835+
}
810836
// Open if the fragment ends mid-word (a letter) or with a wrap hyphen/dash
811837
// — docling joins `vocab-` + `ulary` → `vocab- ulary`.
812838
let a_open = a.trim_end().chars().next_back().is_some_and(|c| {
@@ -817,9 +843,13 @@ pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
817843
continue;
818844
}
819845
// The continuation is the next paragraph, looking past any figures the
820-
// text wraps around (but nothing else).
846+
// text wraps around — and a figure/table caption that was emitted as its
847+
// own paragraph (an above-the-figure caption that didn't pair), since the
848+
// body text resumes after the whole figure+caption block.
821849
let mut j = i + 1;
822-
while matches!(nodes.get(j), Some(Node::Picture { .. })) {
850+
while matches!(nodes.get(j), Some(Node::Picture { .. }))
851+
|| matches!(nodes.get(j), Some(Node::Paragraph { text }) if looks_like_caption(text))
852+
{
823853
j += 1;
824854
}
825855
let cont = matches!(nodes.get(j), Some(Node::Paragraph { text: b })

crates/fleischwolf-pdf/src/layout.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,20 @@ pub struct LayoutModel {
5454
impl LayoutModel {
5555
/// Load the ONNX model from `DOCLING_LAYOUT_ONNX` (or `models/layout_heron.onnx`).
5656
pub fn load() -> Result<Self, String> {
57+
Self::load_with(crate::intra_threads())
58+
}
59+
60+
/// Like [`load`](Self::load) but with an explicit intra-op thread count. A
61+
/// parallel page-worker pool loads its helper models on a single thread each
62+
/// and gets its speed-up from running pages concurrently instead.
63+
pub fn load_with(intra: usize) -> Result<Self, String> {
5764
let path = std::env::var("DOCLING_LAYOUT_ONNX")
5865
.unwrap_or_else(|_| "models/layout_heron.onnx".to_string());
5966
let session = Session::builder()
6067
.map_err(|e| format!("layout: builder: {e}"))?
6168
// Let inference use the available cores (ort otherwise defaults low);
6269
// a large PDF runs this model once per page.
63-
.with_intra_threads(crate::intra_threads())
70+
.with_intra_threads(intra)
6471
.map_err(|e| format!("layout: intra_threads: {e}"))?
6572
.commit_from_file(&path)
6673
.map_err(|e| format!("layout: load {path}: {e}"))?;

0 commit comments

Comments
 (0)