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