Skip to content

Commit a934e6d

Browse files
artizclaude
andcommitted
#157 stage 3: OCR table interiors so browser tables fill
The browser scanned path never recognized text inside table regions — prep_region_lines skips non-text labels and there's no pdfium text layer — so a detected table had no words for the matcher and rendered empty. Add ocr_prep::prep_table_words: crop each table region, split into lines, then split each line into word tokens by a vertical ink-projection profile (segment_words), and prep each word for recognition with its own page-point box. The browser add_page now OCRs those word crops and appends them to the page cells; assemble routes them into the table region (geometric or TableFormer) instead of stray paragraphs. Word-level boxes are what let the matcher distribute a row across its columns. Native untouched (it reads word boxes from pdfium). 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 bc831a6 commit a934e6d

2 files changed

Lines changed: 138 additions & 26 deletions

File tree

crates/docling-pdf/src/ocr_prep.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,99 @@ pub fn prep_region_lines(
212212
(bboxes, lines)
213213
}
214214

215+
/// Split a text line into word tokens by a vertical ink-projection profile:
216+
/// runs of ink columns separated by whitespace wider than ~0.6× the line
217+
/// height (an inter-word/-column gap, not an inter-character one). Returns tight
218+
/// `(l, 0, r, h)` boxes in line-crop pixels. The browser table path recognizes
219+
/// these individually so each word carries its own box for column matching —
220+
/// the native pipeline gets word boxes from the pdfium text layer instead.
221+
pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
222+
let (w, h) = line.dimensions();
223+
if w == 0 || h == 0 {
224+
return Vec::new();
225+
}
226+
let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
227+
let thresh = mean * 0.7;
228+
let mut col_ink = vec![0u32; w as usize];
229+
for y in 0..h {
230+
for x in 0..w {
231+
if luma(line.get_pixel(x, y)) < thresh {
232+
col_ink[x as usize] += 1;
233+
}
234+
}
235+
}
236+
let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
237+
let mut words = Vec::new();
238+
let mut start: Option<u32> = None;
239+
let mut last_ink = 0u32;
240+
let mut gap = 0u32;
241+
for x in 0..w {
242+
if col_ink[x as usize] > 0 {
243+
if start.is_none() {
244+
start = Some(x);
245+
}
246+
last_ink = x;
247+
gap = 0;
248+
} else if let Some(s) = start {
249+
gap += 1;
250+
if gap >= min_gap {
251+
words.push((s, 0, last_ink + 1, h));
252+
start = None;
253+
}
254+
}
255+
}
256+
if let Some(s) = start {
257+
words.push((s, 0, last_ink + 1, h));
258+
}
259+
words
260+
}
261+
262+
/// Gather word crops from a page's *table* regions (browser table path, #157
263+
/// stage 3): crop each table region, split it into lines, split each line into
264+
/// words ([`segment_words`]), prep each word for recognition, and keep the
265+
/// word's page-point bbox. Recognizing table interiors is what gives the cell
266+
/// matcher the word boxes it needs — the native pipeline reads those from
267+
/// pdfium's text layer, which the browser doesn't have. NOT used by native.
268+
pub fn prep_table_words(
269+
img: &RgbImage,
270+
regions: &[crate::layout::Region],
271+
scale: f32,
272+
) -> (Vec<LineBox>, Vec<PrepLine>) {
273+
let (iw, ih) = img.dimensions();
274+
let mut bboxes = Vec::new();
275+
let mut lines = Vec::new();
276+
for region in regions {
277+
if !crate::assemble::is_table_like(region.label) {
278+
continue;
279+
}
280+
let l = (region.l * scale).max(0.0) as u32;
281+
let t = (region.t * scale).max(0.0) as u32;
282+
let r = ((region.r * scale).max(0.0) as u32).min(iw);
283+
let b = ((region.b * scale).max(0.0) as u32).min(ih);
284+
if r <= l || b <= t {
285+
continue;
286+
}
287+
let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
288+
for (lx, ly, rx, ry) in segment_lines(&crop) {
289+
let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
290+
for (wx0, _, wx1, _) in segment_words(&line) {
291+
let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
292+
let Some(pl) = prep_line(&word) else {
293+
continue;
294+
};
295+
bboxes.push((
296+
(l + lx + wx0) as f32 / scale,
297+
(t + ly) as f32 / scale,
298+
(l + lx + wx1) as f32 / scale,
299+
(t + ry) as f32 / scale,
300+
));
301+
lines.push(pl);
302+
}
303+
}
304+
}
305+
(bboxes, lines)
306+
}
307+
215308
/// Normalize an image to the scan polarity every stage assumes — dark ink
216309
/// on light paper (the segmentation threshold and the recognition model's
217310
/// training data both bake it in): a predominantly dark page (mean luma

