Skip to content

Commit 5977a1b

Browse files
artizclaude
andcommitted
pdf: demote uncaptioned text-panel pictures into paragraphs (#157)
A terms-and-conditions box exported as an image — the XXXLutz blue "Folgen des Widerrufs" panel — survives every pipeline as a picture: its words are not in the digital text layer, region-scoped OCR skips picture interiors, and docling itself never serializes cells assigned to a picture cluster. The panel's C.7–C.12 were simply gone from the markdown, in normal, force-OCR and browser conversions alike. Three shared changes: - assemble::recover_text_panels: an *uncaptioned* `picture` whose cells (digital or OCR) form ≥ 3 lines, cover ≥ 20 % of its area and whose median line spans most of its width is a text panel, not a figure — replace it with per-paragraph `text` regions (paragraphs re-derived from the line pitch). A captioned picture is a genuine figure whatever it contains (the corpus' "Figure 3:" document screenshots are exactly as dense), and sparse-label charts fail the width/coverage gate — both keep their crops. Wired into every path: native finish_page, the shared refine_regions chain (browser digital), and the browser scanned/digital bare-picture OCR blocks. - Native pipeline now OCRs big text-less picture crops (docling's bitmap_area_threshold: bitmap areas ≥ 5 % of the page, every page kind) the way the browser paths already did — the demotion's cell source on digital pages whose panels are embedded rasters. On OCR'd pages, non-demoting in-picture text is emitted beside the kept crop (browser scanned parity); on digital pages the speculative cells are discarded so docling-groundtruth photos stay silent and picture_classification stays byte-exact. - ocr_prep::segment_lines: mask thin full-height "rule" columns (a panel's frame) out of the row-ink profile — the border bridged every inter-line gap and the whole framed panel OCR'd as one unreadable strip. The mask backs off when "rules" are a noticeable share of the width (that shape is an inverted dark-mode page, polarity normalization's job). Also: force_full_page_ocr's cell-clearing moves from process() into finish_page so the *batched* layout path honors the flag — it was silently a no-op for multi-page documents (batch is the default). Conformance: 5/14 strict + 6/14 ws-normalized holds. 2203 130→132, normal_4pages 44→50, redp5110 194→196 — each a few lines of *recovered* panel text docling drops (normal_4pages' cover publisher block), documented in docs/PDF_CONFORMANCE.md. Everything else byte-identical; full battery (docling-core/docling/asr/serve/pdf + wasm host tests) green, clippy clean. Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent f76f561 commit 5977a1b

7 files changed

Lines changed: 403 additions & 26 deletions

File tree

crates/docling-pdf/src/assemble.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,143 @@ pub fn add_orphan_regions(regions: &mut Vec<Region>, cells: &[TextCell]) {
358358
regions.extend(merged);
359359
}
360360

