docling_pdf/assemble.rs
1//! Layout-driven assembly: map detected [`Region`]s + text cells to a
2//! [`DoclingDocument`], mirroring docling's page-assembly + reading-order.
3//!
4//! Overlapping detections are resolved greedily by score, each text cell is
5//! assigned to its best-containing region, regions are ordered in reading order
6//! (two-column aware), and each becomes a typed node by its layout label.
7
8use docling_core::{Node, PictureClass, PictureImage, Table};
9#[cfg(feature = "ml")]
10use image::RgbImage;
11
12use crate::layout::Region;
13use crate::pdfium_backend::{PdfPage, TextCell};
14
15fn area(l: f32, t: f32, r: f32, b: f32) -> f32 {
16 ((r - l).max(0.0)) * ((b - t).max(0.0))
17}
18
19/// Intersection area of two boxes.
20fn inter(a: &Region, l: f32, t: f32, r: f32, b: f32) -> f32 {
21 let il = a.l.max(l);
22 let it = a.t.max(t);
23 let ir = a.r.min(r);
24 let ib = a.b.min(b);
25 area(il, it, ir, ib)
26}
27
28/// Wrapper (structured-region) labels, ported from docling
29/// `LayoutPostprocessor.WRAPPER_TYPES`: a region that *contains* other regions
30/// and renders as a structured block (a table / table-of-contents index), not as
31/// its own flat text.
32fn is_wrapper(label: &str) -> bool {
33 matches!(
34 label,
35 "table" | "document_index" | "form" | "key_value_region"
36 )
37}
38
39/// Labels docling's table-structure (TableFormer) model runs on and that render
40/// as a Markdown table: a plain `table` and a `document_index` (a table of
41/// contents), which docling assembles as a `TableItem` too.
42pub fn is_table_like(label: &str) -> bool {
43 matches!(label, "table" | "document_index")
44}
45
46/// Greedily keep regions by descending score, dropping a region that is mostly
47/// covered by an already-kept one (RT-DETR emits overlapping duplicates).
48fn greedy(mut regions: Vec<Region>) -> Vec<Region> {
49 regions.sort_by(|a, b| b.score.total_cmp(&a.score));
50 let mut kept: Vec<Region> = Vec::new();
51 for r in regions {
52 let ra = area(r.l, r.t, r.r, r.b).max(1.0);
53 let covered = kept.iter().any(|k| {
54 let i = inter(&r, k.l, k.t, k.r, k.b);
55 let ka = area(k.l, k.t, k.r, k.b).max(1.0);
56 // drop if most of r is inside k, or they strongly mutually overlap
57 i / ra > 0.7 || i / (ra + ka - i) > 0.5
58 });
59 if !covered {
60 kept.push(r);
61 }
62 }
63 kept
64}
65
66/// Resolve overlapping RT-DETR detections, ported from the bucket structure of
67/// docling's `LayoutPostprocessor`: regular, picture and wrapper clusters live in
68/// **separate** spatial indexes and are de-overlapped independently, so a
69/// high-score picture never suppresses a lower-score table or table-of-contents
70/// index (the redp5110 TOC that was otherwise replaced by a picture box). A
71/// cross-type pass first drops a picture that nearly coincides with a table
72/// (`_handle_cross_type_overlaps`), keeping the structured table.
73/// docling's `_remove_overlapping_clusters("picture")`: same-label picture
74/// detections whose boxes heavily overlap (IoU > 0.8, or either box > 80 %
75/// contained in the other) form one group, and a single survivor is kept per
76/// group. Survivor selection ports `_should_prefer_cluster` /
77/// `_select_best_cluster_from_group` with the picture params
78/// (`area_threshold` 2.0, `conf_threshold` 0.3): a candidate is rejected only
79/// when a rival is both comparable in size (candidate ≤ 2× its area) and
80/// clearly more confident (> 0.3); among the survivors the *larger* box wins
81/// unless it is > 0.3 less confident. Net effect on the corpus: a figure the
82/// detector proposes both whole and as its sub-panels (2206's four-thumbnail
83/// Figure 1) collapses to the whole-figure box, exactly like docling.
84pub(crate) fn dedup_pictures(regions: &mut Vec<Region>) {
85 let idx: Vec<usize> = (0..regions.len())
86 .filter(|&i| regions[i].label == "picture")
87 .collect();
88 if idx.len() < 2 {
89 return;
90 }
91 // Union-find over the picture subset.
92 let mut parent: Vec<usize> = (0..idx.len()).collect();
93 fn find(parent: &mut [usize], i: usize) -> usize {
94 let mut root = i;
95 while parent[root] != root {
96 root = parent[root];
97 }
98 let mut cur = i;
99 while parent[cur] != root {
100 let next = parent[cur];
101 parent[cur] = root;
102 cur = next;
103 }
104 root
105 }
106 let boxed = |r: &Region| (r.l, r.t, r.r, r.b);
107 for a in 0..idx.len() {
108 for b in (a + 1)..idx.len() {
109 let (ra, rb) = (®ions[idx[a]], ®ions[idx[b]]);
110 let (al, at, ar, ab_) = boxed(ra);
111 let (bl, bt, br, bb) = boxed(rb);
112 let ix = (ar.min(br) - al.max(bl)).max(0.0);
113 let iy = (ab_.min(bb) - at.max(bt)).max(0.0);
114 let inter = ix * iy;
115 let aa = area(al, at, ar, ab_).max(f32::EPSILON);
116 let ba = area(bl, bt, br, bb).max(f32::EPSILON);
117 let iou = inter / (aa + ba - inter).max(f32::EPSILON);
118 if iou > 0.8 || inter / aa > 0.8 || inter / ba > 0.8 {
119 let (pa, pb) = (find(&mut parent, a), find(&mut parent, b));
120 if pa != pb {
121 parent[pa] = pb;
122 }
123 }
124 }
125 }
126 // Per group, run docling's pairwise preference + larger-wins selection.
127 let mut groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
128 for i in 0..idx.len() {
129 let root = find(&mut parent, i);
130 groups.entry(root).or_default().push(i);
131 }
132 let mut drop = vec![false; regions.len()];
133 for group in groups.values() {
134 if group.len() < 2 {
135 continue;
136 }
137 const AREA_THRESHOLD: f32 = 2.0;
138 const CONF_THRESHOLD: f32 = 0.3;
139 let area_of = |i: usize| {
140 let r = ®ions[idx[i]];
141 area(r.l, r.t, r.r, r.b).max(f32::EPSILON)
142 };
143 let mut best: Option<usize> = None;
144 for &cand in group {
145 let passes = group.iter().all(|&other| {
146 if other == cand {
147 return true;
148 }
149 let area_ratio = area_of(cand) / area_of(other);
150 let conf_diff = regions[idx[other]].score - regions[idx[cand]].score;
151 !(area_ratio <= AREA_THRESHOLD && conf_diff > CONF_THRESHOLD)
152 });
153 if passes {
154 best = Some(match best {
155 None => cand,
156 Some(cur) => {
157 if area_of(cand) > area_of(cur)
158 && regions[idx[cur]].score - regions[idx[cand]].score <= CONF_THRESHOLD
159 {
160 cand
161 } else {
162 cur
163 }
164 }
165 });
166 }
167 }
168 // Every candidate rejected can't happen with docling's rule (rejection
169 // needs a strictly better rival); guard with highest score anyway.
170 let keep = best.unwrap_or_else(|| {
171 *group
172 .iter()
173 .max_by(|&&a, &&b| regions[idx[a]].score.total_cmp(®ions[idx[b]].score))
174 .expect("non-empty group")
175 });
176 for &i in group {
177 if i != keep {
178 drop[idx[i]] = true;
179 }
180 }
181 }
182 let mut keep_iter = drop.into_iter();
183 regions.retain(|_| !keep_iter.next().expect("aligned"));
184}
185
186/// `intersection_over_union` of two regions.
187fn iou(a: &Region, b: &Region) -> f32 {
188 let i = inter(a, b.l, b.t, b.r, b.b);
189 let u = area(a.l, a.t, a.r, a.b) + area(b.l, b.t, b.r, b.b) - i;
190 if u > 0.0 {
191 i / u
192 } else {
193 0.0
194 }
195}
196
197/// docling's `_resolve_coincident_pairs` (#4059, 2.122): for every (loser,
198/// winner) pair at a near-identical box (IoU > 0.8) whose confidences are
199/// within 0.1 (`loser.score - winner.score < 0.1`), the loser label is dropped
200/// so the label with the richer downstream semantic survives. Nothing else —
201/// containment, area — is considered; a clearly more confident loser stays.
202fn coincident_losers(regions: &[Region], losers: &[usize], winners: &[usize]) -> Vec<usize> {
203 let mut out = Vec::new();
204 for &li in losers {
205 for &wi in winners {
206 if iou(®ions[li], ®ions[wi]) > 0.8 && regions[li].score - regions[wi].score < 0.1
207 {
208 out.push(li);
209 break;
210 }
211 }
212 }
213 out
214}
215
216/// docling's `_handle_cross_type_overlaps` (2.122/2.123 shape): the layout
217/// model can emit one grounded region under several labels, and the picture /
218/// table / container buckets are de-overlapped independently, so such a region
219/// survives twice. Elect a winner for the near-identical pairs:
220///
221/// | pair | loser | winner |
222/// |---------------------------------------|-----------|---------------------|
223/// | TABLE vs DOCUMENT_INDEX | table | document_index |
224/// | PICTURE vs TABLE / DOCUMENT_INDEX | picture | the table-like |
225/// | FORM / KEY_VALUE_REGION vs *surviving* TABLE / DOCUMENT_INDEX / PICTURE | container | structured element |
226///
227/// IoU (not containment) so a genuine small figure inside a large table region
228/// is not removed; the confidence tolerance keeps a clearly more confident
229/// loser (an earlier port dropped every coincident picture regardless).
230fn handle_cross_type_overlaps(regions: Vec<Region>) -> Vec<Region> {
231 let by = |pred: &dyn Fn(&str) -> bool| -> Vec<usize> {
232 (0..regions.len())
233 .filter(|&i| pred(regions[i].label))
234 .collect()
235 };
236 let tables = by(&|l| l == "table");
237 let doc_indices = by(&|l| l == "document_index");
238 let pictures = by(&|l| l == "picture");
239 let containers = by(&|l| matches!(l, "form" | "key_value_region"));
240 let mut drop = vec![false; regions.len()];
241 for i in coincident_losers(®ions, &tables, &doc_indices) {
242 drop[i] = true;
243 }
244 let table_like: Vec<usize> = tables.iter().chain(&doc_indices).copied().collect();
245 for i in coincident_losers(®ions, &pictures, &table_like) {
246 drop[i] = true;
247 }
248 let structured: Vec<usize> = table_like
249 .iter()
250 .chain(&pictures)
251 .copied()
252 .filter(|&i| !drop[i])
253 .collect();
254 for i in coincident_losers(®ions, &containers, &structured) {
255 drop[i] = true;
256 }
257 let mut drop = drop.into_iter();
258 let mut regions = regions;
259 regions.retain(|_| !drop.next().expect("aligned"));
260 regions
261}
262
263pub fn resolve(regions: Vec<Region>) -> Vec<Region> {
264 let regions = handle_cross_type_overlaps(regions);
265 // De-overlap each bucket on its own.
266 let pictures = greedy(
267 regions
268 .iter()
269 .filter(|r| r.label == "picture")
270 .cloned()
271 .collect(),
272 );
273 // Tables and containers are separate buckets since docling 2.123
274 // (`TABLE_TYPES` vs `CONTAINER_TYPES`, docling#4064): a form drawn around a
275 // table no longer competes with it for survival — the table nests inside
276 // the container instead (`order_with_containers`).
277 let tables = greedy(
278 regions
279 .iter()
280 .filter(|r| is_table_like(r.label))
281 .cloned()
282 .collect(),
283 );
284 let containers = greedy(
285 regions
286 .iter()
287 .filter(|r| matches!(r.label, "form" | "key_value_region"))
288 .cloned()
289 .collect(),
290 );
291 let mut kept = greedy(
292 regions
293 .iter()
294 .filter(|r| r.label != "picture" && !is_wrapper(r.label))
295 .cloned()
296 .collect(),
297 );
298 dedup_nested_code(&mut kept);
299 kept.extend(pictures);
300 kept.extend(tables);
301 kept.extend(containers);
302 kept
303}
304
305/// Drop a regular region that is >80% contained in a surviving special region we
306/// render **as a single unit** — a table/table-of-contents index — ported from
307/// docling's "Remove regular clusters that are included in wrappers" step: the
308/// special absorbs it as a child (a table cell), so it must not also be emitted
309/// as its own paragraph/list-item. This stops the survey list-items from
310/// appearing both inside the detected table and again as bullets
311/// (`table_mislabeled_as_picture`).
312///
313/// `picture` regions stay in the swallow set even after #165: docling keeps a
314/// picture's contained clusters as the `PictureItem`'s *children* in the
315/// document JSON (`ReadingOrderModel._add_child_elements`), but its
316/// `MarkdownPictureSerializer` prints only the caption and the image — the
317/// children never reach the Markdown (verified against the corpus groundtruth:
318/// `amt_handbook`'s in-figure callout labels are absent). Dropping the
319/// fully-contained regulars here reproduces exactly that. What #165 *does*
320/// change is upstream, in [`add_orphan_regions`]: pictures no longer claim
321/// cells, so a line only partially under a figure box (straddling its border,
322/// ≤80 % contained) now forms an orphan region that survives this drop — those
323/// words were silently erased before, and docling emits them.
324///
325/// `form` / `key_value_region` wrappers are deliberately **excluded**: this
326/// pipeline does not render them as a structured block (they are skipped), so
327/// their textual content comes precisely from the contained regular regions —
328/// dropping those would erase the page (e.g. `right_to_left_03`'s form-heavy
329/// pages). Runs *after* [`drop_false_pictures`] so a phantom picture can't
330/// swallow real text on its way out.
331pub fn drop_contained_regulars(regions: &mut Vec<Region>) {
332 let specials: Vec<(f32, f32, f32, f32)> = regions
333 .iter()
334 .filter(|r| r.label == "picture" || is_table_like(r.label))
335 .map(|r| (r.l, r.t, r.r, r.b))
336 .collect();
337 if specials.is_empty() {
338 return;
339 }
340 regions.retain(|r| {
341 if r.label == "picture" || is_wrapper(r.label) {
342 return true;
343 }
344 let ra = area(r.l, r.t, r.r, r.b).max(1.0);
345 !specials
346 .iter()
347 .any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.8)
348 });
349}
350
351/// True for a bare, single-token source-code language label (`XML`, `C#`, `JSON`,
352/// `bash`, …) — the little header the docs render above a code block. Matched
353/// case-insensitively; anything with whitespace or longer than a token is out.
354fn is_code_language(t: &str) -> bool {
355 let t = t.trim();
356 if t.is_empty() || t.chars().any(char::is_whitespace) || t.chars().count() > 12 {
357 return false;
358 }
359 const LANGS: &[&str] = &[
360 "xml",
361 "html",
362 "xhtml",
363 "json",
364 "jsonc",
365 "yaml",
366 "yml",
367 "toml",
368 "ini",
369 "c#",
370 "csharp",
371 "f#",
372 "fsharp",
373 "vb",
374 "c",
375 "c++",
376 "cpp",
377 "java",
378 "kotlin",
379 "scala",
380 "go",
381 "golang",
382 "rust",
383 "swift",
384 "javascript",
385 "js",
386 "typescript",
387 "ts",
388 "jsx",
389 "tsx",
390 "python",
391 "py",
392 "ruby",
393 "rb",
394 "php",
395 "perl",
396 "lua",
397 "r",
398 "dart",
399 "bash",
400 "sh",
401 "shell",
402 "powershell",
403 "zsh",
404 "batch",
405 "cmd",
406 "sql",
407 "tsql",
408 "plsql",
409 "graphql",
410 "dockerfile",
411 "makefile",
412 "css",
413 "scss",
414 "sass",
415 "less",
416 "markdown",
417 "md",
418 "tex",
419 "latex",
420 "diff",
421 "proto",
422 "razor",
423 "cshtml",
424 "xaml",
425 "aspx",
426 "http",
427 ];
428 let lower = t.to_ascii_lowercase();
429 LANGS.contains(&lower.as_str())
430}
431
432/// Mark the region indices that are a code block's **language label** — a bare
433/// `XML`/`C#`/… token sitting directly above a `code` region — so they are consumed
434/// rather than emitted as their own stray paragraph/heading. The label may also be
435/// captured inside a wider code box (rendered as the fence's first line); dropping
436/// the standalone copy just removes the duplicate.
437fn code_language_labels(regions: &[Region], cells: &[TextCell]) -> Vec<bool> {
438 let mut drop = vec![false; regions.len()];
439 for (i, r) in regions.iter().enumerate() {
440 if matches!(r.label, "code" | "picture" | "table") {
441 continue;
442 }
443 if !is_code_language(®ion_text(r, cells)) {
444 continue;
445 }
446 // The label sits just above the code (a blank line's gap) or is swallowed
447 // into the top of a wider code box; either way it is that block's label.
448 // The window is generous because the label's own font is small, so a
449 // one-line gap is several times its height.
450 let line_h = (r.b - r.t).abs().max(1.0);
451 let window = (line_h * 4.0).max(28.0);
452 let labels_code = regions.iter().enumerate().any(|(j, c)| {
453 if j == i || c.label != "code" {
454 return false;
455 }
456 let gap = c.t - r.b; // >0 when the code is below the label
457 let h_overlap = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
458 gap > -line_h * 3.0 && gap < window && h_overlap > 0.0
459 });
460 if labels_code {
461 drop[i] = true;
462 }
463 }
464 drop
465}
466
467/// Collapse `code` regions where one is nested inside another, keeping the larger.
468///
469/// RT-DETR sometimes emits a tight code box *and* a wider near-duplicate that also
470/// captures the block's language label (`XML`, `C#`, …). When the tight box scores
471/// higher it is kept first, and the wider container — not "mostly inside" the tight
472/// box — survives [`resolve`]'s greedy pass, so the block is emitted twice. Keeping
473/// the **larger** box (rather than dropping it) collapses the pair without leaking
474/// the container's extra cells back out as orphan text, since the larger box still
475/// covers every cell. Restricted to `code` so genuinely distinct nested regions of
476/// other kinds are untouched.
477fn dedup_nested_code(kept: &mut Vec<Region>) {
478 let mut drop = vec![false; kept.len()];
479 for i in 0..kept.len() {
480 if kept[i].label != "code" {
481 continue;
482 }
483 let ai = area(kept[i].l, kept[i].t, kept[i].r, kept[i].b).max(1.0);
484 for j in 0..kept.len() {
485 if i == j || drop[j] || kept[j].label != "code" {
486 continue;
487 }
488 let aj = area(kept[j].l, kept[j].t, kept[j].r, kept[j].b).max(1.0);
489 // Drop i when it is mostly inside a strictly larger code box j.
490 let overlap = inter(&kept[i], kept[j].l, kept[j].t, kept[j].r, kept[j].b);
491 if aj > ai && overlap / ai > 0.7 {
492 drop[i] = true;
493 break;
494 }
495 }
496 }
497 let mut keep = drop.iter();
498 kept.retain(|_| !*keep.next().unwrap());
499}
500
501/// Fraction of the page's non-empty text cells that some detected region
502/// claims (>0.2 intersection-over-self, docling's assignment rule). 1.0 for a
503/// page without text cells.
504///
505/// The int8-layout guard keys off this: a dense digital page whose detections
506/// cover almost none of its text is the signature of quantized confidences
507/// flipping under the 0.5 label thresholds on this CPU's kernels — not of a
508/// genuinely empty layout — and is worth re-running on the fp32 graph.
509pub fn layout_cell_coverage(regions: &[Region], cells: &[TextCell]) -> f32 {
510 let mut total = 0usize;
511 let mut covered = 0usize;
512 for c in cells {
513 if c.text.trim().is_empty() {
514 continue;
515 }
516 total += 1;
517 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
518 if regions
519 .iter()
520 .any(|r| inter(r, c.l, c.t, c.r, c.b) / ca > 0.2)
521 {
522 covered += 1;
523 }
524 }
525 if total == 0 {
526 1.0
527 } else {
528 covered as f32 / total as f32
529 }
530}
531
532/// Append `text` regions for cells the layout left uncovered ("orphan cells"),
533/// the way docling's `LayoutPostprocessor` does (`create_orphan_clusters`): any
534/// non-empty cell that no kept region covers (>50% of the cell's area) becomes a
535/// text region of its own, so text the detector missed (a stray `.`, a small
536/// label) is still emitted instead of silently dropped. Adjacent orphan cells on a
537/// line are merged so a missed paragraph doesn't shatter into one block per line.
538pub fn add_orphan_regions(regions: &mut Vec<Region>, cells: &[TextCell]) {
539 // docling assigns each cell to its single best-overlapping cluster at
540 // intersection-over-self > 0.2 and serializes exactly the assigned cells —
541 // and since [`region_texts_exclusive`] now emits under that very rule, the
542 // claim test here matches it: any cell over 0.2 will actually render in
543 // its best region, everything else becomes an orphan. Completeness by
544 // construction, with no (0.2, 0.5] hole (the old > 0.5 serializer needed
545 // the claim test raised to > 0.5 to keep right_to_left_03's `20300` from
546 // vanishing; the exclusive port closes that structurally).
547 //
548 // Only *regular* clusters claim cells: docling's `_find_unassigned_cells`
549 // walks `regular_clusters` alone, so a cell under a `picture` or a wrapper
550 // (`table`/`document_index`/`form`/`key_value_region`) that no regular
551 // cluster covers still becomes an orphan text cluster (#165). The orphans
552 // that end up *fully* inside the special are re-dropped by
553 // [`drop_contained_regulars`] (docling's Markdown drops them the same way
554 // — a picture's children never reach its `MarkdownPictureSerializer`
555 // output, a table's text renders through the reconstructed grid). The
556 // observable fix is the border-straddlers: a line only partially under a
557 // figure box used to lose its cells to the picture's 0.2 claim and vanish
558 // — now it forms an orphan region and is emitted, as docling does.
559 let assigned = |c: &TextCell| {
560 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
561 regions
562 .iter()
563 .filter(|r| r.label != "picture" && !is_wrapper(r.label))
564 .any(|r| inter(r, c.l, c.t, c.r, c.b) / ca > 0.2)
565 };
566 // Collect orphan cells (non-empty, unassigned), in page order.
567 let mut orphans: Vec<&TextCell> = cells
568 .iter()
569 .filter(|c| !c.text.trim().is_empty() && !assigned(c))
570 .collect();
571 if orphans.is_empty() {
572 return;
573 }
574 orphans.sort_by(|a, b| a.t.total_cmp(&b.t).then(a.l.total_cmp(&b.l)));
575 // Merge cells that sit on the same line and nearly touch into one region, so a
576 // dropped multi-word line stays one block (docling's refinement merges these).
577 let mut merged: Vec<Region> = Vec::new();
578 for c in orphans {
579 let h = (c.b - c.t).abs().max(1.0);
580 if let Some(last) = merged.last_mut() {
581 let same_line = (last.t - c.t).abs() < h * 0.5;
582 let touching = c.l <= last.r + h && c.l >= last.l - h;
583 if same_line && touching {
584 last.l = last.l.min(c.l);
585 last.r = last.r.max(c.r);
586 last.t = last.t.min(c.t);
587 last.b = last.b.max(c.b);
588 continue;
589 }
590 }
591 merged.push(Region {
592 label: "text",
593 score: 0.0,
594 l: c.l,
595 t: c.t,
596 r: c.r,
597 b: c.b,
598 });
599 }
600 regions.extend(merged);
601}
602
603/// Demote a `picture` region that is really a **text panel** — a paragraph block
604/// the layout model boxed as a figure because it is typeset on a colored
605/// background (terms-and-conditions callouts, quote boxes) — into ordinary
606/// `text` regions, one per paragraph, so its words are read instead of shipped
607/// as pixels. docling loses this text the same way (cells assigned to a picture
608/// cluster are never serialized); this is a deliberate improvement, not parity.
609///
610/// The gate is conservative so a genuine figure keeps its crop: the region must
611/// contain at least three text lines whose median width spans most of the panel
612/// (axis labels and chat bubbles are narrow and varied) and whose cells cover a
613/// substantial fraction of its area (a photo or chart with sparse labels does
614/// not). Paragraph boundaries are re-derived from the line pitch: a vertical gap
615/// clearly larger than the panel's own leading starts a new `text` region, so
616/// the panel doesn't collapse into one giant paragraph.
617///
618/// Works on any cell source — the digital text layer or OCR lines recognized
619/// from the picture crop — so the native and browser paths, with or without
620/// force-OCR, demote identically.
621pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
622 // A *captioned* picture is a genuine figure whatever it contains — the
623 // corpus is full of document screenshots ("Figure 3: …" above a page
624 // image) that are exactly as dense and wide as a text panel. Only an
625 // uncaptioned picture is a demotion candidate.
626 let captioned: Vec<bool> = regions
627 .iter()
628 .map(|r| {
629 r.label == "picture"
630 && regions.iter().any(|c| {
631 c.label == "caption" && c.r.min(r.r) - c.l.max(r.l) > 0.0 && {
632 let gap = if c.t >= r.b {
633 c.t - r.b
634 } else if r.t >= c.b {
635 r.t - c.b
636 } else {
637 f32::MAX // vertically overlapping: not a caption
638 };
639 gap <= 25.0
640 }
641 })
642 })
643 .collect();
644 let mut out: Vec<Region> = Vec::with_capacity(regions.len());
645 // Synthesized paragraphs and the demoted panels' boxes are kept separate
646 // from `out` until the end: the dedup filter below must not confuse a
647 // paragraph we just built with a pre-existing region inside the panel.
648 let mut demoted_paras: Vec<Region> = Vec::new();
649 let mut demoted_boxes: Vec<(f32, f32, f32, f32)> = Vec::new();
650 for (i, r) in regions.drain(..).enumerate() {
651 if r.label != "picture" || captioned[i] {
652 out.push(r);
653 continue;
654 }
655 let inside: Vec<&TextCell> = cells
656 .iter()
657 .filter(|c| {
658 !c.text.trim().is_empty() && {
659 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
660 inter(&r, c.l, c.t, c.r, c.b) / ca > 0.5
661 }
662 })
663 .collect();
664 // Group the contained cells into lines by vertical overlap (the same
665 // rule region_text orders by), tracking each line's union box.
666 let mut lines: Vec<(f32, f32, f32, f32)> = Vec::new(); // (t, b, l, r)
667 for c in &inside {
668 let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
669 match lines.iter_mut().find(|(lt, lb, _, _)| {
670 let ov = cb.min(*lb) - ct.max(*lt);
671 ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
672 }) {
673 Some((lt, lb, ll, lr)) => {
674 *lt = lt.min(ct);
675 *lb = lb.max(cb);
676 *ll = ll.min(c.l);
677 *lr = lr.max(c.r);
678 }
679 None => lines.push((ct, cb, c.l, c.r)),
680 }
681 }
682 if lines.len() < 3 {
683 out.push(r);
684 continue;
685 }
686 let panel_w = (r.r - r.l).max(1.0);
687 let coverage = inside.iter().map(|c| area(c.l, c.t, c.r, c.b)).sum::<f32>()
688 / area(r.l, r.t, r.r, r.b).max(1.0);
689 let mut widths: Vec<f32> = lines.iter().map(|(_, _, l, rr)| rr - l).collect();
690 widths.sort_by(f32::total_cmp);
691 // A figure's text is ragged: a title line, small axis/tick labels, and
692 // OCR boxes over the plot area come out at wildly different heights,
693 // whereas a real text panel is set in one face with constant leading.
694 // Require near-uniform line heights (median absolute deviation ≤ 35%
695 // of the median) so an uncaptioned chart keeps its crop even when its
696 // labels are dense enough to pass the coverage gate (#173) — garbled
697 // OCR of its bars is not content.
698 let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
699 heights.sort_by(f32::total_cmp);
700 let h_med = heights[heights.len() / 2].max(1.0);
701 let mut devs: Vec<f32> = heights.iter().map(|h| (h - h_med).abs()).collect();
702 devs.sort_by(f32::total_cmp);
703 let uniform = devs[devs.len() / 2] <= 0.35 * h_med;
704 let text_panel = coverage >= 0.2 && widths[widths.len() / 2] >= 0.45 * panel_w && uniform;
705 if !text_panel {
706 out.push(r);
707 continue;
708 }
709 lines.sort_by(|a, b| a.0.total_cmp(&b.0));
710 let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
711 heights.sort_by(f32::total_cmp);
712 let h = heights[heights.len() / 2].max(1.0);
713 let mut gaps: Vec<f32> = lines
714 .windows(2)
715 .map(|w| (w[1].0 - w[0].1).max(0.0))
716 .collect();
717 gaps.sort_by(f32::total_cmp);
718 let leading = if gaps.is_empty() {
719 0.0
720 } else {
721 gaps[gaps.len() / 2]
722 };
723 let brk = (1.8 * leading).max(0.75 * h);
724 let mut para: Option<(f32, f32, f32, f32)> = None; // (l, t, r, b) union
725 for (t, b, l, rr) in &lines {
726 match &mut para {
727 Some((pl, _, pr, pb)) if *t - *pb <= brk => {
728 *pl = pl.min(*l);
729 *pr = pr.max(*rr);
730 *pb = pb.max(*b);
731 }
732 _ => {
733 if let Some((pl, pt, pr, pb)) = para.take() {
734 demoted_paras.push(Region {
735 label: "text",
736 score: r.score,
737 l: pl,
738 t: pt,
739 r: pr,
740 b: pb,
741 });
742 }
743 para = Some((*l, *t, *rr, *b));
744 }
745 }
746 }
747 if let Some((pl, pt, pr, pb)) = para {
748 demoted_paras.push(Region {
749 label: "text",
750 score: r.score,
751 l: pl,
752 t: pt,
753 r: pr,
754 b: pb,
755 });
756 }
757 demoted_boxes.push((r.l, r.t, r.r, r.b));
758 }
759 // The paragraphs are rebuilt from *all* of the panel's cells, so any
760 // surviving text region inside a demoted panel (an orphan cluster or a
761 // layout-detected fragment — pictures no longer swallow them, #165) would
762 // say the same words twice. Consume those; wrappers and pictures stay.
763 if !demoted_boxes.is_empty() {
764 out.retain(|r| {
765 r.label == "picture" || is_wrapper(r.label) || {
766 let ra = area(r.l, r.t, r.r, r.b).max(1.0);
767 !demoted_boxes
768 .iter()
769 .any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.5)
770 }
771 });
772 }
773 out.extend(demoted_paras);
774 *regions = out;
775}
776
777/// Drop a `picture` detection that is a small, empty, low-confidence margin box on
778/// a **text page** — a false positive the RT-DETR layout sometimes emits (e.g.
779/// `right_to_left_02`'s phantom right-column picture, score 0.40); docling does not
780/// emit it. The gate is deliberately narrow so a genuine figure is never dropped:
781/// (1) only on pages with a digital text layer — image/scanned/figure pages have
782/// no `cells` yet at this point (OCR runs later), so their pictures, which *are*
783/// the content, are kept; (2) only a box covering < 25 % of the page (a margin
784/// artifact, not a dominant figure); (3) only when it contains no text and scores
785/// below 0.5 (real empty figures in the corpus all score ≥ 0.86).
786pub fn drop_false_pictures(
787 regions: &mut Vec<Region>,
788 cells: &[TextCell],
789 page_w: f32,
790 page_h: f32,
791) {
792 if cells.iter().all(|c| c.text.trim().is_empty()) {
793 return; // no digital text layer (image/scanned page) — keep all pictures
794 }
795 // A text-document page carries several text-bearing non-picture regions (so a
796 // spurious margin picture is clearly extra). A slide / figure page has at most
797 // one — there the picture is the content, so never drop it.
798 let content_regions = regions
799 .iter()
800 .filter(|r| r.label != "picture" && !region_text(r, cells).trim().is_empty())
801 .count();
802 if content_regions < 2 {
803 return;
804 }
805 let page_area = (page_w * page_h).max(1.0);
806 regions.retain(|r| {
807 if r.label != "picture" || r.score >= 0.5 {
808 return true;
809 }
810 if area(r.l, r.t, r.r, r.b) / page_area >= 0.25 {
811 return true; // a dominant figure, not a margin artifact
812 }
813 // Keep it if any text cell falls mostly inside (a real captioned/labelled
814 // figure); drop only the genuinely empty low-confidence boxes.
815 cells.iter().any(|c| {
816 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
817 !c.text.trim().is_empty() && inter(r, c.l, c.t, c.r, c.b) / ca > 0.5
818 })
819 });
820}
821
822/// A small digit-only region in the top/bottom margin: a page number. docling
823/// emits `right_to_left_02`'s bottom `11` as the page's *first* text item (its
824/// reading-order model floats the page number to the front), whereas our
825/// position-based ordering would place a bottom region last.
826fn is_page_number(region: &Region, cells: &[TextCell], page_h: f32) -> bool {
827 let t = region_text(region, cells);
828 let t = t.trim();
829 !t.is_empty()
830 && t.chars().all(|c| c.is_ascii_digit())
831 && (region.b - region.t).abs() < 30.0
832 && (region.t < page_h * 0.12 || region.b > page_h * 0.88)
833}
834
835/// docling's `form` / `key_value_region` *containers* (2.123, docling#4064):
836/// every region sitting > 0.8 inside one — text, list items, and since #4064
837/// tables and pictures too — is that container's child. Children are
838/// reading-ordered among themselves and emitted as one block where the
839/// container falls in the page's top-level order (a `form_area` /
840/// `key_value_area` group upstream), instead of interleaving with the text
841/// around the form. A child inside several containers belongs to the smallest
842/// (then most confident, then first); a container with children shrinks to
843/// their union for the top-level ordering, like upstream's bbox adjustment.
844///
845/// The containers themselves are still not emitted (`is_skipped`), so the
846/// Markdown is exactly upstream's — a group prints only its children.
847fn order_with_containers<T: Clone>(
848 items: &mut Vec<T>,
849 page_w: f32,
850 page_h: f32,
851 reg: impl Fn(&T) -> &Region,
852) {
853 let is_container = |r: &Region| matches!(r.label, "form" | "key_value_region");
854 let containers: Vec<usize> = (0..items.len())
855 .filter(|&i| is_container(reg(&items[i])))
856 .collect();
857 if containers.is_empty() {
858 order_regions(items, page_w, page_h, reg);
859 return;
860 }
861 // Parent container per item (containers never nest in each other here —
862 // upstream assigns regulars and tables/pictures only).
863 let mut parent: Vec<Option<usize>> = vec![None; items.len()];
864 for i in 0..items.len() {
865 let r = reg(&items[i]);
866 if is_container(r) {
867 continue;
868 }
869 let ra = area(r.l, r.t, r.r, r.b).max(1.0);
870 let mut best: Option<(usize, f32, f32)> = None; // (idx, area, -score)
871 for &c in &containers {
872 let cr = reg(&items[c]);
873 if inter(r, cr.l, cr.t, cr.r, cr.b) / ra > 0.8 {
874 let key = (area(cr.l, cr.t, cr.r, cr.b), -cr.score);
875 if best.is_none_or(|(_, a, s)| key.0 < a || (key.0 == a && key.1 < s)) {
876 best = Some((c, key.0, key.1));
877 }
878 }
879 }
880 parent[i] = best.map(|(c, _, _)| c);
881 }
882 // Top-level pass: non-children plus the containers, the latter shrunk to
883 // their children's union.
884 let mut top: Vec<(usize, Region)> = Vec::new();
885 for i in 0..items.len() {
886 if parent[i].is_some() {
887 continue;
888 }
889 let mut r = reg(&items[i]).clone();
890 if is_container(&r) {
891 let kids: Vec<&Region> = (0..items.len())
892 .filter(|&k| parent[k] == Some(i))
893 .map(|k| reg(&items[k]))
894 .collect();
895 if !kids.is_empty() {
896 r.l = kids.iter().map(|k| k.l).fold(f32::INFINITY, f32::min);
897 r.t = kids.iter().map(|k| k.t).fold(f32::INFINITY, f32::min);
898 r.r = kids.iter().map(|k| k.r).fold(f32::NEG_INFINITY, f32::max);
899 r.b = kids.iter().map(|k| k.b).fold(f32::NEG_INFINITY, f32::max);
900 }
901 }
902 top.push((i, r));
903 }
904 order_regions(&mut top, page_w, page_h, |it| &it.1);
905 let mut out: Vec<T> = Vec::with_capacity(items.len());
906 for (i, _) in top {
907 if is_container(reg(&items[i])) {
908 let mut kids: Vec<T> = (0..items.len())
909 .filter(|&k| parent[k] == Some(i))
910 .map(|k| items[k].clone())
911 .collect();
912 order_regions(&mut kids, page_w, page_h, ®);
913 out.push(items[i].clone());
914 out.extend(kids);
915 } else {
916 out.push(items[i].clone());
917 }
918 }
919 *items = out;
920}
921
922/// Furniture / not-yet-emitted labels.
923fn is_skipped(label: &str) -> bool {
924 matches!(
925 label,
926 "page_header" | "page_footer" | "form" | "key_value_region"
927 )
928}
929
930/// Reading-order sort of a page's regions, via the ported rule-based
931/// [`reading_order`](crate::reading_order) predictor (docling's
932/// `ReadingOrderPredictor`): an up/down geometry graph, horizontal dilation and a
933/// depth-first traversal, with `page_header`/`page_footer` ordered as their own
934/// groups (first/last) as docling does.
935fn order_regions<T: Clone>(
936 items: &mut Vec<T>,
937 page_w: f32,
938 page_h: f32,
939 reg: impl Fn(&T) -> &Region,
940) {
941 let boxes: Vec<(f32, f32, f32, f32)> = items
942 .iter()
943 .map(|it| {
944 let r = reg(it);
945 (r.l, r.t, r.r, r.b)
946 })
947 .collect();
948 let is_header: Vec<bool> = items
949 .iter()
950 .map(|it| reg(it).label == "page_header")
951 .collect();
952 let is_footer: Vec<bool> = items
953 .iter()
954 .map(|it| reg(it).label == "page_footer")
955 .collect();
956 let order = crate::reading_order::order_page(&boxes, &is_header, &is_footer, page_w, page_h);
957 *items = order.iter().map(|&i| items[i].clone()).collect();
958}
959
960/// Clean a region's assembled text: undo soft-hyphen line wraps, map curly
961/// quotes and the ellipsis to ASCII (matching docling), and collapse runs of
962/// whitespace. pdfium emits the line-wrap hyphen as U+0002 in this corpus
963/// (U+00AD elsewhere), so `word\u{2} continuation` is one hyphenated word —
964/// drop the hyphen + the joining space and merge (`com\u{2} pact` → `compact`,
965/// `end-to\u{2} end` → `end-toend`), exactly as docling does.
966///
967/// Token spacing is otherwise left as the geometric join produced it. We do not
968/// tighten punctuation spacing: docling preserves the PDF's own spaces (it keeps
969/// `{ ahn }`, `Name 1 .`, `[ 9 ]`), and a geometric gap heuristic diverges from
970/// it more than a plain single-space join does.
971/// An ordered-list enumeration marker at the start of a list item: leading ASCII
972/// digits followed by `.`, e.g. `1. Undo/Redo` → `(1, "Undo/Redo")`. Returns
973/// `None` when the text doesn't start with `digits.`.
974fn parse_ordered_marker(s: &str) -> Option<(u64, String)> {
975 let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
976 if digits.is_empty() {
977 return None;
978 }
979 let rest = s[digits.len()..].strip_prefix('.')?;
980 let number = digits.parse().ok()?;
981 Some((number, rest.trim_start().to_string()))
982}
983
984/// Escape markdown special characters the way docling-core's markdown serializer
985/// does (`markdown.py` post_process): `_` → `\_`, then HTML-escape `&`, `<`, `>`
986/// (quote=False, so quotes are left). Applied to prose (headings, list items,
987/// paragraphs); code blocks, the formula placeholder, and table cells are left raw.
988fn md_escape(text: &str) -> String {
989 text.replace('_', "\\_")
990 .replace('&', "&")
991 .replace('<', "<")
992 .replace('>', ">")
993}
994
995fn clean_text(text: &str) -> String {
996 // Typographic-quote normalization follows docling-parse's sanitizer table
997 // (`pdf_sanitators/constants.h`): every curly quote — single *and double* —
998 // becomes the ASCII apostrophe `'`, and `‚` a comma. A `"` in docling's
999 // output only ever comes from a literal `quotedbl` glyph, never from `“ ”`
1000 // (2206's `'text in the wild"` pairs a curly open with a literal-quote
1001 // close). This replaces an earlier Hangul-only special case that patched
1002 // one symptom of mapping `“ ”` to `"`.
1003 let replaced = text
1004 .replace("\u{2} ", "")
1005 .replace("\u{ad} ", "")
1006 .replace(['\u{2}', '\u{ad}'], "") // any stray wrap hyphens not at a join
1007 .replace(
1008 [
1009 '\u{2018}', '\u{2019}', '\u{201b}', '\u{201c}', '\u{201d}', '\u{201e}', '\u{201f}',
1010 ],
1011 "'",
1012 ) // ‘ ’ ‛ “ ” „ ‟ → '
1013 .replace('\u{201a}', ",") // ‚ → ,
1014 .replace(
1015 [
1016 '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
1017 ],
1018 "-",
1019 ) // hyphen/dash family → -
1020 .replace('\u{2044}', "/") // ⁄ fraction slash → /
1021 .replace('\u{2022}', "\u{b7}") // • → · (docling never emits •; inline CCS-concept separators)
1022 .replace('\u{2026}', "..."); // … → ...
1023 let out = if crate::pdfium_backend::use_dp_lines() {
1024 // The docling-parse sanitizer already placed the correct spacing (e.g.
1025 // justified double spaces); preserve internal runs of spaces, only
1026 // normalizing line breaks/tabs and trimming the ends.
1027 replaced.replace(['\n', '\r', '\t'], " ").trim().to_string()
1028 } else {
1029 // Legacy: collapse all whitespace runs to single spaces.
1030 replaced.split_whitespace().collect::<Vec<_>>().join(" ")
1031 };
1032 fix_arabic_lam_alef(&out)
1033}
1034
1035/// pdfium decomposes the Arabic lam-alef ligature (لا / لإ / لأ / لآ) into its
1036/// glyph constituents in *visual* order — `alef-variant, lam` — but docling keeps
1037/// logical order, `lam, alef-variant`. Swap a mid-word `alef-variant + lam` back
1038/// to `lam + alef-variant`. "Mid-word" (the previous char is an Arabic letter)
1039/// distinguishes the ligature from the definite article `ال` (word-initial
1040/// `alef + lam`), which must stay. No-op for non-Arabic text.
1041fn fix_arabic_lam_alef(s: &str) -> String {
1042 let is_arabic_letter = |c: char| ('\u{0620}'..='\u{064A}').contains(&c);
1043 let chars: Vec<char> = s.chars().collect();
1044 if !chars.iter().any(|&c| is_arabic_letter(c)) {
1045 return s.to_string(); // no-op for non-Arabic text
1046 }
1047 // Pass 1: swap mid-word `alef-variant + lam` → `lam + alef-variant`. Only the
1048 // hamza/madda alef variants (إ أ آ) are safe: the definite article is always
1049 // plain `ا + ل`, so plain `alef + lam` is ambiguous (a legitimate `فعالة` vs a
1050 // reversed `لا` ligature look identical) — leaving plain alef alone avoids
1051 // corrupting legitimate words.
1052 let mut a: Vec<char> = Vec::with_capacity(chars.len());
1053 let mut i = 0;
1054 while i < chars.len() {
1055 let c = chars[i];
1056 if matches!(c, '\u{0622}' | '\u{0623}' | '\u{0625}')
1057 && chars.get(i + 1) == Some(&'\u{0644}')
1058 && i > 0
1059 && is_arabic_letter(chars[i - 1])
1060 // A preceding lam means this alef-variant is *already* the logical
1061 // `lam + alef` ligature; the following lam is the next syllable's
1062 // letter, not a reversed ligature — swapping it corrupts `لآل` → `للآ`
1063 // (e.g. التعلم الآلي → الآلي, not اللآي).
1064 && chars[i - 1] != '\u{0644}'
1065 {
1066 a.push('\u{0644}');
1067 a.push(c);
1068 i += 2;
1069 continue;
1070 }
1071 a.push(c);
1072 i += 1;
1073 }
1074 // Pass 2: insert a space at Arabic↔Latin boundaries (bidi script switch) that
1075 // pdfium runs together — docling separates the embedded Latin run (`وPython`
1076 // → `و Python`).
1077 let mut out: Vec<char> = Vec::with_capacity(a.len());
1078 for (j, &c) in a.iter().enumerate() {
1079 if j > 0 {
1080 let p = a[j - 1];
1081 if (is_arabic_letter(p) && c.is_ascii_alphabetic())
1082 || (p.is_ascii_alphabetic() && is_arabic_letter(c))
1083 {
1084 out.push(' ');
1085 }
1086 }
1087 out.push(c);
1088 }
1089 out.into_iter().collect()
1090}
1091
1092/// docling's `PageAssembleModel._match_hyperlink`: the URI whose link
1093/// annotations cover at least half of the region's box, or `None`. Coverage is
1094/// intersection-over-region-area, **accumulated per URI** — a URL that wraps
1095/// across lines carries several annotation rects that sum toward the same
1096/// target. Ties resolve to the first-seen URI (Python's `max` over dict
1097/// insertion order); the winner still needs `>= 0.5`
1098/// (`_HYPERLINK_COVERAGE_THRESHOLD`).
1099pub(crate) fn region_hyperlink(
1100 region: &Region,
1101 links: &[crate::pdfium_backend::LinkAnnot],
1102) -> Option<String> {
1103 if links.is_empty() {
1104 return None;
1105 }
1106 let area = (region.r - region.l).max(0.0) * (region.b - region.t).max(0.0);
1107 if area <= 0.0 {
1108 return None;
1109 }
1110 let mut coverage: Vec<(&str, f32)> = Vec::new();
1111 for link in links {
1112 let ix = (region.r.min(link.r) - region.l.max(link.l)).max(0.0);
1113 let iy = (region.b.min(link.b) - region.t.max(link.t)).max(0.0);
1114 let c = ix * iy / area;
1115 match coverage.iter_mut().find(|(uri, _)| *uri == link.uri) {
1116 Some((_, acc)) => *acc += c,
1117 None => coverage.push((&link.uri, c)),
1118 }
1119 }
1120 let mut best: Option<(&str, f32)> = None;
1121 for (uri, c) in coverage {
1122 // Strictly greater keeps the first-seen URI on ties, like Python's max.
1123 if best.is_none_or(|(_, bc)| c > bc) {
1124 best = Some((uri, c));
1125 }
1126 }
1127 let (uri, c) = best?;
1128 (c >= 0.5).then(|| normalize_uri(uri))
1129}
1130
1131/// The pydantic-`AnyUrl` normalization docling's hyperlink value passes
1132/// through on its way to the serializer: a URL with an authority but no path
1133/// gains a trailing `/` (`https://arxiv.org` → `https://arxiv.org/`). Other
1134/// AnyUrl canonicalizations (scheme/host lowercasing, percent-encoding) don't
1135/// occur in PDF link annotations in practice, so they are not reproduced.
1136fn normalize_uri(uri: &str) -> String {
1137 if let Some((_, rest)) = uri.split_once("://") {
1138 if !rest.is_empty() && !rest.contains(['/', '?', '#']) {
1139 return format!("{uri}/");
1140 }
1141 }
1142 uri.to_string()
1143}
1144
1145/// Resolve each page hyperlink to the visible text it covers, as `(anchor, uri)`
1146/// in reading order. The anchor is the cells whose centre falls in the link rect,
1147/// joined left-to-right and cleaned the same way prose is (so it matches the
1148/// serialized text), deduped against the immediately-preceding link so pdfium's
1149/// occasional duplicate annotation doesn't double-list. Empty anchors are dropped.
1150pub(crate) fn resolve_link_anchors(page: &PdfPage) -> Vec<(String, String)> {
1151 let mut out: Vec<(String, String)> = Vec::new();
1152 // Use per-word cells, not the line-merged `cells`: a link rect covers a few
1153 // words on a line, and a whole merged line cell would over-capture (its centre
1154 // lands in one link's rect, grabbing the entire line as that link's anchor).
1155 let words = if page.word_cells.is_empty() {
1156 &page.cells
1157 } else {
1158 &page.word_cells
1159 };
1160 for link in &page.links {
1161 // A cell participates when its centre row is inside the rect and it
1162 // overlaps the rect horizontally. A cell can be *wider* than the rect:
1163 // PDFs often draw a whole header line as one text run ("LinkedIn |
1164 // GitHub | Credly"), which docling-parse's word grouping keeps as one
1165 // cell even though each label carries its own link annotation —
1166 // centre-in-rect alone would hand the entire line to every link.
1167 // [`cell_text_in_rect`] clips such a cell to the tokens under the rect.
1168 let mut inside: Vec<(&TextCell, String)> = words
1169 .iter()
1170 .filter(|c| {
1171 let cy = (c.t + c.b) / 2.0;
1172 cy >= link.t && cy <= link.b && c.r.min(link.r) > c.l.max(link.l)
1173 })
1174 .filter_map(|c| {
1175 let text = cell_text_in_rect(c, link.l, link.r);
1176 (!text.is_empty()).then_some((c, text))
1177 })
1178 .collect();
1179 // Reading order: top band then left-to-right (link anchors are LTR).
1180 let band = inside
1181 .iter()
1182 .map(|(c, _)| (c.b - c.t).abs())
1183 .fold(0.0f32, f32::max)
1184 .max(1.0);
1185 inside.sort_by_key(|(c, _)| ((c.t / band).round() as i64, (c.l * 10.0) as i64));
1186 let anchor = clean_text(
1187 &inside
1188 .iter()
1189 .map(|(_, t)| t.trim())
1190 .filter(|t| !t.is_empty())
1191 .collect::<Vec<_>>()
1192 .join(" "),
1193 );
1194 if anchor.is_empty() {
1195 continue;
1196 }
1197 if out
1198 .last()
1199 .is_some_and(|(a, u)| a == &anchor && u == &link.uri)
1200 {
1201 continue;
1202 }
1203 out.push((anchor, link.uri.clone()));
1204 }
1205 out
1206}
1207
1208/// The part of a cell's text that lies under a link rect's x-range. A cell
1209/// fully inside the rect (by centre) returns its whole text. A wider cell is
1210/// split into whitespace tokens whose x-spans are estimated proportionally to
1211/// their character positions (kerning makes this approximate, so selection
1212/// snaps to whole tokens, never characters); tokens whose estimated centre
1213/// falls inside the rect are kept. Returns "" when nothing falls inside.
1214fn cell_text_in_rect(c: &TextCell, l: f32, r: f32) -> String {
1215 let cx = (c.l + c.r) / 2.0;
1216 if cx >= l && cx <= r && c.l >= l - (c.r - c.l) * 0.25 && c.r <= r + (c.r - c.l) * 0.25 {
1217 return c.text.trim().to_string();
1218 }
1219 let chars: Vec<char> = c.text.chars().collect();
1220 let n = chars.len();
1221 if n == 0 || c.r <= c.l {
1222 return String::new();
1223 }
1224 let per = (c.r - c.l) / n as f32;
1225 let mut out: Vec<String> = Vec::new();
1226 let mut token = String::new();
1227 let mut start = 0usize;
1228 // A trailing sentinel space flushes the last token.
1229 for (i, &ch) in chars.iter().enumerate().chain(std::iter::once((n, &' '))) {
1230 if ch.is_whitespace() {
1231 if !token.is_empty() {
1232 let mid = c.l + (start as f32 + (i - start) as f32 / 2.0) * per;
1233 if mid >= l && mid <= r {
1234 out.push(std::mem::take(&mut token));
1235 } else {
1236 token.clear();
1237 }
1238 }
1239 } else {
1240 if token.is_empty() {
1241 start = i;
1242 }
1243 token.push(ch);
1244 }
1245 }
1246 out.join(" ")
1247}
1248
1249/// Cells assigned to a region (best container), in reading order, joined.
1250fn region_text(region: &Region, cells: &[TextCell]) -> String {
1251 let inside: Vec<&TextCell> = cells
1252 .iter()
1253 .filter(|c| {
1254 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1255 inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
1256 })
1257 .collect();
1258 cells_text(inside)
1259}
1260
1261/// docling's exclusive cell assignment (`_assign_cells_to_clusters`): every
1262/// non-empty cell goes to the single best-overlapping *regular* region at
1263/// intersection-over-self > 0.2, and each region serializes exactly its
1264/// assigned cells. A cell under two overlapping boxes is emitted once (by the
1265/// better-covering one), and a cell only partially under its region — e.g.
1266/// normal_4pages' big section numeral, ~30 % inside the heading box — still
1267/// joins it (`## 들어가며 1`) instead of leaking as an orphan. Pictures and
1268/// wrappers never claim (docling walks regular clusters only); ties go to the
1269/// first region, like docling's strict `>` best-overlap scan.
1270pub fn region_texts_exclusive(regions: &[Region], cells: &[TextCell]) -> Vec<String> {
1271 let claimer: Vec<bool> = regions
1272 .iter()
1273 .map(|r| r.label != "picture" && !is_wrapper(r.label))
1274 .collect();
1275 let mut owned: Vec<Vec<&TextCell>> = vec![Vec::new(); regions.len()];
1276 for c in cells {
1277 if c.text.trim().is_empty() {
1278 continue;
1279 }
1280 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1281 let mut best: Option<(usize, f32)> = None;
1282 for (i, r) in regions.iter().enumerate() {
1283 if !claimer[i] {
1284 continue;
1285 }
1286 let ov = inter(r, c.l, c.t, c.r, c.b) / ca;
1287 if ov > 0.2 && best.is_none_or(|(_, b)| ov > b) {
1288 best = Some((i, ov));
1289 }
1290 }
1291 if let Some((i, _)) = best {
1292 owned[i].push(c);
1293 }
1294 }
1295 // Non-claimers (tables/wrappers/pictures) keep the inclusive > 0.5 text:
1296 // docling fills a special cluster's cells from its contained children, and
1297 // downstream table assembly gates on that text being non-empty.
1298 regions
1299 .iter()
1300 .zip(owned)
1301 .map(|(r, cs)| {
1302 if r.label != "picture" && !is_wrapper(r.label) {
1303 cells_text(cs)
1304 } else {
1305 region_text(r, cells)
1306 }
1307 })
1308 .collect()
1309}
1310
1311/// Join a prefiltered cell list into the region's text (docling's
1312/// `sanitize_text` on the docling-parse path, gap-aware band join on legacy).
1313fn cells_text(mut inside: Vec<&TextCell>) -> String {
1314 // Quantize the top coordinate into ~line bands so cells on the same line
1315 // sort in reading order; this is a strict total order (a raw fuzzy comparator
1316 // is not transitive and makes Rust's sort panic). For a right-to-left
1317 // (Arabic-majority) region, cells on a line read right→left, so sort the band
1318 // by descending left edge.
1319 let band = inside
1320 .iter()
1321 .map(|c| (c.b - c.t).abs())
1322 .fold(0.0f32, f32::max)
1323 .max(1.0);
1324 let arabic = inside
1325 .iter()
1326 .flat_map(|c| c.text.chars())
1327 .filter(|&c| ('\u{0600}'..='\u{06FF}').contains(&c))
1328 .count();
1329 let latin = inside
1330 .iter()
1331 .flat_map(|c| c.text.chars())
1332 .filter(|c| c.is_ascii_alphabetic())
1333 .count();
1334 let rtl = arabic > latin;
1335 let dp = crate::pdfium_backend::use_dp_lines();
1336 if dp {
1337 // docling orders a cluster's cells by their docling-parse cell index
1338 // alone (`LayoutPostprocessor._sort_cells`: `sorted(cells, key=c.index)`)
1339 // — the sanitizer's output order, which our `cells` slice already is.
1340 // No geometric re-sort: normal_4pages' big section numerals paint
1341 // *after* their heading text, and docling's `## 들어가며 1` (numeral
1342 // last) only falls out of pure index order — a band sort dragged the
1343 // numeral to the front. The overlap-grouped line restore this replaced
1344 // measured strictly worse on the corpus (it fixed nothing the index
1345 // order broke, and broke the numerals).
1346 } else {
1347 inside.sort_by_key(|c| {
1348 let x = (c.l * 10.0) as i64;
1349 ((c.t / band).round() as i64, if rtl { -x } else { x })
1350 });
1351 }
1352 let joined = if dp {
1353 // docling's `PageAssembleModel.sanitize_text`, ported verbatim over the
1354 // parse-index-ordered lines: append a separating space to a line —
1355 // unless it ends with `-`. A dash-ending line whose last word and the
1356 // next line's first word are both alphanumeric is a wrapped word: the
1357 // dash is dropped and the lines fuse (`platforms-` + `reflects` →
1358 // `platformsreflects`, `pp. 545-` + `561` → `545561`). Any other
1359 // dash-ending line — e.g. the *bare* `-` cell a superscript ORCID or an
1360 // inline `–` bullet splits off (its word list is empty, so the fuse
1361 // test fails) — keeps its dash and still takes no trailing space:
1362 // `[0000` `-` `0002` joins as docling's `[0000 -0002`, and the OTSL
1363 // list's `-` + `"C" cell -` + `a new table cell` collapses to
1364 // `-"C" cell a new table cell`. Our cells still carry the raw dash
1365 // family (docling-parse normalizes to `-` before this; clean_text does
1366 // it after), so the endswith test matches them all.
1367 let texts: Vec<&str> = inside
1368 .iter()
1369 .map(|c| c.text.trim())
1370 // Skip whitespace-only cells (a justified line's trailing space
1371 // glyph): an empty line would double the separator.
1372 .filter(|t| !t.is_empty())
1373 .collect();
1374 let last_word_alnum = |s: &str| {
1375 s.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1376 .rfind(|w| !w.is_empty())
1377 .is_some_and(|w| w.chars().all(char::is_alphanumeric))
1378 };
1379 let first_word_alnum = |s: &str| {
1380 s.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1381 .find(|w| !w.is_empty())
1382 .is_some_and(|w| w.chars().all(char::is_alphanumeric))
1383 };
1384 let mut out = String::new();
1385 for (i, t) in texts.iter().enumerate() {
1386 if i > 0 {
1387 let prev = texts[i - 1];
1388 let dashish = matches!(
1389 prev.chars().last(),
1390 Some(
1391 '-' | '\u{2010}'
1392 | '\u{2011}'
1393 | '\u{2012}'
1394 | '\u{2013}'
1395 | '\u{2014}'
1396 | '\u{2015}'
1397 | '\u{2212}'
1398 )
1399 );
1400 // docling#4052 (2.122): a dash only splits a word when it is
1401 // *attached* to one — the character before it is alphanumeric.
1402 // A dash that follows whitespace (a separator dash, a bullet
1403 // marker, a wrapped `-prefixed` token, the bare `-` cell an
1404 // ORCID splits off) is a literal character: it is kept and the
1405 // lines join with the ordinary space.
1406 let attached = prev.chars().rev().nth(1).is_some_and(char::is_alphanumeric);
1407 if dashish && attached {
1408 if last_word_alnum(prev) && first_word_alnum(t) {
1409 out.pop(); // wrapped word: fuse without the dash
1410 }
1411 // an attached dash never takes a separating space
1412 } else {
1413 out.push(' ');
1414 }
1415 }
1416 out.push_str(t);
1417 }
1418 out
1419 } else {
1420 // Legacy reconstruction: join same-band cells with a space only across a
1421 // real gap, because it can split a word into abutting segments
1422 // (`الت`|`ي` → `التي`).
1423 let mut out = String::new();
1424 let mut prev: Option<&&TextCell> = None;
1425 for c in &inside {
1426 let t = c.text.trim();
1427 if t.is_empty() {
1428 continue;
1429 }
1430 if let Some(p) = prev {
1431 let same_band = ((p.t / band).round() as i64) == ((c.t / band).round() as i64);
1432 let h = (c.b - c.t).abs().max((p.b - p.t).abs()).max(1.0);
1433 let gap = if rtl { p.l - c.r } else { c.l - p.r };
1434 if !same_band || gap > h * 0.25 {
1435 out.push(' ');
1436 }
1437 }
1438 out.push_str(t);
1439 prev = Some(c);
1440 }
1441 out
1442 };
1443 clean_text(&joined)
1444}
1445
1446/// Tighten the spaces pdfium leaves around tight punctuation in a code line
1447/// (`console .log` → `console.log`, `add (3 , 5)` → `add(3, 5)`), matching
1448/// docling-parse's source spacing.
1449fn tighten_code_punct(s: &str) -> String {
1450 s.replace(" .", ".")
1451 .replace(" ,", ",")
1452 .replace(" ;", ";")
1453 .replace(" )", ")")
1454 .replace(" (", "(")
1455}
1456
1457/// Assemble a **code** region's text with its line structure preserved.
1458///
1459/// Unlike [`region_text`] — which joins every cell with a single space, the right
1460/// thing for prose reflow — a code block's line breaks and indentation are
1461/// significant. The `code_cells` are already one physical source line each
1462/// (grouped space-glyph-only, so monospace runs keep their spacing), so this:
1463///
1464/// 1. groups the cells into vertical line bands and orders them top→bottom,
1465/// left→right;
1466/// 2. joins the lines with `\n` (rather than spaces), keeping the carriage
1467/// returns; and
1468/// 3. reconstructs each line's leading indentation from its left offset, in units
1469/// of the block's estimated monospace character width, so nesting survives.
1470///
1471/// Typography is normalized per line via [`clean_text`] (smart quotes, dashes,
1472/// ellipsis), which never merges lines. Returns an empty string if the region has
1473/// no code cells (the caller falls back to the prose text).
1474fn code_region_text(region: &Region, cells: &[TextCell]) -> String {
1475 let mut inside: Vec<&TextCell> = cells
1476 .iter()
1477 .filter(|c| {
1478 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1479 inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
1480 })
1481 .filter(|c| !c.text.trim().is_empty())
1482 .collect();
1483 if inside.is_empty() {
1484 return String::new();
1485 }
1486
1487 // Quantize the top edge into ~line bands (like `region_text`), then order the
1488 // cells by band (top→bottom) and, within a band, by left edge.
1489 let band = inside
1490 .iter()
1491 .map(|c| (c.b - c.t).abs())
1492 .fold(0.0f32, f32::max)
1493 .max(1.0);
1494 let line_of = |c: &TextCell| (c.t / band).round() as i64;
1495 inside.sort_by_key(|c| (line_of(c), (c.l * 10.0) as i64));
1496
1497 // Estimate one monospace character's width (total ink width / total glyphs) to
1498 // convert a line's left offset into a count of leading spaces. Measured over
1499 // all lines so a single short line can't skew it.
1500 let (mut total_w, mut total_chars) = (0.0f32, 0usize);
1501 for c in &inside {
1502 let n = c.text.trim().chars().count();
1503 if n > 0 {
1504 total_w += (c.r - c.l).max(0.0);
1505 total_chars += n;
1506 }
1507 }
1508 let char_w = if total_chars > 0 {
1509 (total_w / total_chars as f32).max(1.0)
1510 } else {
1511 1.0
1512 };
1513 // The block's own left margin is the zero-indent baseline.
1514 let base_l = inside.iter().map(|c| c.l).fold(f32::INFINITY, f32::min);
1515
1516 let mut lines: Vec<String> = Vec::new();
1517 let mut cur: Option<i64> = None;
1518 for c in &inside {
1519 // Tighten pdfium's spaced punctuation per line (on the trimmed content, so
1520 // the reconstructed leading indentation is never nibbled).
1521 let text = tighten_code_punct(&clean_text(c.text.trim()));
1522 if Some(line_of(c)) == cur {
1523 // A second cell sharing this band (rare — e.g. split columns): keep it
1524 // on the same source line, separated by a space.
1525 if let Some(last) = lines.last_mut() {
1526 last.push(' ');
1527 last.push_str(&text);
1528 }
1529 continue;
1530 }
1531 let indent = ((c.l - base_l) / char_w).round().max(0.0) as usize;
1532 lines.push(format!("{}{}", " ".repeat(indent), text));
1533 cur = Some(line_of(c));
1534 }
1535 lines.join("\n")
1536}
1537
1538/// Reconstruct a table's grid geometrically from the text cells inside its
1539/// region: cluster cells into rows (by vertical centre) and columns (by clustered
1540/// left edges), then place each cell. A model-free stand-in for TableFormer that
1541/// recovers grid-aligned tables from the precise PDF text layer (it does not
1542/// resolve row/column spans).
1543pub fn reconstruct_table(region: &Region, cells: &[TextCell]) -> Vec<Vec<String>> {
1544 let mut inside: Vec<&TextCell> = cells
1545 .iter()
1546 .filter(|c| {
1547 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1548 inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
1549 })
1550 .collect();
1551 if inside.is_empty() {
1552 return Vec::new();
1553 }
1554 inside.sort_by(|a, b| a.t.total_cmp(&b.t));
1555
1556 // Rows: consecutive cells whose vertical centre is within ~0.7 line height.
1557 let mut rows: Vec<(f32, Vec<&TextCell>)> = Vec::new();
1558 for c in &inside {
1559 let cyc = (c.t + c.b) / 2.0;
1560 let lh = (c.b - c.t).abs().max(1.0);
1561 if let Some((ryc, row)) = rows.last_mut() {
1562 if (cyc - *ryc).abs() < lh * 0.7 {
1563 row.push(c);
1564 continue;
1565 }
1566 }
1567 rows.push((cyc, vec![c]));
1568 }
1569
1570 // Columns: cluster left edges (merge those within a tolerance).
1571 let tol = {
1572 let mut hs: Vec<f32> = inside.iter().map(|c| (c.b - c.t).abs()).collect();
1573 hs.sort_by(f32::total_cmp);
1574 hs[hs.len() / 2].max(4.0) * 1.5
1575 };
1576 let mut lefts: Vec<f32> = inside.iter().map(|c| c.l).collect();
1577 lefts.sort_by(f32::total_cmp);
1578 let mut col_starts: Vec<f32> = Vec::new();
1579 for l in lefts {
1580 if col_starts.last().is_none_or(|&last| l - last > tol) {
1581 col_starts.push(l);
1582 }
1583 }
1584 let ncols = col_starts.len().max(1);
1585 let col_of = |l: f32| -> usize {
1586 col_starts
1587 .iter()
1588 .rposition(|&s| l + tol * 0.5 >= s)
1589 .unwrap_or(0)
1590 .min(ncols - 1)
1591 };
1592
1593 let mut grid = Vec::with_capacity(rows.len());
1594 for (_, mut row) in rows {
1595 row.sort_by(|a, b| a.l.total_cmp(&b.l));
1596 let mut cols = vec![String::new(); ncols];
1597 for c in row {
1598 let ci = col_of(c.l);
1599 // Strip the wrap-hyphen control char so it never lands in a cell.
1600 let t = c.text.trim().replace(['\u{2}', '\u{ad}'], "");
1601 if cols[ci].is_empty() {
1602 cols[ci] = t;
1603 } else {
1604 cols[ci].push(' ');
1605 cols[ci].push_str(&t);
1606 }
1607 }
1608 grid.push(cols);
1609 }
1610 grid
1611}
1612
1613/// Does the geometric reconstruction of a table look trustworthy enough to use
1614/// as-is, instead of paying for TableFormer?
1615///
1616/// [`reconstruct_table`] derives columns by clustering cell **left edges**. On a
1617/// clean grid that is exact, but when a column's entries are not left-aligned
1618/// (or the OCR boxes wobble) the clustering splits one real column into several,
1619/// and the result is a wide, mostly-empty grid — the "spurious empty columns"
1620/// failure TableFormer exists to fix.
1621///
1622/// Two symptoms separate the two cases, and both are properties of the grid
1623/// alone (no model needed):
1624/// * **density** — a real table is mostly full; a split-up one is mostly holes;
1625/// * **thin columns** — a column carrying at most one entry across several rows
1626/// is almost always a split artefact rather than a real column.
1627///
1628/// Deliberately conservative: it answers `true` only for grids that are plainly
1629/// well-formed, so the expensive path stays the default whenever there is doubt.
1630/// A caller that skips TableFormer on `true` trades no quality for the time.
1631pub fn geometric_table_is_reliable(rows: &[Vec<String>]) -> bool {
1632 let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
1633 // Fewer than two columns is not a grid this heuristic can vouch for: it is
1634 // exactly the shape a collapsed table takes, and TableFormer may recover
1635 // real structure from it.
1636 if rows.len() < 2 || ncols < 2 {
1637 return false;
1638 }
1639 let filled = |c: &String| !c.trim().is_empty();
1640 let total = rows.len() * ncols;
1641 let full = rows.iter().flatten().filter(|c| filled(c)).count();
1642 if (full as f32) < MIN_TABLE_FILL * total as f32 {
1643 return false;
1644 }
1645 // A column used by at most one row, when there are rows enough to tell.
1646 if rows.len() >= 3 {
1647 for ci in 0..ncols {
1648 let used = rows
1649 .iter()
1650 .filter(|r| r.get(ci).is_some_and(filled))
1651 .count();
1652 if used <= 1 {
1653 return false;
1654 }
1655 }
1656 }
1657 true
1658}
1659
1660/// Share of a geometric grid's cells that must carry text for it to be trusted
1661/// without TableFormer. Chosen well above the density a left-edge split
1662/// produces (those land nearer a third) and below what a genuine table with a
1663/// few blank cells reaches.
1664const MIN_TABLE_FILL: f32 = 0.6;
1665
1666/// The union bbox of the text cells assigned to a region (same >50%-overlap
1667/// rule as [`region_text`]), or `None` when no cell lands in it. docling's
1668/// LayoutPostprocessor shrinks a regular cluster's bbox to its cells, and the
1669/// enrichment crops are taken from that cell-tight box — cropping the raw
1670/// detector box instead hands the VLM surrounding chrome (e.g. the `Listing N:`
1671/// caption under a code block) that changes its output.
1672pub fn region_cell_bbox(region: &Region, cells: &[TextCell]) -> Option<[f32; 4]> {
1673 let mut bbox: Option<[f32; 4]> = None;
1674 for c in cells {
1675 let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1676 if inter(region, c.l, c.t, c.r, c.b) / ca <= 0.5 {
1677 continue;
1678 }
1679 bbox = Some(match bbox {
1680 None => [c.l, c.t, c.r, c.b],
1681 Some([l, t, r, b]) => [l.min(c.l), t.min(c.t), r.max(c.r), b.max(c.b)],
1682 });
1683 }
1684 bbox
1685}
1686
1687/// One region's enrichment-model result, produced by the pipeline's opt-in
1688/// passes (issue #76) and applied during assembly.
1689#[derive(Debug, Clone)]
1690pub enum Enrichment {
1691 /// DocumentPictureClassifier predictions, descending confidence.
1692 PictureClasses(Vec<PictureClass>),
1693 /// CodeFormulaV2 output for a `code` region: the rewritten source text and
1694 /// the `<_language_>` prefix (when the model emitted one).
1695 Code {
1696 language: Option<String>,
1697 text: String,
1698 },
1699 /// CodeFormulaV2 output for a `formula` region: the decoded LaTeX.
1700 Formula { latex: String },
1701}
1702
1703/// Crop a region (page points, already expanded by the caller if needed) from
1704/// the rendered page image and resize it to `target_scale` pixels per point —
1705/// the enrichment-model equivalent of docling's
1706/// `page.get_image(scale=…, cropbox=…)`, sourced from the existing
1707/// [`crate::pdfium_backend::RENDER_SCALE`] render instead of a fresh pdfium
1708/// pass (the page bitmap is already the exact docling render at scale 2).
1709#[cfg(feature = "ml")]
1710pub fn crop_region_scaled(page: &PdfPage, bbox: [f32; 4], target_scale: f32) -> Option<RgbImage> {
1711 let s = page.scale;
1712 let [l, t, r, b] = bbox;
1713 let (iw, ih) = (page.image.width(), page.image.height());
1714 let x = (l * s).max(0.0) as u32;
1715 let y = (t * s).max(0.0) as u32;
1716 if x >= iw || y >= ih {
1717 return None;
1718 }
1719 let w = (((r - l.max(0.0)) * s) as u32).min(iw - x);
1720 let h = (((b - t.max(0.0)) * s) as u32).min(ih - y);
1721 if w == 0 || h == 0 {
1722 return None;
1723 }
1724 let crop = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
1725 // docling renders the crop at `target_scale` directly; from the scale-2
1726 // page render that is a resize to the same pixel geometry
1727 // (`round(width_points * scale)`, PIL's BICUBIC ≙ CatmullRom).
1728 let tw = ((w as f32 / s) * target_scale).round().max(1.0) as u32;
1729 let th = ((h as f32 / s) * target_scale).round().max(1.0) as u32;
1730 if (tw, th) == (w, h) {
1731 return Some(crop);
1732 }
1733 Some(image::imageops::resize(
1734 &crop,
1735 tw,
1736 th,
1737 image::imageops::FilterType::CatmullRom,
1738 ))
1739}
1740
1741/// Crop a layout region from the rendered page image and encode it as PNG (the
1742/// figure bytes docling stores on a `PictureItem`). Region coordinates are page
1743/// points; the image is rendered at `page.scale`.
1744#[cfg(feature = "ocr-prep")]
1745fn crop_region(page: &PdfPage, region: &Region) -> Option<PictureImage> {
1746 let s = page.scale;
1747 let (iw, ih) = (page.image.width(), page.image.height());
1748 let x = (region.l * s).max(0.0) as u32;
1749 let y = (region.t * s).max(0.0) as u32;
1750 if x >= iw || y >= ih {
1751 return None;
1752 }
1753 let w = (((region.r - region.l) * s) as u32).min(iw - x);
1754 let h = (((region.b - region.t) * s) as u32).min(ih - y);
1755 if w == 0 || h == 0 {
1756 return None;
1757 }
1758 let sub = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
1759 let mut buf = std::io::Cursor::new(Vec::new());
1760 sub.write_to(&mut buf, image::ImageFormat::Png).ok()?;
1761 Some(PictureImage {
1762 mimetype: "image/png".into(),
1763 width: w,
1764 height: h,
1765 data: buf.into_inner(),
1766 })
1767}
1768
1769/// For each `picture` region, find the `caption` region closest below it (and
1770/// horizontally overlapping); docling pairs them and emits the caption first.
1771/// Each caption is claimed by at most one picture.
1772fn pair_captions(regions: &[Region]) -> Vec<Option<usize>> {
1773 let mut pairs = vec![None; regions.len()];
1774 let mut taken = vec![false; regions.len()];
1775 for (pi, p) in regions.iter().enumerate() {
1776 if p.label != "picture" {
1777 continue;
1778 }
1779 let mut best: Option<(usize, f32)> = None;
1780 for (ci, c) in regions.iter().enumerate() {
1781 if c.label != "caption" || taken[ci] {
1782 continue;
1783 }
1784 let line_h = (c.b - c.t).abs().max(1.0);
1785 let gap = c.t - p.b; // caption sits below the picture
1786 let h_overlap = (p.r.min(c.r) - p.l.max(c.l)).max(0.0);
1787 if gap > -line_h && gap < line_h * 3.0 && h_overlap > 0.0 {
1788 let dist = gap.abs();
1789 if best.is_none_or(|(_, bd)| dist < bd) {
1790 best = Some((ci, dist));
1791 }
1792 }
1793 }
1794 if let Some((ci, _)) = best {
1795 pairs[pi] = Some(ci);
1796 taken[ci] = true;
1797 }
1798 }
1799 pairs
1800}
1801
1802/// Pair each `code` region with the `caption` region just **above** it (a
1803/// `Listing N:` label). docling renders the code block first, then its caption,
1804/// so the caption is consumed from its own (earlier) reading-order slot and
1805/// re-emitted after the code.
1806fn pair_code_captions(regions: &[Region]) -> Vec<Option<usize>> {
1807 let mut pairs = vec![None; regions.len()];
1808 let mut taken = vec![false; regions.len()];
1809 for (pi, p) in regions.iter().enumerate() {
1810 if p.label != "code" {
1811 continue;
1812 }
1813 let mut best: Option<(usize, f32)> = None;
1814 for (ci, c) in regions.iter().enumerate() {
1815 if c.label != "caption" || taken[ci] {
1816 continue;
1817 }
1818 let line_h = (c.b - c.t).abs().max(1.0);
1819 let gap = p.t - c.b; // caption sits above the code
1820 let h_overlap = (p.r.min(c.r) - p.l.max(c.l)).max(0.0);
1821 if gap > -line_h && gap < line_h * 3.0 && h_overlap > 0.0 {
1822 let dist = gap.abs();
1823 if best.is_none_or(|(_, bd)| dist < bd) {
1824 best = Some((ci, dist));
1825 }
1826 }
1827 }
1828 if let Some((ci, _)) = best {
1829 pairs[pi] = Some(ci);
1830 taken[ci] = true;
1831 }
1832 }
1833 pairs
1834}
1835
1836/// Pair each `table`/`document_index` region with its `caption` (#265) the way
1837/// docling's `ReadingOrderPredictor._find_to_captions` does: by **reading-order
1838/// adjacency**, not geometry. A caption claims the media element
1839/// (table/picture/code) immediately next to it in the ordered region sequence,
1840/// and only when exactly one side holds one — a caption sandwiched between two
1841/// media elements stays unattached, and a text paragraph between caption and
1842/// table breaks the bond. This is what lets a flush-left "Table 3: …" label
1843/// bind a centered grid it doesn't horizontally overlap, while a caption in
1844/// the neighbouring column of a two-column page — geometrically close — never
1845/// pairs across the gutter. Runs after the picture and code pairings (the
1846/// picture/code arms of the same upstream matcher), so a caption they claimed
1847/// stays claimed. docling attaches these as `TableItem.captions` refs; the
1848/// paired caption is consumed from its own reading-order slot and rides on the
1849/// table node instead.
1850fn pair_table_captions(regions: &[Region], taken: &mut [bool]) -> Vec<Option<usize>> {
1851 let is_media = |label: &str| is_table_like(label) || matches!(label, "picture" | "code");
1852 let mut pairs: Vec<Option<usize>> = vec![None; regions.len()];
1853 for ci in 0..regions.len() {
1854 if regions[ci].label != "caption" || taken[ci] {
1855 continue;
1856 }
1857 // Furniture (headers/footers, form chrome) is not part of docling's
1858 // body-element sequence, so it neither bonds nor blocks.
1859 let prev = regions[..ci].iter().rposition(|r| !is_skipped(r.label));
1860 let next = regions[ci + 1..]
1861 .iter()
1862 .position(|r| !is_skipped(r.label))
1863 .map(|off| ci + 1 + off);
1864 let prev_media = prev.is_some_and(|j| is_media(regions[j].label));
1865 let next_media = next.is_some_and(|j| is_media(regions[j].label));
1866 let target = match (prev_media, next_media) {
1867 (true, false) => prev,
1868 (false, true) => next,
1869 // Ambiguous (media on both sides) or no media at all: leave the
1870 // caption in its own reading-order slot, as docling does.
1871 _ => None,
1872 };
1873 if let Some(ti) = target {
1874 // A first claim wins (a table with captions above *and* below
1875 // keeps the earlier one — docling's nearest-first tiebreak).
1876 if is_table_like(regions[ti].label) && pairs[ti].is_none() {
1877 pairs[ti] = Some(ci);
1878 taken[ci] = true;
1879 }
1880 }
1881 }
1882 pairs
1883}
1884
1885/// Assemble one page from its (already overlap-resolved) layout regions and
1886/// text cells.
1887/// Normalize a layout region (page points, top-left origin) to DocLang's 0–511
1888/// location grid: `clamp(round(512 · coord / page_dim), 0, 511)`, per axis,
1889/// order `[x0, y0, x1, y1]`. Mirrors docling_core's
1890/// `_doclang_utils._create_location_tokens_for_bbox` (resolution 512) so the
1891/// emitted `<location>` tokens line up with the Python groundtruth. Our heron
1892/// cluster boxes match docling's to within ~1 grid unit; the residual (mainly
1893/// the aspect-ratio-stretch vs letterbox preprocessing difference) is absorbed
1894/// by the conformance harness's geometry tolerance.
1895fn norm_loc(region: &Region, page_w: f32, page_h: f32) -> [u16; 4] {
1896 let q = |v: f32, dim: f32| -> u16 {
1897 if dim <= 0.0 {
1898 return 0;
1899 }
1900 let g = (512.0 * (v as f64) / (dim as f64)).round() as i64;
1901 g.clamp(0, 511) as u16
1902 };
1903 [
1904 q(region.l, page_w),
1905 q(region.t, page_h),
1906 q(region.r, page_w),
1907 q(region.b, page_h),
1908 ]
1909}
1910
1911/// Wrap a node in its layout provenance so the DocLang serializer emits the four
1912/// `<location>` tokens as the element's head (Markdown/JSON render `inner`
1913/// unchanged).
1914fn located(loc: [u16; 4], inner: Node) -> Node {
1915 Node::Located {
1916 location: loc,
1917 inner: Box::new(inner),
1918 }
1919}
1920
1921/// Stamp the real 1-based page number onto a page's leading marker (see
1922/// [`assemble_page`], which emits it with `page_no: 0` because only the
1923/// document-level collector knows the true index — `--pages` windows shift it).
1924pub fn stamp_page_no(nodes: &mut [Node], page_no: usize) {
1925 if let Some(Node::PageInfo { page_no: p, .. }) = nodes.first_mut() {
1926 *p = page_no;
1927 }
1928}
1929
1930/// A dense table grid plus its first-class cells (#240): `rows` is the text
1931/// grid every serializer renders (spans replicate their anchor's text);
1932/// `cells` are the docling-parity per-cell records (text, page-point bbox,
1933/// span rectangle, OTSL header roles). Produced by the TableFormer paths
1934/// (`tf_core`); lives in this always-compiled module so the pure-text (wasm
1935/// `pdf-text`) build sees the type.
1936#[derive(Clone, Debug)]
1937pub struct TableGrid {
1938 pub rows: Vec<Vec<String>>,
1939 pub cells: Vec<docling_core::TableCell>,
1940}
1941
1942/// docling's `_RICH_CELL_PICTURE_COVERAGE_THRESHOLD`.
1943const RICH_CELL_PICTURE_COVERAGE: f32 = 0.8;
1944
1945/// docling `ReadingOrderModel._match_table_pictures` (#3906, 2.118.1): every
1946/// picture ≥ 80 % inside a TableFormer-structured table on the page is matched
1947/// to the cell covering it, and returned per table as `cell index → pictures`.
1948/// A picture that pairs with a caption stays a standalone figure (upstream
1949/// would nest it and lose the caption; keeping the caption is the better
1950/// failure). Tables without first-class cells (geometric fallback) have no cell
1951/// boxes to match against and nest nothing.
1952fn match_table_pictures(
1953 regions: &[Region],
1954 table_rows: &[Option<TableGrid>],
1955 caption_for: &[Option<usize>],
1956) -> std::collections::HashMap<usize, Vec<(usize, Vec<usize>)>> {
1957 let mut out: std::collections::HashMap<usize, Vec<(usize, Vec<usize>)>> =
1958 std::collections::HashMap::new();
1959 for (p, pic) in regions.iter().enumerate() {
1960 if pic.label != "picture" || caption_for.get(p).is_some_and(Option::is_some) {
1961 continue;
1962 }
1963 let pa = area(pic.l, pic.t, pic.r, pic.b).max(1.0);
1964 let mut best: Option<(f32, usize, usize)> = None; // (coverage, table, cell)
1965 for (t, tbl) in regions.iter().enumerate() {
1966 if !is_table_like(tbl.label) {
1967 continue;
1968 }
1969 let Some(grid) = table_rows.get(t).and_then(Option::as_ref) else {
1970 continue;
1971 };
1972 if inter(pic, tbl.l, tbl.t, tbl.r, tbl.b) / pa < RICH_CELL_PICTURE_COVERAGE {
1973 continue;
1974 }
1975 if let Some((cov, cell)) = match_picture_to_cell(pic, &grid.cells) {
1976 if best.is_none_or(|(b, _, _)| cov > b) {
1977 best = Some((cov, t, cell));
1978 }
1979 }
1980 }
1981 if let Some((_, t, cell)) = best {
1982 let entry = out.entry(t).or_default();
1983 match entry.iter_mut().find(|(c, _)| *c == cell) {
1984 Some((_, pics)) => pics.push(p),
1985 None => entry.push((cell, vec![p])),
1986 }
1987 }
1988 }
1989 out
1990}
1991
1992/// docling `_match_picture_to_table_cell`: among the cells covering ≥ 80 % of
1993/// the picture, prefer the one at the picture's inferred grid position (the
1994/// row / column whose median cell center is nearest the picture's center —
1995/// cell boxes can overlap across logical rows and columns), else the best
1996/// coverage. Returns `(coverage, cell index)`.
1997fn match_picture_to_cell(pic: &Region, cells: &[docling_core::TableCell]) -> Option<(f32, usize)> {
1998 let pa = area(pic.l, pic.t, pic.r, pic.b).max(1.0);
1999 let cover = |b: &[f32; 4]| inter(pic, b[0], b[1], b[2], b[3]) / pa;
2000 let eligible: Vec<(f32, usize)> = cells
2001 .iter()
2002 .enumerate()
2003 .filter_map(|(i, c)| {
2004 let b = c.bbox.as_ref()?;
2005 let cov = cover(b);
2006 (cov >= RICH_CELL_PICTURE_COVERAGE).then_some((cov, i))
2007 })
2008 .collect();
2009 if eligible.is_empty() {
2010 return None;
2011 }
2012 let mut row_centers: std::collections::BTreeMap<usize, Vec<f32>> = Default::default();
2013 let mut col_centers: std::collections::BTreeMap<usize, Vec<f32>> = Default::default();
2014 for c in cells {
2015 let Some(b) = c.bbox.as_ref() else { continue };
2016 for r in c.start_row..c.start_row + c.row_span {
2017 row_centers.entry(r).or_default().push((b[1] + b[3]) / 2.0);
2018 }
2019 for k in c.start_col..c.start_col + c.col_span {
2020 col_centers.entry(k).or_default().push((b[0] + b[2]) / 2.0);
2021 }
2022 }
2023 let median = |v: &mut Vec<f32>| -> f32 {
2024 v.sort_by(f32::total_cmp);
2025 let n = v.len();
2026 if n % 2 == 1 {
2027 v[n / 2]
2028 } else {
2029 (v[n / 2 - 1] + v[n / 2]) / 2.0
2030 }
2031 };
2032 let (px, py) = ((pic.l + pic.r) / 2.0, (pic.t + pic.b) / 2.0);
2033 let nearest = |centers: &mut std::collections::BTreeMap<usize, Vec<f32>>, target: f32| {
2034 centers
2035 .iter_mut()
2036 .map(|(&i, v)| (i, (median(v) - target).abs()))
2037 .min_by(|a, b| a.1.total_cmp(&b.1))
2038 .map(|(i, _)| i)
2039 };
2040 let row = nearest(&mut row_centers, py);
2041 let col = nearest(&mut col_centers, px);
2042 let logical: Vec<(f32, usize)> = eligible
2043 .iter()
2044 .copied()
2045 .filter(|&(_, i)| {
2046 let c = &cells[i];
2047 row.is_some_and(|r| c.start_row <= r && r < c.start_row + c.row_span)
2048 && col.is_some_and(|k| c.start_col <= k && k < c.start_col + c.col_span)
2049 })
2050 .collect();
2051 let pool = if logical.is_empty() {
2052 &eligible
2053 } else {
2054 &logical
2055 };
2056 // Python's `max` over `(coverage, cell_index, cell)` tuples: highest
2057 // coverage, ties to the higher index.
2058 pool.iter()
2059 .copied()
2060 .max_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
2061}
2062
2063/// The DocLang structure overlay derived from first-class cells: span
2064/// continuations (`lcel`/`ucel`/`xcel`) and per-cell header roles, so the
2065/// PDF path's DCLX carries real spans instead of a flat grid.
2066fn structure_from_cells(
2067 cells: &[docling_core::TableCell],
2068 nrows: usize,
2069 ncols: usize,
2070) -> docling_core::TableStructure {
2071 let grid = || vec![vec![false; ncols]; nrows];
2072 let mut col_cont = grid();
2073 let mut row_cont = grid();
2074 let mut row_header = grid();
2075 let mut col_header = grid();
2076 for c in cells {
2077 for r in c.start_row..(c.start_row + c.row_span).min(nrows) {
2078 for k in c.start_col..(c.start_col + c.col_span).min(ncols) {
2079 col_cont[r][k] = k > c.start_col;
2080 row_cont[r][k] = r > c.start_row;
2081 row_header[r][k] = c.row_header;
2082 col_header[r][k] = c.column_header;
2083 }
2084 }
2085 }
2086 docling_core::TableStructure {
2087 header_row: Vec::new(),
2088 col_continuation: col_cont,
2089 row_continuation: row_cont,
2090 row_header,
2091 col_header,
2092 }
2093}
2094
2095pub fn assemble_page(
2096 page: &PdfPage,
2097 regions: Vec<Region>,
2098 table_rows: &[Option<TableGrid>],
2099 enrichments: &[Option<Enrichment>],
2100) -> (Vec<Node>, Vec<(String, String)>) {
2101 let mut nodes: Vec<Node> = Vec::new();
2102 // Every page opens with an invisible page marker carrying its size in
2103 // points — what the JSON export needs to build docling's `pages` map and
2104 // denormalize the 0–511 `<location>` grid into point bboxes (#171). The
2105 // page *number* is stamped by the document-level collector (which knows
2106 // the real 1-based index, `--pages` windows included); every serializer
2107 // except JSON skips the marker, so Markdown/DocLang stay byte-identical.
2108 nodes.push(Node::PageInfo {
2109 page_no: 0,
2110 width: page.width,
2111 height: page.height,
2112 });
2113 // Recover this page's hyperlinks (anchor-precise pairs for strict
2114 // Markdown; whole-item docling-parity links are baked below and their
2115 // pairs dropped from this list so strict output doesn't double-wrap).
2116 let mut links = resolve_link_anchors(page);
2117 // Pair each region with its precomputed TableFormer grid and enrichment
2118 // (indexed by original order) and order by reading order together, so they
2119 // stay aligned.
2120 type RegionItem = (Region, Option<TableGrid>, Option<Enrichment>);
2121 let mut items: Vec<RegionItem> = regions
2122 .into_iter()
2123 .enumerate()
2124 .map(|(i, r)| {
2125 (
2126 r,
2127 table_rows.get(i).cloned().flatten(),
2128 enrichments.get(i).cloned().flatten(),
2129 )
2130 })
2131 .collect();
2132 order_with_containers(&mut items, page.width, page.height, |it| &it.0);
2133 // Float a margin page number to the front of reading order (docling parity:
2134 // right_to_left_02's bottom `11` is its first item). Stable, so everything
2135 // else keeps its order; no-op on pages without such a region.
2136 let page_h = page.height;
2137 items.sort_by_key(|(r, _, _)| !is_page_number(r, &page.cells, page_h));
2138 let table_rows: Vec<Option<TableGrid>> = items.iter().map(|(_, t, _)| t.clone()).collect();
2139 let enrichments: Vec<Option<Enrichment>> = items.iter().map(|(_, _, e)| e.clone()).collect();
2140 let regions: Vec<Region> = items.into_iter().map(|(r, _, _)| r).collect();
2141 // docling emits a figure's caption *before* the image marker. Pair each
2142 // picture with the caption region nearest below it and consume that caption,
2143 // so it isn't also emitted in its own (lower) reading-order position.
2144 let caption_for = pair_captions(®ions);
2145 let code_caption_for = pair_code_captions(®ions);
2146 let mut consumed = vec![false; regions.len()];
2147 for ci in caption_for.iter().flatten() {
2148 consumed[*ci] = true;
2149 }
2150 for ci in code_caption_for.iter().flatten() {
2151 consumed[*ci] = true;
2152 }
2153 // Table captions (#265) claim from what the picture/code pairings left.
2154 let mut caption_taken = consumed.clone();
2155 let table_caption_for = pair_table_captions(®ions, &mut caption_taken);
2156 for ci in table_caption_for.iter().flatten() {
2157 consumed[*ci] = true;
2158 }
2159 // Pictures inside a table become rich-cell content (docling#3906, 2.118.1):
2160 // the picture is nested in the cell it covers and not emitted standalone.
2161 let rich_cell_pictures = match_table_pictures(®ions, &table_rows, &caption_for);
2162 for (_, pics) in rich_cell_pictures.values().flatten() {
2163 for &p in pics {
2164 consumed[p] = true;
2165 }
2166 }
2167 // A code block's language label (`XML`, `C#`, …) is chrome, not content — the
2168 // detector emits it as its own region above the code; consume it.
2169 for (i, is_label) in code_language_labels(®ions, &page.cells)
2170 .into_iter()
2171 .enumerate()
2172 {
2173 if is_label {
2174 consumed[i] = true;
2175 }
2176 }
2177
2178 // docling `ReadingOrderPredictor.predict_merges`: join a text fragment with a
2179 // following text fragment strictly to its right (an author column that wraps
2180 // into the next, a paragraph continuing in the next column) into one block —
2181 // the intra-page half of docling's reading-order merges (cross-page/vertical
2182 // continuations stay with [`merge_continuations`]). Already-consumed regions
2183 // (paired captions, code labels) are excluded.
2184 // Exclusive docling cell assignment: computed once for the ordered region
2185 // list and reused for every serialization below, so a cell can never render
2186 // in two regions.
2187 let region_texts: Vec<String> = region_texts_exclusive(®ions, &page.cells);
2188 let is_text: Vec<bool> = regions
2189 .iter()
2190 .enumerate()
2191 .map(|(i, r)| r.label == "text" && !consumed[i])
2192 .collect();
2193 let is_skip: Vec<bool> = regions
2194 .iter()
2195 .enumerate()
2196 .map(|(i, r)| {
2197 consumed[i]
2198 || matches!(
2199 r.label,
2200 "page_header" | "page_footer" | "table" | "picture" | "caption" | "footnote"
2201 )
2202 })
2203 .collect();
2204 let boxes: Vec<(f32, f32, f32, f32)> = regions.iter().map(|r| (r.l, r.t, r.r, r.b)).collect();
2205 if docling_core::env::flag("DOCLING_RS_DEBUG_MERGES") {
2206 for (i, r) in regions.iter().enumerate() {
2207 eprintln!(
2208 "MRG {i:2} {} text={} skip={} [{:.0},{:.0},{:.0},{:.0}] {:?}",
2209 r.label,
2210 is_text[i],
2211 is_skip[i],
2212 r.l,
2213 r.t,
2214 r.r,
2215 r.b,
2216 region_texts[i].chars().take(40).collect::<String>()
2217 );
2218 }
2219 }
2220 let mut merge_suffix: Vec<String> = vec![String::new(); regions.len()];
2221 for (head, children) in
2222 crate::reading_order::predict_merges(&boxes, ®ion_texts, &is_text, &is_skip)
2223 .into_iter()
2224 .enumerate()
2225 {
2226 for c in children {
2227 let t = region_texts[c].trim();
2228 if !t.is_empty() {
2229 merge_suffix[head].push(' ');
2230 merge_suffix[head].push_str(t);
2231 }
2232 consumed[c] = true;
2233 }
2234 }
2235
2236 for (i, region) in regions.iter().enumerate() {
2237 if consumed[i] {
2238 continue;
2239 }
2240 // Page headers/footers: docling emits them as furniture blocks
2241 // (`<page_header>`/`<page_footer>` with a layer + location + text) at
2242 // their reading-order position, not as body — emit them, don't skip.
2243 if matches!(region.label, "page_header" | "page_footer") {
2244 let text = region_texts[i].clone();
2245 if !text.is_empty() {
2246 nodes.push(Node::PageFurniture {
2247 footer: region.label == "page_footer",
2248 location: norm_loc(region, page.width, page_h),
2249 text: md_escape(&text),
2250 });
2251 }
2252 continue;
2253 }
2254 if is_skipped(region.label) {
2255 continue;
2256 }
2257 // Layout provenance for this region, normalized to docling's 0–511 grid.
2258 let loc = norm_loc(region, page.width, page_h);
2259 if region.label == "picture" {
2260 // The figure pixels are cropped from the page render for image export.
2261 // Captions are prose: markdown-escaped like a paragraph (the JSON
2262 // export unescapes back to the raw text, matching docling).
2263 let caption = caption_for[i]
2264 .map(|ci| md_escape(®ion_texts[ci]))
2265 .filter(|t| !t.is_empty());
2266 let classification = match &enrichments[i] {
2267 Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
2268 _ => None,
2269 };
2270 // Without the page render (text-layer-only build) a picture keeps
2271 // its caption/classification but carries no cropped pixels.
2272 #[cfg(feature = "ocr-prep")]
2273 let image = crate::timing::timed("crop_region", || crop_region(page, region));
2274 #[cfg(not(feature = "ocr-prep"))]
2275 let image: Option<PictureImage> = None;
2276 nodes.push(located(
2277 loc,
2278 Node::Picture {
2279 caption,
2280 image,
2281 classification,
2282 },
2283 ));
2284 continue;
2285 }
2286 let mut text = region_texts[i].clone();
2287 text.push_str(&merge_suffix[i]);
2288 if text.is_empty() {
2289 continue;
2290 }
2291 match region.label {
2292 // docling assembles checkboxes as TEXT_ELEM items (the region's
2293 // cells are the option label, e.g. right_to_left_03's بلی/خير)
2294 // and its Markdown serializer renders them as task-list lines
2295 // (`- [x] …`) — mirrored by [`Node::CheckboxItem`].
2296 "checkbox_selected" | "checkbox_unselected" => nodes.push(Node::CheckboxItem {
2297 checked: region.label == "checkbox_selected",
2298 text: md_escape(&text),
2299 }),
2300 // docling renders both the document title and section headers as
2301 // `##` (it never emits a top-level `#` for PDFs), so match that.
2302 "title" | "section_header" => nodes.push(located(
2303 loc,
2304 Node::Heading {
2305 level: 2,
2306 text: md_escape(&text),
2307 },
2308 )),
2309 // docling drops the rendered bullet glyph; the Markdown serializer
2310 // adds its own `- ` marker. An item whose text opens with an `N.`
2311 // enumeration marker is an ordered item (rendered `N. text`).
2312 // A leading dash stays: it is an ordinary text glyph that
2313 // docling-parse keeps, and docling's items carry it into the
2314 // Markdown (2305's OTSL list renders `- -"C" cell …`) — only the
2315 // symbol-font bullets docling-parse filters out are stripped.
2316 "list_item" => {
2317 let stripped = text
2318 .trim_start_matches(['•', '◦', '▪', '·', '*'])
2319 .trim_start()
2320 .to_string();
2321 if let Some((number, rest)) = parse_ordered_marker(&stripped) {
2322 nodes.push(Node::ListItem {
2323 ordered: true,
2324 number,
2325 first_in_list: false,
2326 text: md_escape(&rest),
2327 level: 0,
2328 marker: None,
2329 location: Some(loc),
2330 dclx: None,
2331 href: None,
2332 layer: None,
2333 });
2334 } else {
2335 nodes.push(Node::ListItem {
2336 ordered: false,
2337 number: 0,
2338 first_in_list: false,
2339 text: md_escape(&stripped),
2340 level: 0,
2341 // docling keeps the bullet as the DocLang list marker
2342 // (`<ldiv><marker>·</marker></ldiv>`); Markdown ignores it.
2343 marker: Some("·".into()),
2344 location: Some(loc),
2345 dclx: None,
2346 href: None,
2347 layer: None,
2348 });
2349 }
2350 }
2351 // TableFormer structure (cells + spans, text matched from word cells)
2352 // when available; otherwise geometric grid reconstruction; finally a
2353 // single cell.
2354 "table" | "document_index" => {
2355 // TableFormer grids carry first-class cells (#240: text +
2356 // page-point bbox + span rectangle + OTSL header roles) into
2357 // the public model, and the DocLang structure overlay derives
2358 // from them so DCLX emits real span/header tokens. The
2359 // geometric fallback has no per-cell records.
2360 let (mut rows, cells, structure) = match table_rows[i].clone() {
2361 Some(grid) => {
2362 let nrows = grid.rows.len();
2363 let ncols = grid.rows.first().map_or(0, Vec::len);
2364 let structure = structure_from_cells(&grid.cells, nrows, ncols);
2365 (grid.rows, Some(grid.cells), Some(structure))
2366 }
2367 None => {
2368 let rows = reconstruct_table(region, &page.cells);
2369 let rows = if rows.iter().any(|r| r.len() > 1) {
2370 rows
2371 } else {
2372 vec![vec![text.clone()]]
2373 };
2374 (rows, None, None)
2375 }
2376 };
2377 // The paired caption (#265) rides on the table — docling's
2378 // TableItem.captions ref; Markdown prints it above the grid,
2379 // the JSON export emits the $ref, DocLang the <caption>.
2380 let caption = table_caption_for[i]
2381 .map(|ci| md_escape(®ion_texts[ci]))
2382 .filter(|t| !t.is_empty());
2383 // Rich cells (docling#3906): the covering cell's blocks are its
2384 // text followed by the nested picture(s). docling's Markdown
2385 // renders a `RichTableCell` through the serializer — the
2386 // group's children joined by blank lines, newlines flattened
2387 // to spaces — so the flat `rows` text becomes
2388 // `text <!-- image -->`; the first-class `cells` (the JSON
2389 // `table_cells` / `grid`) keep the plain text, as upstream.
2390 let mut cell_blocks: Option<Vec<Vec<Vec<Node>>>> = None;
2391 if let (Some(by_cell), Some(fc)) = (rich_cell_pictures.get(&i), cells.as_ref()) {
2392 let nrows = rows.len();
2393 let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
2394 let mut blocks = vec![vec![Vec::<Node>::new(); ncols]; nrows];
2395 for (cell_idx, pics) in by_cell {
2396 let cell = &fc[*cell_idx];
2397 let (r, c) = (cell.start_row, cell.start_col);
2398 if r >= nrows || c >= ncols {
2399 continue;
2400 }
2401 let mut parts: Vec<String> = Vec::new();
2402 let mut cell_nodes: Vec<Node> = Vec::new();
2403 if !cell.text.trim().is_empty() {
2404 parts.push(cell.text.clone());
2405 cell_nodes.push(Node::Paragraph {
2406 text: cell.text.clone(),
2407 });
2408 }
2409 for &p in pics {
2410 parts.push("<!-- image -->".to_string());
2411 let classification = match &enrichments[p] {
2412 Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
2413 _ => None,
2414 };
2415 #[cfg(feature = "ocr-prep")]
2416 let image = crop_region(page, ®ions[p]);
2417 #[cfg(not(feature = "ocr-prep"))]
2418 let image: Option<PictureImage> = None;
2419 cell_nodes.push(located(
2420 norm_loc(®ions[p], page.width, page_h),
2421 Node::Picture {
2422 caption: None,
2423 image,
2424 classification,
2425 },
2426 ));
2427 }
2428 let rendered = parts.join(" ");
2429 for row in rows.iter_mut().skip(r).take(cell.row_span) {
2430 for slot in row.iter_mut().skip(c).take(cell.col_span) {
2431 *slot = rendered.clone();
2432 }
2433 }
2434 blocks[r][c] = cell_nodes;
2435 }
2436 cell_blocks = Some(blocks);
2437 }
2438 nodes.push(located(
2439 loc,
2440 Node::Table(Table {
2441 rows,
2442 location: None,
2443 structure,
2444 cell_blocks,
2445 cells,
2446 caption,
2447 }),
2448 ));
2449 }
2450 // With formula enrichment the CodeFormula model decodes the region
2451 // to LaTeX; otherwise docling emits a placeholder comment rather
2452 // than the (garbled) raw glyph text.
2453 "formula" => match &enrichments[i] {
2454 Some(Enrichment::Formula { latex }) => nodes.push(Node::Formula {
2455 latex: latex.clone(),
2456 orig: text.clone(),
2457 location: Some(loc),
2458 }),
2459 _ => nodes.push(Node::Paragraph {
2460 text: "<!-- formula-not-decoded -->".into(),
2461 }),
2462 },
2463 // Code blocks: use the space-glyph-only grouping (monospace keeps its
2464 // source spacing) and emit a fenced block, preserving the line breaks
2465 // and indentation of the source (unlike prose, which reflows). pdfium
2466 // still inserts spaces around tight punctuation (`console .log`,
2467 // `add (3 , 5)`); tighten them to match docling-parse's source spacing.
2468 "code" => {
2469 // `code_region_text` preserves line breaks/indentation and tightens
2470 // each line itself; the fallback prose `text` is tightened here.
2471 let code = code_region_text(region, &page.code_cells);
2472 let code = if code.is_empty() {
2473 tighten_code_punct(&text)
2474 } else {
2475 code
2476 };
2477 // With code enrichment the CodeFormula model rewrites the block
2478 // (and names its language); `orig` keeps the raw extraction in
2479 // docling's shape — its parser has no line-preserving code
2480 // path, so its `orig` is the same code with the lines joined
2481 // by single spaces (indentation collapsed).
2482 // docling's parser has no line-preserving code path — its code
2483 // items carry the lines joined by single spaces. That flat
2484 // form is what every byte-conformance surface serializes
2485 // (legacy Markdown, JSON, DocLang); the line-preserving
2486 // extraction rides in `pretty` for strict Markdown only.
2487 let flat = code
2488 .lines()
2489 .map(str::trim)
2490 .filter(|l| !l.is_empty())
2491 .collect::<Vec<_>>()
2492 .join(" ");
2493 let node = match &enrichments[i] {
2494 Some(Enrichment::Code {
2495 language,
2496 text: enriched,
2497 }) => Node::Code {
2498 language: language.clone(),
2499 text: enriched.clone(),
2500 orig: Some(flat),
2501 pretty: None,
2502 },
2503 _ => Node::Code {
2504 language: None,
2505 text: flat,
2506 orig: None,
2507 pretty: Some(code),
2508 },
2509 };
2510 nodes.push(located(loc, node));
2511 // docling emits the `Listing N:` caption after the code block.
2512 if let Some(ci) = code_caption_for[i] {
2513 let cap = md_escape(®ion_texts[ci]);
2514 if !cap.is_empty() {
2515 nodes.push(Node::Paragraph { text: cap });
2516 }
2517 }
2518 }
2519 // text, caption, footnote → paragraph
2520 _ => {
2521 // docling parity (`PageAssembleModel._match_hyperlink`): when
2522 // link annotations cover ≥ half of the region's box, the
2523 // hyperlink attaches to the item and the legacy Markdown
2524 // serializer wraps its full text — 2206.01062's footnote URLs
2525 // render as `[1 https://…](https://…)`. Sparse in-paragraph
2526 // citation links stay below the 0.5 coverage threshold and
2527 // remain plain text, exactly like docling.
2528 //
2529 // Scope: **footnote regions only.** Upstream's page_assemble
2530 // matches every TEXT_ELEM label, but published docling
2531 // observably carries the hyperlink into the document only for
2532 // footnote items — in both committed groundtruth generations
2533 // (docling-JSON and Markdown, independent runs) the fully
2534 // covered plain-text DOI line of 2206.01062 page 1 has
2535 // `hyperlink: None` while the equally covered footnotes carry
2536 // theirs. The corpus is the conformance reference, so match
2537 // the observed behavior; widen the label set if a future
2538 // groundtruth refresh starts linking plain text too.
2539 let escaped = md_escape(&text);
2540 let hyperlink = (region.label == "footnote")
2541 .then(|| region_hyperlink(region, &page.links))
2542 .flatten();
2543 let text = match hyperlink {
2544 Some(uri) => {
2545 // The strict-mode anchor pairs this item covers are
2546 // superseded by the baked whole-item link.
2547 links.retain(|(anchor, href)| {
2548 !(href == &uri && region_texts[i].contains(anchor.as_str()))
2549 });
2550 format!("[{escaped}]({uri})")
2551 }
2552 None => escaped,
2553 };
2554 nodes.push(located(loc, Node::Paragraph { text }))
2555 }
2556 }
2557 }
2558 // A `/Rotate`-normalized scanned page (see `pdfium_backend`) was assembled
2559 // in upright space; rotate the finished geometry back so locations and the
2560 // page size are display-space, like docling and every viewer report them.
2561 if page.rotation != 0 {
2562 rotate_nodes_to_display(&mut nodes, page.rotation);
2563 }
2564 (nodes, links)
2565}
2566
2567/// Rotate one 0–511 location bbox 90° clockwise on the grid (top-left origin):
2568/// `(x, y) → (511 - y, x)`.
2569fn rot_loc_cw(l: [u16; 4]) -> [u16; 4] {
2570 [511 - l[3], l[0], 511 - l[1], l[2]]
2571}
2572
2573/// Map upright-space geometry back to display space for a page whose `/Rotate`
2574/// was normalized away before inference: every `<location>` rotates `rot`°
2575/// clockwise on the 0–511 grid (the grid is per-axis normalized, so no page
2576/// dims are needed), and the `PageInfo` size returns to the display box. Node
2577/// text and order are untouched — reading order was decided upright, which is
2578/// the whole point.
2579fn rotate_nodes_to_display(nodes: &mut [Node], rot: u16) {
2580 let quarter_turns = (rot / 90) as usize;
2581 let rot_loc = |l: &mut [u16; 4]| {
2582 for _ in 0..quarter_turns {
2583 *l = rot_loc_cw(*l);
2584 }
2585 };
2586 fn walk(node: &mut Node, rot_loc: &impl Fn(&mut [u16; 4]), swap_dims: bool) {
2587 match node {
2588 Node::PageInfo { width, height, .. } => {
2589 if swap_dims {
2590 std::mem::swap(width, height);
2591 }
2592 }
2593 Node::Located { location, inner } => {
2594 rot_loc(location);
2595 walk(inner, rot_loc, swap_dims);
2596 }
2597 Node::Furniture { inner, .. } => walk(inner, rot_loc, swap_dims),
2598 Node::Group { children, .. } => {
2599 for c in children {
2600 walk(c, rot_loc, swap_dims);
2601 }
2602 }
2603 Node::ListItem { location, .. }
2604 | Node::Formula { location, .. }
2605 | Node::Chart { location, .. } => {
2606 if let Some(l) = location {
2607 rot_loc(l);
2608 }
2609 }
2610 Node::PageFurniture { location, .. } => rot_loc(location),
2611 Node::Table(t) => {
2612 if let Some(l) = &mut t.location {
2613 rot_loc(l);
2614 }
2615 }
2616 _ => {}
2617 }
2618 }
2619 let swap_dims = quarter_turns % 2 == 1;
2620 for node in nodes {
2621 walk(node, &rot_loc, swap_dims);
2622 }
2623}
2624
2625/// Merge paragraph fragments split across a column or page break. docling joins a
2626/// paragraph whose previous fragment ends mid-sentence (a letter, not sentence
2627/// punctuation) with a lowercase continuation: `…definition of` + `lists in…` →
2628/// `…definition of lists in…`. The fragments are consecutive paragraphs, or
2629/// separated only by figure(s) the text wraps around: a column whose body flows
2630/// past a figure resumes below it (`…The wing type that is` ⟶[figure]⟶ `the most
2631/// common…`), and docling emits the whole paragraph before the figure. A heading,
2632/// table, or list between them ends the paragraph (no merge).
2633/// A paragraph that is really a figure/table caption (`Fig. 1. …`, `Table 2 …`).
2634/// Used to skip an unpaired caption when stitching a paragraph that wraps around
2635/// a figure.
2636fn looks_like_caption(text: &str) -> bool {
2637 let head: String = text.trim_start().chars().take(14).collect();
2638 (head.starts_with("Fig") || head.starts_with("Table"))
2639 && head.contains(|c: char| c.is_ascii_digit())
2640}
2641
2642/// A paragraph fragment is "open" — i.e. it might continue into the next
2643/// paragraph — when it ends mid-word (a letter) or with a wrap hyphen/dash.
2644/// docling joins `vocab-` + `ulary` → `vocab- ulary`.
2645fn paragraph_is_open(text: &str) -> bool {
2646 // docling's merge head test (`.+([a-z,\-\u00AD])\s*`): at least two chars,
2647 // ending in an ASCII lowercase letter, a comma, a hyphen, or a soft
2648 // hyphen. The comma matters: 2206's "…In phase four," resumes across the
2649 // page break. Uppercase/non-Latin endings do not merge, exactly as
2650 // upstream (the dash family is already `-` here — clean_text normalized).
2651 let t = text.trim_end();
2652 t.chars().count() >= 2
2653 && t.chars()
2654 .next_back()
2655 .is_some_and(|c| matches!(c, 'a'..='z' | ',' | '-' | '\u{ad}'))
2656}
2657
2658/// The paragraph text inside a node, looking through a [`Node::Located`]
2659/// provenance wrapper (PDF body paragraphs are wrapped since they carry a
2660/// `<location>`). Returns `None` for non-paragraph nodes.
2661fn as_paragraph(n: &Node) -> Option<&str> {
2662 match n {
2663 Node::Paragraph { text } => Some(text),
2664 Node::Located { inner, .. } => match inner.as_ref() {
2665 Node::Paragraph { text } => Some(text),
2666 _ => None,
2667 },
2668 _ => None,
2669 }
2670}
2671
2672/// Whether a node is a picture, looking through a [`Node::Located`] wrapper.
2673fn is_picture_node(n: &Node) -> bool {
2674 match n {
2675 Node::Picture { .. } => true,
2676 Node::Located { inner, .. } => matches!(inner.as_ref(), Node::Picture { .. }),
2677 _ => false,
2678 }
2679}
2680
2681/// A node a forward paragraph merge looks straight past: a figure or *table*
2682/// the text wraps around, or a page header/footer that falls between the two
2683/// fragments of a paragraph continuing across a page break (docling's merge
2684/// skip-labels: page_header, page_footer, table, picture, caption, footnote —
2685/// 2206's "…In phase four," resumes after a full caption+table+figure block).
2686fn is_merge_trailer(n: &Node) -> bool {
2687 is_picture_node(n)
2688 || matches!(
2689 n,
2690 Node::PageFurniture { .. } | Node::PageInfo { .. } | Node::Table(_)
2691 )
2692 || matches!(n, Node::Located { inner, .. } if matches!(inner.as_ref(), Node::Table(_)))
2693 || as_paragraph(n).is_some_and(looks_like_caption)
2694}
2695
2696/// Rebuild node `i` as a paragraph with `text`, preserving its `<location>`
2697/// wrapper (and thus provenance) if it had one.
2698fn reparagraph(node: &Node, text: String) -> Node {
2699 match node {
2700 Node::Located { location, .. } => located(*location, Node::Paragraph { text }),
2701 _ => Node::Paragraph { text },
2702 }
2703}
2704
2705pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
2706 let mut i = 0;
2707 while i + 1 < nodes.len() {
2708 let Some(a) = as_paragraph(&nodes[i]) else {
2709 i += 1;
2710 continue;
2711 };
2712 // A figure/table caption is a self-contained unit; body text resuming
2713 // after a figure is the continuation case, not the caption itself. Never
2714 // stitch *from* a caption — otherwise a caption that ends in a lone glyph
2715 // (`Fig. 5. … PubTabNet. μ`) would swallow a following stray figure label
2716 // (a standalone `μ`) into `… μ μ`.
2717 if looks_like_caption(a) {
2718 i += 1;
2719 continue;
2720 }
2721 if !paragraph_is_open(a) {
2722 i += 1;
2723 continue;
2724 }
2725 // The continuation is the next paragraph, looking past any figures the
2726 // text wraps around — and a figure/table caption that was emitted as its
2727 // own paragraph (an above-the-figure caption that didn't pair), since the
2728 // body text resumes after the whole figure+caption block.
2729 let mut j = i + 1;
2730 while nodes.get(j).is_some_and(is_merge_trailer) {
2731 j += 1;
2732 }
2733 // docling's continuation regex allows either case, but its merge runs
2734 // over the pre-assembly element stream; at node level an uppercase
2735 // start is overwhelmingly a new sentence/heading fragment (allowing it
2736 // swallowed 2305's formula blocks and redp's chapter openers), so the
2737 // continuation stays lowercase-start here.
2738 let cont = nodes.get(j).and_then(as_paragraph).is_some_and(|b| {
2739 b.trim_start()
2740 .chars()
2741 .next()
2742 .is_some_and(char::is_lowercase)
2743 });
2744 if cont {
2745 let a = as_paragraph(&nodes[i]).unwrap().trim_end().to_string();
2746 let b = as_paragraph(&nodes[j]).unwrap().trim_start().to_string();
2747 // A soft hyphen -- or a hard hyphen followed by a lowercase
2748 // continuation (guaranteed lowercase by the `cont` gate above) --
2749 // is a word split across the break: strip it and join without a
2750 // space, docling#3888 ("vocab-" + "ulary" -> "vocabulary");
2751 // docling's older serializer kept the artifact ("vocab- ulary").
2752 // Everything else joins with the space, as before.
2753 let merged = match a.strip_suffix('\u{ad}').or_else(|| a.strip_suffix('-')) {
2754 Some(stem) => format!("{stem}{b}"),
2755 None => format!("{a} {b}"),
2756 };
2757 // Keep node i's provenance wrapper; docling's merged paragraph keeps
2758 // the first fragment's geometry as its primary location.
2759 nodes[i] = reparagraph(&nodes[i], merged);
2760 nodes.remove(j);
2761 // Re-check i: the merged paragraph may continue further.
2762 } else {
2763 i += 1;
2764 }
2765 }
2766}
2767
2768/// How many leading nodes of `nodes` are safe to flush now — i.e. cannot be
2769/// rewritten by a future [`merge_continuations`] once more pages are appended.
2770///
2771/// A forward merge can only start from an "open" paragraph (ends mid-word) and
2772/// only reaches across trailing pictures and figure/table captions. So we scan
2773/// from the end past those skippable trailers: if the first non-skippable node is
2774/// an open paragraph, it (and the trailers after it) must be held; anything else —
2775/// a closed paragraph, a heading, a table, a list — blocks any forward merge, so
2776/// the whole buffer is safe to flush.
2777fn hold_start(nodes: &[Node]) -> usize {
2778 for k in (0..nodes.len()).rev() {
2779 // Skippable trailers (figures, page furniture, captions): a forward merge
2780 // looks straight past them.
2781 if is_merge_trailer(&nodes[k]) {
2782 continue;
2783 }
2784 match as_paragraph(&nodes[k]) {
2785 // An open body paragraph might still pull a continuation off the next
2786 // page — hold from here to the end.
2787 Some(text) if paragraph_is_open(text) => return k,
2788 // A closed paragraph, heading, table, list, etc. ends the paragraph:
2789 // nothing after it can merge backwards across it. Flush everything.
2790 _ => return nodes.len(),
2791 }
2792 }
2793 // Only skippable trailers (or empty) and no open paragraph to anchor a merge.
2794 nodes.len()
2795}
2796
2797/// Streaming counterpart of [`merge_continuations`]: feed per-page node batches in
2798/// document order and get back the prefix that is final (its cross-page merges are
2799/// resolved and no future page can change it), holding back only the small tail
2800/// that might still merge into the next page. Concatenating every flushed batch
2801/// (then [`finish`](Self::finish)) yields exactly the same nodes as running
2802/// [`merge_continuations`] once over the whole document.
2803pub(crate) struct StreamAssembler {
2804 pending: Vec<Node>,
2805}
2806
2807impl StreamAssembler {
2808 pub(crate) fn new() -> Self {
2809 Self {
2810 pending: Vec::new(),
2811 }
2812 }
2813
2814 /// Append one page's nodes, resolve merges within the buffer, and return the
2815 /// now-final prefix to emit (possibly empty).
2816 pub(crate) fn push(&mut self, mut nodes: Vec<Node>) -> Vec<Node> {
2817 self.pending.append(&mut nodes);
2818 merge_continuations(&mut self.pending);
2819 let cut = hold_start(&self.pending);
2820 let tail = self.pending.split_off(cut);
2821 std::mem::replace(&mut self.pending, tail)
2822 }
2823
2824 /// Flush whatever is left after the last page (the held tail is final once no
2825 /// more pages can follow).
2826 pub(crate) fn finish(self) -> Vec<Node> {
2827 self.pending
2828 }
2829}
2830
2831#[cfg(test)]
2832mod tests {
2833 use super::{cells_text, clean_text};
2834 use super::{code_region_text, merge_continuations, resolve_link_anchors, StreamAssembler};
2835 use crate::layout::Region;
2836 use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
2837 use docling_core::Node;
2838
2839 /// The int8-layout guard's coverage metric: cells under detections count,
2840 /// cells outside don't, whitespace cells are ignored, and a cell-less page
2841 /// reads as fully covered (nothing to rescue).
2842 #[test]
2843 fn layout_cell_coverage_counts_claimed_text_cells() {
2844 let cell = |text: &str, l: f32, t: f32| TextCell {
2845 text: text.into(),
2846 l,
2847 t,
2848 r: l + 40.0,
2849 b: t + 10.0,
2850 };
2851 let region = Region {
2852 label: "text",
2853 score: 0.9,
2854 l: 0.0,
2855 t: 0.0,
2856 r: 100.0,
2857 b: 50.0,
2858 };
2859 let cells = vec![
2860 cell("inside", 10.0, 10.0),
2861 cell("also inside", 10.0, 30.0),
2862 cell("outside", 10.0, 200.0),
2863 cell(" ", 10.0, 210.0), // whitespace: not counted at all
2864 ];
2865 let cov = super::layout_cell_coverage(std::slice::from_ref(®ion), &cells);
2866 assert!((cov - 2.0 / 3.0).abs() < 1e-6, "got {cov}");
2867 assert_eq!(super::layout_cell_coverage(&[], &[]), 1.0);
2868 assert_eq!(super::layout_cell_coverage(&[], &cells), 0.0);
2869 }
2870
2871 /// #165: a picture no longer claims cells at 0.2 intersection-over-self.
2872 /// A line straddling the figure border (≤80 % contained) becomes an orphan
2873 /// region and survives the contained-regulars drop — before the fix its
2874 /// cells were silently erased. A line fully inside the picture is still
2875 /// re-dropped, matching docling's Markdown (a picture's children never
2876 /// reach its serializer's output).
2877 #[test]
2878 fn border_straddling_lines_survive_picture_interior_is_still_dropped() {
2879 let pic = Region {
2880 label: "picture",
2881 score: 0.9,
2882 l: 0.0,
2883 t: 0.0,
2884 r: 100.0,
2885 b: 100.0,
2886 };
2887 // ~35 % of this cell overlaps the picture (l=90..120 of 0..100): above
2888 // the old 0.2 claim (was swallowed), below full containment (survives).
2889 let straddler = TextCell {
2890 text: "axis label".into(),
2891 l: 90.0,
2892 t: 40.0,
2893 r: 120.0,
2894 b: 48.0,
2895 };
2896 let interior = TextCell {
2897 text: "in-figure callout".into(),
2898 l: 10.0,
2899 t: 10.0,
2900 r: 60.0,
2901 b: 18.0,
2902 };
2903 let mut regions = vec![pic];
2904 super::add_orphan_regions(&mut regions, &[straddler, interior]);
2905 assert_eq!(
2906 regions.iter().filter(|r| r.label == "text").count(),
2907 2,
2908 "both unclaimed lines become orphans"
2909 );
2910 super::drop_contained_regulars(&mut regions);
2911 let texts: Vec<(f32, f32)> = regions
2912 .iter()
2913 .filter(|r| r.label == "text")
2914 .map(|r| (r.l, r.r))
2915 .collect();
2916 assert_eq!(
2917 texts,
2918 [(90.0, 120.0)],
2919 "the straddler is emitted, the fully-contained callout is not"
2920 );
2921 }
2922
2923 /// docling#3906's concern, pinned on our side: a picture detected fully
2924 /// inside a table region must survive the containment drop (upstream now
2925 /// attaches it to the table's cell; we keep it as a body sibling — either
2926 /// way it must not vanish). The text region inside the same table is the
2927 /// control: regulars are the ones the drop swallows.
2928 #[test]
2929 fn picture_inside_a_table_region_survives_the_containment_drop() {
2930 let mut regions = vec![
2931 region("table", 0.9, 0.0, 0.0, 200.0, 200.0),
2932 region("picture", 0.9, 20.0, 20.0, 120.0, 120.0),
2933 region("text", 0.9, 20.0, 140.0, 180.0, 180.0),
2934 ];
2935 super::drop_contained_regulars(&mut regions);
2936 let labels: Vec<&str> = regions.iter().map(|r| r.label).collect();
2937 assert_eq!(
2938 labels,
2939 ["table", "picture"],
2940 "the in-table picture stays; the in-table regular is the special's child"
2941 );
2942 }
2943
2944 /// Table–caption pairing (#265) is reading-order adjacency, docling's
2945 /// `_find_to_captions`: a caption binds the table directly next to it in
2946 /// the region sequence — above-caption and below-caption both work, and
2947 /// geometry is irrelevant (a same-page caption in the other column of a
2948 /// two-column layout is *not* adjacent, however close its box is). A
2949 /// caption with media on both sides, or separated from the table by a
2950 /// text paragraph, stays unattached.
2951 #[test]
2952 fn table_captions_pair_by_reading_order_adjacency() {
2953 // caption → table (above-caption), then table → caption (below-caption),
2954 // then a caption fenced off by a paragraph, then one between two tables.
2955 let regions = vec![
2956 region("text", 0.9, 0.0, 0.0, 100.0, 10.0), // 0 body text
2957 region("caption", 0.9, 0.0, 12.0, 60.0, 20.0), // 1 above-caption
2958 region("table", 0.9, 20.0, 22.0, 90.0, 60.0), // 2 ← pairs with 1
2959 region("table", 0.9, 0.0, 70.0, 100.0, 110.0), // 3 ← pairs with 4
2960 region("caption", 0.9, 0.0, 112.0, 60.0, 120.0), // 4 below-caption
2961 region("text", 0.9, 0.0, 130.0, 100.0, 140.0), // 5 body text
2962 region("caption", 0.9, 0.0, 142.0, 60.0, 150.0), // 6 fenced by 5/7
2963 region("text", 0.9, 0.0, 152.0, 100.0, 162.0), // 7 body text
2964 region("table", 0.9, 0.0, 170.0, 100.0, 200.0), // 8 unpaired
2965 region("caption", 0.9, 0.0, 202.0, 60.0, 210.0), // 9 ambiguous
2966 region("table", 0.9, 0.0, 212.0, 100.0, 240.0), // 10 unpaired
2967 ];
2968 let mut taken = vec![false; regions.len()];
2969 let pairs = super::pair_table_captions(®ions, &mut taken);
2970 assert_eq!(pairs[2], Some(1), "caption directly above its table pairs");
2971 assert_eq!(pairs[3], Some(4), "caption directly below its table pairs");
2972 assert_eq!(
2973 pairs[8], None,
2974 "a text paragraph between caption and table breaks the bond"
2975 );
2976 assert_eq!(
2977 pairs[10], None,
2978 "a caption between two tables is ambiguous and stays loose"
2979 );
2980 assert!(taken[1] && taken[4] && !taken[6] && !taken[9]);
2981 }
2982
2983 /// A colored terms-and-conditions panel detected as `picture` demotes into
2984 /// per-paragraph `text` regions (the blank line between C.7 and C.8 splits
2985 /// them); a chart whose only text is a few narrow axis labels keeps its
2986 /// crop untouched.
2987 #[test]
2988 fn text_panels_demote_to_paragraphs_but_charts_keep_their_crop() {
2989 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
2990 text: text.to_string(),
2991 l,
2992 t,
2993 r,
2994 b,
2995 };
2996 let panel = Region {
2997 label: "picture",
2998 score: 0.9,
2999 l: 0.0,
3000 t: 0.0,
3001 r: 100.0,
3002 b: 100.0,
3003 };
3004 // Three tight lines, a blank-line gap, two more: two paragraphs.
3005 let cells = vec![
3006 cell(
3007 "C.7. Wenn Sie diesen Vertrag widerrufen,",
3008 5.0,
3009 10.0,
3010 95.0,
3011 18.0,
3012 ),
3013 cell(
3014 "haben wir Ihnen alle Zahlungen, die wir",
3015 5.0,
3016 20.0,
3017 95.0,
3018 28.0,
3019 ),
3020 cell(
3021 "von Ihnen erhalten haben, zurückzuzahlen.",
3022 5.0,
3023 30.0,
3024 90.0,
3025 38.0,
3026 ),
3027 cell(
3028 "C.8. Wir können die Rückzahlung verweigern,",
3029 5.0,
3030 52.0,
3031 95.0,
3032 60.0,
3033 ),
3034 cell(
3035 "bis wir die Waren wieder zurückerhalten haben.",
3036 5.0,
3037 62.0,
3038 92.0,
3039 70.0,
3040 ),
3041 ];
3042 let mut regions = vec![panel.clone()];
3043 super::recover_text_panels(&mut regions, &cells);
3044 assert_eq!(
3045 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
3046 ["text", "text"],
3047 "dense panel must demote into one text region per paragraph"
3048 );
3049 assert!(regions[0].b < regions[1].t, "paragraphs split at the gap");
3050 // Sparse narrow labels (a chart): picture survives.
3051 let labels = vec![
3052 cell("0", 5.0, 90.0, 8.0, 95.0),
3053 cell("50", 5.0, 50.0, 10.0, 55.0),
3054 cell("100", 5.0, 10.0, 12.0, 15.0),
3055 cell("t, s", 45.0, 96.0, 55.0, 100.0),
3056 ];
3057 let mut regions = vec![panel];
3058 super::recover_text_panels(&mut regions, &labels);
3059 assert_eq!(
3060 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
3061 ["picture"]
3062 );
3063 }
3064
3065 /// An uncaptioned chart on a scanned page whose title, axis labels, and
3066 /// OCR boxes over the plot area are dense and wide enough to pass the
3067 /// coverage/width gates still keeps its crop: its line heights are ragged
3068 /// (title face vs tick labels vs bar-area OCR), failing the uniform-leading
3069 /// gate — a real text panel is set with constant leading (#173).
3070 #[test]
3071 fn dense_titled_chart_keeps_its_crop() {
3072 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
3073 text: text.to_string(),
3074 l,
3075 t,
3076 r,
3077 b,
3078 };
3079 let chart = Region {
3080 label: "picture",
3081 score: 0.9,
3082 l: 0.0,
3083 t: 0.0,
3084 r: 100.0,
3085 b: 100.0,
3086 };
3087 // Five wide lines at wildly different heights: a 12-pt title, 20-pt OCR
3088 // boxes over the bars, 4–5-pt tick/axis labels. Coverage and median
3089 // width both clear the panel thresholds.
3090 let cells = vec![
3091 cell("Underground Water Storage", 10.0, 5.0, 90.0, 17.0),
3092 cell("aquifer recharge zone", 15.0, 30.0, 75.0, 50.0),
3093 cell("confined | unconfined | perched", 12.0, 55.0, 80.0, 59.0),
3094 cell("saturated thickness", 8.0, 70.0, 60.0, 90.0),
3095 cell("distance from well, km", 20.0, 92.0, 85.0, 97.0),
3096 ];
3097 let mut regions = vec![chart];
3098 super::recover_text_panels(&mut regions, &cells);
3099 assert_eq!(
3100 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
3101 ["picture"],
3102 "ragged line heights mark a figure, not a text panel"
3103 );
3104 }
3105
3106 /// docling serializes a cluster's cells in docling-parse index order
3107 /// (`_sort_cells`) and joins them with `PageAssembleModel.sanitize_text`:
3108 /// a space after every line except one ending in `-`, which either fuses a
3109 /// wrapped word (alnum on both sides — dash dropped) or glues verbatim (a
3110 /// bare `-` cell: `[0000` `-` `0002` → `[0000 -0002`, the 2305 ORCID line;
3111 /// `-` + `"C" cell -` + `a new table cell` → `-"C" cell a new table cell`,
3112 /// its OTSL list). Verified against the corpus: pure index order beats any
3113 /// geometric re-sort (normal_4pages' heading numerals paint after their
3114 /// text and belong last: `## 들어가며 1`).
3115 #[test]
3116 fn cells_join_in_index_order_with_sanitize_text_rules() {
3117 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
3118 text: text.to_string(),
3119 l,
3120 t,
3121 r,
3122 b,
3123 };
3124 let region = Region {
3125 label: "text",
3126 score: 1.0,
3127 l: 0.0,
3128 t: 95.0,
3129 r: 200.0,
3130 b: 130.0,
3131 };
3132 // ORCID superscript: a bare dash cell is a *detached* dash — kept, and
3133 // since docling#4052 (2.122) it joins with the ordinary space on both
3134 // sides (`[0000 -0002 -6960]` before that fix).
3135 let orcid = vec![
3136 cell("[0000", 10.0, 100.0, 30.0, 110.0),
3137 cell("−", 30.0, 100.0, 34.0, 110.0),
3138 cell("0002", 34.0, 100.0, 50.0, 110.0),
3139 cell("−", 50.0, 100.0, 54.0, 110.0),
3140 cell("6960]", 54.0, 100.0, 70.0, 110.0),
3141 ];
3142 assert_eq!(super::region_text(®ion, &orcid), "[0000 - 0002 - 6960]");
3143 // Wrapped word: dash dropped, lines fused (both boundary words alnum).
3144 let wrapped = vec![
3145 cell("platforms-", 10.0, 100.0, 60.0, 110.0),
3146 cell("reflects the design", 10.0, 112.0, 90.0, 122.0),
3147 ];
3148 assert_eq!(
3149 super::region_text(®ion, &wrapped),
3150 "platformsreflects the design"
3151 );
3152 // Dash-ending lines that are *detached* dashes (a bare bullet cell, a
3153 // `cell -` separator): the dash stays and the lines join with a space
3154 // — docling#4052; before it they glued (`-"C" cell a new table cell`,
3155 // 2305's OTSL list bullets).
3156 let otsl = vec![
3157 cell("–", 10.0, 100.0, 14.0, 110.0),
3158 cell("\"C\" cell -", 16.0, 100.0, 60.0, 110.0),
3159 cell("a new table cell", 10.0, 112.0, 80.0, 122.0),
3160 ];
3161 assert_eq!(
3162 super::region_text(®ion, &otsl),
3163 "- \"C\" cell - a new table cell"
3164 );
3165 // Index order is authoritative — no geometric re-sort.
3166 let numeral = vec![
3167 cell("들어가며", 30.0, 100.0, 80.0, 110.0),
3168 cell("1", 10.0, 98.0, 25.0, 112.0), // big numeral painted last
3169 ];
3170 assert_eq!(super::region_text(®ion, &numeral), "들어가며 1");
3171 }
3172
3173 /// The geometric-reliability gate, on the two shapes it has to tell apart.
3174 #[test]
3175 fn geometric_reliability_rejects_split_column_grids() {
3176 let g = |rows: &[&[&str]]| -> Vec<Vec<String>> {
3177 rows.iter()
3178 .map(|r| r.iter().map(|c| c.to_string()).collect())
3179 .collect()
3180 };
3181 // A genuine grid: dense, every column carrying entries. Nothing for
3182 // TableFormer to improve, so geometry is used as-is.
3183 assert!(super::geometric_table_is_reliable(&g(&[
3184 &["Datum", "Leistung", "Anzahl", "Kosten"],
3185 &["04.07", "Internet", "1", "40.30"],
3186 &["04.07", "Telefon", "2", "8.06"],
3187 ])));
3188 // The left-edge split artefact (the shape a scanned invoice produced):
3189 // one real label column plus values scattered across three sparse ones.
3190 assert!(!super::geometric_table_is_reliable(&g(&[
3191 &["www.magenta.at/faq", "", "", ""],
3192 &["Serviceteam", "", "", ""],
3193 &["Telefon", "0676/2000", "", ""],
3194 &["Kundennummer", "", "", "1.21699482"],
3195 &["Rechnungsnummer", "", "922769430725", ""],
3196 &["Rechnungsdatum", "", "", "04.07.2025"],
3197 ])));
3198 // A column only one row ever uses is a split artefact even when the
3199 // grid is otherwise dense.
3200 assert!(!super::geometric_table_is_reliable(&g(&[
3201 &["a", "b", ""],
3202 &["c", "d", ""],
3203 &["e", "f", "g"],
3204 ])));
3205 // Degenerate shapes are never vouched for — TableFormer may recover
3206 // structure a collapsed reconstruction lost.
3207 assert!(!super::geometric_table_is_reliable(&g(&[&[
3208 "only one column"
3209 ]])));
3210 assert!(!super::geometric_table_is_reliable(&[]));
3211 }
3212
3213 /// A `picture` region is cropped out of the rendered page, whatever built
3214 /// that page. The browser pipeline (#157) has no pdfium but does hand over
3215 /// the rasterized bitmap through `from_cells_with_image`, so it must get
3216 /// the same figure bytes the native path does — that is what makes
3217 /// `images = "embedded"` inline real pixels instead of a placeholder.
3218 #[cfg(feature = "ocr-prep")]
3219 #[test]
3220 fn picture_regions_are_cropped_from_a_host_supplied_page_image() {
3221 let mut img = image::RgbImage::new(200, 200);
3222 // Paint the figure area so the crop is distinguishable from the page.
3223 for y in 100..160 {
3224 for x in 20..120 {
3225 img.put_pixel(x, y, image::Rgb([255, 0, 0]));
3226 }
3227 }
3228 // scale 2.0: the region is in page points, the bitmap in pixels.
3229 let page = PdfPage::from_cells_with_image(100.0, 100.0, 2.0, Vec::new(), img);
3230 let region = Region {
3231 label: "picture",
3232 score: 0.9,
3233 l: 10.0,
3234 t: 50.0,
3235 r: 60.0,
3236 b: 80.0,
3237 };
3238 let (nodes, _) = super::assemble_page(&page, vec![region], &[None], &[None]);
3239 // Layout-derived nodes carry provenance, so the picture arrives wrapped.
3240 let image = nodes
3241 .iter()
3242 .find_map(|n| match n {
3243 Node::Located { inner, .. } => match &**inner {
3244 Node::Picture { image, .. } => image.as_ref(),
3245 _ => None,
3246 },
3247 Node::Picture { image, .. } => image.as_ref(),
3248 _ => None,
3249 })
3250 .expect("a picture node with cropped pixels");
3251 assert_eq!(image.mimetype, "image/png");
3252 assert_eq!((image.width, image.height), (100, 60), "region × scale");
3253 assert!(!image.data.is_empty(), "PNG bytes were encoded");
3254 }
3255
3256 #[test]
3257 fn link_anchors_split_a_shared_word_cell_between_adjacent_links() {
3258 // A common header layout: one text run holds several pipe-separated
3259 // labels, each carrying its own link annotation. Every link must get
3260 // its own label as the anchor (and the "|" separators must belong to
3261 // none), not the whole run.
3262 let annot = |l: f32, r: f32, uri: &str| LinkAnnot {
3263 l,
3264 t: 100.0,
3265 r,
3266 b: 114.0,
3267 uri: uri.into(),
3268 };
3269 let page = PdfPage {
3270 width: 600.0,
3271 height: 800.0,
3272 scale: 2.0,
3273 cells: Vec::new(),
3274 code_cells: Vec::new(),
3275 // "LinkedIn | GitHub | Credly" = 26 chars over x 100..360.
3276 word_cells: vec![cell(
3277 "LinkedIn | GitHub | Credly",
3278 100.0,
3279 100.0,
3280 360.0,
3281 114.0,
3282 )],
3283 image: image::RgbImage::new(1, 1),
3284 image_layout: None,
3285 links: vec![
3286 annot(100.0, 180.0, "https://l"),
3287 annot(200.0, 260.0, "https://g"),
3288 annot(290.0, 360.0, "https://c"),
3289 ],
3290 rotation: 0,
3291 };
3292 assert_eq!(
3293 resolve_link_anchors(&page),
3294 vec![
3295 ("LinkedIn".to_string(), "https://l".to_string()),
3296 ("GitHub".to_string(), "https://g".to_string()),
3297 ("Credly".to_string(), "https://c".to_string()),
3298 ]
3299 );
3300 }
3301
3302 /// A one-line code cell at `[l, r] × [t, b]` (top-left coords).
3303 fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
3304 TextCell {
3305 text: text.into(),
3306 l,
3307 t,
3308 r,
3309 b,
3310 }
3311 }
3312
3313 fn region(label: &'static str, score: f32, l: f32, t: f32, r: f32, b: f32) -> Region {
3314 Region {
3315 label,
3316 score,
3317 l,
3318 t,
3319 r,
3320 b,
3321 }
3322 }
3323
3324 #[test]
3325 fn resolve_collapses_nested_code_keeping_the_larger_box() {
3326 // A tight high-score `code` box and a taller lower-score near-duplicate that
3327 // contains it must collapse to one — the *larger* box, so every cell stays
3328 // covered and nothing leaks out as orphan text.
3329 let tight = region("code", 0.95, 78.0, 292.0, 300.0, 330.0);
3330 let wide = region("code", 0.66, 63.0, 260.0, 320.0, 346.0);
3331 let kept = super::resolve(vec![tight, wide]);
3332 assert_eq!(kept.len(), 1, "nested code boxes must collapse to one");
3333 assert!(
3334 kept[0].l == 63.0 && kept[0].b == 346.0,
3335 "the larger containing box is kept"
3336 );
3337 }
3338
3339 #[test]
3340 fn resolve_keeps_distinct_and_differently_typed_regions() {
3341 // A text box fully inside a lower-score *table* must NOT be collapsed (the
3342 // code dedup is code-only), and two separate code blocks stay separate.
3343 let text = region("text", 0.95, 90.0, 210.0, 200.0, 230.0);
3344 let table = region("table", 0.60, 80.0, 200.0, 400.0, 500.0);
3345 assert_eq!(super::resolve(vec![text, table]).len(), 2);
3346
3347 let code_a = region("code", 0.9, 78.0, 100.0, 300.0, 140.0);
3348 let code_b = region("code", 0.9, 78.0, 300.0, 300.0, 360.0); // far below, no overlap
3349 assert_eq!(super::resolve(vec![code_a, code_b]).len(), 2);
3350 }
3351
3352 #[test]
3353 fn code_language_label_above_code_is_detected() {
3354 // A bare "XML" token directly above a code box is a language label; a real
3355 // heading above the same code is not; a language word with no code below is
3356 // left alone.
3357 let label = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
3358 let code = region("code", 0.7, 77.0, 552.0, 290.0, 640.0);
3359 let heading = region("section_header", 0.9, 76.0, 500.0, 260.0, 512.0);
3360 let cells = vec![
3361 cell("XML", 78.0, 541.0, 94.0, 548.0), // inside `label`
3362 cell("Overview", 78.0, 501.0, 250.0, 511.0), // inside `heading`
3363 ];
3364 let drop = super::code_language_labels(&[label, code, heading], &cells);
3365 assert_eq!(drop, vec![true, false, false], "only the label is consumed");
3366
3367 // Same label with no code region present → not consumed.
3368 let label2 = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
3369 let only = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
3370 assert_eq!(super::code_language_labels(&[label2], &only), vec![false]);
3371
3372 // A label swallowed into the top of a wider code box (negative gap) is still
3373 // recognized.
3374 let inside_lbl = region("text", 0.9, 76.0, 540.0, 96.0, 549.0);
3375 let wide_code = region("code", 0.7, 63.0, 531.0, 320.0, 654.0);
3376 let cells2 = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
3377 assert_eq!(
3378 super::code_language_labels(&[inside_lbl, wide_code], &cells2),
3379 vec![true, false]
3380 );
3381
3382 assert!(super::is_code_language("XML") && super::is_code_language("c#"));
3383 assert!(!super::is_code_language("Configure") && !super::is_code_language("XML schema"));
3384 }
3385
3386 #[test]
3387 fn code_region_text_keeps_lines_and_indentation() {
3388 // Three source lines; each glyph is 6 units wide (width / chars = 6), so the
3389 // `int X;` line indented to x=22 is (22-10)/6 = 2 spaces in.
3390 let region = Region {
3391 label: "code",
3392 score: 1.0,
3393 l: 0.0,
3394 t: -5.0,
3395 r: 100.0,
3396 b: 40.0,
3397 };
3398 let cells = vec![
3399 cell("struct P {", 10.0, 0.0, 70.0, 10.0),
3400 cell("int X;", 22.0, 12.0, 58.0, 22.0),
3401 cell("}", 10.0, 24.0, 16.0, 34.0),
3402 ];
3403 assert_eq!(code_region_text(®ion, &cells), "struct P {\n int X;\n}");
3404 }
3405
3406 #[test]
3407 fn code_region_text_tightens_punctuation_without_eating_indentation() {
3408 // A fluent `.Foo()` line at x=22 (2 chars in). Per-line tightening must not
3409 // consume the leading indent space by matching " ." across it.
3410 let region = Region {
3411 label: "code",
3412 score: 1.0,
3413 l: 0.0,
3414 t: -5.0,
3415 r: 100.0,
3416 b: 40.0,
3417 };
3418 let cells = vec![
3419 cell("builder", 10.0, 0.0, 52.0, 10.0),
3420 // pdfium spaced the call: ".Foo (x)" tightens to ".Foo(x)", still 2-indented.
3421 cell(".Foo (x)", 22.0, 12.0, 70.0, 22.0),
3422 ];
3423 assert_eq!(code_region_text(®ion, &cells), "builder\n .Foo(x)");
3424 }
3425
3426 #[test]
3427 fn code_region_text_orders_out_of_order_cells_and_ignores_blank_lines() {
3428 let region = Region {
3429 label: "code",
3430 score: 1.0,
3431 l: 0.0,
3432 t: -5.0,
3433 r: 100.0,
3434 b: 60.0,
3435 };
3436 // Fed bottom-up and with a whitespace-only cell; output is top-down, no blank.
3437 let cells = vec![
3438 cell("b();", 10.0, 24.0, 34.0, 34.0),
3439 cell(" ", 10.0, 12.0, 20.0, 22.0),
3440 cell("a();", 10.0, 0.0, 34.0, 10.0),
3441 ];
3442 assert_eq!(code_region_text(®ion, &cells), "a();\nb();");
3443 // No code cells → empty, so the caller falls back to the prose text.
3444 assert_eq!(code_region_text(®ion, &[]), "");
3445 }
3446
3447 fn para(text: &str) -> Node {
3448 Node::Paragraph { text: text.into() }
3449 }
3450
3451 /// Run a node sequence through [`StreamAssembler`] with the given page splits
3452 /// and assert the flushed result equals one-shot [`merge_continuations`].
3453 fn assert_stream_eq(nodes: &[Node], splits: &[usize]) {
3454 let mut want = nodes.to_vec();
3455 merge_continuations(&mut want);
3456
3457 let mut asm = StreamAssembler::new();
3458 let mut got = Vec::new();
3459 let mut start = 0;
3460 for &end in splits {
3461 got.extend(asm.push(nodes[start..end].to_vec()));
3462 start = end;
3463 }
3464 got.extend(asm.push(nodes[start..].to_vec()));
3465 got.extend(asm.finish());
3466 assert_eq!(got, want, "stream assembly diverged (splits={splits:?})");
3467 }
3468
3469 #[test]
3470 fn stream_assembler_matches_merge_continuations() {
3471 // Open fragment + lowercase continuation split across a page boundary.
3472 let cross = [para("the definition of"), para("lists in scope")];
3473 assert_stream_eq(&cross, &[1]);
3474 assert_stream_eq(&cross, &[]);
3475
3476 // Continuation that wraps around a figure (+ its caption) on the boundary.
3477 let wrap = [
3478 para("the wing type that is"),
3479 Node::Picture {
3480 caption: None,
3481 image: None,
3482 classification: None,
3483 },
3484 para("Fig. 1. a diagram"),
3485 para("the most common kind"),
3486 ];
3487 for splits in [&[][..], &[1][..], &[2][..], &[3][..], &[1, 3][..]] {
3488 assert_stream_eq(&wrap, splits);
3489 }
3490
3491 // A heading between fragments blocks the merge (must still flush correctly).
3492 let blocked = [
3493 para("ends mid word and"),
3494 Node::Heading {
3495 level: 2,
3496 text: "New Section".into(),
3497 },
3498 para("more body here"),
3499 ];
3500 for splits in [&[][..], &[1][..], &[2][..]] {
3501 assert_stream_eq(&blocked, splits);
3502 }
3503
3504 // A chain across three pages: each page is one open lowercase fragment.
3505 let chain = [
3506 para("alpha beta"),
3507 para("gamma delta"),
3508 para("epsilon zeta"),
3509 ];
3510 assert_stream_eq(&chain, &[1, 2]);
3511 }
3512
3513 #[test]
3514 fn clean_text_dehyphenates_and_normalizes_typography() {
3515 // U+0002 line-wrap hyphen + the join space → merged word (like docling).
3516 assert_eq!(clean_text("com\u{2} pact"), "compact");
3517 assert_eq!(clean_text("end-to\u{2} end deep"), "end-toend deep");
3518 // A stray wrap hyphen (no following join) is dropped.
3519 assert_eq!(clean_text("word\u{2}"), "word");
3520 // Typographic punctuation → ASCII: every curly quote becomes `'`
3521 // (docling-parse's sanitizer table), a literal `"` stays.
3522 assert_eq!(
3523 clean_text("Graph\u{2019}s \u{201c}x\u{201d} \"y\""),
3524 "Graph's 'x' \"y\""
3525 );
3526 assert_eq!(clean_text("a\u{2026}"), "a...");
3527 // The dp default (the docling-parse sanitizer) preserves internal spacing
3528 // it placed deliberately; line breaks/tabs normalize to a space, ends trim.
3529 assert_eq!(clean_text("a b\nc"), "a b c");
3530 }
3531
3532 /// docling#4064: a form's children are emitted together where the form
3533 /// sits in the top-level order, not interleaved with surrounding text.
3534 #[test]
3535 fn form_children_stay_together_in_reading_order() {
3536 let reg = |label: &'static str, l: f32, t: f32, r: f32, b: f32| Region {
3537 label,
3538 score: 0.9,
3539 l,
3540 t,
3541 r,
3542 b,
3543 };
3544 // Page: intro text, then a form spanning the left column with two
3545 // fields and a table inside, while a right-column paragraph sits
3546 // level with the form's first field (it would otherwise be read
3547 // between the form's children).
3548 let mut items = vec![
3549 reg("text", 50.0, 50.0, 550.0, 70.0), // 0 intro
3550 reg("form", 50.0, 100.0, 300.0, 400.0), // 1 container
3551 reg("text", 60.0, 110.0, 290.0, 130.0), // 2 field A (child)
3552 reg("text", 320.0, 110.0, 550.0, 130.0), // 3 right column paragraph
3553 reg("table", 60.0, 150.0, 290.0, 300.0), // 4 table (child)
3554 reg("text", 60.0, 320.0, 290.0, 340.0), // 5 field B (child)
3555 reg("text", 50.0, 450.0, 550.0, 470.0), // 6 outro
3556 ];
3557 super::order_with_containers(&mut items, 600.0, 800.0, |r| r);
3558 let order: Vec<(&str, f32)> = items.iter().map(|r| (r.label, r.t)).collect();
3559 // The form block (container, then its children top-down) is one unit.
3560 let form_pos = order.iter().position(|(l, _)| *l == "form").unwrap();
3561 assert_eq!(
3562 &order[form_pos..form_pos + 4],
3563 &[
3564 ("form", 100.0),
3565 ("text", 110.0),
3566 ("table", 150.0),
3567 ("text", 320.0)
3568 ]
3569 );
3570 assert_eq!(order[0], ("text", 50.0));
3571 assert_eq!(order[order.len() - 1], ("text", 450.0));
3572 // Without a container the plain order interleaves by geometry.
3573 let mut flat: Vec<Region> = items
3574 .iter()
3575 .filter(|r| r.label != "form")
3576 .cloned()
3577 .collect();
3578 super::order_regions(&mut flat, 600.0, 800.0, |r| r);
3579 assert_ne!(
3580 flat.iter().map(|r| r.t).collect::<Vec<_>>(),
3581 order
3582 .iter()
3583 .filter(|(l, _)| *l != "form")
3584 .map(|(_, t)| *t)
3585 .collect::<Vec<_>>()
3586 );
3587 }
3588
3589 /// docling#3906: a picture inside a table lands in the covering cell,
3590 /// chosen by the picture's inferred grid position when cell boxes overlap.
3591 #[test]
3592 fn picture_matches_the_cell_at_its_grid_position() {
3593 let cell = |r: usize, c: usize, bbox: [f32; 4]| docling_core::TableCell {
3594 text: format!("r{r}c{c}"),
3595 bbox: Some(bbox),
3596 start_row: r,
3597 start_col: c,
3598 row_span: 1,
3599 col_span: 1,
3600 column_header: false,
3601 row_header: false,
3602 row_section: false,
3603 };
3604 // 2×2 grid; the (1,0) cell box is generous and also covers the picture.
3605 let cells = vec![
3606 cell(0, 0, [0.0, 0.0, 100.0, 50.0]),
3607 cell(0, 1, [100.0, 0.0, 200.0, 50.0]),
3608 cell(1, 0, [0.0, 50.0, 100.0, 100.0]),
3609 cell(1, 1, [100.0, 50.0, 200.0, 100.0]),
3610 ];
3611 let pic = Region {
3612 label: "picture",
3613 score: 0.9,
3614 l: 110.0,
3615 t: 60.0,
3616 r: 190.0,
3617 b: 95.0,
3618 };
3619 assert_eq!(super::match_picture_to_cell(&pic, &cells), Some((1.0, 3)));
3620 // A picture only half inside any cell is not nested.
3621 let straddling = Region {
3622 label: "picture",
3623 score: 0.9,
3624 l: 60.0,
3625 t: 60.0,
3626 r: 160.0,
3627 b: 95.0,
3628 };
3629 assert_eq!(super::match_picture_to_cell(&straddling, &cells), None);
3630 }
3631
3632 /// docling#4052 (2.122): a line-final dash fuses the wrapped word only
3633 /// when attached to it; a detached dash is a literal and the lines join
3634 /// with a space.
3635 #[test]
3636 fn line_final_hyphen_fuses_only_when_attached_to_a_word() {
3637 let line = |text: &str, t: f32| TextCell {
3638 text: text.to_string(),
3639 l: 0.0,
3640 t,
3641 r: 100.0,
3642 b: t + 10.0,
3643 };
3644 // `algo-` / `rithms`: attached hyphen, alnum on both sides → fused.
3645 assert_eq!(
3646 cells_text(vec![&line("algo-", 0.0), &line("rithms", 12.0)]),
3647 "algorithms"
3648 );
3649 // `pp. 545-` / `561`: attached, digits count as alnum → `545561` (upstream).
3650 assert_eq!(
3651 cells_text(vec![&line("pp. 545-", 0.0), &line("561", 12.0)]),
3652 "pp. 545561"
3653 );
3654 // A dash after whitespace — a separator or a lone `-` cell — is kept and
3655 // the lines take the ordinary joining space.
3656 assert_eq!(
3657 cells_text(vec![&line("range -", 0.0), &line("wide", 12.0)]),
3658 "range - wide"
3659 );
3660 assert_eq!(
3661 cells_text(vec![&line("-", 0.0), &line("item", 12.0)]),
3662 "- item"
3663 );
3664 // Attached but the next line opens with no word (`x-` / `...`): dash
3665 // kept and, as before, no separating space.
3666 assert_eq!(
3667 cells_text(vec![&line("x-", 0.0), &line("...", 12.0)]),
3668 "x-..."
3669 );
3670 }
3671
3672 #[test]
3673 fn lam_alef_only_swaps_a_genuinely_reversed_ligature() {
3674 // A mid-word `alef-variant + lam` is pdfium's reversed lam-alef ligature and
3675 // is swapped back to logical `lam + alef-variant` (`ب أ ل` → `ب ل أ`).
3676 assert_eq!(
3677 clean_text("\u{0628}\u{0623}\u{0644}"),
3678 "\u{0628}\u{0644}\u{0623}"
3679 );
3680 // But when the alef-variant is *already* preceded by a lam it is the logical
3681 // ligature `لآ`; the following lam is the next syllable's letter and must not
3682 // move. `التعلم الآلي` must stay `الآلي`, not become `اللآي`.
3683 assert_eq!(
3684 clean_text("\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"),
3685 "\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"
3686 );
3687 }
3688}