crates/docling-wasm/src/scanned.rs

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
2323
use docling_pdf::layout::{decode_layout, layout_input, SIDE};
2424
use docling_pdf::ocr_prep::{
25-
batch_input, decode_row, dict_chars, normalize_polarity, prep_region_lines, width_batches,
26-
REC_HEIGHT,
25+
batch_input, decode_row, dict_chars, normalize_polarity, prep_region_lines, prep_table_words,
26+
width_batches, PrepLine, REC_HEIGHT,
2727
};
2828
use docling_pdf::pdfium_backend::{PdfPage, TextCell};
2929
use docling_pdf::scanned::{assemble_page_with_tables, finish_document, refine_regions};
@@ -112,6 +112,33 @@ impl ScannedConverter {
112112
}
113113

114114
impl ScannedConverter {
115+
/// Recognize a set of prepared line/word crops: width-batch them and run
116+
/// each batch through the JS recognition session, greedy-CTC-decoding the
117+
/// probabilities. Returns one string per input crop, in order.
118+
async fn ocr_lines(&self, rec: &RecSession, lines: &[PrepLine]) -> Result<Vec<String>, JsError> {
119+
let mut texts = vec![String::new(); lines.len()];
120+
for (w, chunk) in width_batches(lines) {
121+
let data = batch_input(w, &chunk, lines);
122+
let out = rec
123+
.run(
124+
chunk.len() as u32,
125+
REC_HEIGHT,
126+
w as u32,
127+
js_sys::Float32Array::from(data.as_slice()),
128+
)
129+
.await
130+
.map_err(|e| JsError::new(&format!("rec session.run: {e:?}")))?;
131+
let (probs, t_len, nc) = tensor_parts(&out)?;
132+
if probs.len() < chunk.len() * t_len * nc {
133+
return Err(JsError::new("rec session.run returned a short tensor"));
134+
}
135+
for (i, &ix) in chunk.iter().enumerate() {
136+
texts[ix] = decode_row(&self.chars, &probs[i * t_len * nc..(i + 1) * t_len * nc], nc);
137+
}
138+
}
139+
Ok(texts)
140+
}
141+
115142
#[allow(clippy::too_many_arguments)]
116143
async fn add_page_impl(
117144
&mut self,
@@ -168,30 +195,7 @@ impl ScannedConverter {
168195

169196
// OCR the text regions (same gather/batch/decode as native ocr_page).
170197
let (bboxes, lines) = prep_region_lines(&img, &regions, scale);
171-
let mut texts = vec![String::new(); lines.len()];
172-
for (w, chunk) in width_batches(&lines) {
173-
let data = batch_input(w, &chunk, &lines);
174-
let out = rec
175-
.run(
176-
chunk.len() as u32,
177-
REC_HEIGHT,
178-
w as u32,
179-
js_sys::Float32Array::from(data.as_slice()),
180-
)
181-
.await
182-
.map_err(|e| JsError::new(&format!("rec session.run: {e:?}")))?;
183-
let (probs, t_len, nc) = tensor_parts(&out)?;
184-
if probs.len() < chunk.len() * t_len * nc {
185-
return Err(JsError::new("rec session.run returned a short tensor"));
186-
}
187-
for (i, &ix) in chunk.iter().enumerate() {
188-
texts[ix] = decode_row(
189-
&self.chars,
190-
&probs[i * t_len * nc..(i + 1) * t_len * nc],
191-
nc,
192-
);
193-
}
194-
}
198+
let texts = self.ocr_lines(rec, &lines).await?;
195199
let mut cells = Vec::new();
196200
for ((l, t, r, b), text) in bboxes.into_iter().zip(texts) {
197201
let text = text.trim().to_string();
@@ -201,6 +205,21 @@ impl ScannedConverter {
201205
cells.push(TextCell { text, l, t, r, b });
202206
}
203207

208+
// Table interiors carry no words yet (prep_region_lines skips non-text
209+
// labels, and the browser has no pdfium text layer). Recognize their
210+
// word crops so the cell matcher — geometric or TableFormer — can fill
211+
// the grid; assemble routes these cells into the table region, not into
212+
// stray paragraphs.
213+
let (tbboxes, tlines) = prep_table_words(&img, &regions, scale);
214+
let ttexts = self.ocr_lines(rec, &tlines).await?;
215+
for ((l, t, r, b), text) in tbboxes.into_iter().zip(ttexts) {
216+
let text = text.trim().to_string();
217+
if text.is_empty() {
218+
continue;
219+
}
220+
cells.push(TextCell { text, l, t, r, b });
221+
}
222+
204223
// TableFormer (opt-in): resolve each table region's structure through
205224
// the ONNX graphs + shared matcher; other regions stay `None` (geometric
206225
// fallback). The lite path passes no session → all geometric.

0 commit comments

Comments
 (0)