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