361+
/// Demote a `picture` region that is really a **text panel** — a paragraph block
362+
/// the layout model boxed as a figure because it is typeset on a colored
363+
/// background (terms-and-conditions callouts, quote boxes) — into ordinary
364+
/// `text` regions, one per paragraph, so its words are read instead of shipped
365+
/// as pixels. docling loses this text the same way (cells assigned to a picture
366+
/// cluster are never serialized); this is a deliberate improvement, not parity.
367+
///
368+
/// The gate is conservative so a genuine figure keeps its crop: the region must
369+
/// contain at least three text lines whose median width spans most of the panel
370+
/// (axis labels and chat bubbles are narrow and varied) and whose cells cover a
371+
/// substantial fraction of its area (a photo or chart with sparse labels does
372+
/// not). Paragraph boundaries are re-derived from the line pitch: a vertical gap
373+
/// clearly larger than the panel's own leading starts a new `text` region, so
374+
/// the panel doesn't collapse into one giant paragraph.
375+
///
376+
/// Works on any cell source — the digital text layer or OCR lines recognized
377+
/// from the picture crop — so the native and browser paths, with or without
378+
/// force-OCR, demote identically.
379+
pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
380+
// A *captioned* picture is a genuine figure whatever it contains — the
381+
// corpus is full of document screenshots ("Figure 3: …" above a page
382+
// image) that are exactly as dense and wide as a text panel. Only an
383+
// uncaptioned picture is a demotion candidate.
384+
let captioned: Vec<bool> = regions
385+
.iter()
386+
.map(|r| {
387+
r.label == "picture"
388+
&& regions.iter().any(|c| {
389+
c.label == "caption" && c.r.min(r.r) - c.l.max(r.l) > 0.0 && {
390+
let gap = if c.t >= r.b {
391+
c.t - r.b
392+
} else if r.t >= c.b {
393+
r.t - c.b
394+
} else {
395+
f32::MAX // vertically overlapping: not a caption
396+
};
397+
gap <= 25.0
398+
}
399+
})
400+
})
401+
.collect();
402+
let mut out: Vec<Region> = Vec::with_capacity(regions.len());
403+
for (i, r) in regions.drain(..).enumerate() {
404+
if r.label != "picture" || captioned[i] {
405+
out.push(r);
406+
continue;
407+
}
408+
let inside: Vec<&TextCell> = cells
409+
.iter()
410+
.filter(|c| {
411+
!c.text.trim().is_empty() && {
412+
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
413+
inter(&r, c.l, c.t, c.r, c.b) / ca > 0.5
414+
}
415+
})
416+
.collect();
417+
// Group the contained cells into lines by vertical overlap (the same
418+
// rule region_text orders by), tracking each line's union box.
419+
let mut lines: Vec<(f32, f32, f32, f32)> = Vec::new(); // (t, b, l, r)
420+
for c in &inside {
421+
let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
422+
match lines.iter_mut().find(|(lt, lb, _, _)| {
423+
let ov = cb.min(*lb) - ct.max(*lt);
424+
ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
425+
}) {
426+
Some((lt, lb, ll, lr)) => {
427+
*lt = lt.min(ct);
428+
*lb = lb.max(cb);
429+
*ll = ll.min(c.l);
430+
*lr = lr.max(c.r);
431+
}
432+
None => lines.push((ct, cb, c.l, c.r)),
433+
}
434+
}
435+
let panel_w = (r.r - r.l).max(1.0);
436+
let coverage = inside.iter().map(|c| area(c.l, c.t, c.r, c.b)).sum::<f32>()
437+
/ area(r.l, r.t, r.r, r.b).max(1.0);
438+
let mut widths: Vec<f32> = lines.iter().map(|(_, _, l, rr)| rr - l).collect();
439+
widths.sort_by(f32::total_cmp);
440+
let text_panel =
441+
lines.len() >= 3 && coverage >= 0.2 && widths[widths.len() / 2] >= 0.45 * panel_w;
442+
if !text_panel {
443+
out.push(r);
444+
continue;
445+
}
446+
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
447+
let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
448+
heights.sort_by(f32::total_cmp);
449+
let h = heights[heights.len() / 2].max(1.0);
450+
let mut gaps: Vec<f32> = lines
451+
.windows(2)
452+
.map(|w| (w[1].0 - w[0].1).max(0.0))
453+
.collect();
454+
gaps.sort_by(f32::total_cmp);
455+
let leading = if gaps.is_empty() {
456+
0.0
457+
} else {
458+
gaps[gaps.len() / 2]
459+
};
460+
let brk = (1.8 * leading).max(0.75 * h);
461+
let mut para: Option<(f32, f32, f32, f32)> = None; // (l, t, r, b) union
462+
for (t, b, l, rr) in &lines {
463+
match &mut para {
464+
Some((pl, _, pr, pb)) if *t - *pb <= brk => {
465+
*pl = pl.min(*l);
466+
*pr = pr.max(*rr);
467+
*pb = pb.max(*b);
468+
}
469+
_ => {
470+
if let Some((pl, pt, pr, pb)) = para.take() {
471+
out.push(Region {
472+
label: "text",
473+
score: r.score,
474+
l: pl,
475+
t: pt,
476+
r: pr,
477+
b: pb,
478+
});
479+
}
480+
para = Some((*l, *t, *rr, *b));
481+
}
482+
}
483+
}
484+
if let Some((pl, pt, pr, pb)) = para {
485+
out.push(Region {
486+
label: "text",
487+
score: r.score,
488+
l: pl,
489+
t: pt,
490+
r: pr,
491+
b: pb,
492+
});
493+
}
494+
}
495+
*regions = out;
496+
}
497+
361498
/// Drop a `picture` detection that is a small, empty, low-confidence margin box on
362499
/// a **text page** — a false positive the RT-DETR layout sometimes emits (e.g.
363500
/// `right_to_left_02`'s phantom right-column picture, score 0.40); docling does not
@@ -1743,6 +1880,88 @@ mod tests {
17431880
use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
17441881
use docling_core::Node;
17451882

1883+
/// A colored terms-and-conditions panel detected as `picture` demotes into
1884+
/// per-paragraph `text` regions (the blank line between C.7 and C.8 splits
1885+
/// them); a chart whose only text is a few narrow axis labels keeps its
1886+
/// crop untouched.
1887+
#[test]
1888+
fn text_panels_demote_to_paragraphs_but_charts_keep_their_crop() {
1889+
let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
1890+
text: text.to_string(),
1891+
l,
1892+
t,
1893+
r,
1894+
b,
1895+
};
1896+
let panel = Region {
1897+
label: "picture",
1898+
score: 0.9,
1899+
l: 0.0,
1900+
t: 0.0,
1901+
r: 100.0,
1902+
b: 100.0,
1903+
};
1904+
// Three tight lines, a blank-line gap, two more: two paragraphs.
1905+
let cells = vec![
1906+
cell(
1907+
"C.7. Wenn Sie diesen Vertrag widerrufen,",
1908+
5.0,
1909+
10.0,
1910+
95.0,
1911+
18.0,
1912+
),
1913+
cell(
1914+
"haben wir Ihnen alle Zahlungen, die wir",
1915+
5.0,
1916+
20.0,
1917+
95.0,
1918+
28.0,
1919+
),
1920+
cell(
1921+
"von Ihnen erhalten haben, zurückzuzahlen.",
1922+
5.0,
1923+
30.0,
1924+
90.0,
1925+
38.0,
1926+
),
1927+
cell(
1928+
"C.8. Wir können die Rückzahlung verweigern,",
1929+
5.0,
1930+
52.0,
1931+
95.0,
1932+
60.0,
1933+
),
1934+
cell(
1935+
"bis wir die Waren wieder zurückerhalten haben.",
1936+
5.0,
1937+
62.0,
1938+
92.0,
1939+
70.0,
1940+
),
1941+
];
1942+
let mut regions = vec![panel.clone()];
1943+
super::recover_text_panels(&mut regions, &cells);
1944+
assert_eq!(
1945+
regions.iter().map(|r| r.label).collect::<Vec<_>>(),
1946+
["text", "text"],
1947+
"dense panel must demote into one text region per paragraph"
1948+
);
1949+
assert!(regions[0].b < regions[1].t, "paragraphs split at the gap");
1950+
// Sparse narrow labels (a chart): picture survives.
1951+
let labels = vec![
1952+
cell("0", 5.0, 90.0, 8.0, 95.0),
1953+
cell("50", 5.0, 50.0, 10.0, 55.0),
1954+
cell("100", 5.0, 10.0, 12.0, 15.0),
1955+
cell("t, s", 45.0, 96.0, 55.0, 100.0),
1956+
];
1957+
let mut regions = vec![panel];
1958+
super::recover_text_panels(&mut regions, &labels);
1959+
assert_eq!(
1960+
regions.iter().map(|r| r.label).collect::<Vec<_>>(),
1961+
["picture"]
1962+
);
1963+
}
1964+
17461965
/// A generator that paints a line's bold runs *after* its regular text
17471966
/// hands the sanitizer the cells out of visual order ("C." | "Zur Wahrung
17481967
/// …" | "6." — the bold label number drawn last). docling's index order

