Skip to main content

docling_pdf/
assemble.rs

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