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