Skip to content

Commit bdd76c0

Browse files
artizclaude
andcommitted
wasm: digital PDFs keep their structure without OCR (#157)
Fixing the text-layer parser made this invoice convert in 63 ms instead of ten seconds of OCR — and lost its tables. That was not a regression but the shape of the choice the browser offered: the pure text-layer path is exact and instant but emits flat paragraphs, because headings, tables and pictures are all things the *layout* model finds; the OCR path has structure but re-reads text the file already contains, slowly and with recognition errors ("Finanzubersicht", "magenta.at/fag"). DigitalConverter is the third path, and it is what the native pipeline already does: take the text from the file, run only the layout model over the rendered pages, then assemble. Region refinement now has the real text cells to work with (orphan-text recovery and the false-picture drop do nothing on an empty slice, which is all the OCR path can pass at that point), and TableFormer stays selective. No recognition model is fetched at all — boot takes a layoutOnly flag, so a visitor converting digital PDFs never downloads it. The page picks the path: a "PDF structure" toggle routes PDFs here, falls back to OCR only on "no text layer", and leaves everything else alone. Verified in headless Chromium against the reporting invoice, with ORT and pdf.js vendored locally since this sandbox cannot reach the CDN: 0 -> 18 table rows, umlauts and the "faq" URL correct, headings and picture placeholders present — the same document the native pipeline produces. Timings here (11-17 s cold, layout model load included) say little: the container is shared and has no GPU. Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent e11be1a commit bdd76c0

7 files changed

Lines changed: 323 additions & 22 deletions

File tree

crates/docling-wasm/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,27 @@ const json = convert(bytes, file.name, "json");
5454
const withPics = convert(bytes, file.name, "md", "embedded");
5555
```
5656

57+
### Digital PDFs with structure (no OCR)
58+
59+
A PDF that carries a text layer needs no recognition at all — but headings,
60+
tables and pictures are things the *layout* model finds, so the pure text-layer
61+
path (`convert`) can only emit flat paragraphs. `DigitalConverter` closes that
62+
gap: the text comes out of the file (exact, no recognition errors) and only the
63+
layout model runs over the rendered pages. It is the same thing the native
64+
pipeline does, which OCRs a page only when it has no text cells.
65+
66+
```js
67+
const conv = new DigitalConverter(pdfBytes); // throws when there is no text layer
68+
for (let i = 0; i < conv.page_count(); i++) {
69+
// rasterize page i with pdf.js at 2 px/point, then:
70+
await conv.add_page(i, rgba, w, h, 2.0, layout);
71+
}
72+
const markdown = conv.finish("bill.pdf", "md", "embedded");
73+
```
74+
75+
`addPageTf(..., tf)` adds TableFormer, still only for the tables whose geometric
76+
reconstruction looks unreliable. No recognition model is fetched on this path.
77+
5778
### Scanned pages (OCR)
5879

5980
Scanned PDFs and images need the ML models; everything else runs with no

crates/docling-wasm/src/digital.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//! Browser pipeline for **digital** PDFs — the ones that carry a text layer
2+
//! (#157). It is the scanned pipeline with the expensive half removed: the text
3+
//! comes from the PDF itself rather than from OCR, so only the layout model
4+
//! runs, and the result is both faster and exact (no recognition errors, no
5+
//! mangled umlauts).
6+
//!
7+
//! This is what the native pipeline does for a digital PDF — it OCRs a page
8+
//! only when `page.cells` is empty — so the same three steps happen here in the
9+
//! same order: layout detection, region refinement against the real text cells,
10+
//! then TableFormer (or the geometric fallback) per table region.
11+
//!
12+
//! Without it the browser had to choose between structure and fidelity: the
13+
//! pure text-layer path (`convert`) is milliseconds but emits flat paragraphs,
14+
//! because headings, tables and pictures are all things the layout model finds.
15+
//!
16+
//! ```js
17+
//! const conv = new DigitalConverter(pdfBytes); // throws when there is no text layer
18+
//! for (let i = 0; i < conv.page_count(); i++) {
19+
//! // rasterize page i with pdf.js at 2 px/point, then:
20+
//! await conv.add_page(i, rgba, w, h, 2.0, layout);
21+
//! }
22+
//! const markdown = conv.finish("bill.pdf", "md", "embedded");
23+
//! ```
24+
25+
use docling_pdf::assemble::{geometric_table_is_reliable, reconstruct_table};
26+
use docling_pdf::layout::{decode_layout, layout_input};
27+
use docling_pdf::pdfium_backend::PdfPage;
28+
use docling_pdf::scanned::{assemble_page_with_tables, finish_document, refine_regions};
29+
use image::RgbImage;
30+
use wasm_bindgen::prelude::*;
31+
32+
use crate::ocr::tensor_parts;
33+
use crate::scanned::{render, LayoutSession};
34+
use crate::tableformer::TfSession;
35+
36+
/// A digital PDF being converted page by page. Construct it from the file's
37+
/// bytes (the text layer is parsed once, in Rust), then feed the rasterized
38+
/// pages in order.
39+
#[wasm_bindgen]
40+
pub struct DigitalConverter {
41+
/// One entry per page, carrying that page's text cells in PDF points.
42+
pages: Vec<PdfPage>,
43+
out: Vec<docling_pdf::scanned::AssembledPage>,
44+
}
45+
46+
#[wasm_bindgen]
47+
impl DigitalConverter {
48+
/// Parse the PDF's text layer. Fails when there is none — the caller should
49+
/// fall back to the scanned pipeline, exactly as the demo page does.
50+
#[wasm_bindgen(constructor)]
51+
pub fn new(bytes: &[u8]) -> Result<DigitalConverter, JsError> {
52+
let pages = docling_pdf::textparse::pdf_text_pages(bytes);
53+
if pages.iter().all(|p| p.cells.is_empty()) {
54+
return Err(JsError::new(
55+
"PDF has no embedded text layer (scanned/image-only?) — use the OCR pipeline",
56+
));
57+
}
58+
Ok(Self {
59+
pages,
60+
out: Vec::new(),
61+
})
62+
}
63+
64+
/// Pages the text parser found.
65+
pub fn page_count(&self) -> usize {
66+
self.pages.len()
67+
}
68+
69+
/// Convert page `index` (0-based) given its rendered bitmap: layout
70+
/// detection plus geometric tables. `scale` is the raster's pixels per PDF
71+
/// point (2.0 for pdf.js `{scale: 2}`).
72+
pub async fn add_page(
73+
&mut self,
74+
index: usize,
75+
rgba: &[u8],
76+
px_w: u32,
77+
px_h: u32,
78+
scale: f32,
79+
layout: &LayoutSession,
80+
) -> Result<(), JsError> {
81+
self.add_page_impl(index, rgba, px_w, px_h, scale, layout, None)
82+
.await
83+
}
84+
85+
/// [`add_page`](Self::add_page) with TableFormer for the table regions whose
86+
/// geometric reconstruction looks unreliable.
87+
#[wasm_bindgen(js_name = addPageTf)]
88+
#[allow(clippy::too_many_arguments)]
89+
pub async fn add_page_tf(
90+
&mut self,
91+
index: usize,
92+
rgba: &[u8],
93+
px_w: u32,
94+
px_h: u32,
95+
scale: f32,
96+
layout: &LayoutSession,
97+
tf: &TfSession,
98+
) -> Result<(), JsError> {
99+
self.add_page_impl(index, rgba, px_w, px_h, scale, layout, Some(tf))
100+
.await
101+
}
102+
103+
/// Assemble the converted pages into a document and render it as `"md"`
104+
/// (default), `"json"` or `"doclang"`, with `images` picking how pictures
105+
/// render in Markdown. Resets the converter.
106+
pub fn finish(
107+
&mut self,
108+
name: &str,
109+
to: Option<String>,
110+
images: Option<String>,
111+
) -> Result<String, JsError> {
112+
let doc = finish_document(name, std::mem::take(&mut self.out));
113+
render(&doc, to.as_deref(), images.as_deref())
114+
}
115+
}
116+
117+
impl DigitalConverter {
118+
#[allow(clippy::too_many_arguments)]
119+
async fn add_page_impl(
120+
&mut self,
121+
index: usize,
122+
rgba: &[u8],
123+
px_w: u32,
124+
px_h: u32,
125+
scale: f32,
126+
layout: &LayoutSession,
127+
tf: Option<&TfSession>,
128+
) -> Result<(), JsError> {
129+
if rgba.len() != (px_w as usize) * (px_h as usize) * 4 {
130+
return Err(JsError::new("rgba buffer size does not match dimensions"));
131+
}
132+
let mut page = self
133+
.pages
134+
.get(index)
135+
.cloned()
136+
.ok_or_else(|| JsError::new("page index is out of range"))?;
137+
138+
let mut img = RgbImage::new(px_w, px_h);
139+
for (i, px) in img.pixels_mut().enumerate() {
140+
px.0 = [rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]];
141+
}
142+
143+
// Layout: Rust preprocessing → JS inference → Rust decoding. The page
144+
// geometry comes from the text parser, not the bitmap, so a raster at
145+
// any scale lines up with the cells (which are in PDF points).
146+
let (page_w, page_h) = (page.width, page.height);
147+
let input = layout_input(&img);
148+
let out = layout
149+
.run(js_sys::Float32Array::from(input.as_slice()))
150+
.await
151+
.map_err(|e| JsError::new(&format!("layout session.run: {e:?}")))?;
152+
let get = |k: &str| {
153+
js_sys::Reflect::get(&out, &JsValue::from_str(k))
154+
.map_err(|_| JsError::new(&format!("layout result has no `{k}`")))
155+
};
156+
let (logits, q, c) = tensor_parts(&get("logits")?)?;
157+
let (boxes, bq, four) = tensor_parts(&get("boxes")?)?;
158+
if bq != q || four != 4 {
159+
return Err(JsError::new("layout boxes dims must be [1, q, 4]"));
160+
}
161+
let regions = decode_layout(&logits, &boxes, q, c, page_w, page_h);
162+
// Refine against the *real* text cells: unlike the OCR path, orphan-text
163+
// recovery and the false-picture drop have something to work with here.
164+
let regions = refine_regions(regions, &page.cells, page_w, page_h);
165+
166+
// Attach the raster so picture regions crop out of it, and record what
167+
// it is scaled by (cells stay in points).
168+
page.image = img;
169+
page.scale = scale;
170+
171+
let mut table_rows = Vec::with_capacity(regions.len());
172+
for r in &regions {
173+
if r.label != "table" {
174+
table_rows.push(None);
175+
continue;
176+
}
177+
// Same bargain as the scanned path: TableFormer costs seconds per
178+
// region, so skip it wherever the free geometric reconstruction is
179+
// already dense and well-formed.
180+
let geometric = reconstruct_table(r, &page.cells);
181+
match tf {
182+
Some(tf) if !geometric_table_is_reliable(&geometric) => {
183+
table_rows.push(
184+
crate::tableformer::predict_table_rows(
185+
tf,
186+
&page.image,
187+
[r.l, r.t, r.r, r.b],
188+
&page.word_cells,
189+
)
190+
.await,
191+
);
192+
}
193+
_ => table_rows.push(None),
194+
}
195+
}
196+
197+
self.out
198+
.push(assemble_page_with_tables(&page, regions, table_rows));
199+
Ok(())
200+
}
201+
}

crates/docling-wasm/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@
1919
use docling::{DocumentConverter, ImageMode, InputFormat, SourceDocument};
2020
use wasm_bindgen::prelude::*;
2121

22+
#[cfg(feature = "ocr")]
23+
mod digital;
2224
#[cfg(feature = "ocr")]
2325
mod ocr;
2426
#[cfg(feature = "ocr")]
2527
mod scanned;
2628
#[cfg(feature = "ocr")]
2729
mod tableformer;
2830
#[cfg(feature = "ocr")]
31+
pub use digital::DigitalConverter;
32+
#[cfg(feature = "ocr")]
2933
pub use ocr::ocr_image;
3034
#[cfg(feature = "ocr")]
3135
pub use scanned::{convert_scanned_image, ScannedConverter};

crates/docling-wasm/src/scanned.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ impl ScannedConverter {
276276
/// Render an assembled document in one of the three output grammars, with the
277277
/// same `images` choice the declarative path offers — picture regions are
278278
/// cropped out of the rendered page, so `embedded` has real bytes to inline.
279-
fn render(
279+
pub(crate) fn render(
280280
doc: &docling_core::DoclingDocument,
281281
to: Option<&str>,
282282
images: Option<&str>,

0 commit comments

Comments
 (0)