crates/docling-pdf/src/lib.rs

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -410,17 +410,6 @@ impl Worker {
410410
/// into its nodes and links. Pure given the page (mutates only the worker's
411411
/// lazily-loaded OCR model), so it is safe to run concurrently across pages.
412412
fn process(&mut self, n: usize, page: &mut PdfPage) -> Result<PageOut, PdfError> {
413-
// Force-OCR is exactly "pretend the text layer is not there": clear
414-
// every cell kind the extractors produced before anything reads them,
415-
// and the ordinary no-text-layer machinery below — full-page OCR,
416-
// OCR-fed TableFormer matching — takes over unchanged. (`no_ocr` wins
417-
// when both are set, mirroring docling, where `force_full_page_ocr`
418-
// is a sub-option of `do_ocr`.)
419-
if self.force_full_page_ocr && !self.no_ocr {
420-
page.cells.clear();
421-
page.code_cells.clear();
422-
page.word_cells.clear();
423-
}
424413
if self.no_ocr {
425414
// Fastest path: no layout/OCR/TableFormer inference at all. The PDF's
426415
// embedded text cells (if any) become flat, line-grouped paragraphs in
@@ -493,6 +482,19 @@ impl Worker {
493482
page: &mut PdfPage,
494483
regions: Vec<layout::Region>,
495484
) -> Result<PageOut, PdfError> {
485+
// Force-OCR is exactly "pretend the text layer is not there": clear
486+
// every cell kind the extractors produced before anything reads them,
487+
// and the ordinary no-text-layer machinery below — full-page OCR,
488+
// OCR-fed TableFormer matching — takes over unchanged. (`no_ocr` wins
489+
// when both are set, mirroring docling, where `force_full_page_ocr`
490+
// is a sub-option of `do_ocr`; the no-ocr path never reaches here.)
491+
// Done here rather than in `process` so the batched layout path
492+
// (`process_batch` → `finish_page`) honors the flag too.
493+
if self.force_full_page_ocr {
494+
page.cells.clear();
495+
page.code_cells.clear();
496+
page.word_cells.clear();
497+
}
496498
// docling's LayoutPostprocessor drops each detection below its label's
497499
// confidence threshold (stricter than the 0.3 base the predictor keeps),
498500
// before any overlap resolution. This removes the low-confidence tables /
@@ -510,7 +512,8 @@ impl Worker {
510512
// remove it so it isn't emitted twice (docling parity).
511513
assemble::drop_contained_regulars(&mut regions);
512514
// No text layer → recognise text from the page image via OCR.
513-
if page.cells.is_empty() {
515+
let ocred = page.cells.is_empty();
516+
if ocred {
514517
if self.ocr.is_none() {
515518
self.ocr = Some(ocr::OcrModel::load(self.ocr_lang).map_err(PdfError::Ocr)?);
516519
}
@@ -523,6 +526,101 @@ impl Worker {
523526
.map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
524527
page.cells = cells;
525528
}
529+
// Region-scoped OCR skips `picture` interiors, and a digital page's
530+
// text layer cannot see into an embedded raster either — so a figure
531+
// that is really a text box (terms-and-conditions exported as an
532+
// image) lost its words on every page kind. Python docling OCRs the
533+
// bitmap-covered areas of *every* page — even digital ones — once they
534+
// exceed `bitmap_area_threshold` (5 % of the page); the browser paths
535+
// already do. Recognize the big text-less crops here too; the panel
536+
// demotion / orphan recovery below place the lines.
537+
let mut pic_cells: Vec<pdfium_backend::TextCell> = Vec::new();
538+
{
539+
let page_area = (page.width * page.height).max(1.0);
540+
let has_text = |r: &layout::Region| {
541+
page.cells.iter().any(|c| {
542+
let ca = ((c.r - c.l) * (c.b - c.t)).max(1.0);
543+
let ix = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
544+
let iy = (r.b.min(c.b) - r.t.max(c.t)).max(0.0);
545+
!c.text.trim().is_empty() && ix * iy / ca > 0.5
546+
})
547+
};
548+
// A captioned picture can never demote to a text panel (see
549+
// recover_text_panels), and on digital pages its speculative OCR
550+
// would be discarded anyway — don't pay for it.
551+
let captioned = |r: &layout::Region| {
552+
regions.iter().any(|c| {
553+
c.label == "caption"
554+
&& c.r.min(r.r) - c.l.max(r.l) > 0.0
555+
&& ((c.t >= r.b && c.t - r.b <= 25.0) || (r.t >= c.b && r.t - c.b <= 25.0))
556+
})
557+
};
558+
let bare: Vec<layout::Region> = regions
559+
.iter()
560+
.filter(|r| {
561+
r.label == "picture"
562+
&& (r.r - r.l) * (r.b - r.t) / page_area >= 0.05
563+
&& !has_text(r)
564+
&& (ocred || !captioned(r))
565+
})
566+
.map(|r| layout::Region {
567+
label: "text",
568+
..r.clone()
569+
})
570+
.collect();
571+
if !bare.is_empty() {
572+
if self.ocr.is_none() {
573+
self.ocr = Some(ocr::OcrModel::load(self.ocr_lang).map_err(PdfError::Ocr)?);
574+
}
575+
pic_cells = timing::timed("ocr.pictures", || {
576+
self.ocr
577+
.as_mut()
578+
.unwrap()
579+
.ocr_page(&page.image, &bare, page.scale)
580+
})
581+
.map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
582+
page.cells.extend(pic_cells.iter().cloned());
583+
}
584+
}
585+
let cells_before_pic_ocr = page.cells.len() - pic_cells.len();
586+
// A "picture" that is really a colored text panel — dense, wide,
587+
// multi-line — reads out as paragraphs instead of shipping as pixels;
588+
// sparse in-picture text (a chart's labels) keeps the crop and is
589+
// emitted beside it via orphan recovery.
590+
assemble::recover_text_panels(&mut regions, &page.cells);
591+
// On an OCR'd page, in-picture text that did NOT demote its picture is
592+
// still emitted beside the kept crop (matching the browser scanned
593+
// path). On a digital page it is not: docling's groundtruth keeps
594+
// photos silent even when our OCR reads noise off them
595+
// (picture_classification stays byte-exact), so the recognized cells
596+
// there only ever serve the panel-demotion decision above.
597+
if ocred && !pic_cells.is_empty() {
598+
let mut probe: Vec<layout::Region> = regions
599+
.iter()
600+
.filter(|r| r.label != "picture")
601+
.cloned()
602+
.collect();
603+
let keep_from = probe.len();
604+
assemble::add_orphan_regions(&mut probe, &pic_cells);
605+
regions.extend(probe.drain(keep_from..));
606+
} else if !ocred && !pic_cells.is_empty() {
607+
// Digital page, picture kept: its speculative OCR cells must not
608+
// linger in the text-cell set (they were appended at the tail).
609+
let kept: Vec<layout::Region> = regions
610+
.iter()
611+
.filter(|r| r.label == "picture")
612+
.cloned()
613+
.collect();
614+
let tail = page.cells.split_off(cells_before_pic_ocr);
615+
page.cells.extend(tail.into_iter().filter(|c| {
616+
!kept.iter().any(|r| {
617+
let ca = ((c.r - c.l) * (c.b - c.t)).max(1.0);
618+
let ix = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
619+
let iy = (r.b.min(c.b) - r.t.max(c.t)).max(0.0);
620+
ix * iy / ca > 0.5
621+
})
622+
}));
623+
}
526624
// TableFormer structure per table region (else geometric fallback). The
527625
// shared slot is only locked (and lazily loaded) when the page actually
528626
// has a table, so table-free documents never pay for TableFormer at all.

0 commit comments

Comments
 (0)