Skip to content

Commit f76f561

Browse files
artizclaude
andcommitted
pdf: read interleaved font runs in visual order ("C.6." kept whole) (#157)
PDF generators that paint a line's bold runs *after* the surrounding regular text (the XXXLutz terms document does this for every bold token) hand the sanitizer cells in stream order, not reading order. docling's cluster join follows the docling-parse cell index — that same stream order — so the bold label number drifted to the line's end: "C.6. Zur Wahrung der Widerrufsfrist …" read "C. Zur Wahrung … Mitteilung 6.", "binnen 14 Tagen ohne Angabe" read "binnen ohne 2. 14 Tagen Angabe", and searching the output for "C.6" found nothing. Two changes, both in the shared text path (native + browser, flat + ML): - region_text: restore reading order geometrically before joining — group a region's cells into lines by vertical *overlap* (not a quantized band: 2206's inline `>` sits ~2 pt above its line's band, and a plain band-sort drifted it into the next line, which is why the join trusted index order until now), sort lines top to bottom and cells left to right (right to left for Arabic-majority regions), stably — index order still breaks ties. - dp_lines contraction: never merge two cells across a horizontal gap that another active cell occupies — an occupied gap is not a gap, and bridging it with a space would stitch the line wrong before any ordering could help. Space-only cells never block; glyph-adjacent merges skip the scan. The full corpus battery is the arbiter that this deviation from raw index order is safe: docling-core, docling (regression suite included), docling-asr, docling-serve and docling-pdf suites all green, plus a unit test locking the interleaved-runs order and the off-baseline `>` grouping. fmt/clippy clean; the no-default-features and wasm targets compile. 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 d2a3b63 commit f76f561

2 files changed

Lines changed: 129 additions & 7 deletions

File tree

crates/docling-pdf/src/assemble.rs

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -729,10 +729,43 @@ fn region_text(region: &Region, cells: &[TextCell]) -> String {
729729
if dp {
730730
// docling orders a cluster's cells by their docling-parse cell index
731731
// (`LayoutPostprocessor._sort_cells`: `sorted(cells, key=c.index)`) —
732-
// the sanitizer's output order, which our `cells` slice already is. A
733-
// geometric band-sort loses that on off-baseline glyphs: 2206's inline
734-
// math `>` sits ~2 pt above its line's band and drifted into the next
735-
// one, `( > 10 pages)` → `( 10 pages) … complex > tables`.
732+
// the sanitizer's output order, which our `cells` slice already is.
733+
// That is *stream* order, and a generator that paints a line's bold
734+
// runs after the surrounding regular text strands them out of place
735+
// ("C.6. Zur Wahrung …" read "C. Zur Wahrung … 6."). Restore reading
736+
// order geometrically — but group lines by vertical *overlap*, not a
737+
// quantized band: 2206's inline math `>` sits ~2 pt above its line's
738+
// band and a plain band-sort drifted it into the next line
739+
// (`( > 10 pages)` → `( 10 pages) … complex > tables`); overlap
740+
// grouping keeps it home. Sorts are stable, so cells sharing a line
741+
// and an x-position keep their index order.
742+
let mut lines: Vec<(f32, f32, Vec<&TextCell>)> = Vec::new();
743+
for c in inside.drain(..) {
744+
let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
745+
let line = lines.iter_mut().find(|(lt, lb, _)| {
746+
let ov = cb.min(*lb) - ct.max(*lt);
747+
ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
748+
});
749+
match line {
750+
Some((lt, lb, cs)) => {
751+
*lt = lt.min(ct);
752+
*lb = lb.max(cb);
753+
cs.push(c);
754+
}
755+
None => lines.push((ct, cb, vec![c])),
756+
}
757+
}
758+
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
759+
for (_, _, mut cs) in lines {
760+
cs.sort_by(|a, b| {
761+
if rtl {
762+
b.l.total_cmp(&a.l)
763+
} else {
764+
a.l.total_cmp(&b.l)
765+
}
766+
});
767+
inside.extend(cs);
768+
}
736769
} else {
737770
inside.sort_by_key(|c| {
738771
let x = (c.l * 10.0) as i64;
@@ -1710,6 +1743,50 @@ mod tests {
17101743
use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
17111744
use docling_core::Node;
17121745

1746+
/// A generator that paints a line's bold runs *after* its regular text
1747+
/// hands the sanitizer the cells out of visual order ("C." | "Zur Wahrung
1748+
/// …" | "6." — the bold label number drawn last). docling's index order
1749+
/// would strand the bold token at the line's end ("… Mitteilung 6."); the
1750+
/// overlap-grouped line sort restores reading order, and an off-baseline
1751+
/// glyph (2206's inline `>` sits ~2 pt above its line) still belongs to
1752+
/// its own line rather than drifting into the next band.
1753+
#[test]
1754+
fn interleaved_font_runs_read_in_visual_order() {
1755+
let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
1756+
text: text.to_string(),
1757+
l,
1758+
t,
1759+
r,
1760+
b,
1761+
};
1762+
let cells = vec![
1763+
cell("C.", 10.0, 100.0, 18.0, 110.0),
1764+
cell("Zur Wahrung der Widerrufsfrist", 30.0, 100.0, 150.0, 110.0),
1765+
cell("über die Ausübung", 10.0, 112.0, 90.0, 122.0),
1766+
cell("6.", 19.0, 100.0, 27.0, 110.0), // bold run, drawn last
1767+
];
1768+
let region = Region {
1769+
label: "text",
1770+
score: 1.0,
1771+
l: 0.0,
1772+
t: 95.0,
1773+
r: 200.0,
1774+
b: 130.0,
1775+
};
1776+
assert_eq!(
1777+
super::region_text(&region, &cells),
1778+
"C. 6. Zur Wahrung der Widerrufsfrist über die Ausübung"
1779+
);
1780+
// Off-baseline glyph: raised but overlapping its line by more than half
1781+
// its height — stays on that line, ordered by x.
1782+
let raised = vec![
1783+
cell("(", 10.0, 100.0, 14.0, 110.0),
1784+
cell(">", 15.0, 97.0, 20.0, 104.0),
1785+
cell("10 pages)", 21.0, 100.0, 60.0, 110.0),
1786+
];
1787+
assert_eq!(super::region_text(&region, &raised), "( > 10 pages)");
1788+
}
1789+
17131790
/// The geometric-reliability gate, on the two shapes it has to tell apart.
17141791
#[test]
17151792
fn geometric_reliability_rejects_split_column_grids() {

crates/docling-pdf/src/dp_lines.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,48 @@ impl Cell {
170170
}
171171
}
172172

173+
/// Axis-aligned bounds of a cell's quad, `(l, r, b, t)` in PDF points (y-up).
174+
fn bounds(c: &Cell) -> (f64, f64, f64, f64) {
175+
let xs = [c.rx0, c.rx1, c.rx2, c.rx3];
176+
let ys = [c.ry0, c.ry1, c.ry2, c.ry3];
177+
let fold = |it: &[f64], f: fn(f64, f64) -> f64| it.iter().copied().reduce(f).unwrap();
178+
(
179+
fold(&xs, f64::min),
180+
fold(&xs, f64::max),
181+
fold(&ys, f64::min),
182+
fold(&ys, f64::max),
183+
)
184+
}
185+
186+
/// Is another active cell painted inside the horizontal gap between `i` and
187+
/// `j`? The contraction walks cells in **stream** order, and a generator that
188+
/// draws a line's bold runs after its regular text leaves them as later cells
189+
/// — the space tolerance would then stitch `C.[ ]Zur Wahrung …` straight
190+
/// across the hole where the bold `6.` sits, and the stranded token ends up at
191+
/// the line's end ("… wenn Sie die Mitteilung 6."). An occupied gap is not a
192+
/// gap. Space-only cells never block (they *are* the gap), and the scan is
193+
/// skipped entirely for glyph-adjacent merges (no room for anything).
194+
fn gap_occupied(cells: &[Cell], i: usize, j: usize) -> bool {
195+
let (al, ar, ab, at) = bounds(&cells[i]);
196+
let (bl, br, bb, bt) = bounds(&cells[j]);
197+
let (gl, gr) = if ar <= bl { (ar, bl) } else { (br, al) };
198+
if gr - gl < 0.5 {
199+
return false; // touching or overlapping — nothing fits in between
200+
}
201+
let (band_b, band_t) = (ab.min(bb), at.max(bt));
202+
cells.iter().enumerate().any(|(k, c)| {
203+
if k == i || k == j || !c.active || c.text.trim().is_empty() {
204+
return false;
205+
}
206+
let (cl, cr, cb, ct) = bounds(c);
207+
// Vertically on this line: most of the candidate inside the pair's band.
208+
let overlap = (ct.min(band_t) - cb.max(band_b)).max(0.0);
209+
overlap > 0.5 * (ct - cb).max(f64::EPSILON)
210+
// Horizontally: real ink inside the gap interval.
211+
&& cr.min(gr) - cl.max(gl) > 0.1
212+
})
213+
}
214+
173215
/// `applicable_for_merge`: both active and same reading orientation. A different
174216
/// font normally blocks the merge (keeps a bold label and its value as separate
175217
/// line cells). On the clean-box parser path, **punctuation/space cells bridge
@@ -214,13 +256,16 @@ fn pass_ltr(cells: &mut [Cell], allow_reverse: bool, euclidean: bool) {
214256
let d0 = cells[i].avg_char_width() * MERGE;
215257
let d1 = cells[i].avg_char_width() * MERGE_WITH_SPACE;
216258
let adj_d1 = d0 + if i_lig || j_lig { H_TOL } else { 0.0 };
217-
if cells[i].adjacent(&cells[j], d0, adj_d1) {
259+
if cells[i].adjacent(&cells[j], d0, adj_d1) && !gap_occupied(cells, i, j) {
218260
let other = cells[j].clone();
219261
cells[i].merge_with(&other, d1, euclidean);
220262
cells[i].lig_carry = is_ligature(&other.text);
221263
cells[j].active = false;
222264
j += 1; // i keeps absorbing the next cell to its right
223-
} else if allow_reverse && cells[j].adjacent(&cells[i], d0, adj_d1) {
265+
} else if allow_reverse
266+
&& cells[j].adjacent(&cells[i], d0, adj_d1)
267+
&& !gap_occupied(cells, j, i)
268+
{
224269
let other = cells[i].clone();
225270
cells[j].merge_with(&other, d1, euclidean);
226271
cells[j].lig_carry = is_ligature(&other.text);
@@ -251,7 +296,7 @@ fn pass_rtl(cells: &mut [Cell], euclidean: bool) {
251296
let d0 = cells[i].avg_char_width() * MERGE;
252297
let d1 = cells[i].avg_char_width() * MERGE_WITH_SPACE;
253298
let adj_d1 = d0 + if i_lig || j_lig { H_TOL } else { 0.0 };
254-
if cells[j].adjacent(&cells[i], d0, adj_d1) {
299+
if cells[j].adjacent(&cells[i], d0, adj_d1) && !gap_occupied(cells, j, i) {
255300
let other = cells[i].clone();
256301
cells[j].merge_with(&other, d1, euclidean);
257302
cells[j].lig_carry = is_ligature(&other.text);

0 commit comments

Comments
 (0)