Skip to content

Commit 3f70d97

Browse files
artizclaude
andcommitted
wasm: don't read the same cells twice when layout detections overlap (#157)
Both structured-path screenshots of the reporting invoice print the Einspruch paragraph twice. Localized it by elimination: the flat text-layer output contains the text once, the native pipeline emits it once, and a native replica of the digital path (same textparse cells, same layout model, same refinement — pdfium raster instead of pdf.js) also emits it once. So the pdf.js raster teases overlapping detections out of the model — a block plus its own halves — and since assembly assigns each region the cells >50% inside it without consuming them, every such overlap duplicates text. drop_duplicate_text_claims removes the re-readers: a text-like region is dropped only when every cell it claims is also claimed by one single other region — a strict part-of relation, so disjoint neighbours and non-text labels (a table legitimately shares cells with its caption) are untouched, and identical claims keep the higher score. The surviving block matches what the native raster produces on the same page (one merged paragraph). Wired into the digital browser path only, where the artifact was observed; the shared assemble stays byte-identical for the native pipeline. Covered by unit tests over the three shapes: block-plus-halves drops the halves, disjoint/non-text stays, identical claims dedupe by score. Full docling-pdf suite green (39 tests), wasm clippy clean. 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 bdd76c0 commit 3f70d97

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

crates/docling-pdf/src/scanned.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,69 @@ pub fn refine_regions(
3333
regions
3434
}
3535

36+
/// Drop text regions that would read the same cells twice.
37+
///
38+
/// The layout model can emit overlapping detections — typically one region
39+
/// covering a whole block *and* smaller ones covering its parts, when the
40+
/// raster is noisier than the model likes (the browser's pdf.js render, where
41+
/// this was observed as a paragraph printed twice; pdfium's render of the same
42+
/// page did not trigger it). Assembly assigns each region the cells >50%
43+
/// inside it without consuming them, so every such overlap duplicates text.
44+
///
45+
/// The rule is deliberately narrow: a *text-like* region is dropped only when
46+
/// every cell it claims is also claimed by one *single* other region — i.e. it
47+
/// is a strict re-reader of part of a larger region. The larger region
48+
/// survives, which is also what the native raster produces on the observed
49+
/// page (one merged paragraph). Ties (two regions claiming identical sets)
50+
/// keep the higher-scoring one.
51+
pub fn drop_duplicate_text_claims(regions: Vec<Region>, cells: &[TextCell]) -> Vec<Region> {
52+
let claimed: Vec<Vec<usize>> = regions
53+
.iter()
54+
.map(|r| {
55+
cells
56+
.iter()
57+
.enumerate()
58+
.filter(|(_, c)| {
59+
let ca = ((c.r - c.l) * (c.b - c.t)).max(1.0);
60+
let iw = (c.r.min(r.r) - c.l.max(r.l)).max(0.0);
61+
let ih = (c.b.min(r.b) - c.t.max(r.t)).max(0.0);
62+
iw * ih / ca > 0.5
63+
})
64+
.map(|(i, _)| i)
65+
.collect()
66+
})
67+
.collect();
68+
let text_like = |label: &str| matches!(label, "text" | "list_item" | "section_header");
69+
let mut drop = vec![false; regions.len()];
70+
for a in 0..regions.len() {
71+
if !text_like(regions[a].label) || claimed[a].is_empty() {
72+
continue;
73+
}
74+
for b in 0..regions.len() {
75+
if a == b || drop[b] || !text_like(regions[b].label) {
76+
continue;
77+
}
78+
let subset = claimed[a].iter().all(|i| claimed[b].contains(i));
79+
if !subset {
80+
continue;
81+
}
82+
// Identical sets: keep the higher score (stable on ties by index).
83+
let identical = claimed[a].len() == claimed[b].len();
84+
if identical && (regions[a].score, b) > (regions[b].score, a) {
85+
continue;
86+
}
87+
drop[a] = true;
88+
break;
89+
}
90+
}
91+
regions
92+
.into_iter()
93+
.zip(drop)
94+
.filter(|(_, d)| !d)
95+
.map(|(r, _)| r)
96+
.collect()
97+
}
98+
3699
/// Assemble one refined page — geometric tables (no TableFormer), no
37100
/// enrichments — into its nodes and hyperlink pairs.
38101
pub fn assemble_page(page: &PdfPage, regions: Vec<Region>) -> AssembledPage {
@@ -65,3 +128,73 @@ pub fn finish_document(name: &str, pages: Vec<AssembledPage>) -> DoclingDocument
65128
crate::assemble::merge_continuations(&mut doc.nodes);
66129
doc
67130
}
131+
132+
#[cfg(test)]
133+
mod duplicate_claims {
134+
use crate::layout::Region;
135+
use crate::pdfium_backend::TextCell;
136+
137+
fn cell(l: f32, t: f32, r: f32, b: f32) -> TextCell {
138+
TextCell {
139+
text: "x".into(),
140+
l,
141+
t,
142+
r,
143+
b,
144+
}
145+
}
146+
fn region(label: &'static str, score: f32, l: f32, t: f32, r: f32, b: f32) -> Region {
147+
Region {
148+
label,
149+
score,
150+
l,
151+
t,
152+
r,
153+
b,
154+
}
155+
}
156+
157+
/// The observed browser failure: the model detects a block *and* its two
158+
/// halves, so both halves re-read cells the block already claims and a
159+
/// paragraph prints twice. The parts must go, the block must stay — that
160+
/// matches what the native raster produced (one merged paragraph).
161+
#[test]
162+
fn part_regions_of_a_block_are_dropped() {
163+
let cells = vec![cell(10.0, 10.0, 90.0, 20.0), cell(10.0, 30.0, 90.0, 40.0)];
164+
let regions = vec![
165+
region("text", 0.9, 5.0, 5.0, 95.0, 45.0), // the block
166+
region("text", 0.8, 5.0, 5.0, 95.0, 25.0), // its top half
167+
region("text", 0.8, 5.0, 25.0, 95.0, 45.0), // its bottom half
168+
];
169+
let kept = super::drop_duplicate_text_claims(regions, &cells);
170+
assert_eq!(kept.len(), 1, "kept: {kept:?}");
171+
assert_eq!((kept[0].t, kept[0].b), (5.0, 45.0), "the block survives");
172+
}
173+
174+
/// Disjoint regions — the normal page shape — are untouched, and so are
175+
/// non-text labels even when they overlap text (a table region legitimately
176+
/// contains the same cells its caption/text neighbours touch).
177+
#[test]
178+
fn disjoint_and_non_text_regions_are_kept() {
179+
let cells = vec![cell(10.0, 10.0, 90.0, 20.0), cell(10.0, 50.0, 90.0, 60.0)];
180+
let regions = vec![
181+
region("text", 0.9, 5.0, 5.0, 95.0, 25.0),
182+
region("text", 0.9, 5.0, 45.0, 95.0, 65.0),
183+
region("table", 0.9, 5.0, 5.0, 95.0, 65.0), // overlaps both, not text
184+
];
185+
assert_eq!(super::drop_duplicate_text_claims(regions, &cells).len(), 3);
186+
}
187+
188+
/// Two identical claims keep exactly one — the higher score.
189+
#[test]
190+
fn identical_claims_keep_the_higher_score() {
191+
let cells = vec![cell(10.0, 10.0, 90.0, 20.0)];
192+
let regions = vec![
193+
region("text", 0.7, 5.0, 5.0, 95.0, 25.0),
194+
region("text", 0.9, 6.0, 6.0, 94.0, 24.0),
195+
];
196+
let kept = super::drop_duplicate_text_claims(regions, &cells);
197+
assert_eq!(kept.len(), 1);
198+
assert_eq!(kept[0].score, 0.9);
199+
}
200+
}

crates/docling-wasm/src/digital.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
use docling_pdf::assemble::{geometric_table_is_reliable, reconstruct_table};
2626
use docling_pdf::layout::{decode_layout, layout_input};
2727
use docling_pdf::pdfium_backend::PdfPage;
28-
use docling_pdf::scanned::{assemble_page_with_tables, finish_document, refine_regions};
28+
use docling_pdf::scanned::{
29+
assemble_page_with_tables, drop_duplicate_text_claims, finish_document, refine_regions,
30+
};
2931
use image::RgbImage;
3032
use wasm_bindgen::prelude::*;
3133

@@ -162,6 +164,10 @@ impl DigitalConverter {
162164
// Refine against the *real* text cells: unlike the OCR path, orphan-text
163165
// recovery and the false-picture drop have something to work with here.
164166
let regions = refine_regions(regions, &page.cells, page_w, page_h);
167+
// The pdf.js raster can tease overlapping text detections out of the
168+
// model (a block plus its own parts), and every overlap reads the same
169+
// cells twice — drop the re-readers.
170+
let regions = drop_duplicate_text_claims(regions, &page.cells);
165171

166172
// Attach the raster so picture regions crop out of it, and record what
167173
// it is scaled by (cells stay in points).

0 commit comments

Comments
 (0)