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, ListItemDclx, Node, PictureClass, PictureImage, Table};
9#[cfg(any(feature = "ml", feature = "ocr-prep"))]
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/// A `page_footer` that is really the body of the last heading on the page
491/// becomes `text`.
492///
493/// The layout model labels the bottom margin by position as much as by
494/// content: a one-line paragraph that happens to sit where a running footer
495/// would — a CV's `Languages` line under its `## Languages` heading, the last
496/// entry of a section that runs to the page edge — comes back as
497/// `page_footer` (0.88 against 0.49 for `text` on the reporting file, with
498/// the fp32 model and either renderer alike), and Markdown drops furniture,
499/// so the document loses real content while the JSON keeps it under the
500/// wrong label. docling fails the same way. Deliberate deviation, gated so a
501/// real footer keeps its label: the nearest `section_header` above the footer
502/// must overlap it horizontally, end within 2.5 footer heights of its top,
503/// and be the **last body element** of its column — no other non-furniture
504/// region starts at or below the heading's bottom edge over the same span —
505/// and the footer must be a line of text, not a page number: at least 40 %
506/// of the page width. A running footer never follows a heading that has no
507/// body of its own, so the combination is the misread heading body.
508pub fn reclaim_heading_body_footers(regions: &mut [Region], page_w: f32) {
509    let n = regions.len();
510    let overlap_x = |a: &Region, b: &Region| a.r.min(b.r) - a.l.max(b.l) > 0.0;
511    for fi in 0..n {
512        let f = regions[fi].clone();
513        if f.label != "page_footer" || f.r - f.l < 0.4 * page_w {
514            continue;
515        }
516        let fh = (f.b - f.t).max(1.0);
517        // The nearest heading above the footer, over the footer's span.
518        let heading = (0..n)
519            .filter(|&j| {
520                let h = &regions[j];
521                j != fi && h.label == "section_header" && h.b <= f.t + 0.5 * fh && overlap_x(h, &f)
522            })
523            .min_by(|&a, &b| regions[b].b.total_cmp(&regions[a].b));
524        let Some(hi) = heading else {
525            continue;
526        };
527        let h = regions[hi].clone();
528        if f.t - h.b > 2.5 * fh {
529            continue;
530        }
531        // The heading must have no body of its own: nothing but the footer
532        // starts at or below its bottom edge over the heading's or footer's
533        // span (a heading whose paragraph follows is not this case, and a
534        // heading with the footer far below it was filtered above).
535        let has_body = (0..n).any(|j| {
536            let r = &regions[j];
537            j != fi
538                && j != hi
539                && !matches!(r.label, "page_footer" | "page_header")
540                && r.t >= h.b - 0.5 * fh
541                && (overlap_x(r, &h) || overlap_x(r, &f))
542        });
543        if has_body {
544            continue;
545        }
546        regions[fi].label = "text";
547    }
548}
549
550/// dropping those would erase the page (e.g. `right_to_left_03`'s form-heavy
551/// pages). Runs *after* [`drop_false_pictures`] so a phantom picture can't
552/// swallow real text on its way out.
553pub fn drop_contained_regulars(regions: &mut Vec<Region>) {
554    let specials: Vec<(f32, f32, f32, f32)> = regions
555        .iter()
556        .filter(|r| is_table_like(r.label))
557        .map(|r| (r.l, r.t, r.r, r.b))
558        .collect();
559    if specials.is_empty() {
560        return;
561    }
562    regions.retain(|r| {
563        if r.label == "picture" || is_wrapper(r.label) {
564            return true;
565        }
566        let ra = area(r.l, r.t, r.r, r.b).max(1.0);
567        !specials
568            .iter()
569            .any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.8)
570    });
571}
572
573/// docling's `_set_cluster_children` for pictures: a regular region (one that
574/// claims cells) whose box is > 80 % inside a picture's box
575/// (`intersection_over_self`) is that picture's child — not a page element
576/// (it leaves the reading order and the layout score), but an item under the
577/// `PictureItem` in the JSON. Returns, per region, the index of its parent
578/// picture: the smallest containing one when pictures nest (upstream would
579/// attach it to each).
580pub fn picture_parents(regions: &[Region]) -> Vec<Option<usize>> {
581    regions
582        .iter()
583        .map(|r| {
584            if !claims_cells(r) {
585                return None;
586            }
587            let ra = area(r.l, r.t, r.r, r.b).max(1.0);
588            regions
589                .iter()
590                .enumerate()
591                .filter(|(_, p)| p.label == "picture" && inter(r, p.l, p.t, p.r, p.b) / ra > 0.8)
592                .min_by(|(_, a), (_, b)| {
593                    area(a.l, a.t, a.r, a.b).total_cmp(&area(b.l, b.t, b.r, b.b))
594                })
595                .map(|(i, _)| i)
596        })
597        .collect()
598}
599
600/// True for a bare, single-token source-code language label (`XML`, `C#`, `JSON`,
601/// `bash`, …) — the little header the docs render above a code block. Matched
602/// case-insensitively; anything with whitespace or longer than a token is out.
603fn is_code_language(t: &str) -> bool {
604    let t = t.trim();
605    if t.is_empty() || t.chars().any(char::is_whitespace) || t.chars().count() > 12 {
606        return false;
607    }
608    const LANGS: &[&str] = &[
609        "xml",
610        "html",
611        "xhtml",
612        "json",
613        "jsonc",
614        "yaml",
615        "yml",
616        "toml",
617        "ini",
618        "c#",
619        "csharp",
620        "f#",
621        "fsharp",
622        "vb",
623        "c",
624        "c++",
625        "cpp",
626        "java",
627        "kotlin",
628        "scala",
629        "go",
630        "golang",
631        "rust",
632        "swift",
633        "javascript",
634        "js",
635        "typescript",
636        "ts",
637        "jsx",
638        "tsx",
639        "python",
640        "py",
641        "ruby",
642        "rb",
643        "php",
644        "perl",
645        "lua",
646        "r",
647        "dart",
648        "bash",
649        "sh",
650        "shell",
651        "powershell",
652        "zsh",
653        "batch",
654        "cmd",
655        "sql",
656        "tsql",
657        "plsql",
658        "graphql",
659        "dockerfile",
660        "makefile",
661        "css",
662        "scss",
663        "sass",
664        "less",
665        "markdown",
666        "md",
667        "tex",
668        "latex",
669        "diff",
670        "proto",
671        "razor",
672        "cshtml",
673        "xaml",
674        "aspx",
675        "http",
676    ];
677    let lower = t.to_ascii_lowercase();
678    LANGS.contains(&lower.as_str())
679}
680
681/// Mark the region indices that are a code block's **language label** — a bare
682/// `XML`/`C#`/… token sitting directly above a `code` region — so they are consumed
683/// rather than emitted as their own stray paragraph/heading. The label may also be
684/// captured inside a wider code box (rendered as the fence's first line); dropping
685/// the standalone copy just removes the duplicate.
686fn code_language_labels(regions: &[Region], cells: &[TextCell]) -> Vec<bool> {
687    let mut drop = vec![false; regions.len()];
688    for (i, r) in regions.iter().enumerate() {
689        if matches!(r.label, "code" | "picture" | "table") {
690            continue;
691        }
692        if !is_code_language(&region_text(r, cells)) {
693            continue;
694        }
695        // The label sits just above the code (a blank line's gap) or is swallowed
696        // into the top of a wider code box; either way it is that block's label.
697        // The window is generous because the label's own font is small, so a
698        // one-line gap is several times its height.
699        let line_h = (r.b - r.t).abs().max(1.0);
700        let window = (line_h * 4.0).max(28.0);
701        let labels_code = regions.iter().enumerate().any(|(j, c)| {
702            if j == i || c.label != "code" {
703                return false;
704            }
705            let gap = c.t - r.b; // >0 when the code is below the label
706            let h_overlap = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
707            gap > -line_h * 3.0 && gap < window && h_overlap > 0.0
708        });
709        if labels_code {
710            drop[i] = true;
711        }
712    }
713    drop
714}
715
716/// Collapse `code` regions where one is nested inside another, keeping the larger.
717///
718/// RT-DETR sometimes emits a tight code box *and* a wider near-duplicate that also
719/// captures the block's language label (`XML`, `C#`, …). When the tight box scores
720/// higher it is kept first, and the wider container — not "mostly inside" the tight
721/// box — survives [`resolve`]'s greedy pass, so the block is emitted twice. Keeping
722/// the **larger** box (rather than dropping it) collapses the pair without leaking
723/// the container's extra cells back out as orphan text, since the larger box still
724/// covers every cell. Restricted to `code` so genuinely distinct nested regions of
725/// other kinds are untouched.
726fn dedup_nested_code(kept: &mut Vec<Region>) {
727    let mut drop = vec![false; kept.len()];
728    for i in 0..kept.len() {
729        if kept[i].label != "code" {
730            continue;
731        }
732        let ai = area(kept[i].l, kept[i].t, kept[i].r, kept[i].b).max(1.0);
733        for j in 0..kept.len() {
734            if i == j || drop[j] || kept[j].label != "code" {
735                continue;
736            }
737            let aj = area(kept[j].l, kept[j].t, kept[j].r, kept[j].b).max(1.0);
738            // Drop i when it is mostly inside a strictly larger code box j.
739            let overlap = inter(&kept[i], kept[j].l, kept[j].t, kept[j].r, kept[j].b);
740            if aj > ai && overlap / ai > 0.7 {
741                drop[i] = true;
742                break;
743            }
744        }
745    }
746    let mut keep = drop.iter();
747    kept.retain(|_| !*keep.next().unwrap());
748}
749
750/// Fraction of the page's non-empty text cells that some detected region
751/// claims (>0.2 intersection-over-self, docling's assignment rule). 1.0 for a
752/// page without text cells.
753///
754/// The int8-layout guard keys off this: a dense digital page whose detections
755/// cover almost none of its text is the signature of quantized confidences
756/// flipping under the 0.5 label thresholds on this CPU's kernels — not of a
757/// genuinely empty layout — and is worth re-running on the fp32 graph.
758pub fn layout_cell_coverage(regions: &[Region], cells: &[TextCell]) -> f32 {
759    let mut total = 0usize;
760    let mut covered = 0usize;
761    for c in cells {
762        if c.text.trim().is_empty() {
763            continue;
764        }
765        total += 1;
766        let ca = area(c.l, c.t, c.r, c.b).max(1.0);
767        if regions
768            .iter()
769            .any(|r| inter(r, c.l, c.t, c.r, c.b) / ca > 0.2)
770        {
771            covered += 1;
772        }
773    }
774    if total == 0 {
775        1.0
776    } else {
777        covered as f32 / total as f32
778    }
779}
780
781/// Append `text` regions for cells the layout left uncovered ("orphan cells"),
782/// the way docling's `LayoutPostprocessor` does (`create_orphan_clusters`): any
783/// non-empty cell that no kept region covers (>50% of the cell's area) becomes a
784/// text region of its own, so text the detector missed (a stray `.`, a small
785/// label) is still emitted instead of silently dropped. Adjacent orphan cells on a
786/// line are merged so a missed paragraph doesn't shatter into one block per line.
787pub fn add_orphan_regions(regions: &mut Vec<Region>, cells: &[TextCell]) {
788    // docling assigns each cell to its single best-overlapping cluster at
789    // intersection-over-self > 0.2 and serializes exactly the assigned cells —
790    // and since [`region_texts_exclusive`] now emits under that very rule, the
791    // claim test here matches it: any cell over 0.2 will actually render in
792    // its best region, everything else becomes an orphan. Completeness by
793    // construction, with no (0.2, 0.5] hole (the old > 0.5 serializer needed
794    // the claim test raised to > 0.5 to keep right_to_left_03's `20300` from
795    // vanishing; the exclusive port closes that structurally).
796    //
797    // Only *regular* clusters claim cells: docling's `_find_unassigned_cells`
798    // walks `regular_clusters` alone, so a cell under a `picture` or a wrapper
799    // (`table`/`document_index`/`form`/`key_value_region`) that no regular
800    // cluster covers still becomes an orphan text cluster (#165). The orphans
801    // that end up *fully* inside the special are re-dropped by
802    // [`drop_contained_regulars`] (docling's Markdown drops them the same way
803    // — a picture's children never reach its `MarkdownPictureSerializer`
804    // output, a table's text renders through the reconstructed grid). The
805    // observable fix is the border-straddlers: a line only partially under a
806    // figure box used to lose its cells to the picture's 0.2 claim and vanish
807    // — now it forms an orphan region and is emitted, as docling does.
808    let assigned = |c: &TextCell| {
809        let ca = area(c.l, c.t, c.r, c.b).max(1.0);
810        regions
811            .iter()
812            .filter(|r| r.label != "picture" && !is_wrapper(r.label))
813            .any(|r| inter(r, c.l, c.t, c.r, c.b) / ca > 0.2)
814    };
815    // Collect orphan cells (non-empty, unassigned), in page order.
816    let mut orphans: Vec<&TextCell> = cells
817        .iter()
818        .filter(|c| !c.text.trim().is_empty() && !assigned(c))
819        .collect();
820    if orphans.is_empty() {
821        return;
822    }
823    orphans.sort_by(|a, b| a.t.total_cmp(&b.t).then(a.l.total_cmp(&b.l)));
824    // Merge cells that sit on the same line and nearly touch into one region, so a
825    // dropped multi-word line stays one block (docling's refinement merges these).
826    let mut merged: Vec<Region> = Vec::new();
827    for c in orphans {
828        let h = (c.b - c.t).abs().max(1.0);
829        if let Some(last) = merged.last_mut() {
830            let same_line = (last.t - c.t).abs() < h * 0.5;
831            let touching = c.l <= last.r + h && c.l >= last.l - h;
832            // Both tolerances scale with the cell's own height, so a run set
833            // vertically (the arXiv stamp up the margin, #528 — a cell a few
834            // points wide and hundreds tall) would read as "on the line" of
835            // whatever precedes it and glue a whole column into one region.
836            // Lines of one row differ by a drop cap's few multiples at most.
837            let lh = (last.b - last.t).abs().max(1.0);
838            let comparable = h <= 4.0 * lh && lh <= 4.0 * h;
839            if same_line && touching && comparable {
840                last.l = last.l.min(c.l);
841                last.r = last.r.max(c.r);
842                last.t = last.t.min(c.t);
843                last.b = last.b.max(c.b);
844                continue;
845            }
846        }
847        merged.push(Region {
848            label: "text",
849            score: 0.0,
850            l: c.l,
851            t: c.t,
852            r: c.r,
853            b: c.b,
854        });
855    }
856    regions.extend(merged);
857}
858
859/// Demote a `picture` region that is really a **text panel** — a paragraph block
860/// the layout model boxed as a figure because it is typeset on a colored
861/// background (terms-and-conditions callouts, quote boxes) — into ordinary
862/// `text` regions, one per paragraph, so its words are read instead of shipped
863/// as pixels. docling loses this text the same way (cells assigned to a picture
864/// cluster are never serialized); this is a deliberate improvement, not parity.
865///
866/// The gate is conservative so a genuine figure keeps its crop: the region must
867/// contain at least three text lines whose median width spans most of the panel
868/// (axis labels and chat bubbles are narrow and varied) and whose cells cover a
869/// substantial fraction of its area (a photo or chart with sparse labels does
870/// not). Paragraph boundaries are re-derived from the line pitch: a vertical gap
871/// clearly larger than the panel's own leading starts a new `text` region, so
872/// the panel doesn't collapse into one giant paragraph.
873///
874/// Works on any cell source — the digital text layer or OCR lines recognized
875/// from the picture crop — so the native and browser paths, with or without
876/// force-OCR, demote identically.
877pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
878    // A *captioned* picture is a genuine figure whatever it contains — the
879    // corpus is full of document screenshots ("Figure 3: …" above a page
880    // image) that are exactly as dense and wide as a text panel. Only an
881    // uncaptioned picture is a demotion candidate.
882    let captioned: Vec<bool> = regions
883        .iter()
884        .map(|r| {
885            r.label == "picture"
886                && regions.iter().any(|c| {
887                    c.label == "caption" && c.r.min(r.r) - c.l.max(r.l) > 0.0 && {
888                        let gap = if c.t >= r.b {
889                            c.t - r.b
890                        } else if r.t >= c.b {
891                            r.t - c.b
892                        } else {
893                            f32::MAX // vertically overlapping: not a caption
894                        };
895                        gap <= 25.0
896                    }
897                })
898        })
899        .collect();
900    let mut out: Vec<Region> = Vec::with_capacity(regions.len());
901    // Synthesized paragraphs and the demoted panels' boxes are kept separate
902    // from `out` until the end: the dedup filter below must not confuse a
903    // paragraph we just built with a pre-existing region inside the panel.
904    let mut demoted_paras: Vec<Region> = Vec::new();
905    let mut demoted_boxes: Vec<(f32, f32, f32, f32)> = Vec::new();
906    for (i, r) in regions.drain(..).enumerate() {
907        if r.label != "picture" || captioned[i] {
908            out.push(r);
909            continue;
910        }
911        let inside: Vec<&TextCell> = cells
912            .iter()
913            .filter(|c| {
914                !c.text.trim().is_empty() && {
915                    let ca = area(c.l, c.t, c.r, c.b).max(1.0);
916                    inter(&r, c.l, c.t, c.r, c.b) / ca > 0.5
917                }
918            })
919            .collect();
920        // Group the contained cells into lines by vertical overlap (the same
921        // rule region_text orders by), tracking each line's union box.
922        let mut lines: Vec<(f32, f32, f32, f32)> = Vec::new(); // (t, b, l, r)
923        for c in &inside {
924            let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
925            match lines.iter_mut().find(|(lt, lb, _, _)| {
926                let ov = cb.min(*lb) - ct.max(*lt);
927                ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
928            }) {
929                Some((lt, lb, ll, lr)) => {
930                    *lt = lt.min(ct);
931                    *lb = lb.max(cb);
932                    *ll = ll.min(c.l);
933                    *lr = lr.max(c.r);
934                }
935                None => lines.push((ct, cb, c.l, c.r)),
936            }
937        }
938        if lines.len() < 3 {
939            out.push(r);
940            continue;
941        }
942        let panel_w = (r.r - r.l).max(1.0);
943        let coverage = inside.iter().map(|c| area(c.l, c.t, c.r, c.b)).sum::<f32>()
944            / area(r.l, r.t, r.r, r.b).max(1.0);
945        let mut widths: Vec<f32> = lines.iter().map(|(_, _, l, rr)| rr - l).collect();
946        widths.sort_by(f32::total_cmp);
947        // A figure's text is ragged: a title line, small axis/tick labels, and
948        // OCR boxes over the plot area come out at wildly different heights,
949        // whereas a real text panel is set in one face with constant leading.
950        // Require near-uniform line heights (median absolute deviation ≤ 35%
951        // of the median) so an uncaptioned chart keeps its crop even when its
952        // labels are dense enough to pass the coverage gate (#173) — garbled
953        // OCR of its bars is not content.
954        let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
955        heights.sort_by(f32::total_cmp);
956        let h_med = heights[heights.len() / 2].max(1.0);
957        let mut devs: Vec<f32> = heights.iter().map(|h| (h - h_med).abs()).collect();
958        devs.sort_by(f32::total_cmp);
959        let uniform = devs[devs.len() / 2] <= 0.35 * h_med;
960        let text_panel = coverage >= 0.2 && widths[widths.len() / 2] >= 0.45 * panel_w && uniform;
961        if !text_panel {
962            out.push(r);
963            continue;
964        }
965        lines.sort_by(|a, b| a.0.total_cmp(&b.0));
966        let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
967        heights.sort_by(f32::total_cmp);
968        let h = heights[heights.len() / 2].max(1.0);
969        let mut gaps: Vec<f32> = lines
970            .windows(2)
971            .map(|w| (w[1].0 - w[0].1).max(0.0))
972            .collect();
973        gaps.sort_by(f32::total_cmp);
974        let leading = if gaps.is_empty() {
975            0.0
976        } else {
977            gaps[gaps.len() / 2]
978        };
979        let brk = (1.8 * leading).max(0.75 * h);
980        let mut para: Option<(f32, f32, f32, f32)> = None; // (l, t, r, b) union
981        for (t, b, l, rr) in &lines {
982            match &mut para {
983                Some((pl, _, pr, pb)) if *t - *pb <= brk => {
984                    *pl = pl.min(*l);
985                    *pr = pr.max(*rr);
986                    *pb = pb.max(*b);
987                }
988                _ => {
989                    if let Some((pl, pt, pr, pb)) = para.take() {
990                        demoted_paras.push(Region {
991                            label: "text",
992                            score: r.score,
993                            l: pl,
994                            t: pt,
995                            r: pr,
996                            b: pb,
997                        });
998                    }
999                    para = Some((*l, *t, *rr, *b));
1000                }
1001            }
1002        }
1003        if let Some((pl, pt, pr, pb)) = para {
1004            demoted_paras.push(Region {
1005                label: "text",
1006                score: r.score,
1007                l: pl,
1008                t: pt,
1009                r: pr,
1010                b: pb,
1011            });
1012        }
1013        demoted_boxes.push((r.l, r.t, r.r, r.b));
1014    }
1015    // The paragraphs are rebuilt from *all* of the panel's cells, so any
1016    // surviving text region inside a demoted panel (an orphan cluster or a
1017    // layout-detected fragment — pictures no longer swallow them, #165) would
1018    // say the same words twice. Consume those; wrappers and pictures stay.
1019    if !demoted_boxes.is_empty() {
1020        out.retain(|r| {
1021            r.label == "picture" || is_wrapper(r.label) || {
1022                let ra = area(r.l, r.t, r.r, r.b).max(1.0);
1023                !demoted_boxes
1024                    .iter()
1025                    .any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.5)
1026            }
1027        });
1028    }
1029    // docling's "Remove regular clusters that are included in wrappers" (a
1030    // regular > 80 % inside a table is absorbed by it) already ran as
1031    // [`drop_contained_regulars`], but before this demotion created new
1032    // regulars. Apply it to them too: a panel that coincides with a table (a
1033    // dense data table detected as picture 0.80 and table 0.62 on one box;
1034    // `_handle_cross_type_overlaps` keeps both once the picture is ≥ 0.1 more
1035    // confident) rebuilds the table's words as a paragraph the grid already
1036    // renders. A panel inside another picture is left as it was.
1037    demoted_paras.retain(|p| {
1038        let pa = area(p.l, p.t, p.r, p.b).max(1.0);
1039        !out.iter()
1040            .any(|s| is_table_like(s.label) && inter(p, s.l, s.t, s.r, s.b) / pa > 0.8)
1041    });
1042    out.extend(demoted_paras);
1043    *regions = out;
1044}
1045
1046/// Drop a `picture` detection covering more than 90 % of the page — docling's
1047/// `LayoutPostprocessor._process_special_clusters` "Filter out full-page
1048/// pictures" (upstream since 2.15), applied to the thresholded detections
1049/// before overlap resolution. A box that big is the page itself, not a figure
1050/// on it: the layout model emits one for a whole-page diagram (a LaTeX figure
1051/// PDF cropped to its drawing), a plate, or a scan, and keeping it would swallow
1052/// every text cell on the page as picture children — the diagram's labels and
1053/// caption vanish behind a lone `<!-- image -->`, where docling reads them out as
1054/// text. `page_w`/`page_h` is the display-frame page box.
1055pub fn drop_full_page_pictures(regions: &mut Vec<Region>, page_w: f32, page_h: f32) {
1056    let page_area = (page_w * page_h).max(1.0);
1057    regions.retain(|r| r.label != "picture" || area(r.l, r.t, r.r, r.b) / page_area <= 0.90);
1058}
1059
1060/// Drop a `picture` detection that is a small, empty, low-confidence margin box on
1061/// a **text page** — a false positive the RT-DETR layout sometimes emits (e.g.
1062/// `right_to_left_02`'s phantom right-column picture, score 0.40); docling does not
1063/// emit it. The gate is deliberately narrow so a genuine figure is never dropped:
1064/// (1) only on pages with a digital text layer — image/scanned/figure pages have
1065/// no `cells` yet at this point (OCR runs later), so their pictures, which *are*
1066/// the content, are kept; (2) only a box covering < 25 % of the page (a margin
1067/// artifact, not a dominant figure); (3) only when it contains no text and scores
1068/// below 0.5 (real empty figures in the corpus all score ≥ 0.86).
1069pub fn drop_false_pictures(
1070    regions: &mut Vec<Region>,
1071    cells: &[TextCell],
1072    page_w: f32,
1073    page_h: f32,
1074) {
1075    if cells.iter().all(|c| c.text.trim().is_empty()) {
1076        return; // no digital text layer (image/scanned page) — keep all pictures
1077    }
1078    // A text-document page carries several text-bearing non-picture regions (so a
1079    // spurious margin picture is clearly extra). A slide / figure page has at most
1080    // one — there the picture is the content, so never drop it.
1081    let content_regions = regions
1082        .iter()
1083        .filter(|r| r.label != "picture" && !region_text(r, cells).trim().is_empty())
1084        .count();
1085    if content_regions < 2 {
1086        return;
1087    }
1088    let page_area = (page_w * page_h).max(1.0);
1089    regions.retain(|r| {
1090        if r.label != "picture" || r.score >= 0.5 {
1091            return true;
1092        }
1093        if area(r.l, r.t, r.r, r.b) / page_area >= 0.25 {
1094            return true; // a dominant figure, not a margin artifact
1095        }
1096        // Keep it if any text cell falls mostly inside (a real captioned/labelled
1097        // figure); drop only the genuinely empty low-confidence boxes.
1098        cells.iter().any(|c| {
1099            let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1100            !c.text.trim().is_empty() && inter(r, c.l, c.t, c.r, c.b) / ca > 0.5
1101        })
1102    });
1103}
1104
1105/// A small digit-only region in the top/bottom margin: a page number. docling
1106/// emits `right_to_left_02`'s bottom `11` as the page's *first* text item (its
1107/// reading-order model floats the page number to the front), whereas our
1108/// position-based ordering would place a bottom region last.
1109fn is_page_number(region: &Region, cells: &[TextCell], page_h: f32) -> bool {
1110    let t = region_text(region, cells);
1111    let t = t.trim();
1112    !t.is_empty()
1113        && t.chars().all(|c| c.is_ascii_digit())
1114        && (region.b - region.t).abs() < 30.0
1115        && (region.t < page_h * 0.12 || region.b > page_h * 0.88)
1116}
1117
1118/// docling's `form` / `key_value_region` *containers* (2.123, docling#4064):
1119/// every region sitting > 0.8 inside one — text, list items, and since #4064
1120/// tables and pictures too — is that container's child. Children are
1121/// reading-ordered among themselves and emitted as one block where the
1122/// container falls in the page's top-level order (a `form_area` /
1123/// `key_value_area` group upstream), instead of interleaving with the text
1124/// around the form. A child inside several containers belongs to the smallest
1125/// (then most confident, then first); a container with children shrinks to
1126/// their union for the top-level ordering, like upstream's bbox adjustment.
1127///
1128/// The containers themselves are still not emitted (`is_skipped`), so the
1129/// Markdown is exactly upstream's — a group prints only its children.
1130///
1131/// `cids` are the items' positions in docling's assembly order
1132/// ([`cluster_cids`]) — the reading-order predictor's same-row rule (#424)
1133/// pairs consecutive ones, within the top level and within each container.
1134fn order_with_containers<T: Clone>(
1135    items: &mut Vec<T>,
1136    cids: &[usize],
1137    page_w: f32,
1138    page_h: f32,
1139    reg: impl Fn(&T) -> &Region,
1140) {
1141    let is_container = |r: &Region| matches!(r.label, "form" | "key_value_region");
1142    let containers: Vec<usize> = (0..items.len())
1143        .filter(|&i| is_container(reg(&items[i])))
1144        .collect();
1145    if containers.is_empty() {
1146        order_regions(items, cids, page_w, page_h, reg);
1147        return;
1148    }
1149    // Parent container per item (containers never nest in each other here —
1150    // upstream assigns regulars and tables/pictures only).
1151    let mut parent: Vec<Option<usize>> = vec![None; items.len()];
1152    for i in 0..items.len() {
1153        let r = reg(&items[i]);
1154        if is_container(r) {
1155            continue;
1156        }
1157        let ra = area(r.l, r.t, r.r, r.b).max(1.0);
1158        let mut best: Option<(usize, f32, f32)> = None; // (idx, area, -score)
1159        for &c in &containers {
1160            let cr = reg(&items[c]);
1161            if inter(r, cr.l, cr.t, cr.r, cr.b) / ra > 0.8 {
1162                let key = (area(cr.l, cr.t, cr.r, cr.b), -cr.score);
1163                if best.is_none_or(|(_, a, s)| key.0 < a || (key.0 == a && key.1 < s)) {
1164                    best = Some((c, key.0, key.1));
1165                }
1166            }
1167        }
1168        parent[i] = best.map(|(c, _, _)| c);
1169    }
1170    // Top-level pass: non-children plus the containers, the latter shrunk to
1171    // their children's union.
1172    let mut top: Vec<(usize, Region)> = Vec::new();
1173    for i in 0..items.len() {
1174        if parent[i].is_some() {
1175            continue;
1176        }
1177        let mut r = reg(&items[i]).clone();
1178        if is_container(&r) {
1179            let kids: Vec<&Region> = (0..items.len())
1180                .filter(|&k| parent[k] == Some(i))
1181                .map(|k| reg(&items[k]))
1182                .collect();
1183            if !kids.is_empty() {
1184                r.l = kids.iter().map(|k| k.l).fold(f32::INFINITY, f32::min);
1185                r.t = kids.iter().map(|k| k.t).fold(f32::INFINITY, f32::min);
1186                r.r = kids.iter().map(|k| k.r).fold(f32::NEG_INFINITY, f32::max);
1187                r.b = kids.iter().map(|k| k.b).fold(f32::NEG_INFINITY, f32::max);
1188            }
1189        }
1190        top.push((i, r));
1191    }
1192    let top_cids: Vec<usize> = top.iter().map(|(i, _)| cids[*i]).collect();
1193    order_regions(&mut top, &top_cids, page_w, page_h, |it| &it.1);
1194    let mut out: Vec<T> = Vec::with_capacity(items.len());
1195    for (i, _) in top {
1196        if is_container(reg(&items[i])) {
1197            let kid_idx: Vec<usize> = (0..items.len()).filter(|&k| parent[k] == Some(i)).collect();
1198            let mut kids: Vec<T> = kid_idx.iter().map(|&k| items[k].clone()).collect();
1199            let kid_cids: Vec<usize> = kid_idx.iter().map(|&k| cids[k]).collect();
1200            order_regions(&mut kids, &kid_cids, page_w, page_h, &reg);
1201            out.push(items[i].clone());
1202            out.extend(kids);
1203        } else {
1204            out.push(items[i].clone());
1205        }
1206    }
1207    *items = out;
1208}
1209
1210/// Furniture / not-yet-emitted labels.
1211fn is_skipped(label: &str) -> bool {
1212    matches!(
1213        label,
1214        "page_header" | "page_footer" | "form" | "key_value_region"
1215    )
1216}
1217
1218/// Reading-order sort of a page's regions, via the ported rule-based
1219/// [`reading_order`](crate::reading_order) predictor (docling's
1220/// `ReadingOrderPredictor`): an up/down geometry graph with same-row links
1221/// between `cids`-consecutive elements (#424), horizontal dilation and a
1222/// depth-first traversal, with `page_header`/`page_footer` ordered as their own
1223/// groups (first/last) as docling does.
1224fn order_regions<T: Clone>(
1225    items: &mut Vec<T>,
1226    cids: &[usize],
1227    page_w: f32,
1228    page_h: f32,
1229    reg: impl Fn(&T) -> &Region,
1230) {
1231    let boxes: Vec<(f32, f32, f32, f32)> = items
1232        .iter()
1233        .map(|it| {
1234            let r = reg(it);
1235            (r.l, r.t, r.r, r.b)
1236        })
1237        .collect();
1238    let is_header: Vec<bool> = items
1239        .iter()
1240        .map(|it| reg(it).label == "page_header")
1241        .collect();
1242    let is_footer: Vec<bool> = items
1243        .iter()
1244        .map(|it| reg(it).label == "page_footer")
1245        .collect();
1246    let order =
1247        crate::reading_order::order_page(&boxes, cids, &is_header, &is_footer, page_w, page_h);
1248    *items = order.iter().map(|&i| items[i].clone()).collect();
1249}
1250
1251/// docling's assembly order of a page's clusters (`LayoutPostprocessor`'s
1252/// final `_sort_clusters(mode="id")`, #424): each region's rank when sorted by
1253/// its first source cell, then by top edge, then left edge; a region with no
1254/// cells sorts after every one that has some. docling numbers its page
1255/// elements (`cid`) in this order, and the reading-order predictor's same-row
1256/// rule pairs elements with consecutive numbers, so the ranks are what
1257/// [`order_with_containers`] hands the predictor.
1258///
1259/// A regular region's first cell is the smallest index among the cells it
1260/// claims. A table, picture or container has no cells of its own upstream
1261/// either — its cells are its *children's*: the regular clusters > 0.8 inside
1262/// it, and upstream every cell no regular cluster claimed is an orphan cluster
1263/// of its own, so a table's interior text (which no regular cluster claims)
1264/// reaches the table through those orphans. Here that is the cells > 0.8
1265/// inside the region plus the claimed cells of the regular regions > 0.8
1266/// inside it. Without the interior cells every table would sort last, and two
1267/// side-by-side tables would then be consecutive and row-linked — reading the
1268/// right table's caption ahead of the left column's headings (2206 page 8).
1269pub fn cluster_cids(regions: &[Region], cells: &[TextCell]) -> Vec<usize> {
1270    let owned = assign_cells(regions, cells);
1271    let first_cell: Vec<usize> = regions
1272        .iter()
1273        .enumerate()
1274        .map(|(i, r)| {
1275            if claims_cells(r) {
1276                return owned[i].iter().copied().min().unwrap_or(usize::MAX);
1277            }
1278            let interior = cells
1279                .iter()
1280                .enumerate()
1281                .filter(|(_, c)| {
1282                    !c.text.trim().is_empty()
1283                        && inter(r, c.l, c.t, c.r, c.b) / area(c.l, c.t, c.r, c.b).max(1.0) > 0.8
1284                })
1285                .map(|(ci, _)| ci)
1286                .min();
1287            let children = regions
1288                .iter()
1289                .enumerate()
1290                .filter(|(j, child)| {
1291                    *j != i && claims_cells(child) && {
1292                        let ca = area(child.l, child.t, child.r, child.b).max(1.0);
1293                        inter(r, child.l, child.t, child.r, child.b) / ca > 0.8
1294                    }
1295                })
1296                .filter_map(|(j, _)| owned[j].iter().copied().min())
1297                .min();
1298            interior
1299                .into_iter()
1300                .chain(children)
1301                .min()
1302                .unwrap_or(usize::MAX)
1303        })
1304        .collect();
1305    let mut by_source: Vec<usize> = (0..regions.len()).collect();
1306    // Stable, like Python's `sorted`: full ties keep the layout order.
1307    by_source.sort_by(|&a, &b| {
1308        first_cell[a]
1309            .cmp(&first_cell[b])
1310            .then(regions[a].t.total_cmp(&regions[b].t))
1311            .then(regions[a].l.total_cmp(&regions[b].l))
1312    });
1313    let mut cids = vec![0; regions.len()];
1314    for (rank, &i) in by_source.iter().enumerate() {
1315        cids[i] = rank;
1316    }
1317    cids
1318}
1319
1320/// Clean a region's assembled text: undo soft-hyphen line wraps, map curly
1321/// quotes and the ellipsis to ASCII (matching docling), and collapse runs of
1322/// whitespace. pdfium emits the line-wrap hyphen as U+0002 in this corpus
1323/// (U+00AD elsewhere), so `word\u{2} continuation` is one hyphenated word —
1324/// drop the hyphen + the joining space and merge (`com\u{2} pact` → `compact`,
1325/// `end-to\u{2} end` → `end-toend`), exactly as docling does.
1326///
1327/// Token spacing is otherwise left as the geometric join produced it. We do not
1328/// tighten punctuation spacing: docling preserves the PDF's own spaces (it keeps
1329/// `{ ahn }`, `Name 1 .`, `[ 9 ]`), and a geometric gap heuristic diverges from
1330/// it more than a plain single-space join does.
1331/// An ordered-list enumeration marker at the start of a list item: leading ASCII
1332/// digits followed by `.`, e.g. `1. Undo/Redo` → `(1, "Undo/Redo")`. Returns
1333/// `None` when the text doesn't start with `digits.`.
1334/// docling's `ListItemMarkerProcessor` bullet patterns
1335/// (`docling/models/postprocessing/list_marker_processor.py`), one glyph each.
1336const LIST_BULLET_MARKERS: &str = "\u{2022}\u{2023}\u{25E6}\u{2043}\u{204C}\u{204D}\u{2219}\u{25AA}\u{25AB}\u{25CF}\u{25CB}-*+•·‣⁃►▶▸➤➢✓✔✗✘";
1337
1338/// docling's numbered-marker patterns as byte-length scanners over the start
1339/// of the text, in its first-wins order (the compound ones first, as they are
1340/// the more specific). Each returns the marker's candidate lengths, longest
1341/// (greedy) first — the alternatives Python's regex would backtrack through
1342/// before the `\s(.+)` tail. Hand-rolled rather than `regex` because this file
1343/// is part of the `pdf-text` (wasm) build, where the `regex` crate is not.
1344/// `\d` is Unicode-aware in Python, hence `char::is_numeric`; the letters are
1345/// ASCII classes in both.
1346const LIST_NUMBERED_MARKERS: &[fn(&str) -> Vec<usize>] = &[
1347    // `\d+(?:\.\d+)+\.?` — 1.1  1.2.3  1.1.
1348    |s| {
1349        let mut i = digits(s, 0);
1350        if i == 0 {
1351            return Vec::new();
1352        }
1353        let mut groups = 0;
1354        while s[i..].starts_with('.') && digits(s, i + 1) > i + 1 {
1355            i = digits(s, i + 1);
1356            groups += 1;
1357        }
1358        if groups == 0 {
1359            return Vec::new();
1360        }
1361        if s[i..].starts_with('.') {
1362            vec![i + 1, i]
1363        } else {
1364            vec![i]
1365        }
1366    },
1367    // `\d+\.?[a-zA-Z]\.` — 9a. 3.a.
1368    |s| digits_dot_letter(s, 0, '.').into_iter().collect(),
1369    // `\d+\.?[a-zA-Z]\)` — 9a) 3.a)
1370    |s| digits_dot_letter(s, 0, ')').into_iter().collect(),
1371    // `\(\d+\.?[a-zA-Z]\)` — (9a) (3.a)
1372    |s| {
1373        if !s.starts_with('(') {
1374            return Vec::new();
1375        }
1376        digits_dot_letter(s, 1, ')').into_iter().collect()
1377    },
1378    // `\d+\.` — 1. 2. 3.
1379    |s| digits_then(s, 0, '.').into_iter().collect(),
1380    // `\d+\)` — 1) 2) 3)
1381    |s| digits_then(s, 0, ')').into_iter().collect(),
1382    // `\(\d+\)` — (1) (2) (3)
1383    |s| {
1384        if !s.starts_with('(') {
1385            return Vec::new();
1386        }
1387        digits_then(s, 1, ')').into_iter().collect()
1388    },
1389    // `\[\d+\]` — [1] [2] [3]
1390    |s| {
1391        if !s.starts_with('[') {
1392            return Vec::new();
1393        }
1394        digits_then(s, 1, ']').into_iter().collect()
1395    },
1396    // `[ivxlcdm]+\.` — i. ii. iii.
1397    |s| class_run_then(s, "ivxlcdm", '.').into_iter().collect(),
1398    // `[IVXLCDM]+\.` — I. II. III.
1399    |s| class_run_then(s, "IVXLCDM", '.').into_iter().collect(),
1400    // `[a-z]\.` / `[A-Z]\.` / `[a-z]\)` / `[A-Z]\)`
1401    |s| {
1402        letter_then(s, char::is_ascii_lowercase, '.')
1403            .into_iter()
1404            .collect()
1405    },
1406    |s| {
1407        letter_then(s, char::is_ascii_uppercase, '.')
1408            .into_iter()
1409            .collect()
1410    },
1411    |s| {
1412        letter_then(s, char::is_ascii_lowercase, ')')
1413            .into_iter()
1414            .collect()
1415    },
1416    |s| {
1417        letter_then(s, char::is_ascii_uppercase, ')')
1418            .into_iter()
1419            .collect()
1420    },
1421];
1422
1423/// Byte offset just past the run of `\d` characters starting at `from`
1424/// (`from` itself when there is none).
1425fn digits(s: &str, from: usize) -> usize {
1426    s[from..]
1427        .char_indices()
1428        .find(|(_, c)| !c.is_numeric())
1429        .map_or(s.len(), |(i, _)| from + i)
1430}
1431
1432/// `\d+<close>` from `from`: the length through `close`, if it matches.
1433fn digits_then(s: &str, from: usize, close: char) -> Option<usize> {
1434    let end = digits(s, from);
1435    (end > from && s[end..].starts_with(close)).then(|| end + close.len_utf8())
1436}
1437
1438/// `\d+\.?[a-zA-Z]<close>` from `from`.
1439fn digits_dot_letter(s: &str, from: usize, close: char) -> Option<usize> {
1440    let mut i = digits(s, from);
1441    if i == from {
1442        return None;
1443    }
1444    if s[i..].starts_with('.') {
1445        i += 1;
1446    }
1447    let letter = s[i..].chars().next().filter(char::is_ascii_alphabetic)?;
1448    i += letter.len_utf8();
1449    s[i..].starts_with(close).then(|| i + close.len_utf8())
1450}
1451
1452/// `[<class>]+<close>` at the start.
1453fn class_run_then(s: &str, class: &str, close: char) -> Option<usize> {
1454    let end = s
1455        .char_indices()
1456        .find(|(_, c)| !class.contains(*c))
1457        .map_or(s.len(), |(i, _)| i);
1458    (end > 0 && s[end..].starts_with(close)).then(|| end + close.len_utf8())
1459}
1460
1461/// `[<letter class>]<close>` at the start.
1462fn letter_then(s: &str, class: fn(&char) -> bool, close: char) -> Option<usize> {
1463    let letter = s.chars().next().filter(class)?;
1464    let i = letter.len_utf8();
1465    s[i..].starts_with(close).then(|| i + close.len_utf8())
1466}
1467
1468/// docling's `ListItemMarkerProcessor.process_list_item`: the item's original
1469/// text is matched against `^(marker)\s(.+)` (DOTALL) for the bullet patterns,
1470/// then the numbered ones in order; a hit splits it into
1471/// `(marker, text, enumerated)`. `\s` is one Unicode whitespace character and
1472/// `.+` everything after it, which must be non-empty.
1473fn split_list_marker(text: &str) -> Option<(&str, &str, bool)> {
1474    let tail_after = |len: usize| -> Option<&str> {
1475        let ws = text[len..].chars().next()?;
1476        if !ws.is_whitespace() {
1477            return None;
1478        }
1479        let rest = &text[len + ws.len_utf8()..];
1480        (!rest.is_empty()).then_some(rest)
1481    };
1482    let first = text.chars().next()?;
1483    if LIST_BULLET_MARKERS.contains(first) {
1484        if let Some(rest) = tail_after(first.len_utf8()) {
1485            return Some((&text[..first.len_utf8()], rest, false));
1486        }
1487    }
1488    for matcher in LIST_NUMBERED_MARKERS {
1489        for len in matcher(text) {
1490            if let Some(rest) = tail_after(len) {
1491                return Some((&text[..len], rest, true));
1492            }
1493        }
1494    }
1495    None
1496}
1497
1498/// A PDF `list_item` region as docling emits it: `ListItemMarkerProcessor`
1499/// splits the marker off (see [`split_list_marker`]), and docling-core's
1500/// Markdown list serializer (default `orig_list_item_marker_mode = AUTO`,
1501/// `ensure_valid_list_item_marker`) prints it as `N. text` for an `N.` marker
1502/// (`case_already_valid`: the marker verbatim); as `- text` for a bullet glyph
1503/// (no letter or digit in the marker: only the `-` the serializer adds); and
1504/// as `- a) text` / `- 1.2 text` / `- [3] text` for any other marker
1505/// (`case_auto`: the serializer's `-`, then the original marker) — spelled
1506/// here as a bullet item whose text carries the marker, the way the DOCX and
1507/// DOC backends already spell theirs: the Markdown fields then carry no
1508/// marker (the serializer applies `case_auto` to a marker it is given, so it
1509/// would print it a second time, `- (1) (1) text`), and the DocLang
1510/// `<marker>` rides in the [`ListItemDclx`] overlay. An item without a recognizable marker is
1511/// a plain bullet. The symbol-font bullets docling-parse filters out of its
1512/// cells (`•◦▪·*` glued to the text) are stripped before the match, as before.
1513fn list_item_node(text: &str, loc: [u16; 4], first_in_list: bool) -> Node {
1514    // docling's match runs on the text as docling-parse hands it over; the
1515    // glued symbol-font bullets it never sees are stripped only when the raw
1516    // text matches no marker (`•Text` → `Text`, but `• Text` → marker `•`).
1517    let stripped = text
1518        .trim_start_matches(['•', '◦', '▪', '·', '*'])
1519        .trim_start();
1520    let split = split_list_marker(text).or_else(|| split_list_marker(stripped));
1521    let bullet = |text: String, marker: &str| Node::ListItem {
1522        ordered: false,
1523        number: 0,
1524        first_in_list,
1525        text: md_escape(&text),
1526        level: 0,
1527        // docling keeps the marker as the DocLang list marker
1528        // (`<ldiv><marker>·</marker></ldiv>`); Markdown prints its own `-`.
1529        marker: Some(marker.to_string()),
1530        location: Some(loc),
1531        dclx: None,
1532        href: None,
1533        layer: None,
1534    };
1535    // docling-core's `case_already_valid` is a *full* `\d+\.` match: `3.a.`
1536    // and `1.2` are `case_auto` markers (`- 3.a. text`), not item numbers.
1537    let is_number_dot = |m: &str| {
1538        m.strip_suffix('.')
1539            .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
1540    };
1541    match split {
1542        Some((marker, body, true)) if is_number_dot(marker) => {
1543            let number = parse_ordered_marker(marker).map_or(0, |(n, _)| n);
1544            Node::ListItem {
1545                ordered: true,
1546                number,
1547                first_in_list,
1548                text: md_escape(body),
1549                level: 0,
1550                marker: Some(marker.to_string()),
1551                location: Some(loc),
1552                dclx: None,
1553                href: None,
1554                layer: None,
1555            }
1556        }
1557        // `case_auto`: a marker holding a letter or digit rides in the text,
1558        // so not in the Markdown `marker` (docling-core's serializer would add
1559        // it again); DocLang keeps it through the overlay, whose text is the
1560        // node's own, so the DocLang output is what it was.
1561        Some((marker, body, true)) => {
1562            let text = md_escape(&format!("{marker} {body}"));
1563            Node::ListItem {
1564                ordered: false,
1565                number: 0,
1566                first_in_list,
1567                text: text.clone(),
1568                level: 0,
1569                marker: None,
1570                location: Some(loc),
1571                dclx: Some(ListItemDclx {
1572                    ordered: false,
1573                    marker: Some(marker.to_string()),
1574                    text,
1575                    runs: Vec::new(),
1576                }),
1577                href: None,
1578                layer: None,
1579            }
1580        }
1581        Some((marker, body, false)) => bullet(body.to_string(), marker),
1582        None => bullet(stripped.to_string(), "·"),
1583    }
1584}
1585
1586fn parse_ordered_marker(s: &str) -> Option<(u64, String)> {
1587    let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
1588    if digits.is_empty() {
1589        return None;
1590    }
1591    let rest = s[digits.len()..].strip_prefix('.')?;
1592    let number = digits.parse().ok()?;
1593    Some((number, rest.trim_start().to_string()))
1594}
1595
1596/// Escape markdown special characters the way docling-core's markdown serializer
1597/// does (`markdown.py` post_process): `_` → `\_`, then HTML-escape `&`, `<`, `>`
1598/// (quote=False, so quotes are left). Applied to prose (headings, list items,
1599/// paragraphs); code blocks, the formula placeholder, and table cells are left raw.
1600fn md_escape(text: &str) -> String {
1601    text.replace('_', "\\_")
1602        .replace('&', "&amp;")
1603        .replace('<', "&lt;")
1604        .replace('>', "&gt;")
1605}
1606
1607fn clean_text(text: &str) -> String {
1608    // Typographic-quote normalization follows docling-parse's sanitizer table
1609    // (`pdf_sanitators/constants.h`): every curly quote — single *and double* —
1610    // becomes the ASCII apostrophe `'`, and `‚` a comma. A `"` in docling's
1611    // output only ever comes from a literal `quotedbl` glyph, never from `“ ”`
1612    // (2206's `'text in the wild"` pairs a curly open with a literal-quote
1613    // close). This replaces an earlier Hangul-only special case that patched
1614    // one symptom of mapping `“ ”` to `"`.
1615    let replaced = text
1616        .replace("\u{2} ", "")
1617        .replace("\u{ad} ", "")
1618        .replace(['\u{2}', '\u{ad}'], "") // any stray wrap hyphens not at a join
1619        .replace(
1620            [
1621                '\u{2018}', '\u{2019}', '\u{201b}', '\u{201c}', '\u{201d}', '\u{201e}', '\u{201f}',
1622            ],
1623            "'",
1624        ) // ‘ ’ ‛ “ ” „ ‟ → '
1625        .replace('\u{201a}', ",") // ‚ → ,
1626        .replace(
1627            [
1628                '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
1629            ],
1630            "-",
1631        ) // hyphen/dash family → -
1632        .replace('\u{2044}', "/") // ⁄ fraction slash → /
1633        .replace('\u{2022}', "\u{b7}") // • → · (docling never emits •; inline CCS-concept separators)
1634        .replace('\u{2026}', "..."); // … → ...
1635                                     // The docling-parse sanitizer already placed the correct spacing (e.g.
1636                                     // justified double spaces); preserve internal runs of spaces, only
1637                                     // normalizing line breaks/tabs and trimming the ends.
1638    let out = replaced.replace(['\n', '\r', '\t'], " ").trim().to_string();
1639    fix_arabic_lam_alef(&out)
1640}
1641
1642/// pdfium decomposes the Arabic lam-alef ligature (لا / لإ / لأ / لآ) into its
1643/// glyph constituents in *visual* order — `alef-variant, lam` — but docling keeps
1644/// logical order, `lam, alef-variant`. Swap a mid-word `alef-variant + lam` back
1645/// to `lam + alef-variant`. "Mid-word" (the previous char is an Arabic letter)
1646/// distinguishes the ligature from the definite article `ال` (word-initial
1647/// `alef + lam`), which must stay. No-op for non-Arabic text.
1648fn fix_arabic_lam_alef(s: &str) -> String {
1649    let is_arabic_letter = |c: char| ('\u{0620}'..='\u{064A}').contains(&c);
1650    let chars: Vec<char> = s.chars().collect();
1651    if !chars.iter().any(|&c| is_arabic_letter(c)) {
1652        return s.to_string(); // no-op for non-Arabic text
1653    }
1654    // Pass 1: swap mid-word `alef-variant + lam` → `lam + alef-variant`. Only the
1655    // hamza/madda alef variants (إ أ آ) are safe: the definite article is always
1656    // plain `ا + ل`, so plain `alef + lam` is ambiguous (a legitimate `فعالة` vs a
1657    // reversed `لا` ligature look identical) — leaving plain alef alone avoids
1658    // corrupting legitimate words.
1659    let mut a: Vec<char> = Vec::with_capacity(chars.len());
1660    let mut i = 0;
1661    while i < chars.len() {
1662        let c = chars[i];
1663        if matches!(c, '\u{0622}' | '\u{0623}' | '\u{0625}')
1664            && chars.get(i + 1) == Some(&'\u{0644}')
1665            && i > 0
1666            && is_arabic_letter(chars[i - 1])
1667            // A preceding lam means this alef-variant is *already* the logical
1668            // `lam + alef` ligature; the following lam is the next syllable's
1669            // letter, not a reversed ligature — swapping it corrupts `لآل` → `للآ`
1670            // (e.g. التعلم الآلي → الآلي, not اللآي).
1671            && chars[i - 1] != '\u{0644}'
1672        {
1673            a.push('\u{0644}');
1674            a.push(c);
1675            i += 2;
1676            continue;
1677        }
1678        a.push(c);
1679        i += 1;
1680    }
1681    // Pass 2: insert a space at Arabic↔Latin boundaries (bidi script switch) that
1682    // pdfium runs together — docling separates the embedded Latin run (`وPython`
1683    // → `و Python`).
1684    let mut out: Vec<char> = Vec::with_capacity(a.len());
1685    for (j, &c) in a.iter().enumerate() {
1686        if j > 0 {
1687            let p = a[j - 1];
1688            if (is_arabic_letter(p) && c.is_ascii_alphabetic())
1689                || (p.is_ascii_alphabetic() && is_arabic_letter(c))
1690            {
1691                out.push(' ');
1692            }
1693        }
1694        out.push(c);
1695    }
1696    out.into_iter().collect()
1697}
1698
1699/// docling's `PageAssembleModel._match_hyperlink`: the URI whose link
1700/// annotations cover at least half of the region's box, or `None`. Coverage is
1701/// intersection-over-region-area, **accumulated per URI** — a URL that wraps
1702/// across lines carries several annotation rects that sum toward the same
1703/// target. Ties resolve to the first-seen URI (Python's `max` over dict
1704/// insertion order); the winner still needs `>= 0.5`
1705/// (`_HYPERLINK_COVERAGE_THRESHOLD`).
1706pub(crate) fn region_hyperlink(
1707    region: &Region,
1708    links: &[crate::pdfium_backend::LinkAnnot],
1709) -> Option<String> {
1710    if links.is_empty() {
1711        return None;
1712    }
1713    let area = (region.r - region.l).max(0.0) * (region.b - region.t).max(0.0);
1714    if area <= 0.0 {
1715        return None;
1716    }
1717    let mut coverage: Vec<(&str, f32)> = Vec::new();
1718    for link in links {
1719        let ix = (region.r.min(link.r) - region.l.max(link.l)).max(0.0);
1720        let iy = (region.b.min(link.b) - region.t.max(link.t)).max(0.0);
1721        let c = ix * iy / area;
1722        match coverage.iter_mut().find(|(uri, _)| *uri == link.uri) {
1723            Some((_, acc)) => *acc += c,
1724            None => coverage.push((&link.uri, c)),
1725        }
1726    }
1727    let mut best: Option<(&str, f32)> = None;
1728    for (uri, c) in coverage {
1729        // Strictly greater keeps the first-seen URI on ties, like Python's max.
1730        if best.is_none_or(|(_, bc)| c > bc) {
1731            best = Some((uri, c));
1732        }
1733    }
1734    let (uri, c) = best?;
1735    (c >= 0.5).then(|| normalize_uri(uri))
1736}
1737
1738/// The pydantic-`AnyUrl` normalization docling's hyperlink value passes
1739/// through on its way to the serializer: a URL with an authority but no path
1740/// gains a trailing `/` (`https://arxiv.org` → `https://arxiv.org/`). Other
1741/// AnyUrl canonicalizations (scheme/host lowercasing, percent-encoding) don't
1742/// occur in PDF link annotations in practice, so they are not reproduced.
1743fn normalize_uri(uri: &str) -> String {
1744    if let Some((_, rest)) = uri.split_once("://") {
1745        if !rest.is_empty() && !rest.contains(['/', '?', '#']) {
1746            return format!("{uri}/");
1747        }
1748    }
1749    uri.to_string()
1750}
1751
1752/// Resolve each page hyperlink to the visible text it covers, as `(anchor, uri)`
1753/// in reading order. The anchor is the cells whose centre falls in the link rect,
1754/// joined left-to-right and cleaned the same way prose is (so it matches the
1755/// serialized text), deduped against the immediately-preceding link so pdfium's
1756/// occasional duplicate annotation doesn't double-list. Empty anchors are dropped.
1757pub(crate) fn resolve_link_anchors(page: &PdfPage) -> Vec<(String, String)> {
1758    let mut out: Vec<(String, String)> = Vec::new();
1759    // Use per-word cells, not the line-merged `cells`: a link rect covers a few
1760    // words on a line, and a whole merged line cell would over-capture (its centre
1761    // lands in one link's rect, grabbing the entire line as that link's anchor).
1762    let words = if page.word_cells.is_empty() {
1763        &page.cells
1764    } else {
1765        &page.word_cells
1766    };
1767    for link in &page.links {
1768        // A cell participates when its centre row is inside the rect and it
1769        // overlaps the rect horizontally. A cell can be *wider* than the rect:
1770        // PDFs often draw a whole header line as one text run ("LinkedIn |
1771        // GitHub | Credly"), which docling-parse's word grouping keeps as one
1772        // cell even though each label carries its own link annotation —
1773        // centre-in-rect alone would hand the entire line to every link.
1774        // [`cell_text_in_rect`] clips such a cell to the tokens under the rect.
1775        let mut inside: Vec<(&TextCell, String)> = words
1776            .iter()
1777            .filter(|c| {
1778                let cy = (c.t + c.b) / 2.0;
1779                cy >= link.t && cy <= link.b && c.r.min(link.r) > c.l.max(link.l)
1780            })
1781            .filter_map(|c| {
1782                let text = cell_text_in_rect(c, link.l, link.r);
1783                (!text.is_empty()).then_some((c, text))
1784            })
1785            .collect();
1786        // Reading order: top band then left-to-right (link anchors are LTR).
1787        let band = inside
1788            .iter()
1789            .map(|(c, _)| (c.b - c.t).abs())
1790            .fold(0.0f32, f32::max)
1791            .max(1.0);
1792        inside.sort_by_key(|(c, _)| ((c.t / band).round() as i64, (c.l * 10.0) as i64));
1793        let anchor = clean_text(
1794            &inside
1795                .iter()
1796                .map(|(_, t)| t.trim())
1797                .filter(|t| !t.is_empty())
1798                .collect::<Vec<_>>()
1799                .join(" "),
1800        );
1801        if anchor.is_empty() {
1802            continue;
1803        }
1804        if out
1805            .last()
1806            .is_some_and(|(a, u)| a == &anchor && u == &link.uri)
1807        {
1808            continue;
1809        }
1810        out.push((anchor, link.uri.clone()));
1811    }
1812    out
1813}
1814
1815/// The part of a cell's text that lies under a link rect's x-range. A cell
1816/// fully inside the rect (by centre) returns its whole text. A wider cell is
1817/// split into whitespace tokens whose x-spans are estimated proportionally to
1818/// their character positions (kerning makes this approximate, so selection
1819/// snaps to whole tokens, never characters); tokens whose estimated centre
1820/// falls inside the rect are kept. Returns "" when nothing falls inside.
1821fn cell_text_in_rect(c: &TextCell, l: f32, r: f32) -> String {
1822    let cx = (c.l + c.r) / 2.0;
1823    if cx >= l && cx <= r && c.l >= l - (c.r - c.l) * 0.25 && c.r <= r + (c.r - c.l) * 0.25 {
1824        return c.text.trim().to_string();
1825    }
1826    let chars: Vec<char> = c.text.chars().collect();
1827    let n = chars.len();
1828    if n == 0 || c.r <= c.l {
1829        return String::new();
1830    }
1831    let per = (c.r - c.l) / n as f32;
1832    let mut out: Vec<String> = Vec::new();
1833    let mut token = String::new();
1834    let mut start = 0usize;
1835    // A trailing sentinel space flushes the last token.
1836    for (i, &ch) in chars.iter().enumerate().chain(std::iter::once((n, &' '))) {
1837        if ch.is_whitespace() {
1838            if !token.is_empty() {
1839                let mid = c.l + (start as f32 + (i - start) as f32 / 2.0) * per;
1840                if mid >= l && mid <= r {
1841                    out.push(std::mem::take(&mut token));
1842                } else {
1843                    token.clear();
1844                }
1845            }
1846        } else {
1847            if token.is_empty() {
1848                start = i;
1849            }
1850            token.push(ch);
1851        }
1852    }
1853    out.join(" ")
1854}
1855
1856/// Cells assigned to a region (best container), in reading order, joined.
1857fn region_text(region: &Region, cells: &[TextCell]) -> String {
1858    let inside: Vec<&TextCell> = cells
1859        .iter()
1860        .filter(|c| {
1861            let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1862            inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
1863        })
1864        .collect();
1865    cells_text(inside)
1866}
1867
1868/// docling's exclusive cell assignment (`_assign_cells_to_clusters`): every
1869/// non-empty cell goes to the single best-overlapping *regular* region at
1870/// intersection-over-self > 0.2, and each region serializes exactly its
1871/// assigned cells. A cell under two overlapping boxes is emitted once (by the
1872/// better-covering one), and a cell only partially under its region — e.g.
1873/// normal_4pages' big section numeral, ~30 % inside the heading box — still
1874/// joins it (`## 들어가며 1`) instead of leaking as an orphan. Pictures and
1875/// wrappers never claim (docling walks regular clusters only); ties go to the
1876/// first region, like docling's strict `>` best-overlap scan.
1877pub fn region_texts_exclusive(regions: &[Region], cells: &[TextCell]) -> Vec<String> {
1878    let owned = assign_cells(regions, cells);
1879    // Non-claimers (tables/wrappers/pictures) keep the inclusive > 0.5 text:
1880    // docling fills a special cluster's cells from its contained children, and
1881    // downstream table assembly gates on that text being non-empty.
1882    regions
1883        .iter()
1884        .zip(owned)
1885        .map(|(r, cs)| {
1886            if claims_cells(r) {
1887                cells_text(cs.iter().map(|&i| &cells[i]).collect())
1888            } else {
1889                region_text(r, cells)
1890            }
1891        })
1892        .collect()
1893}
1894
1895/// A *regular* region in docling's sense — one that claims cells. Pictures and
1896/// the wrappers (`table`, `document_index`, `form`, `key_value_region`) fill
1897/// their cells from contained children instead.
1898fn claims_cells(r: &Region) -> bool {
1899    r.label != "picture" && !is_wrapper(r.label)
1900}
1901
1902/// docling's `_assign_cells_to_clusters`: each non-empty cell's index goes to
1903/// the single best-overlapping regular region at intersection-over-self > 0.2
1904/// (ties to the first region, like docling's strict `>` scan). One entry per
1905/// region, in region order.
1906fn assign_cells(regions: &[Region], cells: &[TextCell]) -> Vec<Vec<usize>> {
1907    let mut owned: Vec<Vec<usize>> = vec![Vec::new(); regions.len()];
1908    for (ci, c) in cells.iter().enumerate() {
1909        if c.text.trim().is_empty() {
1910            continue;
1911        }
1912        let ca = area(c.l, c.t, c.r, c.b).max(1.0);
1913        let mut best: Option<(usize, f32)> = None;
1914        for (i, r) in regions.iter().enumerate() {
1915            if !claims_cells(r) {
1916                continue;
1917            }
1918            let ov = inter(r, c.l, c.t, c.r, c.b) / ca;
1919            if ov > 0.2 && best.is_none_or(|(_, b)| ov > b) {
1920                best = Some((i, ov));
1921            }
1922        }
1923        if let Some((i, _)) = best {
1924            owned[i].push(ci);
1925        }
1926    }
1927    owned
1928}
1929
1930/// The ballot-box glyph a checkbox line can open with (#609): `Some(checked)`.
1931/// Empty boxes — `☐`, the white squares `□ ▢ ◻` and the shadowed `❏ ❐ ❑ ❒`
1932/// Word's checkbox bullets use — are unchecked; `☑ ☒ ⊠ ⌧ ▣ 🗹 🗷 🗵` are
1933/// checked. A bare tick or cross (`✓ ✗`) is a list bullet
1934/// ([`LIST_BULLET_MARKERS`]), not a box. Symbol-font boxes that reach the text
1935/// layer as Private Use Area codes or as their ASCII byte (Wingdings `o`,
1936/// `þ`) are not recognized: without the font the code is ambiguous (`U+F06F`
1937/// is Symbol's omicron too).
1938pub(crate) fn checkbox_glyph(c: char) -> Option<bool> {
1939    match c {
1940        '☐' | '□' | '▢' | '◻' | '❏' | '❐' | '❑' | '❒' => Some(false),
1941        '☑' | '☒' | '⊠' | '⌧' | '▣' | '\u{1F5F9}' | '\u{1F5F7}' | '\u{1F5F5}' => {
1942            Some(true)
1943        }
1944        _ => None,
1945    }
1946}
1947
1948/// `text` without its leading ballot-box glyph and the space after it — a
1949/// checkbox item's label is the option text, the box is its state.
1950pub(crate) fn strip_checkbox_glyph(text: &str) -> &str {
1951    let t = text.trim_start();
1952    match t.chars().next() {
1953        Some(c) if checkbox_glyph(c).is_some() => t[c.len_utf8()..].trim_start(),
1954        _ => text,
1955    }
1956}
1957
1958/// Give every checklist line its own checkbox region (#609). The layout
1959/// model often reads a checklist as one `text` (or `list_item`) block — on
1960/// the reporter's ReportLab page all four options are one region, which then
1961/// printed as `First option Second option …`, and docling itself splits it
1962/// into two garbled paragraphs. What marks an item is on the page: a drawn
1963/// square just left of the line ([`crate::checkbox`]), or a ballot-box glyph
1964/// the line opens with ([`checkbox_glyph`]: `☐ Yes`, `☒ Done`). A region
1965/// whose lines carry either splits into a `checkbox_selected` /
1966/// `checkbox_unselected` region per marked line (its box spans the square
1967/// and the line; a following unmarked line — a wrapped label — stays with
1968/// it), so assembly emits [`Node::CheckboxItem`]s exactly as for the model's
1969/// own checkbox labels, the glyph stripped from the label. Lines above the
1970/// first mark stay one region of the original label. The first piece
1971/// replaces the region in place and the rest are appended, so per-region
1972/// slices indexed by the original order (table grids, enrichments — neither
1973/// applies to text) stay aligned.
1974///
1975/// A square counts for a line when its vertical centre lies on the line and
1976/// it sits just left of the line's first cell: no more than 1.5 sides (at
1977/// least 6 pt) of gap, at most 1 pt of overlap. Text set *inside* squares (a
1978/// comb field) never matches. A glyph counts when it opens the line and is
1979/// the line's only box glyph; a line with several (`☐ Yes ☐ No`) stays a text
1980/// piece of its own rather than become one item labelled `Yes ☐ No`. A
1981/// region the model already labelled a checkbox is not split, but its state
1982/// follows its first line's mark when it has one — the glyph or the square
1983/// is what the page says, the model's label a guess from the pixels (it read
1984/// a `☑ Bread` line as unselected). With neither on the page this is a no-op.
1985pub fn split_checkbox_lines(
1986    regions: &mut Vec<Region>,
1987    cells: &[TextCell],
1988    boxes: &[crate::checkbox::CheckBox],
1989) {
1990    let has_glyph = || {
1991        cells
1992            .iter()
1993            .any(|c| c.text.chars().any(|ch| checkbox_glyph(ch).is_some()))
1994    };
1995    if boxes.is_empty() && !has_glyph() {
1996        return;
1997    }
1998    let owned = assign_cells(regions, cells);
1999    let mut used = vec![false; boxes.len()];
2000    let mut appended: Vec<Region> = Vec::new();
2001    for (i, mine) in owned.iter().enumerate() {
2002        let region = regions[i].clone();
2003        let model_checkbox = matches!(region.label, "checkbox_selected" | "checkbox_unselected");
2004        if !(model_checkbox || matches!(region.label, "text" | "list_item")) || mine.is_empty() {
2005            continue;
2006        }
2007        // The region's lines, top down: cells sharing a vertical centre band,
2008        // with the row's text in left-to-right order (for the glyph check).
2009        let mut sorted: Vec<&TextCell> = mine.iter().map(|&c| &cells[c]).collect();
2010        sorted.sort_by(|a, b| a.t.total_cmp(&b.t).then(a.l.total_cmp(&b.l)));
2011        // (l, t, r, b) of each line, and its cells.
2012        type Row<'c> = ((f32, f32, f32, f32), Vec<&'c TextCell>);
2013        let mut rows: Vec<Row> = Vec::new();
2014        for c in sorted {
2015            let mid = (c.t + c.b) / 2.0;
2016            match rows.last_mut() {
2017                Some((row, members)) if mid >= row.1 && mid <= row.3 => {
2018                    *row = (
2019                        row.0.min(c.l),
2020                        row.1.min(c.t),
2021                        row.2.max(c.r),
2022                        row.3.max(c.b),
2023                    );
2024                    members.push(c);
2025                }
2026                _ => rows.push(((c.l, c.t, c.r, c.b), vec![c])),
2027            }
2028        }
2029        // What each row does to the grouping.
2030        enum Mark {
2031            /// Opens a checkbox item (`square`: the drawn one, if any).
2032            Item {
2033                square: Option<usize>,
2034                checked: bool,
2035            },
2036            /// Several box glyphs on one line (`☐ Yes ☐ No`): a text piece of
2037            /// its own, closing the open item.
2038            Plain,
2039            /// Unmarked: continues the open piece (a wrapped label).
2040            Continue,
2041        }
2042        let mut marks: Vec<Mark> = Vec::with_capacity(rows.len());
2043        for ((l, t, _, b), members) in &rows {
2044            let square = boxes
2045                .iter()
2046                .enumerate()
2047                .filter(|&(k, sq)| {
2048                    let side = sq.r - sq.l;
2049                    let mid = (sq.t + sq.b) / 2.0;
2050                    let gap = l - sq.r;
2051                    !used[k]
2052                        && mid >= t - 2.0
2053                        && mid <= b + 2.0
2054                        && gap >= -1.0
2055                        && gap <= (1.5 * side).max(6.0)
2056                })
2057                .min_by(|a, b| (l - a.1.r).total_cmp(&(l - b.1.r)))
2058                .map(|(k, _)| k);
2059            let mark = match square {
2060                Some(k) => {
2061                    used[k] = true;
2062                    Mark::Item {
2063                        square: Some(k),
2064                        checked: boxes[k].checked,
2065                    }
2066                }
2067                None => {
2068                    let mut members = members.clone();
2069                    members.sort_by(|a, b| a.l.total_cmp(&b.l));
2070                    let text: String = members.iter().map(|c| c.text.as_str()).collect();
2071                    let first = text.trim_start().chars().next().and_then(checkbox_glyph);
2072                    let glyphs = text
2073                        .chars()
2074                        .filter(|&c| checkbox_glyph(c).is_some())
2075                        .count();
2076                    match (first, glyphs) {
2077                        (Some(checked), 1) => Mark::Item {
2078                            square: None,
2079                            checked,
2080                        },
2081                        (_, 0) => Mark::Continue,
2082                        _ => Mark::Plain,
2083                    }
2084                }
2085            };
2086            marks.push(mark);
2087        }
2088        // A region the model already labelled a checkbox keeps its extent;
2089        // only its state follows the mark — a glyph or a drawn square is the
2090        // page's own record, where the model reads it off the pixels (on a
2091        // `☑ Bread` line Heron says unselected).
2092        if model_checkbox {
2093            if let Some(Mark::Item { checked, .. }) = marks.first() {
2094                regions[i].label = if *checked {
2095                    "checkbox_selected"
2096                } else {
2097                    "checkbox_unselected"
2098                };
2099            }
2100            continue;
2101        }
2102        if !marks.iter().any(|m| matches!(m, Mark::Item { .. })) {
2103            continue;
2104        }
2105        // Group rows: a marked row opens an item; an unmarked one continues
2106        // the open group (the leading group keeps the region's own label).
2107        let mut pieces: Vec<Region> = Vec::new();
2108        for ((row, _), mark) in rows.iter().zip(&marks) {
2109            match mark {
2110                Mark::Item { square, checked } => {
2111                    let (mut l, mut t, mut r, mut b) = *row;
2112                    if let Some(s) = square.map(|k| &boxes[k]) {
2113                        (l, t, r, b) = (l.min(s.l), t.min(s.t), r.max(s.r), b.max(s.b));
2114                    }
2115                    pieces.push(Region {
2116                        label: if *checked {
2117                            "checkbox_selected"
2118                        } else {
2119                            "checkbox_unselected"
2120                        },
2121                        score: region.score,
2122                        l,
2123                        t,
2124                        r,
2125                        b,
2126                    });
2127                }
2128                Mark::Plain => pieces.push(Region {
2129                    l: row.0,
2130                    t: row.1,
2131                    r: row.2,
2132                    b: row.3,
2133                    ..region.clone()
2134                }),
2135                Mark::Continue => match pieces.last_mut() {
2136                    Some(p) => {
2137                        p.l = p.l.min(row.0);
2138                        p.t = p.t.min(row.1);
2139                        p.r = p.r.max(row.2);
2140                        p.b = p.b.max(row.3);
2141                    }
2142                    None => pieces.push(Region {
2143                        l: row.0,
2144                        t: row.1,
2145                        r: row.2,
2146                        b: row.3,
2147                        ..region.clone()
2148                    }),
2149                },
2150            }
2151        }
2152        let mut pieces = pieces.into_iter();
2153        if let Some(first) = pieces.next() {
2154            regions[i] = first;
2155        }
2156        appended.extend(pieces);
2157    }
2158    regions.extend(appended);
2159}
2160
2161/// docling's regular-cluster refinement after cell assignment
2162/// (`LayoutPostprocessor._process_regular_clusters`, #419), run once the page's
2163/// cells are final and before reading order:
2164///
2165/// 1. every regular region's box becomes the union of the cells it claimed
2166///    (`_adjust_cluster_bboxes` — a regular cluster's bbox *is* its cells'
2167///    bbox; a table's is the union with the model box, and pictures keep
2168///    theirs, so neither is touched here);
2169/// 2. a regular region that claimed no cell is dropped (`keep_empty_clusters`
2170///    is off; a `formula` is kept, as upstream keeps it);
2171/// 3. an orphan text region (`score == 0.0`, from [`add_orphan_regions`]) that
2172///    now sits > 0.8 inside another regular region's fitted box is folded into
2173///    it (`_remove_overlapping_clusters` at containment 0.8, the larger box
2174///    winning the group) — up to three rounds, like upstream's loop.
2175///
2176/// Why it matters: the layout model's box can end partway through a line. That
2177/// line fails the 0.2 claim and becomes an orphan — recoverable — but the
2178/// *model* box still overlaps the orphan's line by a few points, so the
2179/// reading-order graph, which links only strictly-above pairs, gets no edge
2180/// between them and may emit the next paragraph first, stranding the line
2181/// after the paragraph it belongs in (1540 of 6050 text blocks on the #419
2182/// book began mid-sentence). Fitted to its cells, the box ends on a line
2183/// boundary and the orphan slots in between; an orphan the fitted box
2184/// swallows joins the paragraph outright. Cell assignment is untouched: a
2185/// region's fitted box contains every cell it claimed, so
2186/// [`region_texts_exclusive`] hands it the same cells afterwards.
2187///
2188/// A page with no cells yet (a scan before OCR) is left alone: dropping every
2189/// text region for want of cells would be wrong, and the OCR paths call this
2190/// again once the cells exist.
2191pub fn fit_regions_to_cells(regions: &mut Vec<Region>, cells: &[TextCell]) {
2192    if !cells.iter().any(|c| !c.text.trim().is_empty()) {
2193        return;
2194    }
2195    for _ in 0..3 {
2196        let owned = assign_cells(regions, cells);
2197        let mut fitted: Vec<Region> = Vec::with_capacity(regions.len());
2198        for (r, own) in regions.iter().zip(&owned) {
2199            if !claims_cells(r) {
2200                fitted.push(r.clone());
2201                continue;
2202            }
2203            if own.is_empty() {
2204                if r.label == "formula" {
2205                    fitted.push(r.clone());
2206                }
2207                continue;
2208            }
2209            let mut f = r.clone();
2210            f.l = own
2211                .iter()
2212                .map(|&i| cells[i].l)
2213                .fold(f32::INFINITY, f32::min);
2214            f.t = own
2215                .iter()
2216                .map(|&i| cells[i].t)
2217                .fold(f32::INFINITY, f32::min);
2218            f.r = own
2219                .iter()
2220                .map(|&i| cells[i].r)
2221                .fold(f32::NEG_INFINITY, f32::max);
2222            f.b = own
2223                .iter()
2224                .map(|&i| cells[i].b)
2225                .fold(f32::NEG_INFINITY, f32::max);
2226            fitted.push(f);
2227        }
2228        let mut changed = fitted.len() != regions.len();
2229        // Fold orphans into the regular region whose fitted box holds them.
2230        let mut drop = vec![false; fitted.len()];
2231        for i in 0..fitted.len() {
2232            let o = &fitted[i];
2233            if !(o.score == 0.0 && o.label == "text") {
2234                continue;
2235            }
2236            let oa = area(o.l, o.t, o.r, o.b).max(1.0);
2237            let mut best: Option<(usize, f32)> = None;
2238            for (j, r) in fitted.iter().enumerate() {
2239                if j == i || drop[j] || r.score == 0.0 || !claims_cells(r) {
2240                    continue;
2241                }
2242                let ov = inter(r, o.l, o.t, o.r, o.b) / oa;
2243                if ov > 0.8 && best.is_none_or(|(_, b)| ov > b) {
2244                    best = Some((j, ov));
2245                }
2246            }
2247            if let Some((j, _)) = best {
2248                let (l, t, r, b) = (o.l, o.t, o.r, o.b);
2249                let host = &mut fitted[j];
2250                host.l = host.l.min(l);
2251                host.t = host.t.min(t);
2252                host.r = host.r.max(r);
2253                host.b = host.b.max(b);
2254                drop[i] = true;
2255                changed = true;
2256            }
2257        }
2258        let mut drop = drop.into_iter();
2259        fitted.retain(|_| !drop.next().expect("aligned"));
2260        *regions = fitted;
2261        if !changed {
2262            break;
2263        }
2264    }
2265}
2266
2267/// Join a prefiltered cell list into the region's text (docling's
2268/// `sanitize_text` over the sanitizer's cell order).
2269fn cells_text(inside: Vec<&TextCell>) -> String {
2270    // docling orders a cluster's cells by their docling-parse cell index
2271    // alone (`LayoutPostprocessor._sort_cells`: `sorted(cells, key=c.index)`)
2272    // — the sanitizer's output order, which our `cells` slice already is.
2273    // No geometric re-sort: normal_4pages' big section numerals paint
2274    // *after* their heading text, and docling's `## 들어가며 1` (numeral
2275    // last) only falls out of pure index order — a band sort dragged the
2276    // numeral to the front. The overlap-grouped line restore this replaced
2277    // measured strictly worse on the corpus (it fixed nothing the index
2278    // order broke, and broke the numerals).
2279    let joined = {
2280        // docling's `PageAssembleModel.sanitize_text`, ported verbatim over the
2281        // parse-index-ordered lines: append a separating space to a line —
2282        // unless it ends with `-`. A dash-ending line whose last word and the
2283        // next line's first word are both alphanumeric is a wrapped word: the
2284        // dash is dropped and the lines fuse (`platforms-` + `reflects` →
2285        // `platformsreflects`, `pp. 545-` + `561` → `545561`). Any other
2286        // dash-ending line — e.g. the *bare* `-` cell a superscript ORCID or an
2287        // inline `–` bullet splits off (its word list is empty, so the fuse
2288        // test fails) — keeps its dash and still takes no trailing space:
2289        // `[0000` `-` `0002` joins as docling's `[0000 -0002`, and the OTSL
2290        // list's `-` + `"C" cell -` + `a new table cell` collapses to
2291        // `-"C" cell a new table cell`. Our cells still carry the raw dash
2292        // family (docling-parse normalizes to `-` before this; clean_text does
2293        // it after), so the endswith test matches them all.
2294        let texts: Vec<&str> = inside
2295            .iter()
2296            .map(|c| c.text.trim())
2297            // Skip whitespace-only cells (a justified line's trailing space
2298            // glyph): an empty line would double the separator.
2299            .filter(|t| !t.is_empty())
2300            .collect();
2301        let last_word_alnum = |s: &str| {
2302            s.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2303                .rfind(|w| !w.is_empty())
2304                .is_some_and(|w| w.chars().all(char::is_alphanumeric))
2305        };
2306        let first_word_alnum = |s: &str| {
2307            s.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2308                .find(|w| !w.is_empty())
2309                .is_some_and(|w| w.chars().all(char::is_alphanumeric))
2310        };
2311        let mut out = String::new();
2312        for (i, t) in texts.iter().enumerate() {
2313            if i > 0 {
2314                let prev = texts[i - 1];
2315                let dashish = matches!(
2316                    prev.chars().last(),
2317                    Some(
2318                        '-' | '\u{2010}'
2319                            | '\u{2011}'
2320                            | '\u{2012}'
2321                            | '\u{2013}'
2322                            | '\u{2014}'
2323                            | '\u{2015}'
2324                            | '\u{2212}'
2325                    )
2326                );
2327                // docling#4052 (2.122): a dash only splits a word when it is
2328                // *attached* to one — the character before it is alphanumeric.
2329                // A dash that follows whitespace (a separator dash, a bullet
2330                // marker, a wrapped `-prefixed` token, the bare `-` cell an
2331                // ORCID splits off) is a literal character: it is kept and the
2332                // lines join with the ordinary space.
2333                let attached = prev.chars().rev().nth(1).is_some_and(char::is_alphanumeric);
2334                if dashish && attached {
2335                    if last_word_alnum(prev) && first_word_alnum(t) {
2336                        out.pop(); // wrapped word: fuse without the dash
2337                    }
2338                    // an attached dash never takes a separating space
2339                } else {
2340                    out.push(' ');
2341                }
2342            }
2343            out.push_str(t);
2344        }
2345        out
2346    };
2347    clean_text(&joined)
2348}
2349
2350/// Tighten the spaces pdfium leaves around tight punctuation in a code line
2351/// (`console .log` → `console.log`, `add (3 , 5)` → `add(3, 5)`), matching
2352/// docling-parse's source spacing.
2353fn tighten_code_punct(s: &str) -> String {
2354    s.replace(" .", ".")
2355        .replace(" ,", ",")
2356        .replace(" ;", ";")
2357        .replace(" )", ")")
2358        .replace(" (", "(")
2359}
2360
2361/// Assemble a **code** region's text with its line structure preserved.
2362///
2363/// Unlike [`region_text`] — which joins every cell with a single space, the right
2364/// thing for prose reflow — a code block's line breaks and indentation are
2365/// significant. The `code_cells` are already one physical source line each
2366/// (grouped space-glyph-only, so monospace runs keep their spacing), so this:
2367///
2368/// 1. groups the cells into vertical line bands and orders them top→bottom,
2369///    left→right;
2370/// 2. joins the lines with `\n` (rather than spaces), keeping the carriage
2371///    returns; and
2372/// 3. reconstructs each line's leading indentation from its left offset, in units
2373///    of the block's estimated monospace character width, so nesting survives.
2374///
2375/// Typography is normalized per line via [`clean_text`] (smart quotes, dashes,
2376/// ellipsis), which never merges lines. Returns an empty string if the region has
2377/// no code cells (the caller falls back to the prose text).
2378fn code_region_text(region: &Region, cells: &[TextCell]) -> String {
2379    let mut inside: Vec<&TextCell> = cells
2380        .iter()
2381        .filter(|c| {
2382            let ca = area(c.l, c.t, c.r, c.b).max(1.0);
2383            inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
2384        })
2385        .filter(|c| !c.text.trim().is_empty())
2386        .collect();
2387    if inside.is_empty() {
2388        return String::new();
2389    }
2390
2391    // Quantize the top edge into ~line bands (like `region_text`), then order the
2392    // cells by band (top→bottom) and, within a band, by left edge.
2393    let band = inside
2394        .iter()
2395        .map(|c| (c.b - c.t).abs())
2396        .fold(0.0f32, f32::max)
2397        .max(1.0);
2398    let line_of = |c: &TextCell| (c.t / band).round() as i64;
2399    inside.sort_by_key(|c| (line_of(c), (c.l * 10.0) as i64));
2400
2401    // Estimate one monospace character's width (total ink width / total glyphs) to
2402    // convert a line's left offset into a count of leading spaces. Measured over
2403    // all lines so a single short line can't skew it.
2404    let (mut total_w, mut total_chars) = (0.0f32, 0usize);
2405    for c in &inside {
2406        let n = c.text.trim().chars().count();
2407        if n > 0 {
2408            total_w += (c.r - c.l).max(0.0);
2409            total_chars += n;
2410        }
2411    }
2412    let char_w = if total_chars > 0 {
2413        (total_w / total_chars as f32).max(1.0)
2414    } else {
2415        1.0
2416    };
2417    // The block's own left margin is the zero-indent baseline.
2418    let base_l = inside.iter().map(|c| c.l).fold(f32::INFINITY, f32::min);
2419
2420    let mut lines: Vec<String> = Vec::new();
2421    let mut cur: Option<i64> = None;
2422    for c in &inside {
2423        // Tighten pdfium's spaced punctuation per line (on the trimmed content, so
2424        // the reconstructed leading indentation is never nibbled).
2425        let text = tighten_code_punct(&clean_text(c.text.trim()));
2426        if Some(line_of(c)) == cur {
2427            // A second cell sharing this band (rare — e.g. split columns): keep it
2428            // on the same source line, separated by a space.
2429            if let Some(last) = lines.last_mut() {
2430                last.push(' ');
2431                last.push_str(&text);
2432            }
2433            continue;
2434        }
2435        let indent = ((c.l - base_l) / char_w).round().max(0.0) as usize;
2436        lines.push(format!("{}{}", " ".repeat(indent), text));
2437        cur = Some(line_of(c));
2438    }
2439    lines.join("\n")
2440}
2441
2442/// Reconstruct a table's grid geometrically from the text cells inside its
2443/// region: cluster cells into rows (by vertical centre) and columns (by clustered
2444/// left edges), then place each cell. A model-free stand-in for TableFormer that
2445/// recovers grid-aligned tables from the precise PDF text layer (it does not
2446/// resolve row/column spans).
2447pub fn reconstruct_table(region: &Region, cells: &[TextCell]) -> Vec<Vec<String>> {
2448    let mut inside: Vec<&TextCell> = cells
2449        .iter()
2450        .filter(|c| {
2451            let ca = area(c.l, c.t, c.r, c.b).max(1.0);
2452            inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
2453        })
2454        .collect();
2455    if inside.is_empty() {
2456        return Vec::new();
2457    }
2458    inside.sort_by(|a, b| a.t.total_cmp(&b.t));
2459
2460    // Rows: consecutive cells whose vertical centre is within ~0.7 line height.
2461    let mut rows: Vec<(f32, Vec<&TextCell>)> = Vec::new();
2462    for c in &inside {
2463        let cyc = (c.t + c.b) / 2.0;
2464        let lh = (c.b - c.t).abs().max(1.0);
2465        if let Some((ryc, row)) = rows.last_mut() {
2466            if (cyc - *ryc).abs() < lh * 0.7 {
2467                row.push(c);
2468                continue;
2469            }
2470        }
2471        rows.push((cyc, vec![c]));
2472    }
2473
2474    // Columns: cluster left edges (merge those within a tolerance).
2475    let tol = {
2476        let mut hs: Vec<f32> = inside.iter().map(|c| (c.b - c.t).abs()).collect();
2477        hs.sort_by(f32::total_cmp);
2478        hs[hs.len() / 2].max(4.0) * 1.5
2479    };
2480    let mut lefts: Vec<f32> = inside.iter().map(|c| c.l).collect();
2481    lefts.sort_by(f32::total_cmp);
2482    let mut col_starts: Vec<f32> = Vec::new();
2483    for l in lefts {
2484        if col_starts.last().is_none_or(|&last| l - last > tol) {
2485            col_starts.push(l);
2486        }
2487    }
2488    let ncols = col_starts.len().max(1);
2489    let col_of = |l: f32| -> usize {
2490        col_starts
2491            .iter()
2492            .rposition(|&s| l + tol * 0.5 >= s)
2493            .unwrap_or(0)
2494            .min(ncols - 1)
2495    };
2496
2497    let mut grid = Vec::with_capacity(rows.len());
2498    for (_, mut row) in rows {
2499        row.sort_by(|a, b| a.l.total_cmp(&b.l));
2500        let mut cols = vec![String::new(); ncols];
2501        for c in row {
2502            let ci = col_of(c.l);
2503            // Strip the wrap-hyphen control char so it never lands in a cell.
2504            let t = c.text.trim().replace(['\u{2}', '\u{ad}'], "");
2505            if cols[ci].is_empty() {
2506                cols[ci] = t;
2507            } else {
2508                cols[ci].push(' ');
2509                cols[ci].push_str(&t);
2510            }
2511        }
2512        grid.push(cols);
2513    }
2514    grid
2515}
2516
2517/// Does the geometric reconstruction of a table look trustworthy enough to use
2518/// as-is, instead of paying for TableFormer?
2519///
2520/// [`reconstruct_table`] derives columns by clustering cell **left edges**. On a
2521/// clean grid that is exact, but when a column's entries are not left-aligned
2522/// (or the OCR boxes wobble) the clustering splits one real column into several,
2523/// and the result is a wide, mostly-empty grid — the "spurious empty columns"
2524/// failure TableFormer exists to fix.
2525///
2526/// Two symptoms separate the two cases, and both are properties of the grid
2527/// alone (no model needed):
2528/// * **density** — a real table is mostly full; a split-up one is mostly holes;
2529/// * **thin columns** — a column carrying at most one entry across several rows
2530///   is almost always a split artefact rather than a real column.
2531///
2532/// Deliberately conservative: it answers `true` only for grids that are plainly
2533/// well-formed, so the expensive path stays the default whenever there is doubt.
2534/// A caller that skips TableFormer on `true` trades no quality for the time.
2535pub fn geometric_table_is_reliable(rows: &[Vec<String>]) -> bool {
2536    let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
2537    // Fewer than two columns is not a grid this heuristic can vouch for: it is
2538    // exactly the shape a collapsed table takes, and TableFormer may recover
2539    // real structure from it.
2540    if rows.len() < 2 || ncols < 2 {
2541        return false;
2542    }
2543    let filled = |c: &String| !c.trim().is_empty();
2544    let total = rows.len() * ncols;
2545    let full = rows.iter().flatten().filter(|c| filled(c)).count();
2546    if (full as f32) < MIN_TABLE_FILL * total as f32 {
2547        return false;
2548    }
2549    // A column used by at most one row, when there are rows enough to tell.
2550    if rows.len() >= 3 {
2551        for ci in 0..ncols {
2552            let used = rows
2553                .iter()
2554                .filter(|r| r.get(ci).is_some_and(filled))
2555                .count();
2556            if used <= 1 {
2557                return false;
2558            }
2559        }
2560    }
2561    true
2562}
2563
2564/// Share of a geometric grid's cells that must carry text for it to be trusted
2565/// without TableFormer. Chosen well above the density a left-edge split
2566/// produces (those land nearer a third) and below what a genuine table with a
2567/// few blank cells reaches.
2568const MIN_TABLE_FILL: f32 = 0.6;
2569
2570/// The union bbox of the text cells assigned to a region (same >50%-overlap
2571/// rule as [`region_text`]), or `None` when no cell lands in it. docling's
2572/// LayoutPostprocessor shrinks a regular cluster's bbox to its cells, and the
2573/// enrichment crops are taken from that cell-tight box — cropping the raw
2574/// detector box instead hands the VLM surrounding chrome (e.g. the `Listing N:`
2575/// caption under a code block) that changes its output.
2576pub fn region_cell_bbox(region: &Region, cells: &[TextCell]) -> Option<[f32; 4]> {
2577    let mut bbox: Option<[f32; 4]> = None;
2578    for c in cells {
2579        let ca = area(c.l, c.t, c.r, c.b).max(1.0);
2580        if inter(region, c.l, c.t, c.r, c.b) / ca <= 0.5 {
2581            continue;
2582        }
2583        bbox = Some(match bbox {
2584            None => [c.l, c.t, c.r, c.b],
2585            Some([l, t, r, b]) => [l.min(c.l), t.min(c.t), r.max(c.r), b.max(c.b)],
2586        });
2587    }
2588    bbox
2589}
2590
2591/// One region's enrichment-model result, produced by the pipeline's opt-in
2592/// passes (issue #76) and applied during assembly.
2593#[derive(Debug, Clone)]
2594pub enum Enrichment {
2595    /// DocumentPictureClassifier predictions, descending confidence.
2596    PictureClasses(Vec<PictureClass>),
2597    /// CodeFormulaV2 output for a `code` region: the rewritten source text and
2598    /// the `<_language_>` prefix (when the model emitted one).
2599    Code {
2600        language: Option<String>,
2601        text: String,
2602    },
2603    /// CodeFormulaV2 output for a `formula` region: the decoded LaTeX.
2604    Formula { latex: String },
2605}
2606
2607/// Crop a region (page points, already expanded by the caller if needed) from
2608/// the rendered page image and resize it to `target_scale` pixels per point —
2609/// the enrichment-model equivalent of docling's
2610/// `page.get_image(scale=…, cropbox=…)`, sourced from the existing
2611/// [`crate::pdfium_backend::RENDER_SCALE`] render instead of a fresh pdfium
2612/// pass (the page bitmap is already the exact docling render at scale 2).
2613#[cfg(feature = "ml")]
2614pub fn crop_region_scaled(page: &PdfPage, bbox: [f32; 4], target_scale: f32) -> Option<RgbImage> {
2615    let s = page.scale;
2616    let [l, t, r, b] = bbox;
2617    let (iw, ih) = (page.image.width(), page.image.height());
2618    let x = (l * s).max(0.0) as u32;
2619    let y = (t * s).max(0.0) as u32;
2620    if x >= iw || y >= ih {
2621        return None;
2622    }
2623    let w = (((r - l.max(0.0)) * s) as u32).min(iw - x);
2624    let h = (((b - t.max(0.0)) * s) as u32).min(ih - y);
2625    if w == 0 || h == 0 {
2626        return None;
2627    }
2628    let crop = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
2629    // docling renders the crop at `target_scale` directly; from the scale-2
2630    // page render that is a resize to the same pixel geometry
2631    // (`round(width_points * scale)`, PIL's BICUBIC ≙ CatmullRom).
2632    let tw = ((w as f32 / s) * target_scale).round().max(1.0) as u32;
2633    let th = ((h as f32 / s) * target_scale).round().max(1.0) as u32;
2634    if (tw, th) == (w, h) {
2635        return Some(crop);
2636    }
2637    Some(image::imageops::resize(
2638        &crop,
2639        tw,
2640        th,
2641        image::imageops::FilterType::CatmullRom,
2642    ))
2643}
2644
2645/// Resample `img` (rendered at `from` px/pt, covering `w_pt`×`h_pt` points)
2646/// to `to` px/pt — docling's `round(points * scale)` pixel geometry, PIL's
2647/// BICUBIC ≙ CatmullRom. Unchanged when the geometry already matches.
2648#[cfg(feature = "ocr-prep")]
2649fn rescale(img: RgbImage, w_pt: f32, h_pt: f32, to: f32) -> RgbImage {
2650    let tw = (w_pt * to).round().max(1.0) as u32;
2651    let th = (h_pt * to).round().max(1.0) as u32;
2652    if (tw, th) == img.dimensions() {
2653        return img;
2654    }
2655    image::imageops::resize(&img, tw, th, image::imageops::FilterType::CatmullRom)
2656}
2657
2658/// Encode `img` as a PNG [`PictureImage`] rendered at `scale` px/pt.
2659#[cfg(feature = "ocr-prep")]
2660fn png_image(img: &RgbImage, scale: f32) -> Option<PictureImage> {
2661    let mut buf = std::io::Cursor::new(Vec::new());
2662    img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
2663    Some(PictureImage {
2664        mimetype: "image/png".into(),
2665        width: img.width(),
2666        height: img.height(),
2667        data: buf.into_inner(),
2668        dpi: PictureImage::dpi_for_scale(scale),
2669        display_size: None,
2670    })
2671}
2672
2673/// The whole page render as docling's `PageItem.image` (#520): at `scale`
2674/// px/pt (`None` = the render's own), `None` when the page has no bitmap.
2675#[cfg(feature = "ocr-prep")]
2676pub fn page_image(page: &PdfPage, scale: Option<f32>) -> Option<PictureImage> {
2677    if page.image.width() == 0 || page.image.height() == 0 || page.scale <= 0.0 {
2678        return None;
2679    }
2680    let scale = scale.unwrap_or(page.scale);
2681    let img = rescale(page.image.clone(), page.width, page.height, scale);
2682    png_image(&img, scale)
2683}
2684
2685/// Crop a layout region from the rendered page image and encode it as PNG (the
2686/// figure bytes docling stores on a `PictureItem`). Region coordinates are page
2687/// points; the image is rendered at `page.scale` and resampled to `scale`
2688/// px/pt when one is given (docling's `images_scale`, #520). The image's `dpi`
2689/// is 72·scale (#519).
2690#[cfg(feature = "ocr-prep")]
2691fn crop_region(page: &PdfPage, region: &Region, scale: Option<f32>) -> Option<PictureImage> {
2692    let s = page.scale;
2693    let (iw, ih) = (page.image.width(), page.image.height());
2694    let x = (region.l * s).max(0.0) as u32;
2695    let y = (region.t * s).max(0.0) as u32;
2696    if x >= iw || y >= ih {
2697        return None;
2698    }
2699    let w = (((region.r - region.l) * s) as u32).min(iw - x);
2700    let h = (((region.b - region.t) * s) as u32).min(ih - y);
2701    if w == 0 || h == 0 {
2702        return None;
2703    }
2704    let sub = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
2705    match scale {
2706        Some(to) if (to - s).abs() > f32::EPSILON => {
2707            // The crop's own point extent (the pixel box, back in points), so
2708            // the resampled geometry is `round(points * scale)`.
2709            let img = rescale(sub, w as f32 / s, h as f32 / s, to);
2710            png_image(&img, to)
2711        }
2712        _ => png_image(&sub, s),
2713    }
2714}
2715
2716/// A `text` region that *says* it is a caption — `Table 5: …`, `Figure 2.
2717/// …`, `Fig. 3: …` — and sits right next to a graphic in reading order is one
2718/// (a docling.rs extension, `docs/PDF_CONFORMANCE.md`): the layout model
2719/// labels a long caption block `text` now and then (2206.01062's four-line
2720/// Table 5 caption: `text` 0.99 here, `caption` in docling's own run), and a
2721/// mislabelled caption stays a loose paragraph while its table goes
2722/// uncaptioned. The gate is the label's own pattern — the word, a number,
2723/// then `:` or `.` — so a paragraph that merely opens with `Table 5 shows`
2724/// is left alone, plus the geometry docling's matcher wants: the graphic is
2725/// the region's neighbour in reading order, overlaps it horizontally and
2726/// stands within 25 pt above or below. Relabelling before
2727/// [`pair_graphic_captions`] lets the regular matcher bind it.
2728fn promote_labelled_captions(regions: &mut [Region], cells: &[TextCell]) {
2729    let body: Vec<usize> = (0..regions.len())
2730        .filter(|&i| !is_skipped(regions[i].label))
2731        .collect();
2732    let mut promote = Vec::new();
2733    for (pos, &i) in body.iter().enumerate() {
2734        let r = &regions[i];
2735        if r.label != "text" || !is_caption_label(&region_text(r, cells)) {
2736            continue;
2737        }
2738        let beside = |g: &Region| {
2739            is_graphic(g.label) && g.r.min(r.r) - g.l.max(r.l) > 0.0 && {
2740                let gap = if r.t >= g.b {
2741                    r.t - g.b
2742                } else if g.t >= r.b {
2743                    g.t - r.b
2744                } else {
2745                    f32::MAX
2746                };
2747                gap <= 25.0
2748            }
2749        };
2750        let prev = pos.checked_sub(1).map(|p| &regions[body[p]]);
2751        let next = body.get(pos + 1).map(|&n| &regions[n]);
2752        if prev.is_some_and(beside) || next.is_some_and(beside) {
2753            promote.push(i);
2754        }
2755    }
2756    for i in promote {
2757        regions[i].label = "caption";
2758    }
2759}
2760
2761/// `Table 5:`, `Figure 2.1.`, `Fig. 3:`, `Listing 4:` — a caption label: the
2762/// word, whitespace, a number (dotted or lettered: `2.1`, `3a`), then `:` or
2763/// `.`, as the publishers' styles print it. Case-sensitive on purpose.
2764fn is_caption_label(text: &str) -> bool {
2765    let t = text.trim_start();
2766    let Some(rest) = ["Table", "Figure", "Fig.", "Listing"]
2767        .iter()
2768        .find_map(|w| t.strip_prefix(w))
2769    else {
2770        return false;
2771    };
2772    let rest = rest.trim_start();
2773    if !rest.starts_with(|c: char| c.is_ascii_digit()) {
2774        return false;
2775    }
2776    let num_end = rest
2777        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '.'))
2778        .unwrap_or(rest.len());
2779    let num = rest[..num_end].trim_end_matches('.');
2780    let after = &rest[num.len()..];
2781    after.starts_with(':') || after.starts_with('.')
2782}
2783
2784/// docling's `GRAPHIC_LABELS` (`reading_order_rb.py`): the elements a
2785/// `caption` can be attached to. `document_index` rides along as table-like
2786/// (#265) so an index's title binds the way a table's does.
2787fn is_graphic(label: &str) -> bool {
2788    is_table_like(label) || matches!(label, "picture" | "code")
2789}
2790
2791/// docling's `_shortest_box_gap`: the shortest distance between two boxes,
2792/// 0 once they touch or overlap — along either axis whatever the union has
2793/// left over once both boxes are laid down, combined as the hypotenuse.
2794fn shortest_box_gap(a: &Region, b: &Region) -> f32 {
2795    let dx = ((a.r.max(b.r) - a.l.min(b.l)) - (a.r - a.l) - (b.r - b.l)).max(0.0);
2796    let dy = ((a.b.max(b.b) - a.t.min(b.t)) - (a.b - a.t) - (b.b - b.t)).max(0.0);
2797    dx.hypot(dy)
2798}
2799
2800/// Pair each graphic (`picture`, `table`/`document_index`, `code`) with its
2801/// `caption` region (#265, #813): a port of docling's
2802/// `ReadingOrderPredictor._find_to_captions` — `_rank_caption_candidates` +
2803/// `_match_caption` — over the page's regions in reading order. Returns, per
2804/// region, the index of the caption it owns.
2805///
2806/// A caption reaches the graphics in the **unbroken run** of graphics on
2807/// either side of it in reading order — above *and* below (#813: the
2808/// geometric predecessor only looked below a picture, so a `Figure N:` label
2809/// printed above its figure never bound), never across intervening text, and
2810/// never across the gutter of a two-column page however close the boxes are.
2811/// The candidates rank by the gap each leaves on the page (nearest first,
2812/// equal gaps to the preceding graphic), not by position in the run.
2813/// Graphics are then matched to captions by augmenting paths rather than
2814/// best-first: a caption with a second choice gives way to one that has none,
2815/// so a figure between two captions and a figure with one do not end up
2816/// sharing the former while the latter stays orphaned.
2817///
2818/// Furniture (page headers/footers, form chrome) is not part of docling's
2819/// body-element sequence here, so it neither bonds nor breaks a run. The
2820/// matched caption is consumed from its own reading-order slot and rides on
2821/// its graphic — docling's `captions` ref: Markdown prints it before a
2822/// picture or table and after a code block, JSON emits the `$ref`, DocLang
2823/// the `<caption>`.
2824fn pair_graphic_captions(regions: &[Region]) -> Vec<Option<usize>> {
2825    let body: Vec<usize> = (0..regions.len())
2826        .filter(|&i| !is_skipped(regions[i].label))
2827        .collect();
2828    // `_rank_caption_candidates`: caption index → its graphics, nearest first.
2829    let mut captions: Vec<usize> = Vec::new();
2830    let mut preferred: Vec<Vec<usize>> = vec![Vec::new(); regions.len()];
2831    for (pos, &ci) in body.iter().enumerate() {
2832        if regions[ci].label != "caption" {
2833            continue;
2834        }
2835        let graphic = |&&i: &&usize| is_graphic(regions[i].label);
2836        let preceding = body[..pos].iter().rev().take_while(graphic);
2837        let following = body[pos + 1..].iter().take_while(graphic);
2838        // Tagged 0 preceding / 1 following so equal gaps go to the graphic
2839        // above; a stable sort keeps run order among equals on one side.
2840        let mut ranked: Vec<(f32, u8, usize)> = preceding
2841            .map(|&g| (shortest_box_gap(&regions[ci], &regions[g]), 0, g))
2842            .chain(following.map(|&g| (shortest_box_gap(&regions[ci], &regions[g]), 1, g)))
2843            .collect();
2844        ranked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
2845        preferred[ci] = ranked.into_iter().map(|(_, _, g)| g).collect();
2846        captions.push(ci);
2847    }
2848    // `_match_caption`, caption by caption in reading order: graphic → caption.
2849    let mut matched: Vec<Option<usize>> = vec![None; regions.len()];
2850    for &caption in &captions {
2851        if let Some(next) = augment_caption_match(caption, &preferred, &matched) {
2852            matched = next;
2853        }
2854    }
2855    // Side-by-side figures (a docling.rs extension, `docs/PDF_CONFORMANCE.md`):
2856    // a row of pictures reads as a run of graphics followed (or preceded) by
2857    // the run of their captions, and upstream's run rule serves only the
2858    // first caption — for the others the caption before them breaks the run,
2859    // and docling leaves them loose. A caption still loose whose only
2860    // separation from a graphic run is other captions takes the nearest
2861    // unclaimed graphic of that run it sits under or over (horizontal
2862    // overlap, within three line heights — the old geometric rule).
2863    for (pos, &ci) in body.iter().enumerate() {
2864        let is_caption = |i: &usize| regions[*i].label == "caption";
2865        if !is_caption(&ci) || matched.contains(&Some(ci)) {
2866            continue;
2867        }
2868        let c = &regions[ci];
2869        let line_h = (c.b - c.t).abs().max(1.0);
2870        let graphic = |i: &usize| is_graphic(regions[*i].label);
2871        let before = body[..pos]
2872            .iter()
2873            .rev()
2874            .skip_while(|i| is_caption(i))
2875            .take_while(|i| graphic(i));
2876        let after = body[pos + 1..]
2877            .iter()
2878            .skip_while(|i| is_caption(i))
2879            .take_while(|i| graphic(i));
2880        let best = before
2881            .chain(after)
2882            .filter(|&&g| matched[g].is_none())
2883            .filter_map(|&g| {
2884                let p = &regions[g];
2885                let h_overlap = p.r.min(c.r) - p.l.max(c.l);
2886                let gap = (c.t - p.b).max(p.t - c.b); // below or above the graphic
2887                (h_overlap > 0.0 && gap > -line_h && gap < line_h * 3.0).then_some((g, gap))
2888            })
2889            .min_by(|a, b| a.1.total_cmp(&b.1));
2890        if let Some((g, _)) = best {
2891            matched[g] = Some(ci);
2892        }
2893    }
2894    matched
2895}
2896
2897/// docling's `_match_caption`: a new matching in which `caption` owns one of
2898/// its graphics, displacing an earlier caption when that one can rehouse
2899/// itself; `None` when no graphic can be freed up. The displacement chain is
2900/// walked on an explicit stack (it can grow as long as the page): each frame
2901/// is a claimant and the graphics it has left to try, and the moves in
2902/// `chain` take effect only once it reaches a graphic nobody holds. `seen`
2903/// keeps a chain from revisiting a graphic.
2904fn augment_caption_match(
2905    caption: usize,
2906    preferred: &[Vec<usize>],
2907    matched: &[Option<usize>],
2908) -> Option<Vec<Option<usize>>> {
2909    let mut seen = vec![false; matched.len()];
2910    // (claimant, next candidate to try) frames; (graphic, claimant) moves.
2911    let mut stack: Vec<(usize, usize)> = vec![(caption, 0)];
2912    let mut chain: Vec<(usize, usize)> = Vec::new();
2913    while let Some(frame) = stack.last_mut() {
2914        let (claimant, cursor) = *frame;
2915        let candidates = &preferred[claimant];
2916        let Some(off) = candidates[cursor..].iter().position(|&g| !seen[g]) else {
2917            // Out of options: undo the move that got here and let the
2918            // caption below resume its own search.
2919            stack.pop();
2920            chain.pop();
2921            continue;
2922        };
2923        let graphic = candidates[cursor + off];
2924        frame.1 = cursor + off + 1;
2925        seen[graphic] = true;
2926        chain.push((graphic, claimant));
2927        match matched[graphic] {
2928            None => {
2929                let mut next = matched.to_vec();
2930                for &(g, c) in &chain {
2931                    next[g] = Some(c);
2932                }
2933                return Some(next);
2934            }
2935            // The graphic is taken; let its caption look for another one.
2936            Some(holder) => stack.push((holder, 0)),
2937        }
2938    }
2939    None
2940}
2941
2942/// Assemble one page from its (already overlap-resolved) layout regions and
2943/// text cells.
2944/// Normalize a layout region (page points, top-left origin) to DocLang's 0–511
2945/// location grid: `clamp(round(512 · coord / page_dim), 0, 511)`, per axis,
2946/// order `[x0, y0, x1, y1]`. Mirrors docling_core's
2947/// `_doclang_utils._create_location_tokens_for_bbox` (resolution 512) so the
2948/// emitted `<location>` tokens line up with the Python groundtruth. Our heron
2949/// cluster boxes match docling's to within ~1 grid unit; the residual (mainly
2950/// the aspect-ratio-stretch vs letterbox preprocessing difference) is absorbed
2951/// by the conformance harness's geometry tolerance.
2952fn norm_loc(region: &Region, page_w: f32, page_h: f32) -> [u16; 4] {
2953    let q = |v: f32, dim: f32| -> u16 {
2954        if dim <= 0.0 {
2955            return 0;
2956        }
2957        let g = (512.0 * (v as f64) / (dim as f64)).round() as i64;
2958        g.clamp(0, 511) as u16
2959    };
2960    [
2961        q(region.l, page_w),
2962        q(region.t, page_h),
2963        q(region.r, page_w),
2964        q(region.b, page_h),
2965    ]
2966}
2967
2968/// Wrap a node in its layout provenance so the DocLang serializer emits the four
2969/// `<location>` tokens as the element's head (Markdown/JSON render `inner`
2970/// unchanged).
2971fn located(loc: [u16; 4], inner: Node) -> Node {
2972    Node::Located {
2973        location: loc,
2974        inner: Box::new(inner),
2975    }
2976}
2977
2978/// Stamp the real 1-based page number onto a page's leading marker (see
2979/// [`assemble_page`], which emits it with `page_no: 0` because only the
2980/// document-level collector knows the true index — `--pages` windows shift it).
2981pub fn stamp_page_no(nodes: &mut [Node], page_no: usize) {
2982    if let Some(Node::PageInfo { page_no: p, .. }) = nodes.first_mut() {
2983        *p = page_no;
2984    }
2985}
2986
2987/// A dense table grid plus its first-class cells (#240): `rows` is the text
2988/// grid every serializer renders (spans replicate their anchor's text);
2989/// `cells` are the docling-parity per-cell records (text, page-point bbox,
2990/// span rectangle, OTSL header roles). Produced by the TableFormer paths
2991/// (`tf_core`); lives in this always-compiled module so the pure-text (wasm
2992/// `pdf-text`) build sees the type.
2993#[derive(Clone, Debug)]
2994pub struct TableGrid {
2995    pub rows: Vec<Vec<String>>,
2996    pub cells: Vec<docling_core::TableCell>,
2997}
2998
2999/// docling's `_RICH_CELL_PICTURE_COVERAGE_THRESHOLD`.
3000const RICH_CELL_PICTURE_COVERAGE: f32 = 0.8;
3001
3002/// docling `ReadingOrderModel._match_table_pictures` (#3906, 2.118.1): every
3003/// picture ≥ 80 % inside a TableFormer-structured table on the page is matched
3004/// to the cell covering it, and returned per table as `cell index → pictures`.
3005/// A picture that pairs with a caption stays a standalone figure (upstream
3006/// would nest it and lose the caption; keeping the caption is the better
3007/// failure). Tables without first-class cells (geometric fallback) have no cell
3008/// boxes to match against and nest nothing.
3009fn match_table_pictures(
3010    regions: &[Region],
3011    table_rows: &[Option<TableGrid>],
3012    caption_for: &[Option<usize>],
3013) -> std::collections::HashMap<usize, Vec<(usize, Vec<usize>)>> {
3014    let mut out: std::collections::HashMap<usize, Vec<(usize, Vec<usize>)>> =
3015        std::collections::HashMap::new();
3016    for (p, pic) in regions.iter().enumerate() {
3017        if pic.label != "picture" || caption_for.get(p).is_some_and(Option::is_some) {
3018            continue;
3019        }
3020        let pa = area(pic.l, pic.t, pic.r, pic.b).max(1.0);
3021        let mut best: Option<(f32, usize, usize)> = None; // (coverage, table, cell)
3022        for (t, tbl) in regions.iter().enumerate() {
3023            if !is_table_like(tbl.label) {
3024                continue;
3025            }
3026            let Some(grid) = table_rows.get(t).and_then(Option::as_ref) else {
3027                continue;
3028            };
3029            if inter(pic, tbl.l, tbl.t, tbl.r, tbl.b) / pa < RICH_CELL_PICTURE_COVERAGE {
3030                continue;
3031            }
3032            if let Some((cov, cell)) = match_picture_to_cell(pic, &grid.cells) {
3033                if best.is_none_or(|(b, _, _)| cov > b) {
3034                    best = Some((cov, t, cell));
3035                }
3036            }
3037        }
3038        if let Some((_, t, cell)) = best {
3039            let entry = out.entry(t).or_default();
3040            match entry.iter_mut().find(|(c, _)| *c == cell) {
3041                Some((_, pics)) => pics.push(p),
3042                None => entry.push((cell, vec![p])),
3043            }
3044        }
3045    }
3046    out
3047}
3048
3049/// docling `_match_picture_to_table_cell`: among the cells covering ≥ 80 % of
3050/// the picture, prefer the one at the picture's inferred grid position (the
3051/// row / column whose median cell center is nearest the picture's center —
3052/// cell boxes can overlap across logical rows and columns), else the best
3053/// coverage. Returns `(coverage, cell index)`.
3054fn match_picture_to_cell(pic: &Region, cells: &[docling_core::TableCell]) -> Option<(f32, usize)> {
3055    let pa = area(pic.l, pic.t, pic.r, pic.b).max(1.0);
3056    let cover = |b: &[f32; 4]| inter(pic, b[0], b[1], b[2], b[3]) / pa;
3057    let eligible: Vec<(f32, usize)> = cells
3058        .iter()
3059        .enumerate()
3060        .filter_map(|(i, c)| {
3061            let b = c.bbox.as_ref()?;
3062            let cov = cover(b);
3063            (cov >= RICH_CELL_PICTURE_COVERAGE).then_some((cov, i))
3064        })
3065        .collect();
3066    if eligible.is_empty() {
3067        return None;
3068    }
3069    let mut row_centers: std::collections::BTreeMap<usize, Vec<f32>> = Default::default();
3070    let mut col_centers: std::collections::BTreeMap<usize, Vec<f32>> = Default::default();
3071    for c in cells {
3072        let Some(b) = c.bbox.as_ref() else { continue };
3073        for r in c.start_row..c.start_row + c.row_span {
3074            row_centers.entry(r).or_default().push((b[1] + b[3]) / 2.0);
3075        }
3076        for k in c.start_col..c.start_col + c.col_span {
3077            col_centers.entry(k).or_default().push((b[0] + b[2]) / 2.0);
3078        }
3079    }
3080    let median = |v: &mut Vec<f32>| -> f32 {
3081        v.sort_by(f32::total_cmp);
3082        let n = v.len();
3083        if n % 2 == 1 {
3084            v[n / 2]
3085        } else {
3086            (v[n / 2 - 1] + v[n / 2]) / 2.0
3087        }
3088    };
3089    let (px, py) = ((pic.l + pic.r) / 2.0, (pic.t + pic.b) / 2.0);
3090    let nearest = |centers: &mut std::collections::BTreeMap<usize, Vec<f32>>, target: f32| {
3091        centers
3092            .iter_mut()
3093            .map(|(&i, v)| (i, (median(v) - target).abs()))
3094            .min_by(|a, b| a.1.total_cmp(&b.1))
3095            .map(|(i, _)| i)
3096    };
3097    let row = nearest(&mut row_centers, py);
3098    let col = nearest(&mut col_centers, px);
3099    let logical: Vec<(f32, usize)> = eligible
3100        .iter()
3101        .copied()
3102        .filter(|&(_, i)| {
3103            let c = &cells[i];
3104            row.is_some_and(|r| c.start_row <= r && r < c.start_row + c.row_span)
3105                && col.is_some_and(|k| c.start_col <= k && k < c.start_col + c.col_span)
3106        })
3107        .collect();
3108    let pool = if logical.is_empty() {
3109        &eligible
3110    } else {
3111        &logical
3112    };
3113    // Python's `max` over `(coverage, cell_index, cell)` tuples: highest
3114    // coverage, ties to the higher index.
3115    pool.iter()
3116        .copied()
3117        .max_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
3118}
3119
3120/// The DocLang structure overlay derived from first-class cells: span
3121/// continuations (`lcel`/`ucel`/`xcel`) and per-cell header roles, so the
3122/// PDF path's DCLX carries real spans instead of a flat grid.
3123fn structure_from_cells(
3124    cells: &[docling_core::TableCell],
3125    nrows: usize,
3126    ncols: usize,
3127) -> docling_core::TableStructure {
3128    let grid = || vec![vec![false; ncols]; nrows];
3129    let mut col_cont = grid();
3130    let mut row_cont = grid();
3131    let mut row_header = grid();
3132    let mut col_header = grid();
3133    for c in cells {
3134        for r in c.start_row..(c.start_row + c.row_span).min(nrows) {
3135            for k in c.start_col..(c.start_col + c.col_span).min(ncols) {
3136                col_cont[r][k] = k > c.start_col;
3137                row_cont[r][k] = r > c.start_row;
3138                row_header[r][k] = c.row_header;
3139                col_header[r][k] = c.column_header;
3140            }
3141        }
3142    }
3143    docling_core::TableStructure {
3144        header_row: Vec::new(),
3145        col_continuation: col_cont,
3146        row_continuation: row_cont,
3147        row_header,
3148        col_header,
3149    }
3150}
3151
3152pub fn assemble_page(
3153    page: &PdfPage,
3154    mut regions: Vec<Region>,
3155    table_rows: &[Option<TableGrid>],
3156    enrichments: &[Option<Enrichment>],
3157    // Picture-crop scale in px/pt (docling's `images_scale`, #520); `None`
3158    // keeps the page render's own scale.
3159    picture_scale: Option<f32>,
3160) -> (Vec<Node>, Vec<(String, String)>) {
3161    // Without pixels (the text-layer-only wasm build) no picture is cropped.
3162    #[cfg(not(feature = "ocr-prep"))]
3163    let _ = picture_scale;
3164    let mut nodes: Vec<Node> = Vec::new();
3165    // Every page opens with an invisible page marker carrying its size in
3166    // points — what the JSON export needs to build docling's `pages` map and
3167    // denormalize the 0–511 `<location>` grid into point bboxes (#171). The
3168    // page *number* is stamped by the document-level collector (which knows
3169    // the real 1-based index, `--pages` windows included); every serializer
3170    // except JSON skips the marker, so Markdown/DocLang stay byte-identical.
3171    nodes.push(Node::PageInfo {
3172        page_no: 0,
3173        width: page.width,
3174        height: page.height,
3175    });
3176    // Recover this page's hyperlinks (anchor-precise pairs for strict
3177    // Markdown; whole-item docling-parity links are baked below and their
3178    // pairs dropped from this list so strict output doesn't double-wrap).
3179    let mut links = resolve_link_anchors(page);
3180    // Lines with a drawn checkbox square in front become checkbox items (#609).
3181    split_checkbox_lines(&mut regions, &page.cells, &page.checkboxes);
3182    // Pair each region with its precomputed TableFormer grid and enrichment
3183    // (indexed by original order) and order by reading order together, so they
3184    // stay aligned.
3185    // A picture's children (docling's `_set_cluster_children`: the regulars
3186    // > 80 % inside it) are not page elements — they leave the reading order
3187    // here and ride with their picture, to be written under it in the JSON.
3188    let parents = picture_parents(&regions);
3189    let mut kids: Vec<Vec<Region>> = vec![Vec::new(); regions.len()];
3190    let mut top: Vec<(usize, Region)> = Vec::with_capacity(regions.len());
3191    for (i, (r, parent)) in regions.into_iter().zip(parents).enumerate() {
3192        match parent {
3193            Some(p) => kids[p].push(r),
3194            None => top.push((i, r)),
3195        }
3196    }
3197    // docling's assembly order of the regions — what its reading-order
3198    // predictor knows as `cid` (#424) — before they are shuffled.
3199    let top_regions: Vec<Region> = top.iter().map(|(_, r)| r.clone()).collect();
3200    let cids = cluster_cids(&top_regions, &page.cells);
3201    type RegionItem = (Region, Option<TableGrid>, Option<Enrichment>, Vec<Region>);
3202    let mut items: Vec<RegionItem> = top
3203        .into_iter()
3204        .map(|(i, r)| {
3205            (
3206                r,
3207                table_rows.get(i).cloned().flatten(),
3208                enrichments.get(i).cloned().flatten(),
3209                std::mem::take(&mut kids[i]),
3210            )
3211        })
3212        .collect();
3213    order_with_containers(&mut items, &cids, page.width, page.height, |it| &it.0);
3214    // Float a margin page number to the front of reading order (docling parity:
3215    // right_to_left_02's bottom `11` is its first item). Stable, so everything
3216    // else keeps its order; no-op on pages without such a region.
3217    let page_h = page.height;
3218    items.sort_by_key(|(r, _, _, _)| !is_page_number(r, &page.cells, page_h));
3219    let table_rows: Vec<Option<TableGrid>> = items.iter().map(|(_, t, _, _)| t.clone()).collect();
3220    let enrichments: Vec<Option<Enrichment>> = items.iter().map(|(_, _, e, _)| e.clone()).collect();
3221    let mut picture_children: Vec<Vec<Region>> = items
3222        .iter_mut()
3223        .map(|it| std::mem::take(&mut it.3))
3224        .collect();
3225    let mut regions: Vec<Region> = items.into_iter().map(|(r, _, _, _)| r).collect();
3226    promote_labelled_captions(&mut regions, &page.cells);
3227    // Children in docling's `_sort_clusters(mode="id")` order: first source
3228    // cell, then top, then left.
3229    for kids in picture_children.iter_mut().filter(|k| k.len() > 1) {
3230        let rank = cluster_cids(kids, &page.cells);
3231        let mut ranked: Vec<(usize, Region)> = rank.into_iter().zip(kids.drain(..)).collect();
3232        ranked.sort_by_key(|(k, _)| *k);
3233        kids.extend(ranked.into_iter().map(|(_, r)| r));
3234    }
3235    // docling's `_find_to_captions` (#265, #813): each graphic claims its
3236    // caption by reading-order adjacency and gap; the caption is consumed
3237    // from its own slot and re-emitted with the graphic (before a picture or
3238    // table, after a code block, as docling renders a `captions` ref).
3239    let graphic_caption_for = pair_graphic_captions(&regions);
3240    let only = |pred: fn(&str) -> bool| -> Vec<Option<usize>> {
3241        regions
3242            .iter()
3243            .zip(&graphic_caption_for)
3244            .map(|(r, c)| c.filter(|_| pred(r.label)))
3245            .collect()
3246    };
3247    let caption_for = only(|l| l == "picture");
3248    let code_caption_for = only(|l| l == "code");
3249    let table_caption_for = only(is_table_like);
3250    let mut consumed = vec![false; regions.len()];
3251    for ci in graphic_caption_for.iter().flatten() {
3252        consumed[*ci] = true;
3253    }
3254    // Pictures inside a table become rich-cell content (docling#3906, 2.118.1):
3255    // the picture is nested in the cell it covers and not emitted standalone.
3256    let rich_cell_pictures = match_table_pictures(&regions, &table_rows, &caption_for);
3257    for (_, pics) in rich_cell_pictures.values().flatten() {
3258        for &p in pics {
3259            consumed[p] = true;
3260        }
3261    }
3262    // A code block's language label (`XML`, `C#`, …) is chrome, not content — the
3263    // detector emits it as its own region above the code; consume it.
3264    for (i, is_label) in code_language_labels(&regions, &page.cells)
3265        .into_iter()
3266        .enumerate()
3267    {
3268        if is_label {
3269            consumed[i] = true;
3270        }
3271    }
3272
3273    // docling `ReadingOrderPredictor.predict_merges`: join a text fragment with a
3274    // following text fragment strictly to its right (an author column that wraps
3275    // into the next, a paragraph continuing in the next column) into one block —
3276    // the intra-page half of docling's reading-order merges (cross-page/vertical
3277    // continuations stay with [`merge_continuations`]). Already-consumed regions
3278    // (paired captions, code labels) are excluded.
3279    // Exclusive docling cell assignment: computed once for the ordered region
3280    // list and reused for every serialization below, so a cell can never render
3281    // in two regions. The picture children take part (docling assigns cells to
3282    // every regular cluster before it nests any); their texts are split off.
3283    let with_children: Vec<Region> = regions
3284        .iter()
3285        .chain(picture_children.iter().flatten())
3286        .cloned()
3287        .collect();
3288    let mut region_texts: Vec<String> = region_texts_exclusive(&with_children, &page.cells);
3289    let mut kid_texts = region_texts.split_off(regions.len()).into_iter();
3290    let child_texts: Vec<Vec<String>> = picture_children
3291        .iter()
3292        .map(|k| kid_texts.by_ref().take(k.len()).collect())
3293        .collect();
3294    let is_text: Vec<bool> = regions
3295        .iter()
3296        .enumerate()
3297        .map(|(i, r)| r.label == "text" && !consumed[i])
3298        .collect();
3299    let is_skip: Vec<bool> = regions
3300        .iter()
3301        .enumerate()
3302        .map(|(i, r)| {
3303            consumed[i]
3304                || matches!(
3305                    r.label,
3306                    "page_header" | "page_footer" | "table" | "picture" | "caption" | "footnote"
3307                )
3308        })
3309        .collect();
3310    let boxes: Vec<(f32, f32, f32, f32)> = regions.iter().map(|r| (r.l, r.t, r.r, r.b)).collect();
3311    if docling_core::env::flag("DOCLING_RS_DEBUG_MERGES") {
3312        for (i, r) in regions.iter().enumerate() {
3313            eprintln!(
3314                "MRG {i:2} {} text={} skip={} [{:.0},{:.0},{:.0},{:.0}] {:?}",
3315                r.label,
3316                is_text[i],
3317                is_skip[i],
3318                r.l,
3319                r.t,
3320                r.r,
3321                r.b,
3322                region_texts[i].chars().take(40).collect::<String>()
3323            );
3324        }
3325    }
3326    let mut merge_suffix: Vec<String> = vec![String::new(); regions.len()];
3327    for (head, children) in
3328        crate::reading_order::predict_merges(&boxes, &region_texts, &is_text, &is_skip)
3329            .into_iter()
3330            .enumerate()
3331    {
3332        for c in children {
3333            let t = region_texts[c].trim();
3334            if !t.is_empty() {
3335                merge_suffix[head].push(' ');
3336                merge_suffix[head].push_str(t);
3337            }
3338            consumed[c] = true;
3339        }
3340    }
3341
3342    for (i, region) in regions.iter().enumerate() {
3343        if consumed[i] {
3344            continue;
3345        }
3346        // Page headers/footers: docling emits them as furniture blocks
3347        // (`<page_header>`/`<page_footer>` with a layer + location + text) at
3348        // their reading-order position, not as body — emit them, don't skip.
3349        if matches!(region.label, "page_header" | "page_footer") {
3350            let text = region_texts[i].clone();
3351            if !text.is_empty() {
3352                nodes.push(Node::PageFurniture {
3353                    footer: region.label == "page_footer",
3354                    location: norm_loc(region, page.width, page_h),
3355                    text: md_escape(&text),
3356                });
3357            }
3358            continue;
3359        }
3360        if is_skipped(region.label) {
3361            continue;
3362        }
3363        // Layout provenance for this region, normalized to docling's 0–511 grid.
3364        let loc = norm_loc(region, page.width, page_h);
3365        if region.label == "picture" {
3366            // The figure pixels are cropped from the page render for image export.
3367            // Captions are prose: markdown-escaped like a paragraph (the JSON
3368            // export unescapes back to the raw text, matching docling).
3369            let caption = caption_for[i]
3370                .map(|ci| md_escape(&region_texts[ci]))
3371                .filter(|t| !t.is_empty());
3372            // The caption's own region box (#609): docling gives every caption
3373            // the cluster it came from as its `prov`, not the picture's.
3374            let caption_location = caption_for[i]
3375                .filter(|_| caption.is_some())
3376                .map(|ci| norm_loc(&regions[ci], page.width, page_h));
3377            let classification = match &enrichments[i] {
3378                Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
3379                _ => None,
3380            };
3381            // Without the page render (text-layer-only build) a picture keeps
3382            // its caption/classification but carries no cropped pixels.
3383            #[cfg(feature = "ocr-prep")]
3384            let image =
3385                crate::timing::timed("crop_region", || crop_region(page, region, picture_scale));
3386            #[cfg(not(feature = "ocr-prep"))]
3387            let image: Option<PictureImage> = None;
3388            nodes.push(located(
3389                loc,
3390                Node::Picture {
3391                    caption,
3392                    caption_href: None,
3393                    image,
3394                    classification,
3395                    description: None,
3396                    // docling's layout pipeline parents a figure's caption to
3397                    // the picture itself (#390) — the one backend that does.
3398                    caption_parent: CaptionParent::Item,
3399                    caption_location,
3400                },
3401            ));
3402            let children: Vec<Node> = picture_children[i]
3403                .iter()
3404                .zip(&child_texts[i])
3405                .filter_map(|(r, text)| {
3406                    picture_child_node(r, text, norm_loc(r, page.width, page_h))
3407                })
3408                .collect();
3409            if !children.is_empty() {
3410                nodes.push(Node::PictureChildren(children));
3411            }
3412            continue;
3413        }
3414        let mut text = region_texts[i].clone();
3415        text.push_str(&merge_suffix[i]);
3416        if text.is_empty() {
3417            continue;
3418        }
3419        match region.label {
3420            // docling assembles checkboxes as TEXT_ELEM items (the region's
3421            // cells are the option label, e.g. right_to_left_03's بلی/خير)
3422            // and its Markdown serializer renders them as task-list lines
3423            // (`- [x] …`) — mirrored by [`Node::CheckboxItem`].
3424            // Located like every other text item, so the JSON item carries
3425            // its page and box (#609) — a chunk of checkboxes has a page.
3426            // The label without a leading ballot-box glyph: the item's state
3427            // already says what `☐` / `☒` drew (#609).
3428            "checkbox_selected" | "checkbox_unselected" => nodes.push(located(
3429                loc,
3430                Node::CheckboxItem {
3431                    checked: region.label == "checkbox_selected",
3432                    text: md_escape(strip_checkbox_glyph(&text)),
3433                },
3434            )),
3435            // docling renders both the document title and section headers as
3436            // `##` (it never emits a top-level `#` for PDFs), so match that.
3437            "title" | "section_header" => nodes.push(located(
3438                loc,
3439                Node::Heading {
3440                    level: 2,
3441                    text: md_escape(&text),
3442                },
3443            )),
3444            // docling's `ListItemMarkerProcessor.process_list_item` runs on
3445            // every PDF list item: a leading bullet glyph or enumeration marker
3446            // followed by whitespace is split off into the item's `marker`, and
3447            // docling-core's Markdown then prints `- text` for a bullet, `N. text`
3448            // for an `N.` marker and `- a) text` for any other marker holding a
3449            // letter or digit (see [`list_item_node`]). The symbol-font bullets
3450            // docling-parse filters out of its cells are stripped first.
3451            "list_item" => nodes.push(list_item_node(&text, loc, false)),
3452            // TableFormer structure (cells + spans, text matched from word cells)
3453            // when available; otherwise geometric grid reconstruction; finally a
3454            // single cell.
3455            "table" | "document_index" => {
3456                // TableFormer grids carry first-class cells (#240: text +
3457                // page-point bbox + span rectangle + OTSL header roles) into
3458                // the public model, and the DocLang structure overlay derives
3459                // from them so DCLX emits real span/header tokens. The
3460                // geometric fallback has no per-cell records.
3461                let (mut rows, cells, structure) = match table_rows[i].clone() {
3462                    Some(grid) => {
3463                        let nrows = grid.rows.len();
3464                        let ncols = grid.rows.first().map_or(0, Vec::len);
3465                        let structure = structure_from_cells(&grid.cells, nrows, ncols);
3466                        (grid.rows, Some(grid.cells), Some(structure))
3467                    }
3468                    None => {
3469                        let rows = reconstruct_table(region, &page.cells);
3470                        let rows = if rows.iter().any(|r| r.len() > 1) {
3471                            rows
3472                        } else {
3473                            vec![vec![text.clone()]]
3474                        };
3475                        (rows, None, None)
3476                    }
3477                };
3478                // The paired caption (#265) rides on the table — docling's
3479                // TableItem.captions ref; Markdown prints it above the grid,
3480                // the JSON export emits the $ref, DocLang the <caption>.
3481                let caption = table_caption_for[i]
3482                    .map(|ci| md_escape(&region_texts[ci]))
3483                    .filter(|t| !t.is_empty());
3484                // Its own region box becomes the caption item's `prov` (#609).
3485                let caption_location = table_caption_for[i]
3486                    .filter(|_| caption.is_some())
3487                    .map(|ci| norm_loc(&regions[ci], page.width, page_h));
3488                // Rich cells (docling#3906): the covering cell's blocks are its
3489                // text followed by the nested picture(s). docling's Markdown
3490                // renders a `RichTableCell` through the serializer — the
3491                // group's children joined by blank lines, newlines flattened
3492                // to spaces — so the flat `rows` text becomes
3493                // `text  <!-- image -->`; the first-class `cells` (the JSON
3494                // `table_cells` / `grid`) keep the plain text, as upstream.
3495                let mut cell_blocks: Option<Vec<Vec<Vec<Node>>>> = None;
3496                if let (Some(by_cell), Some(fc)) = (rich_cell_pictures.get(&i), cells.as_ref()) {
3497                    let nrows = rows.len();
3498                    let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
3499                    let mut blocks = vec![vec![Vec::<Node>::new(); ncols]; nrows];
3500                    for (cell_idx, pics) in by_cell {
3501                        let cell = &fc[*cell_idx];
3502                        let (r, c) = (cell.start_row, cell.start_col);
3503                        if r >= nrows || c >= ncols {
3504                            continue;
3505                        }
3506                        let mut parts: Vec<String> = Vec::new();
3507                        let mut cell_nodes: Vec<Node> = Vec::new();
3508                        if !cell.text.trim().is_empty() {
3509                            parts.push(cell.text.clone());
3510                            cell_nodes.push(Node::Paragraph {
3511                                text: cell.text.clone(),
3512                            });
3513                        }
3514                        for &p in pics {
3515                            parts.push("<!-- image -->".to_string());
3516                            let classification = match &enrichments[p] {
3517                                Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
3518                                _ => None,
3519                            };
3520                            #[cfg(feature = "ocr-prep")]
3521                            let image = crop_region(page, &regions[p], picture_scale);
3522                            #[cfg(not(feature = "ocr-prep"))]
3523                            let image: Option<PictureImage> = None;
3524                            cell_nodes.push(located(
3525                                norm_loc(&regions[p], page.width, page_h),
3526                                Node::Picture {
3527                                    caption: None,
3528                                    caption_href: None,
3529                                    image,
3530                                    classification,
3531                                    description: None,
3532                                    caption_parent: Default::default(),
3533                                    caption_location: None,
3534                                },
3535                            ));
3536                        }
3537                        let rendered = parts.join("  ");
3538                        for row in rows.iter_mut().skip(r).take(cell.row_span) {
3539                            for slot in row.iter_mut().skip(c).take(cell.col_span) {
3540                                *slot = rendered.clone();
3541                            }
3542                        }
3543                        blocks[r][c] = cell_nodes;
3544                    }
3545                    cell_blocks = Some(blocks);
3546                }
3547                nodes.push(located(
3548                    loc,
3549                    Node::Table(Table {
3550                        rows,
3551                        location: None,
3552                        structure,
3553                        cell_blocks,
3554                        cells,
3555                        caption,
3556                        // As for pictures: the caption is the table's child.
3557                        caption_parent: CaptionParent::Item,
3558                        caption_location,
3559                    }),
3560                ));
3561            }
3562            // With formula enrichment the CodeFormula model decodes the region
3563            // to LaTeX; otherwise docling emits a placeholder comment rather
3564            // than the (garbled) raw glyph text.
3565            "formula" => match &enrichments[i] {
3566                Some(Enrichment::Formula { latex }) => nodes.push(Node::Formula {
3567                    latex: latex.clone(),
3568                    orig: text.clone(),
3569                    location: Some(loc),
3570                }),
3571                _ => nodes.push(Node::Paragraph {
3572                    text: "<!-- formula-not-decoded -->".into(),
3573                }),
3574            },
3575            // Code blocks: use the space-glyph-only grouping (monospace keeps its
3576            // source spacing) and emit a fenced block, preserving the line breaks
3577            // and indentation of the source (unlike prose, which reflows). pdfium
3578            // still inserts spaces around tight punctuation (`console .log`,
3579            // `add (3 , 5)`); tighten them to match docling-parse's source spacing.
3580            "code" => {
3581                // `code_region_text` preserves line breaks/indentation and tightens
3582                // each line itself; the fallback prose `text` is tightened here.
3583                let code = code_region_text(region, &page.code_cells);
3584                let code = if code.is_empty() {
3585                    tighten_code_punct(&text)
3586                } else {
3587                    code
3588                };
3589                // With code enrichment the CodeFormula model rewrites the block
3590                // (and names its language); `orig` keeps the raw extraction in
3591                // docling's shape — its parser has no line-preserving code
3592                // path, so its `orig` is the same code with the lines joined
3593                // by single spaces (indentation collapsed).
3594                // docling's parser has no line-preserving code path — its code
3595                // items carry the lines joined by single spaces. That flat
3596                // form is what every byte-conformance surface serializes
3597                // (legacy Markdown, JSON, DocLang); the line-preserving
3598                // extraction rides in `pretty` for strict Markdown only.
3599                let flat = code
3600                    .lines()
3601                    .map(str::trim)
3602                    .filter(|l| !l.is_empty())
3603                    .collect::<Vec<_>>()
3604                    .join(" ");
3605                let node = match &enrichments[i] {
3606                    Some(Enrichment::Code {
3607                        language,
3608                        text: enriched,
3609                    }) => Node::Code {
3610                        language: language.clone(),
3611                        text: enriched.clone(),
3612                        orig: Some(flat),
3613                        pretty: None,
3614                    },
3615                    _ => Node::Code {
3616                        language: None,
3617                        text: flat,
3618                        orig: None,
3619                        pretty: Some(code),
3620                    },
3621                };
3622                nodes.push(located(loc, node));
3623                // docling emits the `Listing N:` caption after the code block.
3624                if let Some(ci) = code_caption_for[i] {
3625                    let cap = md_escape(&region_texts[ci]);
3626                    if !cap.is_empty() {
3627                        // With its own region box, like every caption (#609).
3628                        nodes.push(located(
3629                            norm_loc(&regions[ci], page.width, page_h),
3630                            Node::Paragraph { text: cap },
3631                        ));
3632                    }
3633                }
3634            }
3635            // text, caption, footnote → paragraph
3636            _ => {
3637                // docling parity (`PageAssembleModel._match_hyperlink`): when
3638                // link annotations cover ≥ half of the region's box, the
3639                // hyperlink attaches to the item and the legacy Markdown
3640                // serializer wraps its full text — 2206.01062's footnote URLs
3641                // render as `[1 https://…](https://…)`. Sparse in-paragraph
3642                // citation links stay below the 0.5 coverage threshold and
3643                // remain plain text, exactly like docling.
3644                //
3645                // Scope: **footnote regions only.** Upstream's page_assemble
3646                // matches every TEXT_ELEM label, but published docling
3647                // observably carries the hyperlink into the document only for
3648                // footnote items — in both committed groundtruth generations
3649                // (docling-JSON and Markdown, independent runs) the fully
3650                // covered plain-text DOI line of 2206.01062 page 1 has
3651                // `hyperlink: None` while the equally covered footnotes carry
3652                // theirs. The corpus is the conformance reference, so match
3653                // the observed behavior; widen the label set if a future
3654                // groundtruth refresh starts linking plain text too.
3655                let escaped = md_escape(&text);
3656                let hyperlink = (region.label == "footnote")
3657                    .then(|| region_hyperlink(region, &page.links))
3658                    .flatten();
3659                if let Some(uri) = &hyperlink {
3660                    // The strict-mode anchor pairs this item covers are
3661                    // superseded by the whole-item link.
3662                    links.retain(|(anchor, href)| {
3663                        !(href == uri && region_texts[i].contains(anchor.as_str()))
3664                    });
3665                }
3666                // A footnote keeps docling's label and carries its link as
3667                // the item's `hyperlink` (the JSON's `text` stays the raw
3668                // footnote, not Markdown); every other serializer renders it
3669                // as the `[text](uri)` paragraph it was before.
3670                let node = if region.label == "footnote" {
3671                    Node::LabeledText {
3672                        label: "footnote".into(),
3673                        text: escaped,
3674                        href: hyperlink,
3675                    }
3676                } else {
3677                    Node::Paragraph { text: escaped }
3678                };
3679                nodes.push(located(loc, node))
3680            }
3681        }
3682    }
3683    // A `/Rotate`-normalized scanned page (see `pdfium_backend`) was assembled
3684    // in upright space; rotate the finished geometry back so locations and the
3685    // page size are display-space, like docling and every viewer report them.
3686    if page.rotation != 0 {
3687        rotate_nodes_to_display(&mut nodes, page.rotation);
3688    }
3689    (nodes, links)
3690}
3691
3692/// One child of a picture as docling's `ReadingOrderModel._add_child_elements`
3693/// writes it under the `PictureItem`: a heading for a `section_header` /
3694/// `title` (upstream remaps title to section header), a list item for a
3695/// `list_item`, a furniture-layer text for a page header/footer, a caption,
3696/// otherwise a text item. `None` for a child that claimed no text.
3697fn picture_child_node(region: &Region, text: &str, loc: [u16; 4]) -> Option<Node> {
3698    if text.is_empty() {
3699        return None;
3700    }
3701    Some(match region.label {
3702        "title" | "section_header" => located(
3703            loc,
3704            Node::Heading {
3705                level: 2,
3706                text: md_escape(text),
3707            },
3708        ),
3709        // docling-core's `add_list_item` under a non-list parent opens a
3710        // list group per item, so every child item starts its own list;
3711        // `_add_child_elements` runs the marker processor on it too.
3712        "list_item" => list_item_node(text, loc, true),
3713        "page_header" | "page_footer" => Node::PageFurniture {
3714            footer: region.label == "page_footer",
3715            location: loc,
3716            text: md_escape(text),
3717        },
3718        "caption" => located(
3719            loc,
3720            Node::Caption {
3721                text: md_escape(text),
3722                href: None,
3723            },
3724        ),
3725        _ => located(
3726            loc,
3727            Node::Paragraph {
3728                text: md_escape(text),
3729            },
3730        ),
3731    })
3732}
3733
3734/// Rotate one 0–511 location bbox 90° clockwise on the grid (top-left origin):
3735/// `(x, y) → (511 - y, x)`.
3736fn rot_loc_cw(l: [u16; 4]) -> [u16; 4] {
3737    [511 - l[3], l[0], 511 - l[1], l[2]]
3738}
3739
3740/// Map upright-space geometry back to display space for a page whose `/Rotate`
3741/// was normalized away before inference: every `<location>` rotates `rot`°
3742/// clockwise on the 0–511 grid (the grid is per-axis normalized, so no page
3743/// dims are needed), and the `PageInfo` size returns to the display box. Node
3744/// text and order are untouched — reading order was decided upright, which is
3745/// the whole point.
3746fn rotate_nodes_to_display(nodes: &mut [Node], rot: u16) {
3747    let quarter_turns = (rot / 90) as usize;
3748    let rot_loc = |l: &mut [u16; 4]| {
3749        for _ in 0..quarter_turns {
3750            *l = rot_loc_cw(*l);
3751        }
3752    };
3753    fn walk(node: &mut Node, rot_loc: &impl Fn(&mut [u16; 4]), swap_dims: bool) {
3754        match node {
3755            Node::PageInfo { width, height, .. } => {
3756                if swap_dims {
3757                    std::mem::swap(width, height);
3758                }
3759            }
3760            Node::Located { location, inner } => {
3761                rot_loc(location);
3762                walk(inner, rot_loc, swap_dims);
3763            }
3764            Node::Furniture { inner, .. } => walk(inner, rot_loc, swap_dims),
3765            Node::Group { children, .. } | Node::PictureChildren(children) => {
3766                for c in children {
3767                    walk(c, rot_loc, swap_dims);
3768                }
3769            }
3770            Node::ListItem { location, .. }
3771            | Node::Formula { location, .. }
3772            | Node::Chart { location, .. } => {
3773                if let Some(l) = location {
3774                    rot_loc(l);
3775                }
3776            }
3777            Node::PageFurniture { location, .. } => rot_loc(location),
3778            Node::Table(t) => {
3779                if let Some(l) = &mut t.location {
3780                    rot_loc(l);
3781                }
3782            }
3783            _ => {}
3784        }
3785    }
3786    let swap_dims = quarter_turns % 2 == 1;
3787    for node in nodes {
3788        walk(node, &rot_loc, swap_dims);
3789    }
3790}
3791
3792/// Merge paragraph fragments split across a column or page break. docling joins a
3793/// paragraph whose previous fragment ends mid-sentence (a letter, not sentence
3794/// punctuation) with a lowercase continuation: `…definition of` + `lists in…` →
3795/// `…definition of lists in…`. The fragments are consecutive paragraphs, or
3796/// separated only by figure(s) the text wraps around: a column whose body flows
3797/// past a figure resumes below it (`…The wing type that is` ⟶[figure]⟶ `the most
3798/// common…`), and docling emits the whole paragraph before the figure. A heading,
3799/// table, or list between them ends the paragraph (no merge).
3800/// A paragraph that is really a figure/table caption (`Fig. 1. …`, `Table 2 …`).
3801/// Used to skip an unpaired caption when stitching a paragraph that wraps around
3802/// a figure.
3803fn looks_like_caption(text: &str) -> bool {
3804    let head: String = text.trim_start().chars().take(14).collect();
3805    (head.starts_with("Fig") || head.starts_with("Table"))
3806        && head.contains(|c: char| c.is_ascii_digit())
3807}
3808
3809/// A paragraph fragment is "open" — i.e. it might continue into the next
3810/// paragraph — when it ends mid-word (a letter) or with a wrap hyphen/dash.
3811/// docling joins `vocab-` + `ulary` → `vocab- ulary`.
3812fn paragraph_is_open(text: &str) -> bool {
3813    // docling's merge head test (`.+([a-z,\-\u00AD])\s*`): at least two chars,
3814    // ending in an ASCII lowercase letter, a comma, a hyphen, or a soft
3815    // hyphen. The comma matters: 2206's "…In phase four," resumes across the
3816    // page break. Uppercase/non-Latin endings do not merge, exactly as
3817    // upstream (the dash family is already `-` here — clean_text normalized).
3818    let t = text.trim_end();
3819    t.chars().count() >= 2
3820        && t.chars()
3821            .next_back()
3822            .is_some_and(|c| matches!(c, 'a'..='z' | ',' | '-' | '\u{ad}'))
3823}
3824
3825/// The paragraph text inside a node, looking through a [`Node::Located`]
3826/// provenance wrapper (PDF body paragraphs are wrapped since they carry a
3827/// `<location>`). Returns `None` for non-paragraph nodes.
3828fn as_paragraph(n: &Node) -> Option<&str> {
3829    match n {
3830        Node::Paragraph { text } => Some(text),
3831        Node::Located { inner, .. } => match inner.as_ref() {
3832            Node::Paragraph { text } => Some(text),
3833            _ => None,
3834        },
3835        _ => None,
3836    }
3837}
3838
3839/// Whether a node is a picture, looking through a [`Node::Located`] wrapper.
3840fn is_picture_node(n: &Node) -> bool {
3841    match n {
3842        Node::Picture { .. } => true,
3843        Node::Located { inner, .. } => matches!(inner.as_ref(), Node::Picture { .. }),
3844        _ => false,
3845    }
3846}
3847
3848/// A node a forward paragraph merge looks straight past: a figure or *table*
3849/// the text wraps around, or a page header/footer that falls between the two
3850/// fragments of a paragraph continuing across a page break (docling's merge
3851/// skip-labels: page_header, page_footer, table, picture, caption, footnote —
3852/// 2206's "…In phase four," resumes after a full caption+table+figure block).
3853fn is_merge_trailer(n: &Node) -> bool {
3854    is_picture_node(n)
3855        || matches!(
3856            n,
3857            Node::PageFurniture { .. }
3858                | Node::PageInfo { .. }
3859                | Node::Table(_)
3860                | Node::PictureChildren(_)
3861        )
3862        || matches!(n, Node::Located { inner, .. } if matches!(inner.as_ref(), Node::Table(_)))
3863        || is_footnote_node(n)
3864        || as_paragraph(n).is_some_and(looks_like_caption)
3865}
3866
3867/// Whether a node is a footnote item (a [`Node::LabeledText`] labelled
3868/// `footnote`), looking through a [`Node::Located`] wrapper — one of
3869/// docling's merge skip-labels: a footnote between the two halves of a
3870/// paragraph is looked past, never merged into.
3871fn is_footnote_node(n: &Node) -> bool {
3872    let n = match n {
3873        Node::Located { inner, .. } => inner.as_ref(),
3874        other => other,
3875    };
3876    matches!(n, Node::LabeledText { label, .. } if label == "footnote")
3877}
3878
3879/// Rebuild node `i` as a paragraph with `text`, preserving its `<location>`
3880/// wrapper (and thus provenance) if it had one.
3881fn reparagraph(node: &Node, text: String) -> Node {
3882    match node {
3883        Node::Located { location, .. } => located(*location, Node::Paragraph { text }),
3884        _ => Node::Paragraph { text },
3885    }
3886}
3887
3888pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
3889    let mut i = 0;
3890    while i + 1 < nodes.len() {
3891        let Some(a) = as_paragraph(&nodes[i]) else {
3892            i += 1;
3893            continue;
3894        };
3895        // A figure/table caption is a self-contained unit; body text resuming
3896        // after a figure is the continuation case, not the caption itself. Never
3897        // stitch *from* a caption — otherwise a caption that ends in a lone glyph
3898        // (`Fig. 5. … PubTabNet. μ`) would swallow a following stray figure label
3899        // (a standalone `μ`) into `… μ μ`.
3900        if looks_like_caption(a) {
3901            i += 1;
3902            continue;
3903        }
3904        if !paragraph_is_open(a) {
3905            i += 1;
3906            continue;
3907        }
3908        // The continuation is the next paragraph, looking past any figures the
3909        // text wraps around — and a figure/table caption that was emitted as its
3910        // own paragraph (an above-the-figure caption that didn't pair), since the
3911        // body text resumes after the whole figure+caption block.
3912        let mut j = i + 1;
3913        while nodes.get(j).is_some_and(is_merge_trailer) {
3914            j += 1;
3915        }
3916        // docling's continuation regex allows either case, but its merge runs
3917        // over the pre-assembly element stream; at node level an uppercase
3918        // start is overwhelmingly a new sentence/heading fragment (allowing it
3919        // swallowed 2305's formula blocks and redp's chapter openers), so the
3920        // continuation stays lowercase-start here.
3921        let cont = nodes.get(j).and_then(as_paragraph).is_some_and(|b| {
3922            b.trim_start()
3923                .chars()
3924                .next()
3925                .is_some_and(char::is_lowercase)
3926        });
3927        if cont {
3928            let a = as_paragraph(&nodes[i]).unwrap().trim_end().to_string();
3929            let b = as_paragraph(&nodes[j]).unwrap().trim_start().to_string();
3930            // A soft hyphen -- or a hard hyphen followed by a lowercase
3931            // continuation (guaranteed lowercase by the `cont` gate above) --
3932            // is a word split across the break: strip it and join without a
3933            // space, docling#3888 ("vocab-" + "ulary" -> "vocabulary");
3934            // docling's older serializer kept the artifact ("vocab- ulary").
3935            // Everything else joins with the space, as before.
3936            let merged = match a.strip_suffix('\u{ad}').or_else(|| a.strip_suffix('-')) {
3937                Some(stem) => format!("{stem}{b}"),
3938                None => format!("{a} {b}"),
3939            };
3940            // Keep node i's provenance wrapper; docling's merged paragraph keeps
3941            // the first fragment's geometry as its primary location.
3942            nodes[i] = reparagraph(&nodes[i], merged);
3943            nodes.remove(j);
3944            // Re-check i: the merged paragraph may continue further.
3945        } else {
3946            i += 1;
3947        }
3948    }
3949}
3950
3951/// How many leading nodes of `nodes` are safe to flush now — i.e. cannot be
3952/// rewritten by a future [`merge_continuations`] once more pages are appended.
3953///
3954/// A forward merge can only start from an "open" paragraph (ends mid-word) and
3955/// only reaches across trailing pictures and figure/table captions. So we scan
3956/// from the end past those skippable trailers: if the first non-skippable node is
3957/// an open paragraph, it (and the trailers after it) must be held; anything else —
3958/// a closed paragraph, a heading, a table, a list — blocks any forward merge, so
3959/// the whole buffer is safe to flush.
3960fn hold_start(nodes: &[Node]) -> usize {
3961    for k in (0..nodes.len()).rev() {
3962        // Skippable trailers (figures, page furniture, captions): a forward merge
3963        // looks straight past them.
3964        if is_merge_trailer(&nodes[k]) {
3965            continue;
3966        }
3967        match as_paragraph(&nodes[k]) {
3968            // An open body paragraph might still pull a continuation off the next
3969            // page — hold from here to the end.
3970            Some(text) if paragraph_is_open(text) => return k,
3971            // A closed paragraph, heading, table, list, etc. ends the paragraph:
3972            // nothing after it can merge backwards across it. Flush everything.
3973            _ => return nodes.len(),
3974        }
3975    }
3976    // Only skippable trailers (or empty) and no open paragraph to anchor a merge.
3977    nodes.len()
3978}
3979
3980/// Streaming counterpart of [`merge_continuations`]: feed per-page node batches in
3981/// document order and get back the prefix that is final (its cross-page merges are
3982/// resolved and no future page can change it), holding back only the small tail
3983/// that might still merge into the next page. Concatenating every flushed batch
3984/// (then [`finish`](Self::finish)) yields exactly the same nodes as running
3985/// [`merge_continuations`] once over the whole document.
3986pub(crate) struct StreamAssembler {
3987    pending: Vec<Node>,
3988}
3989
3990impl StreamAssembler {
3991    pub(crate) fn new() -> Self {
3992        Self {
3993            pending: Vec::new(),
3994        }
3995    }
3996
3997    /// Append one page's nodes, resolve merges within the buffer, and return the
3998    /// now-final prefix to emit (possibly empty).
3999    pub(crate) fn push(&mut self, mut nodes: Vec<Node>) -> Vec<Node> {
4000        self.pending.append(&mut nodes);
4001        merge_continuations(&mut self.pending);
4002        let cut = hold_start(&self.pending);
4003        let tail = self.pending.split_off(cut);
4004        std::mem::replace(&mut self.pending, tail)
4005    }
4006
4007    /// Flush whatever is left after the last page (the held tail is final once no
4008    /// more pages can follow).
4009    pub(crate) fn finish(self) -> Vec<Node> {
4010        self.pending
4011    }
4012}
4013
4014#[cfg(test)]
4015mod tests {
4016    use super::{cells_text, clean_text, merge_overlapping_regulars, reclaim_heading_body_footers};
4017
4018    /// docling drops a picture covering > 90 % of the page (its labels then
4019    /// read out as text); a dominant-but-not-full figure and any other label
4020    /// stay whatever their size.
4021    #[test]
4022    fn full_page_pictures_are_dropped_like_docling() {
4023        use super::drop_full_page_pictures;
4024        use crate::layout::Region;
4025        let region = |label: &'static str, l, t, r, b| Region {
4026            label,
4027            score: 0.99,
4028            l,
4029            t,
4030            r,
4031            b,
4032        };
4033        let mut regions = vec![
4034            region("picture", 0.0, 0.5, 478.9, 241.8),
4035            region("picture", 10.0, 10.0, 400.0, 200.0),
4036            region("table", 0.0, 0.0, 480.0, 243.0),
4037            region("text", 5.0, 5.0, 100.0, 20.0),
4038        ];
4039        drop_full_page_pictures(&mut regions, 480.75, 243.75);
4040        let labels: Vec<_> = regions.iter().map(|r| (r.label, r.l)).collect();
4041        assert_eq!(
4042            labels,
4043            vec![("picture", 10.0), ("table", 0.0), ("text", 5.0)]
4044        );
4045    }
4046    use super::{code_region_text, merge_continuations, resolve_link_anchors, StreamAssembler};
4047    use crate::layout::Region;
4048    use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
4049    use docling_core::Node;
4050
4051    /// The int8-layout guard's coverage metric: cells under detections count,
4052    /// cells outside don't, whitespace cells are ignored, and a cell-less page
4053    /// reads as fully covered (nothing to rescue).
4054    #[test]
4055    fn layout_cell_coverage_counts_claimed_text_cells() {
4056        let cell = |text: &str, l: f32, t: f32| TextCell {
4057            text: text.into(),
4058            l,
4059            t,
4060            r: l + 40.0,
4061            b: t + 10.0,
4062        };
4063        let region = Region {
4064            label: "text",
4065            score: 0.9,
4066            l: 0.0,
4067            t: 0.0,
4068            r: 100.0,
4069            b: 50.0,
4070        };
4071        let cells = vec![
4072            cell("inside", 10.0, 10.0),
4073            cell("also inside", 10.0, 30.0),
4074            cell("outside", 10.0, 200.0),
4075            cell("   ", 10.0, 210.0), // whitespace: not counted at all
4076        ];
4077        let cov = super::layout_cell_coverage(std::slice::from_ref(&region), &cells);
4078        assert!((cov - 2.0 / 3.0).abs() < 1e-6, "got {cov}");
4079        assert_eq!(super::layout_cell_coverage(&[], &[]), 1.0);
4080        assert_eq!(super::layout_cell_coverage(&[], &cells), 0.0);
4081    }
4082
4083    /// #165: a picture no longer claims cells at 0.2 intersection-over-self.
4084    /// A line straddling the figure border (≤80 % contained) becomes an orphan
4085    /// region and is emitted as page text — before the fix its cells were
4086    /// silently erased. A line fully inside the picture is the picture's child
4087    /// (docling's `_set_cluster_children`): it survives the containment drop,
4088    /// leaves the page's reading order, and is written only under the picture
4089    /// in the JSON — never in the Markdown, like docling's picture serializer.
4090    #[test]
4091    fn border_straddlers_are_page_text_picture_interior_is_a_picture_child() {
4092        let pic = Region {
4093            label: "picture",
4094            score: 0.9,
4095            l: 0.0,
4096            t: 0.0,
4097            r: 100.0,
4098            b: 100.0,
4099        };
4100        // ~35 % of this cell overlaps the picture (l=90..120 of 0..100): above
4101        // the old 0.2 claim (was swallowed), below full containment (survives).
4102        let straddler = TextCell {
4103            text: "axis label".into(),
4104            l: 90.0,
4105            t: 40.0,
4106            r: 120.0,
4107            b: 48.0,
4108        };
4109        let interior = TextCell {
4110            text: "in-figure callout".into(),
4111            l: 10.0,
4112            t: 10.0,
4113            r: 60.0,
4114            b: 18.0,
4115        };
4116        let cells = vec![straddler, interior];
4117        let mut regions = vec![pic];
4118        super::add_orphan_regions(&mut regions, &cells);
4119        super::drop_contained_regulars(&mut regions);
4120        assert_eq!(
4121            regions.iter().filter(|r| r.label == "text").count(),
4122            2,
4123            "both unclaimed lines become orphans, and a picture swallows neither"
4124        );
4125        let parents = super::picture_parents(&regions);
4126        let parent_of = |l: f32| {
4127            regions
4128                .iter()
4129                .zip(&parents)
4130                .find(|(r, _)| r.label == "text" && r.l == l)
4131                .and_then(|(_, p)| *p)
4132        };
4133        assert_eq!(
4134            parent_of(10.0),
4135            Some(0),
4136            "the callout is the picture's child"
4137        );
4138        assert_eq!(parent_of(90.0), None, "the straddler is a page element");
4139
4140        let page = PdfPage::from_cells(200.0, 200.0, 2.0, cells);
4141        let n = regions.len();
4142        let (nodes, _) = super::assemble_page(&page, regions, &vec![None; n], &vec![None; n], None);
4143        let children: Vec<&Node> = nodes
4144            .iter()
4145            .filter_map(|n| match n {
4146                Node::PictureChildren(c) => Some(c),
4147                _ => None,
4148            })
4149            .flatten()
4150            .collect();
4151        assert!(
4152            matches!(children.as_slice(), [Node::Located { inner, .. }]
4153                if matches!(inner.as_ref(), Node::Paragraph { text } if text == "in-figure callout")),
4154            "{children:?}"
4155        );
4156        let mut doc = docling_core::DoclingDocument::new("t");
4157        doc.nodes = nodes;
4158        let md = doc.export_to_markdown();
4159        assert!(md.contains("axis label"), "{md}");
4160        assert!(!md.contains("in-figure callout"), "{md}");
4161        let json = doc.export_to_json_value();
4162        let pic = &json["pictures"][0];
4163        let child = pic["children"][0]["$ref"].as_str().expect("a child ref");
4164        let idx: usize = child.rsplit('/').next().unwrap().parse().unwrap();
4165        assert_eq!(json["texts"][idx]["text"], "in-figure callout");
4166        assert_eq!(json["texts"][idx]["parent"]["$ref"], "#/pictures/0");
4167        assert_eq!(json["texts"][idx]["content_layer"], "body");
4168    }
4169
4170    /// docling#3906's concern, pinned on our side: a picture detected fully
4171    /// inside a table region must survive the containment drop (upstream now
4172    /// attaches it to the table's cell; we keep it as a body sibling — either
4173    /// way it must not vanish). The text region inside the same table is the
4174    /// control: regulars are the ones the drop swallows.
4175    #[test]
4176    fn picture_inside_a_table_region_survives_the_containment_drop() {
4177        let mut regions = vec![
4178            region("table", 0.9, 0.0, 0.0, 200.0, 200.0),
4179            region("picture", 0.9, 20.0, 20.0, 120.0, 120.0),
4180            region("text", 0.9, 20.0, 140.0, 180.0, 180.0),
4181        ];
4182        super::drop_contained_regulars(&mut regions);
4183        let labels: Vec<&str> = regions.iter().map(|r| r.label).collect();
4184        assert_eq!(
4185            labels,
4186            ["table", "picture"],
4187            "the in-table picture stays; the in-table regular is the special's child"
4188        );
4189    }
4190
4191    /// Caption pairing (#265, #813) is docling's `_find_to_captions`: a
4192    /// caption binds a graphic in the unbroken run next to it in the region
4193    /// sequence — above-caption and below-caption both work, and geometry
4194    /// only ranks (a same-page caption in the other column of a two-column
4195    /// layout is *not* adjacent, however close its box is). A caption
4196    /// separated from its graphic by a text paragraph stays unattached; one
4197    /// between two graphics goes to the nearer, a tie to the one above.
4198    #[test]
4199    fn captions_pair_by_reading_order_adjacency() {
4200        // caption → table (above-caption), then table → caption (below-caption),
4201        // then a caption fenced off by a paragraph, then one between two tables.
4202        let regions = vec![
4203            region("text", 0.9, 0.0, 0.0, 100.0, 10.0), // 0 body text
4204            region("caption", 0.9, 0.0, 12.0, 60.0, 20.0), // 1 above-caption
4205            region("table", 0.9, 20.0, 22.0, 90.0, 60.0), // 2 ← pairs with 1
4206            region("table", 0.9, 0.0, 70.0, 100.0, 110.0), // 3 ← pairs with 4
4207            region("caption", 0.9, 0.0, 112.0, 60.0, 120.0), // 4 below-caption
4208            region("text", 0.9, 0.0, 130.0, 100.0, 140.0), // 5 body text
4209            region("caption", 0.9, 0.0, 142.0, 60.0, 150.0), // 6 fenced by 5/7
4210            region("text", 0.9, 0.0, 152.0, 100.0, 162.0), // 7 body text
4211            region("table", 0.9, 0.0, 170.0, 100.0, 200.0), // 8 ← tie, above wins
4212            region("caption", 0.9, 0.0, 202.0, 60.0, 210.0), // 9 2 pt from both
4213            region("table", 0.9, 0.0, 212.0, 100.0, 240.0), // 10 unpaired
4214            region("page_footer", 0.9, 0.0, 250.0, 100.0, 260.0), // 11 furniture
4215        ];
4216        let pairs = super::pair_graphic_captions(&regions);
4217        assert_eq!(pairs[2], Some(1), "caption directly above its table pairs");
4218        assert_eq!(pairs[3], Some(4), "caption directly below its table pairs");
4219        assert!(
4220            !pairs.contains(&Some(6)),
4221            "a text paragraph between caption and table breaks the bond"
4222        );
4223        assert_eq!(pairs[8], Some(9), "equal gaps go to the preceding graphic");
4224        assert_eq!(pairs[10], None);
4225        assert!(
4226            pairs.iter().filter(|p| p.is_some()).count() == 3,
4227            "{pairs:?}"
4228        );
4229    }
4230
4231    /// #813: a `Figure N:` label printed *above* its figure (DP-Bench
4232    /// 01030000000036: caption [40,255,287,266] over picture [118,277,423,469])
4233    /// binds the picture; the old geometric matcher only looked below. A
4234    /// caption below a code block binds too (docling's run is label-blind).
4235    #[test]
4236    fn a_caption_above_its_picture_binds() {
4237        let regions = vec![
4238            region("section_header", 0.9, 40.0, 200.0, 300.0, 215.0),
4239            region("text", 0.9, 40.0, 220.0, 500.0, 250.0),
4240            region("caption", 0.93, 40.0, 255.0, 287.0, 266.0), // 2
4241            region("picture", 0.98, 118.0, 277.0, 423.0, 469.0), // 3 ← 2
4242            region("text", 0.9, 40.0, 480.0, 500.0, 520.0),
4243            region("code", 0.9, 40.0, 530.0, 500.0, 600.0), // 5 ← 6
4244            region("caption", 0.9, 40.0, 604.0, 300.0, 614.0), // 6
4245        ];
4246        let pairs = super::pair_graphic_captions(&regions);
4247        assert_eq!(pairs[3], Some(2));
4248        assert_eq!(pairs[5], Some(6));
4249    }
4250
4251    /// Within a run the *nearest* graphic wins, not the adjacent one in the
4252    /// sequence; and the matching augments: a caption whose first choice is
4253    /// held by a caption that can rehouse itself displaces it, so no figure
4254    /// ends up orphaned while another holds a caption it did not need.
4255    #[test]
4256    fn captions_rank_by_gap_and_displace_rehousable_claims() {
4257        // P1 and P2 are an unbroken run; C1 under it sits right below P2
4258        // (gap 2) and far from P1 (gap 62), so it takes P2, not the run's
4259        // first graphic.
4260        let regions = vec![
4261            region("picture", 0.9, 0.0, 0.0, 100.0, 50.0),   // 0 P1
4262            region("picture", 0.9, 0.0, 60.0, 100.0, 110.0), // 1 P2
4263            region("caption", 0.9, 0.0, 112.0, 100.0, 120.0), // 2 C1: P2 (2) < P1 (62)
4264        ];
4265        let pairs = super::pair_graphic_captions(&regions);
4266        assert_eq!(pairs, vec![None, Some(2), None]);
4267
4268        // C1 above the run prefers P1 (gap 2) over P2 (62); C2 below the run
4269        // reaches P2 (2) then P1 (62). Each gets its nearest.
4270        let regions = vec![
4271            region("caption", 0.9, 0.0, 0.0, 100.0, 8.0),     // 0 C1
4272            region("picture", 0.9, 0.0, 10.0, 100.0, 50.0),   // 1 P1
4273            region("picture", 0.9, 0.0, 60.0, 100.0, 110.0),  // 2 P2
4274            region("caption", 0.9, 0.0, 112.0, 100.0, 120.0), // 3 C2
4275        ];
4276        let pairs = super::pair_graphic_captions(&regions);
4277        assert_eq!(pairs, vec![None, Some(0), Some(3), None]);
4278
4279        // Augmenting path: C1 (between P1 and P2, nearer P2) takes P2 first;
4280        // C2 below reaches only P2 and displaces C1, which rehouses on P1.
4281        let regions = vec![
4282            region("picture", 0.9, 0.0, 0.0, 100.0, 50.0),   // 0 P1
4283            region("caption", 0.9, 0.0, 70.0, 100.0, 78.0),  // 1 C1: P2 (2) < P1 (20)
4284            region("picture", 0.9, 0.0, 80.0, 100.0, 120.0), // 2 P2
4285            region("caption", 0.9, 0.0, 122.0, 100.0, 130.0), // 3 C2: P2 only
4286        ];
4287        let pairs = super::pair_graphic_captions(&regions);
4288        assert_eq!(pairs, vec![Some(1), None, Some(3), None]);
4289
4290        // No graphic can be freed: the first claim stands, the second stays loose.
4291        let regions = vec![
4292            region("caption", 0.9, 0.0, 0.0, 100.0, 8.0),   // 0 C1
4293            region("picture", 0.9, 0.0, 10.0, 100.0, 50.0), // 1 P1
4294            region("caption", 0.9, 0.0, 52.0, 100.0, 60.0), // 2 C2
4295        ];
4296        let pairs = super::pair_graphic_captions(&regions);
4297        assert_eq!(pairs, vec![None, Some(0), None]);
4298    }
4299
4300    #[test]
4301    fn caption_labels_are_recognized() {
4302        for t in [
4303            "Table 5: Prediction Performance",
4304            "Figure 2.1: Surveyed MSMEs",
4305            "Fig. 3: Overview",
4306            "Table 1. Pour volume by month (m3)",
4307            "  Figure 12a. Left panel",
4308            "Listing 4: main.rs",
4309        ] {
4310            assert!(super::is_caption_label(t), "{t}");
4311        }
4312        for t in [
4313            "Table 5 shows the results",
4314            "Tables 5: and 6:",
4315            "Figure",
4316            "Figure: no number",
4317            "table 5: lowercase",
4318            "Table : no number",
4319        ] {
4320            assert!(!super::is_caption_label(t), "{t}");
4321        }
4322    }
4323
4324    /// 2206.01062's Table 5 caption: a four-line `text` block the model did
4325    /// not call `caption`, 22 pt above its table, becomes one; the same block
4326    /// with a paragraph between it and the table, or without the label's
4327    /// punctuation, stays text.
4328    #[test]
4329    fn a_labelled_text_block_beside_a_graphic_becomes_a_caption() {
4330        let cells = vec![
4331            cell(
4332                "Table 5: Prediction Performance of a network",
4333                54.0,
4334                87.0,
4335                296.0,
4336                100.0,
4337            ),
4338            cell("across the datasets.", 54.0, 100.0, 200.0, 113.0),
4339            cell("In this section we discuss", 54.0, 400.0, 296.0, 412.0),
4340        ];
4341        let mut regions = vec![
4342            region("text", 0.99, 54.0, 87.0, 296.0, 150.0),
4343            region("table", 0.99, 73.0, 172.0, 275.0, 340.0),
4344            region("text", 0.99, 54.0, 400.0, 296.0, 500.0),
4345        ];
4346        super::promote_labelled_captions(&mut regions, &cells);
4347        assert_eq!(regions[0].label, "caption");
4348        assert_eq!(regions[2].label, "text");
4349
4350        // A paragraph between the label and the table breaks the adjacency.
4351        let mut regions = vec![
4352            region("text", 0.99, 54.0, 87.0, 296.0, 150.0),
4353            region("text", 0.99, 54.0, 152.0, 296.0, 170.0),
4354            region("table", 0.99, 73.0, 172.0, 275.0, 340.0),
4355        ];
4356        super::promote_labelled_captions(&mut regions, &cells);
4357        assert_eq!(regions[0].label, "text");
4358
4359        // `Table 5 shows …` is prose.
4360        let cells = vec![cell("Table 5 shows the results", 54.0, 87.0, 296.0, 100.0)];
4361        let mut regions = vec![
4362            region("text", 0.99, 54.0, 87.0, 296.0, 150.0),
4363            region("table", 0.99, 73.0, 172.0, 275.0, 340.0),
4364        ];
4365        super::promote_labelled_captions(&mut regions, &cells);
4366        assert_eq!(regions[0].label, "text");
4367    }
4368
4369    /// Side-by-side figures (`tests/snapshots/latex/…/swa.pdf`): three
4370    /// pictures in a row, their three captions in a row under them. docling's
4371    /// run rule binds only the first caption (the one before each other
4372    /// caption breaks its run); the extension gives the other two the
4373    /// unclaimed picture each sits under. A caption whose run is broken by
4374    /// *text* (2206.01062's Figure 4 behind a hash line) stays loose, as
4375    /// docling leaves it.
4376    #[test]
4377    fn side_by_side_figure_captions_bind_their_own_picture() {
4378        let regions = vec![
4379            region("picture", 0.8, 9.0, 2.0, 367.0, 347.0),   // 0
4380            region("picture", 0.7, 423.0, 3.0, 718.0, 346.0), // 1
4381            region("picture", 0.7, 756.0, 13.0, 1040.0, 341.0), // 2
4382            region("caption", 0.6, 128.0, 371.0, 315.0, 392.0), // 3 ← under 0
4383            region("caption", 0.6, 425.0, 371.0, 713.0, 396.0), // 4 ← under 1
4384            region("caption", 0.6, 785.0, 371.0, 1069.0, 397.0), // 5 ← under 2
4385        ];
4386        let pairs = super::pair_graphic_captions(&regions);
4387        assert_eq!(pairs[..3], [Some(3), Some(4), Some(5)]);
4388
4389        let regions = vec![
4390            region("picture", 0.9, 316.0, 82.0, 564.0, 459.0),
4391            region("text", 0.9, 400.0, 458.0, 476.0, 461.0), // a hash line
4392            region("caption", 0.9, 318.0, 473.0, 559.0, 504.0),
4393        ];
4394        assert_eq!(
4395            super::pair_graphic_captions(&regions),
4396            vec![None, None, None]
4397        );
4398    }
4399
4400    /// A colored terms-and-conditions panel detected as `picture` demotes into
4401    /// per-paragraph `text` regions (the blank line between C.7 and C.8 splits
4402    /// them); a chart whose only text is a few narrow axis labels keeps its
4403    /// crop untouched.
4404    #[test]
4405    fn text_panels_demote_to_paragraphs_but_charts_keep_their_crop() {
4406        let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
4407            text: text.to_string(),
4408            l,
4409            t,
4410            r,
4411            b,
4412        };
4413        let panel = Region {
4414            label: "picture",
4415            score: 0.9,
4416            l: 0.0,
4417            t: 0.0,
4418            r: 100.0,
4419            b: 100.0,
4420        };
4421        // Three tight lines, a blank-line gap, two more: two paragraphs.
4422        let cells = vec![
4423            cell(
4424                "C.7. Wenn Sie diesen Vertrag widerrufen,",
4425                5.0,
4426                10.0,
4427                95.0,
4428                18.0,
4429            ),
4430            cell(
4431                "haben wir Ihnen alle Zahlungen, die wir",
4432                5.0,
4433                20.0,
4434                95.0,
4435                28.0,
4436            ),
4437            cell(
4438                "von Ihnen erhalten haben, zurückzuzahlen.",
4439                5.0,
4440                30.0,
4441                90.0,
4442                38.0,
4443            ),
4444            cell(
4445                "C.8. Wir können die Rückzahlung verweigern,",
4446                5.0,
4447                52.0,
4448                95.0,
4449                60.0,
4450            ),
4451            cell(
4452                "bis wir die Waren wieder zurückerhalten haben.",
4453                5.0,
4454                62.0,
4455                92.0,
4456                70.0,
4457            ),
4458        ];
4459        let mut regions = vec![panel.clone()];
4460        super::recover_text_panels(&mut regions, &cells);
4461        assert_eq!(
4462            regions.iter().map(|r| r.label).collect::<Vec<_>>(),
4463            ["text", "text"],
4464            "dense panel must demote into one text region per paragraph"
4465        );
4466        assert!(regions[0].b < regions[1].t, "paragraphs split at the gap");
4467        // Sparse narrow labels (a chart): picture survives.
4468        let labels = vec![
4469            cell("0", 5.0, 90.0, 8.0, 95.0),
4470            cell("50", 5.0, 50.0, 10.0, 55.0),
4471            cell("100", 5.0, 10.0, 12.0, 15.0),
4472            cell("t, s", 45.0, 96.0, 55.0, 100.0),
4473        ];
4474        let mut regions = vec![panel];
4475        super::recover_text_panels(&mut regions, &labels);
4476        assert_eq!(
4477            regions.iter().map(|r| r.label).collect::<Vec<_>>(),
4478            ["picture"]
4479        );
4480    }
4481
4482    /// An uncaptioned chart on a scanned page whose title, axis labels, and
4483    /// OCR boxes over the plot area are dense and wide enough to pass the
4484    /// coverage/width gates still keeps its crop: its line heights are ragged
4485    /// (title face vs tick labels vs bar-area OCR), failing the uniform-leading
4486    /// gate — a real text panel is set with constant leading (#173).
4487    #[test]
4488    fn dense_titled_chart_keeps_its_crop() {
4489        let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
4490            text: text.to_string(),
4491            l,
4492            t,
4493            r,
4494            b,
4495        };
4496        let chart = Region {
4497            label: "picture",
4498            score: 0.9,
4499            l: 0.0,
4500            t: 0.0,
4501            r: 100.0,
4502            b: 100.0,
4503        };
4504        // Five wide lines at wildly different heights: a 12-pt title, 20-pt OCR
4505        // boxes over the bars, 4–5-pt tick/axis labels. Coverage and median
4506        // width both clear the panel thresholds.
4507        let cells = vec![
4508            cell("Underground Water Storage", 10.0, 5.0, 90.0, 17.0),
4509            cell("aquifer recharge zone", 15.0, 30.0, 75.0, 50.0),
4510            cell("confined | unconfined | perched", 12.0, 55.0, 80.0, 59.0),
4511            cell("saturated thickness", 8.0, 70.0, 60.0, 90.0),
4512            cell("distance from well, km", 20.0, 92.0, 85.0, 97.0),
4513        ];
4514        let mut regions = vec![chart];
4515        super::recover_text_panels(&mut regions, &cells);
4516        assert_eq!(
4517            regions.iter().map(|r| r.label).collect::<Vec<_>>(),
4518            ["picture"],
4519            "ragged line heights mark a figure, not a text panel"
4520        );
4521    }
4522
4523    /// docling serializes a cluster's cells in docling-parse index order
4524    /// (`_sort_cells`) and joins them with `PageAssembleModel.sanitize_text`:
4525    /// a space after every line except one ending in `-`, which either fuses a
4526    /// wrapped word (alnum on both sides — dash dropped) or glues verbatim (a
4527    /// bare `-` cell: `[0000` `-` `0002` → `[0000 -0002`, the 2305 ORCID line;
4528    /// `-` + `"C" cell -` + `a new table cell` → `-"C" cell a new table cell`,
4529    /// its OTSL list). Verified against the corpus: pure index order beats any
4530    /// geometric re-sort (normal_4pages' heading numerals paint after their
4531    /// text and belong last: `## 들어가며 1`).
4532    #[test]
4533    fn cells_join_in_index_order_with_sanitize_text_rules() {
4534        let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
4535            text: text.to_string(),
4536            l,
4537            t,
4538            r,
4539            b,
4540        };
4541        let region = Region {
4542            label: "text",
4543            score: 1.0,
4544            l: 0.0,
4545            t: 95.0,
4546            r: 200.0,
4547            b: 130.0,
4548        };
4549        // ORCID superscript: a bare dash cell is a *detached* dash — kept, and
4550        // since docling#4052 (2.122) it joins with the ordinary space on both
4551        // sides (`[0000 -0002 -6960]` before that fix).
4552        let orcid = vec![
4553            cell("[0000", 10.0, 100.0, 30.0, 110.0),
4554            cell("−", 30.0, 100.0, 34.0, 110.0),
4555            cell("0002", 34.0, 100.0, 50.0, 110.0),
4556            cell("−", 50.0, 100.0, 54.0, 110.0),
4557            cell("6960]", 54.0, 100.0, 70.0, 110.0),
4558        ];
4559        assert_eq!(super::region_text(&region, &orcid), "[0000 - 0002 - 6960]");
4560        // Wrapped word: dash dropped, lines fused (both boundary words alnum).
4561        let wrapped = vec![
4562            cell("platforms-", 10.0, 100.0, 60.0, 110.0),
4563            cell("reflects the design", 10.0, 112.0, 90.0, 122.0),
4564        ];
4565        assert_eq!(
4566            super::region_text(&region, &wrapped),
4567            "platformsreflects the design"
4568        );
4569        // Dash-ending lines that are *detached* dashes (a bare bullet cell, a
4570        // `cell -` separator): the dash stays and the lines join with a space
4571        // — docling#4052; before it they glued (`-"C" cell a new table cell`,
4572        // 2305's OTSL list bullets).
4573        let otsl = vec![
4574            cell("–", 10.0, 100.0, 14.0, 110.0),
4575            cell("\"C\" cell -", 16.0, 100.0, 60.0, 110.0),
4576            cell("a new table cell", 10.0, 112.0, 80.0, 122.0),
4577        ];
4578        assert_eq!(
4579            super::region_text(&region, &otsl),
4580            "- \"C\" cell - a new table cell"
4581        );
4582        // Index order is authoritative — no geometric re-sort.
4583        let numeral = vec![
4584            cell("들어가며", 30.0, 100.0, 80.0, 110.0),
4585            cell("1", 10.0, 98.0, 25.0, 112.0), // big numeral painted last
4586        ];
4587        assert_eq!(super::region_text(&region, &numeral), "들어가며 1");
4588    }
4589
4590    /// The geometric-reliability gate, on the two shapes it has to tell apart.
4591    #[test]
4592    fn geometric_reliability_rejects_split_column_grids() {
4593        let g = |rows: &[&[&str]]| -> Vec<Vec<String>> {
4594            rows.iter()
4595                .map(|r| r.iter().map(|c| c.to_string()).collect())
4596                .collect()
4597        };
4598        // A genuine grid: dense, every column carrying entries. Nothing for
4599        // TableFormer to improve, so geometry is used as-is.
4600        assert!(super::geometric_table_is_reliable(&g(&[
4601            &["Datum", "Leistung", "Anzahl", "Kosten"],
4602            &["04.07", "Internet", "1", "40.30"],
4603            &["04.07", "Telefon", "2", "8.06"],
4604        ])));
4605        // The left-edge split artefact (the shape a scanned invoice produced):
4606        // one real label column plus values scattered across three sparse ones.
4607        assert!(!super::geometric_table_is_reliable(&g(&[
4608            &["www.magenta.at/faq", "", "", ""],
4609            &["Serviceteam", "", "", ""],
4610            &["Telefon", "0676/2000", "", ""],
4611            &["Kundennummer", "", "", "1.21699482"],
4612            &["Rechnungsnummer", "", "922769430725", ""],
4613            &["Rechnungsdatum", "", "", "04.07.2025"],
4614        ])));
4615        // A column only one row ever uses is a split artefact even when the
4616        // grid is otherwise dense.
4617        assert!(!super::geometric_table_is_reliable(&g(&[
4618            &["a", "b", ""],
4619            &["c", "d", ""],
4620            &["e", "f", "g"],
4621        ])));
4622        // Degenerate shapes are never vouched for — TableFormer may recover
4623        // structure a collapsed reconstruction lost.
4624        assert!(!super::geometric_table_is_reliable(&g(&[&[
4625            "only one column"
4626        ]])));
4627        assert!(!super::geometric_table_is_reliable(&[]));
4628    }
4629
4630    /// A `picture` region is cropped out of the rendered page, whatever built
4631    /// that page. The browser pipeline (#157) has no pdfium but does hand over
4632    /// the rasterized bitmap through `from_cells_with_image`, so it must get
4633    /// the same figure bytes the native path does — that is what makes
4634    /// `images = "embedded"` inline real pixels instead of a placeholder.
4635    #[cfg(feature = "ocr-prep")]
4636    #[test]
4637    fn picture_regions_are_cropped_from_a_host_supplied_page_image() {
4638        let mut img = image::RgbImage::new(200, 200);
4639        // Paint the figure area so the crop is distinguishable from the page.
4640        for y in 100..160 {
4641            for x in 20..120 {
4642                img.put_pixel(x, y, image::Rgb([255, 0, 0]));
4643            }
4644        }
4645        // scale 2.0: the region is in page points, the bitmap in pixels.
4646        let page = PdfPage::from_cells_with_image(100.0, 100.0, 2.0, Vec::new(), img);
4647        let region = Region {
4648            label: "picture",
4649            score: 0.9,
4650            l: 10.0,
4651            t: 50.0,
4652            r: 60.0,
4653            b: 80.0,
4654        };
4655        let (nodes, _) = super::assemble_page(&page, vec![region], &[None], &[None], None);
4656        // Layout-derived nodes carry provenance, so the picture arrives wrapped.
4657        let image = nodes
4658            .iter()
4659            .find_map(|n| match n {
4660                Node::Located { inner, .. } => match &**inner {
4661                    Node::Picture { image, .. } => image.as_ref(),
4662                    _ => None,
4663                },
4664                Node::Picture { image, .. } => image.as_ref(),
4665                _ => None,
4666            })
4667            .expect("a picture node with cropped pixels");
4668        assert_eq!(image.mimetype, "image/png");
4669        assert_eq!((image.width, image.height), (100, 60), "region × scale");
4670        assert!(!image.data.is_empty(), "PNG bytes were encoded");
4671    }
4672
4673    #[test]
4674    fn link_anchors_split_a_shared_word_cell_between_adjacent_links() {
4675        // A common header layout: one text run holds several pipe-separated
4676        // labels, each carrying its own link annotation. Every link must get
4677        // its own label as the anchor (and the "|" separators must belong to
4678        // none), not the whole run.
4679        let annot = |l: f32, r: f32, uri: &str| LinkAnnot {
4680            l,
4681            t: 100.0,
4682            r,
4683            b: 114.0,
4684            uri: uri.into(),
4685        };
4686        let page = PdfPage {
4687            width: 600.0,
4688            height: 800.0,
4689            scale: 2.0,
4690            cells: Vec::new(),
4691            code_cells: Vec::new(),
4692            checkboxes: Vec::new(),
4693            // "LinkedIn | GitHub | Credly" = 26 chars over x 100..360.
4694            word_cells: vec![cell(
4695                "LinkedIn | GitHub | Credly",
4696                100.0,
4697                100.0,
4698                360.0,
4699                114.0,
4700            )],
4701            image: image::RgbImage::new(1, 1),
4702            image_layout: None,
4703            links: vec![
4704                annot(100.0, 180.0, "https://l"),
4705                annot(200.0, 260.0, "https://g"),
4706                annot(290.0, 360.0, "https://c"),
4707            ],
4708            rotation: 0,
4709            #[cfg(feature = "ml")]
4710            pending_image: None,
4711        };
4712        assert_eq!(
4713            resolve_link_anchors(&page),
4714            vec![
4715                ("LinkedIn".to_string(), "https://l".to_string()),
4716                ("GitHub".to_string(), "https://g".to_string()),
4717                ("Credly".to_string(), "https://c".to_string()),
4718            ]
4719        );
4720    }
4721
4722    /// A one-line code cell at `[l, r] × [t, b]` (top-left coords).
4723    fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
4724        TextCell {
4725            text: text.into(),
4726            l,
4727            t,
4728            r,
4729            b,
4730        }
4731    }
4732
4733    /// OCR-path grouping (docling's `_remove_overlapping_clusters("regular")`):
4734    /// the low-score paragraph box RT-DETR draws over its own high-score line
4735    /// boxes collapses to one region — the group's union, with the survivor's
4736    /// label and score — so region-scoped OCR reads each line once. Regions
4737    /// that merely sit near each other, and specials, are untouched.
4738    #[test]
4739    fn merge_overlapping_regulars_collapses_a_block_over_its_lines() {
4740        let mut regions = vec![
4741            region("text", 0.84, 60.0, 186.0, 270.0, 198.0),
4742            region("text", 0.80, 60.0, 160.0, 294.0, 172.0),
4743            region("text", 0.79, 59.0, 107.0, 272.0, 119.0),
4744            // The paragraph box, lower score, containing all three lines.
4745            region("text", 0.52, 59.0, 107.0, 295.0, 200.0),
4746            // Elsewhere on the page: stays as is.
4747            region("section_header", 0.77, 60.0, 71.0, 253.0, 86.0),
4748            // A picture the block overlaps is not a regular — never grouped.
4749            region("picture", 0.9, 50.0, 100.0, 300.0, 210.0),
4750        ];
4751        merge_overlapping_regulars(&mut regions);
4752        assert_eq!(regions.len(), 3, "{regions:?}");
4753        let block = regions
4754            .iter()
4755            .find(|r| r.label == "text")
4756            .expect("one text");
4757        // docling keeps the largest passing candidate unless a rival is both
4758        // comparable in size and > 0.05 more confident; the 16× larger block
4759        // passes, and a smaller line never replaces a larger current best.
4760        // Either way the survivor spans the whole group.
4761        assert_eq!(
4762            (block.l, block.t, block.r, block.b),
4763            (59.0, 107.0, 295.0, 200.0)
4764        );
4765        assert!(regions.iter().any(|r| r.label == "section_header"));
4766        assert!(regions.iter().any(|r| r.label == "picture"));
4767    }
4768
4769    /// The pairwise rules, each in the arrangement where it decides the
4770    /// outcome: docling seeds the survivor with the group's first passing
4771    /// cluster and a later one replaces it only when larger *and* within
4772    /// 0.05 confidence, so a rule that merely lets a cluster pass matters
4773    /// exactly when that cluster comes first — a same-sized list item ahead
4774    /// of a far more confident text box, a code box ahead of the text it
4775    /// contains. Without the rule either would be rejected outright (similar
4776    /// size, rival > 0.05 more confident) and the text box would win.
4777    #[test]
4778    fn merge_overlapping_regulars_follows_the_preference_rules() {
4779        let mut regions = vec![
4780            region("list_item", 0.6, 0.0, 0.0, 102.0, 20.0),
4781            region("text", 0.9, 0.0, 0.0, 100.0, 20.0),
4782        ];
4783        merge_overlapping_regulars(&mut regions);
4784        assert_eq!(regions.len(), 1);
4785        assert_eq!(regions[0].label, "list_item");
4786
4787        let mut regions = vec![
4788            region("code", 0.6, 0.0, 0.0, 100.0, 100.0),
4789            region("text", 0.9, 2.0, 2.0, 98.0, 98.0),
4790        ];
4791        merge_overlapping_regulars(&mut regions);
4792        assert_eq!(regions.len(), 1);
4793        assert_eq!(regions[0].label, "code");
4794
4795        // No rule applies: a near-identical rival that is > 0.05 more
4796        // confident rejects the candidate whatever the order.
4797        for order in [[0.9, 0.6], [0.6, 0.9]] {
4798            let mut regions = vec![
4799                region("text", order[0], 0.0, 0.0, 100.0, 20.0),
4800                region("text", order[1], 0.0, 0.0, 105.0, 21.0),
4801            ];
4802            merge_overlapping_regulars(&mut regions);
4803            assert_eq!(regions.len(), 1);
4804            assert_eq!(regions[0].score, 0.9, "the confident twin wins");
4805            assert_eq!(
4806                (regions[0].r, regions[0].b),
4807                (105.0, 21.0),
4808                "on the union box"
4809            );
4810        }
4811
4812        // Side by side (no containment, IoU 0): nothing to merge.
4813        let mut regions = vec![
4814            region("text", 0.9, 0.0, 0.0, 100.0, 20.0),
4815            region("text", 0.9, 0.0, 22.0, 100.0, 42.0),
4816        ];
4817        merge_overlapping_regulars(&mut regions);
4818        assert_eq!(regions.len(), 2);
4819    }
4820
4821    #[test]
4822    fn footer_under_a_body_less_heading_is_its_text() {
4823        // The reporting CV's last page: `## Languages` at t=773.7..783.4 and
4824        // its one-line body at 798.5..808.7, labelled page_footer (A4, 595 pt).
4825        let mut regions = vec![
4826            region("list_item", 0.9, 66.0, 754.0, 353.0, 765.0),
4827            region("section_header", 0.94, 43.0, 773.7, 89.5, 783.4),
4828            region("page_footer", 0.88, 43.0, 798.5, 527.8, 808.7),
4829        ];
4830        reclaim_heading_body_footers(&mut regions, 595.28);
4831        assert_eq!(regions[2].label, "text");
4832        assert_eq!(regions[1].label, "section_header");
4833
4834        // A heading with its own paragraph and a running footer below: kept.
4835        let mut regions = vec![
4836            region("section_header", 0.9, 43.0, 700.0, 120.0, 710.0),
4837            region("text", 0.9, 43.0, 714.0, 520.0, 780.0),
4838            region("page_footer", 0.9, 43.0, 798.0, 520.0, 808.0),
4839        ];
4840        reclaim_heading_body_footers(&mut regions, 595.28);
4841        assert_eq!(regions[2].label, "page_footer");
4842
4843        // A page number under a trailing heading is too narrow to be a body.
4844        let mut regions = vec![
4845            region("section_header", 0.9, 43.0, 773.0, 120.0, 783.0),
4846            region("page_footer", 0.9, 280.0, 798.0, 300.0, 808.0),
4847        ];
4848        reclaim_heading_body_footers(&mut regions, 595.28);
4849        assert_eq!(regions[1].label, "page_footer");
4850
4851        // Too far below the heading (a real footer after a heading that ends
4852        // the page): kept.
4853        let mut regions = vec![
4854            region("section_header", 0.9, 43.0, 700.0, 120.0, 710.0),
4855            region("page_footer", 0.9, 43.0, 798.0, 520.0, 808.0),
4856        ];
4857        reclaim_heading_body_footers(&mut regions, 595.28);
4858        assert_eq!(regions[1].label, "page_footer");
4859    }
4860
4861    fn region(label: &'static str, score: f32, l: f32, t: f32, r: f32, b: f32) -> Region {
4862        Region {
4863            label,
4864            score,
4865            l,
4866            t,
4867            r,
4868            b,
4869        }
4870    }
4871
4872    #[test]
4873    fn resolve_collapses_nested_code_keeping_the_larger_box() {
4874        // A tight high-score `code` box and a taller lower-score near-duplicate that
4875        // contains it must collapse to one — the *larger* box, so every cell stays
4876        // covered and nothing leaks out as orphan text.
4877        let tight = region("code", 0.95, 78.0, 292.0, 300.0, 330.0);
4878        let wide = region("code", 0.66, 63.0, 260.0, 320.0, 346.0);
4879        let kept = super::resolve(vec![tight, wide]);
4880        assert_eq!(kept.len(), 1, "nested code boxes must collapse to one");
4881        assert!(
4882            kept[0].l == 63.0 && kept[0].b == 346.0,
4883            "the larger containing box is kept"
4884        );
4885    }
4886
4887    #[test]
4888    fn resolve_keeps_distinct_and_differently_typed_regions() {
4889        // A text box fully inside a lower-score *table* must NOT be collapsed (the
4890        // code dedup is code-only), and two separate code blocks stay separate.
4891        let text = region("text", 0.95, 90.0, 210.0, 200.0, 230.0);
4892        let table = region("table", 0.60, 80.0, 200.0, 400.0, 500.0);
4893        assert_eq!(super::resolve(vec![text, table]).len(), 2);
4894
4895        let code_a = region("code", 0.9, 78.0, 100.0, 300.0, 140.0);
4896        let code_b = region("code", 0.9, 78.0, 300.0, 300.0, 360.0); // far below, no overlap
4897        assert_eq!(super::resolve(vec![code_a, code_b]).len(), 2);
4898    }
4899
4900    /// A two-column glossary page came out as three column
4901    /// tables *and* one low-score whole-page table over them. docling's wrapper
4902    /// `_remove_overlapping_clusters` keeps one table per overlapping group
4903    /// (here the whole-page one: > 2× every rival's area and ≤ 0.2 less
4904    /// confident than the running best); `greedy` alone kept all four and
4905    /// emitted every cell twice.
4906    #[test]
4907    fn resolve_keeps_one_table_per_nested_group() {
4908        let kept = super::resolve(vec![
4909            region("table", 0.71, 26.0, 203.0, 183.0, 558.0),
4910            region("table", 0.67, 26.0, 55.0, 183.0, 196.0),
4911            region("table", 0.66, 196.0, 56.0, 354.0, 561.0),
4912            region("table", 0.53, 25.0, 53.0, 354.0, 561.0),
4913        ]);
4914        assert_eq!(kept.len(), 1, "one survivor per overlapping group");
4915        assert_eq!((kept[0].l, kept[0].b), (25.0, 561.0));
4916        // Side-by-side tables that don't overlap stay separate.
4917        let kept = super::resolve(vec![
4918            region("table", 0.9, 26.0, 55.0, 183.0, 558.0),
4919            region("table", 0.9, 196.0, 56.0, 354.0, 561.0),
4920        ]);
4921        assert_eq!(kept.len(), 2);
4922    }
4923
4924    /// A dense data table detected as both picture (0.80) and table (0.62) on one box.
4925    /// The picture is ≥ 0.1 more confident, so `_handle_cross_type_overlaps`
4926    /// keeps both, and the dense table text passed the text-panel gates: the
4927    /// demoted paragraph repeated every cell the table grid renders. A
4928    /// paragraph > 80 % inside a surviving table is the table's child and is
4929    /// not emitted; a panel with no table under it still demotes.
4930    #[test]
4931    fn text_panel_over_a_table_does_not_repeat_its_cells() {
4932        let lines = |t0: f32| -> Vec<TextCell> {
4933            (0..4)
4934                .map(|i| {
4935                    let t = t0 + 10.0 * i as f32;
4936                    cell("1 2 3 4 5 6 7 8 9 10 11 12", 5.0, t, 95.0, t + 8.0)
4937                })
4938                .collect()
4939        };
4940        let mut cells = lines(0.0);
4941        cells.extend(lines(200.0));
4942        let mut regions = vec![
4943            region("picture", 0.80, 0.0, 0.0, 100.0, 45.0),
4944            region("table", 0.62, 0.0, 0.0, 100.0, 45.0),
4945            region("picture", 0.80, 0.0, 200.0, 100.0, 245.0),
4946        ];
4947        super::recover_text_panels(&mut regions, &cells);
4948        assert_eq!(
4949            regions.iter().map(|r| r.label).collect::<Vec<_>>(),
4950            ["table", "text"]
4951        );
4952        assert_eq!(regions[1].t, 200.0, "the table-free panel still demotes");
4953    }
4954
4955    /// docling's `ListItemMarkerProcessor` + docling-core's Markdown list rules
4956    /// (see `list_item_node`): a bullet glyph or `-` is split off and the
4957    /// serializer's own `-` printed (2305's OTSL list reads `- "C" cell`, not
4958    /// `- - "C" cell`); `N.` is an ordered item; any other marker with a letter
4959    /// or digit stays in the text behind the bullet; no marker → plain bullet.
4960    #[test]
4961    fn list_item_markers_split_like_docling() {
4962        // The marker as DocLang reads it: the overlay's, else the node's.
4963        let text_of = |n: &Node| match n {
4964            Node::ListItem {
4965                ordered,
4966                number,
4967                text,
4968                marker,
4969                dclx,
4970                ..
4971            } => (
4972                *ordered,
4973                *number,
4974                text.clone(),
4975                dclx.as_ref().map_or(marker.clone(), |d| d.marker.clone()),
4976            ),
4977            other => panic!("{other:?}"),
4978        };
4979        let loc = [0, 0, 100, 10];
4980        assert_eq!(
4981            text_of(&super::list_item_node(
4982                "- \"C\" cell - a new table cell",
4983                loc,
4984                false
4985            )),
4986            (
4987                false,
4988                0,
4989                "\"C\" cell - a new table cell".into(),
4990                Some("-".into())
4991            )
4992        );
4993        assert_eq!(
4994            text_of(&super::list_item_node("• Bullet text", loc, false)),
4995            (false, 0, "Bullet text".into(), Some("•".into()))
4996        );
4997        assert_eq!(
4998            text_of(&super::list_item_node("3. Third step", loc, false)),
4999            (true, 3, "Third step".into(), Some("3.".into()))
5000        );
5001        assert_eq!(
5002            text_of(&super::list_item_node("a) Option", loc, false)),
5003            (false, 0, "a) Option".into(), Some("a)".into()))
5004        );
5005        assert_eq!(
5006            text_of(&super::list_item_node("1.2 Nested outline", loc, false)),
5007            (false, 0, "1.2 Nested outline".into(), Some("1.2".into()))
5008        );
5009        // 2203's `3.a. If all IOU scores…`: a compound marker is not an item
5010        // number — docling prints `- 3.a. If all…`.
5011        assert_eq!(
5012            text_of(&super::list_item_node("3.a. If all IOU scores", loc, false)),
5013            (
5014                false,
5015                0,
5016                "3.a. If all IOU scores".into(),
5017                Some("3.a.".into())
5018            )
5019        );
5020        // A glued symbol-font bullet is stripped, a spaced one is the marker.
5021        assert_eq!(
5022            text_of(&super::list_item_node("•Glued", loc, false)),
5023            (false, 0, "Glued".into(), Some("·".into()))
5024        );
5025        // No whitespace after the glyph → not a marker (docling's `\s` is required).
5026        assert_eq!(
5027            text_of(&super::list_item_node("-5 degrees", loc, false)),
5028            (false, 0, "-5 degrees".into(), Some("·".into()))
5029        );
5030        // The remaining numbered shapes, first-wins like docling's list.
5031        for (input, marker, body) in [
5032            ("1.2.3. Deep", "1.2.3.", "Deep"),
5033            ("9a) Nine-a", "9a)", "Nine-a"),
5034            ("(3.a) Paren", "(3.a)", "Paren"),
5035            ("12) Twelve", "12)", "Twelve"),
5036            ("(4) Four", "(4)", "Four"),
5037            ("[7] Seven", "[7]", "Seven"),
5038            ("iv. Roman", "iv.", "Roman"),
5039            ("IX. Roman", "IX.", "Roman"),
5040            ("b. Letter", "b.", "Letter"),
5041            ("B) Letter", "B)", "Letter"),
5042        ] {
5043            assert_eq!(
5044                super::split_list_marker(input),
5045                Some((marker, body, true)),
5046                "{input}"
5047            );
5048        }
5049        // A `1.2.` whose optional dot would eat the separator backtracks like
5050        // Python's regex; a marker with nothing after the whitespace is none.
5051        assert_eq!(
5052            super::split_list_marker("1.2.\tx"),
5053            Some(("1.2.", "x", true))
5054        );
5055        assert_eq!(super::split_list_marker("1. "), None);
5056        assert_eq!(super::split_list_marker("• "), None);
5057        assert_eq!(
5058            text_of(&super::list_item_node("Plain item", loc, false)),
5059            (false, 0, "Plain item".into(), Some("·".into()))
5060        );
5061    }
5062
5063    /// A `case_auto` marker is printed once: it rides in the item's text, so
5064    /// the Markdown serializer — which applies docling-core's AUTO rule to a
5065    /// marker it is handed — must not be handed it too (`- (1) (1) text`, the
5066    /// four drifted PDF snapshots of the docling 2.142 serializer change).
5067    /// The bullet glyph and `N.` shapes keep their Markdown marker.
5068    #[test]
5069    fn case_auto_marker_prints_once_in_markdown() {
5070        let loc = [0, 0, 100, 10];
5071        let nodes: Vec<Node> = [
5072            "(1) Human annotation",
5073            "[21] Peng Zhang",
5074            "a) Option",
5075            "• Bullet",
5076            "3. Third",
5077        ]
5078        .iter()
5079        .enumerate()
5080        .map(|(i, t)| super::list_item_node(t, loc, i == 0))
5081        .collect();
5082        for n in &nodes[..3] {
5083            let Node::ListItem { marker, dclx, .. } = n else {
5084                unreachable!()
5085            };
5086            assert_eq!(marker, &None, "{n:?}");
5087            assert!(dclx.as_ref().is_some_and(|d| d.marker.is_some()), "{n:?}");
5088        }
5089        let mut doc = docling_core::DoclingDocument::new("t");
5090        doc.nodes = nodes;
5091        let md = doc.export_to_markdown();
5092        for line in [
5093            "- (1) Human annotation",
5094            "- [21] Peng Zhang",
5095            "- a) Option",
5096            "- Bullet",
5097            "3. Third",
5098        ] {
5099            assert!(md.lines().any(|l| l == line), "missing {line:?} in:\n{md}");
5100        }
5101        assert!(!md.contains("(1) (1)") && !md.contains("[21] [21]"), "{md}");
5102    }
5103
5104    #[test]
5105    fn code_language_label_above_code_is_detected() {
5106        // A bare "XML" token directly above a code box is a language label; a real
5107        // heading above the same code is not; a language word with no code below is
5108        // left alone.
5109        let label = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
5110        let code = region("code", 0.7, 77.0, 552.0, 290.0, 640.0);
5111        let heading = region("section_header", 0.9, 76.0, 500.0, 260.0, 512.0);
5112        let cells = vec![
5113            cell("XML", 78.0, 541.0, 94.0, 548.0),       // inside `label`
5114            cell("Overview", 78.0, 501.0, 250.0, 511.0), // inside `heading`
5115        ];
5116        let drop = super::code_language_labels(&[label, code, heading], &cells);
5117        assert_eq!(drop, vec![true, false, false], "only the label is consumed");
5118
5119        // Same label with no code region present → not consumed.
5120        let label2 = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
5121        let only = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
5122        assert_eq!(super::code_language_labels(&[label2], &only), vec![false]);
5123
5124        // A label swallowed into the top of a wider code box (negative gap) is still
5125        // recognized.
5126        let inside_lbl = region("text", 0.9, 76.0, 540.0, 96.0, 549.0);
5127        let wide_code = region("code", 0.7, 63.0, 531.0, 320.0, 654.0);
5128        let cells2 = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
5129        assert_eq!(
5130            super::code_language_labels(&[inside_lbl, wide_code], &cells2),
5131            vec![true, false]
5132        );
5133
5134        assert!(super::is_code_language("XML") && super::is_code_language("c#"));
5135        assert!(!super::is_code_language("Configure") && !super::is_code_language("XML schema"));
5136    }
5137
5138    #[test]
5139    fn code_region_text_keeps_lines_and_indentation() {
5140        // Three source lines; each glyph is 6 units wide (width / chars = 6), so the
5141        // `int X;` line indented to x=22 is (22-10)/6 = 2 spaces in.
5142        let region = Region {
5143            label: "code",
5144            score: 1.0,
5145            l: 0.0,
5146            t: -5.0,
5147            r: 100.0,
5148            b: 40.0,
5149        };
5150        let cells = vec![
5151            cell("struct P {", 10.0, 0.0, 70.0, 10.0),
5152            cell("int X;", 22.0, 12.0, 58.0, 22.0),
5153            cell("}", 10.0, 24.0, 16.0, 34.0),
5154        ];
5155        assert_eq!(code_region_text(&region, &cells), "struct P {\n  int X;\n}");
5156    }
5157
5158    #[test]
5159    fn code_region_text_tightens_punctuation_without_eating_indentation() {
5160        // A fluent `.Foo()` line at x=22 (2 chars in). Per-line tightening must not
5161        // consume the leading indent space by matching " ." across it.
5162        let region = Region {
5163            label: "code",
5164            score: 1.0,
5165            l: 0.0,
5166            t: -5.0,
5167            r: 100.0,
5168            b: 40.0,
5169        };
5170        let cells = vec![
5171            cell("builder", 10.0, 0.0, 52.0, 10.0),
5172            // pdfium spaced the call: ".Foo (x)" tightens to ".Foo(x)", still 2-indented.
5173            cell(".Foo (x)", 22.0, 12.0, 70.0, 22.0),
5174        ];
5175        assert_eq!(code_region_text(&region, &cells), "builder\n  .Foo(x)");
5176    }
5177
5178    #[test]
5179    fn code_region_text_orders_out_of_order_cells_and_ignores_blank_lines() {
5180        let region = Region {
5181            label: "code",
5182            score: 1.0,
5183            l: 0.0,
5184            t: -5.0,
5185            r: 100.0,
5186            b: 60.0,
5187        };
5188        // Fed bottom-up and with a whitespace-only cell; output is top-down, no blank.
5189        let cells = vec![
5190            cell("b();", 10.0, 24.0, 34.0, 34.0),
5191            cell("   ", 10.0, 12.0, 20.0, 22.0),
5192            cell("a();", 10.0, 0.0, 34.0, 10.0),
5193        ];
5194        assert_eq!(code_region_text(&region, &cells), "a();\nb();");
5195        // No code cells → empty, so the caller falls back to the prose text.
5196        assert_eq!(code_region_text(&region, &[]), "");
5197    }
5198
5199    fn para(text: &str) -> Node {
5200        Node::Paragraph { text: text.into() }
5201    }
5202
5203    /// Run a node sequence through [`StreamAssembler`] with the given page splits
5204    /// and assert the flushed result equals one-shot [`merge_continuations`].
5205    fn assert_stream_eq(nodes: &[Node], splits: &[usize]) {
5206        let mut want = nodes.to_vec();
5207        merge_continuations(&mut want);
5208
5209        let mut asm = StreamAssembler::new();
5210        let mut got = Vec::new();
5211        let mut start = 0;
5212        for &end in splits {
5213            got.extend(asm.push(nodes[start..end].to_vec()));
5214            start = end;
5215        }
5216        got.extend(asm.push(nodes[start..].to_vec()));
5217        got.extend(asm.finish());
5218        assert_eq!(got, want, "stream assembly diverged (splits={splits:?})");
5219    }
5220
5221    #[test]
5222    fn stream_assembler_matches_merge_continuations() {
5223        // Open fragment + lowercase continuation split across a page boundary.
5224        let cross = [para("the definition of"), para("lists in scope")];
5225        assert_stream_eq(&cross, &[1]);
5226        assert_stream_eq(&cross, &[]);
5227
5228        // Continuation that wraps around a figure (+ its caption) on the boundary.
5229        let wrap = [
5230            para("the wing type that is"),
5231            Node::Picture {
5232                caption: None,
5233                caption_href: None,
5234                image: None,
5235                classification: None,
5236                description: None,
5237                caption_parent: Default::default(),
5238                caption_location: None,
5239            },
5240            para("Fig. 1. a diagram"),
5241            para("the most common kind"),
5242        ];
5243        for splits in [&[][..], &[1][..], &[2][..], &[3][..], &[1, 3][..]] {
5244            assert_stream_eq(&wrap, splits);
5245        }
5246
5247        // A heading between fragments blocks the merge (must still flush correctly).
5248        let blocked = [
5249            para("ends mid word and"),
5250            Node::Heading {
5251                level: 2,
5252                text: "New Section".into(),
5253            },
5254            para("more body here"),
5255        ];
5256        for splits in [&[][..], &[1][..], &[2][..]] {
5257            assert_stream_eq(&blocked, splits);
5258        }
5259
5260        // A chain across three pages: each page is one open lowercase fragment.
5261        let chain = [
5262            para("alpha beta"),
5263            para("gamma delta"),
5264            para("epsilon zeta"),
5265        ];
5266        assert_stream_eq(&chain, &[1, 2]);
5267    }
5268
5269    #[test]
5270    fn clean_text_dehyphenates_and_normalizes_typography() {
5271        // U+0002 line-wrap hyphen + the join space → merged word (like docling).
5272        assert_eq!(clean_text("com\u{2} pact"), "compact");
5273        assert_eq!(clean_text("end-to\u{2} end deep"), "end-toend deep");
5274        // A stray wrap hyphen (no following join) is dropped.
5275        assert_eq!(clean_text("word\u{2}"), "word");
5276        // Typographic punctuation → ASCII: every curly quote becomes `'`
5277        // (docling-parse's sanitizer table), a literal `"` stays.
5278        assert_eq!(
5279            clean_text("Graph\u{2019}s \u{201c}x\u{201d} \"y\""),
5280            "Graph's 'x' \"y\""
5281        );
5282        assert_eq!(clean_text("a\u{2026}"), "a...");
5283        // The docling-parse sanitizer's internal spacing is preserved as
5284        // placed; line breaks/tabs normalize to a space, ends trim.
5285        assert_eq!(clean_text("a   b\nc"), "a   b c");
5286    }
5287
5288    /// docling#4064: a form's children are emitted together where the form
5289    /// sits in the top-level order, not interleaved with surrounding text.
5290    #[test]
5291    fn form_children_stay_together_in_reading_order() {
5292        let reg = |label: &'static str, l: f32, t: f32, r: f32, b: f32| Region {
5293            label,
5294            score: 0.9,
5295            l,
5296            t,
5297            r,
5298            b,
5299        };
5300        // Page: intro text, then a form spanning the left column with two
5301        // fields and a table inside, while a right-column paragraph sits
5302        // level with the form's first field (it would otherwise be read
5303        // between the form's children).
5304        let mut items = vec![
5305            reg("text", 50.0, 50.0, 550.0, 70.0),    // 0 intro
5306            reg("form", 50.0, 100.0, 300.0, 400.0),  // 1 container
5307            reg("text", 60.0, 110.0, 290.0, 130.0),  // 2 field A (child)
5308            reg("text", 320.0, 110.0, 550.0, 130.0), // 3 right column paragraph
5309            reg("table", 60.0, 150.0, 290.0, 300.0), // 4 table (child)
5310            reg("text", 60.0, 320.0, 290.0, 340.0),  // 5 field B (child)
5311            reg("text", 50.0, 450.0, 550.0, 470.0),  // 6 outro
5312        ];
5313        let cids = super::cluster_cids(&items, &[]);
5314        super::order_with_containers(&mut items, &cids, 600.0, 800.0, |r| r);
5315        let order: Vec<(&str, f32)> = items.iter().map(|r| (r.label, r.t)).collect();
5316        // The form block (container, then its children top-down) is one unit.
5317        let form_pos = order.iter().position(|(l, _)| *l == "form").unwrap();
5318        assert_eq!(
5319            &order[form_pos..form_pos + 4],
5320            &[
5321                ("form", 100.0),
5322                ("text", 110.0),
5323                ("table", 150.0),
5324                ("text", 320.0)
5325            ]
5326        );
5327        assert_eq!(order[0], ("text", 50.0));
5328        assert_eq!(order[order.len() - 1], ("text", 450.0));
5329        // Without a container the plain order interleaves by geometry.
5330        let mut flat: Vec<Region> = items
5331            .iter()
5332            .filter(|r| r.label != "form")
5333            .cloned()
5334            .collect();
5335        let cids = super::cluster_cids(&flat, &[]);
5336        super::order_regions(&mut flat, &cids, 600.0, 800.0, |r| r);
5337        assert_ne!(
5338            flat.iter().map(|r| r.t).collect::<Vec<_>>(),
5339            order
5340                .iter()
5341                .filter(|(l, _)| *l != "form")
5342                .map(|(_, t)| *t)
5343                .collect::<Vec<_>>()
5344        );
5345    }
5346
5347    /// docling#3906: a picture inside a table lands in the covering cell,
5348    /// chosen by the picture's inferred grid position when cell boxes overlap.
5349    #[test]
5350    fn picture_matches_the_cell_at_its_grid_position() {
5351        let cell = |r: usize, c: usize, bbox: [f32; 4]| docling_core::TableCell {
5352            text: format!("r{r}c{c}"),
5353            bbox: Some(bbox),
5354            start_row: r,
5355            start_col: c,
5356            row_span: 1,
5357            col_span: 1,
5358            column_header: false,
5359            row_header: false,
5360            row_section: false,
5361        };
5362        // 2×2 grid; the (1,0) cell box is generous and also covers the picture.
5363        let cells = vec![
5364            cell(0, 0, [0.0, 0.0, 100.0, 50.0]),
5365            cell(0, 1, [100.0, 0.0, 200.0, 50.0]),
5366            cell(1, 0, [0.0, 50.0, 100.0, 100.0]),
5367            cell(1, 1, [100.0, 50.0, 200.0, 100.0]),
5368        ];
5369        let pic = Region {
5370            label: "picture",
5371            score: 0.9,
5372            l: 110.0,
5373            t: 60.0,
5374            r: 190.0,
5375            b: 95.0,
5376        };
5377        assert_eq!(super::match_picture_to_cell(&pic, &cells), Some((1.0, 3)));
5378        // A picture only half inside any cell is not nested.
5379        let straddling = Region {
5380            label: "picture",
5381            score: 0.9,
5382            l: 60.0,
5383            t: 60.0,
5384            r: 160.0,
5385            b: 95.0,
5386        };
5387        assert_eq!(super::match_picture_to_cell(&straddling, &cells), None);
5388    }
5389
5390    /// docling#4052 (2.122): a line-final dash fuses the wrapped word only
5391    /// when attached to it; a detached dash is a literal and the lines join
5392    /// with a space.
5393    #[test]
5394    fn line_final_hyphen_fuses_only_when_attached_to_a_word() {
5395        let line = |text: &str, t: f32| TextCell {
5396            text: text.to_string(),
5397            l: 0.0,
5398            t,
5399            r: 100.0,
5400            b: t + 10.0,
5401        };
5402        // `algo-` / `rithms`: attached hyphen, alnum on both sides → fused.
5403        assert_eq!(
5404            cells_text(vec![&line("algo-", 0.0), &line("rithms", 12.0)]),
5405            "algorithms"
5406        );
5407        // `pp. 545-` / `561`: attached, digits count as alnum → `545561` (upstream).
5408        assert_eq!(
5409            cells_text(vec![&line("pp. 545-", 0.0), &line("561", 12.0)]),
5410            "pp. 545561"
5411        );
5412        // A dash after whitespace — a separator or a lone `-` cell — is kept and
5413        // the lines take the ordinary joining space.
5414        assert_eq!(
5415            cells_text(vec![&line("range -", 0.0), &line("wide", 12.0)]),
5416            "range - wide"
5417        );
5418        assert_eq!(
5419            cells_text(vec![&line("-", 0.0), &line("item", 12.0)]),
5420            "- item"
5421        );
5422        // Attached but the next line opens with no word (`x-` / `...`): dash
5423        // kept and, as before, no separating space.
5424        assert_eq!(
5425            cells_text(vec![&line("x-", 0.0), &line("...", 12.0)]),
5426            "x-..."
5427        );
5428    }
5429
5430    #[test]
5431    fn lam_alef_only_swaps_a_genuinely_reversed_ligature() {
5432        // A mid-word `alef-variant + lam` is pdfium's reversed lam-alef ligature and
5433        // is swapped back to logical `lam + alef-variant` (`ب أ ل` → `ب ل أ`).
5434        assert_eq!(
5435            clean_text("\u{0628}\u{0623}\u{0644}"),
5436            "\u{0628}\u{0644}\u{0623}"
5437        );
5438        // But when the alef-variant is *already* preceded by a lam it is the logical
5439        // ligature `لآ`; the following lam is the next syllable's letter and must not
5440        // move. `التعلم الآلي` must stay `الآلي`, not become `اللآي`.
5441        assert_eq!(
5442            clean_text("\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"),
5443            "\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"
5444        );
5445    }
5446
5447    /// The #419 page, in points: three layout boxes over one paragraph, two of
5448    /// them ending partway through a line. The sliced lines miss the 0.2 claim
5449    /// and become orphans; the third model box starts above the second orphan,
5450    /// so unfitted the reading order emits that box first and strands the line.
5451    fn sliced_paragraph() -> (Vec<Region>, Vec<TextCell>) {
5452        let line = |text: &str, t: f32, r: f32| cell(text, 60.0, t, r, t + 11.0);
5453        let cells = vec![
5454            line("The mission of this series is to improve", 135.0, 458.0),
5455            line("The books in this series are technical,", 147.0, 458.0),
5456            line("substantial. The authors are", 159.0, 458.0),
5457            line("highly experienced craftsmen and", 171.5, 458.0), // sliced: 1.5/11 under box A
5458            line("actually works in practice, as opposed", 185.0, 458.0),
5459            line("about what the author has done, not", 197.0, 458.0),
5460            line("about programming, there will be lots", 210.5, 458.0), // sliced: 1.5/11 under box B
5461            line("will be lots of case studies from real", 223.0, 206.0), // C's line
5462        ];
5463        let regions = vec![
5464            region("text", 0.9, 60.0, 132.0, 458.0, 173.0), // A: three lines + a sliver of the 4th
5465            region("text", 0.9, 60.0, 184.0, 458.0, 212.0), // B: two lines + a sliver of the 7th
5466            region("text", 0.9, 60.0, 216.0, 206.0, 227.0), // C: last line, box opening 5.5pt too early
5467        ];
5468        (regions, cells)
5469    }
5470
5471    fn ordered_texts(regions: &[Region], cells: &[TextCell]) -> Vec<String> {
5472        let mut items: Vec<Region> = regions.to_vec();
5473        let cids = super::cluster_cids(&items, cells);
5474        super::order_regions(&mut items, &cids, 500.0, 700.0, |r| r);
5475        super::region_texts_exclusive(&items, cells)
5476            .into_iter()
5477            .map(|t| t.chars().take(9).collect())
5478            .collect()
5479    }
5480
5481    /// #419: fitted to its cells, a model box that cut a line in half no longer
5482    /// overlaps the orphan that line became, so the orphan orders where it
5483    /// reads; unfitted, the same page strands the line after the paragraph.
5484    #[test]
5485    fn fitting_boxes_to_cells_puts_a_sliced_line_back_in_order() {
5486        let (mut regions, cells) = sliced_paragraph();
5487        super::add_orphan_regions(&mut regions, &cells);
5488        assert_eq!(regions.len(), 5, "two orphan lines");
5489        // The defect, for the record: C (top 216) is not strictly below the
5490        // orphan at 210.5–221.5, so the graph orders C first.
5491        assert_eq!(
5492            ordered_texts(&regions, &cells).last().map(String::as_str),
5493            Some("about pro")
5494        );
5495
5496        super::fit_regions_to_cells(&mut regions, &cells);
5497        assert_eq!(regions.len(), 5);
5498        // A ends on its last claimed line, C starts on its only one.
5499        assert_eq!((regions[0].t, regions[0].b), (135.0, 170.0));
5500        assert_eq!((regions[2].t, regions[2].b), (223.0, 234.0));
5501        assert_eq!(
5502            ordered_texts(&regions, &cells),
5503            [
5504                "The missi",
5505                "highly ex",
5506                "actually ",
5507                "about pro",
5508                "will be l"
5509            ]
5510        );
5511    }
5512
5513    /// An orphan the fitted paragraph box surrounds (a short middle line the
5514    /// narrow model box missed while claiming the lines around it) is folded
5515    /// into the paragraph; an empty regular box goes away, a formula stays, a
5516    /// picture is never refitted, and a page with no cells is left untouched.
5517    #[test]
5518    fn fitting_folds_surrounded_orphans_and_drops_empty_regulars() {
5519        let wide = |text: &str, t: f32| cell(text, 60.0, t, 400.0, t + 11.0);
5520        let cells = vec![
5521            wide("first line of the paragraph", 100.0),
5522            cell("stray", 250.0, 112.0, 400.0, 123.0), // clear of the narrow box
5523            wide("third line of the paragraph", 124.0),
5524        ];
5525        let mut regions = vec![
5526            // Narrow box: claims the wide lines at 0.41, misses the short one.
5527            region("text", 0.9, 60.0, 98.0, 200.0, 136.0),
5528            region("section_header", 0.8, 60.0, 300.0, 200.0, 320.0), // no cells
5529            region("formula", 0.8, 60.0, 340.0, 200.0, 360.0),        // no cells, kept
5530            region("picture", 0.8, 0.0, 400.0, 500.0, 600.0),
5531        ];
5532        super::add_orphan_regions(&mut regions, &cells);
5533        assert_eq!(regions.len(), 5, "the short line became an orphan");
5534        super::fit_regions_to_cells(&mut regions, &cells);
5535        let labels: Vec<&str> = regions.iter().map(|r| r.label).collect();
5536        assert_eq!(labels, ["text", "formula", "picture"]);
5537        let para = &regions[0];
5538        assert_eq!(
5539            (para.l, para.t, para.r, para.b),
5540            (60.0, 100.0, 400.0, 135.0)
5541        );
5542        assert_eq!(
5543            super::region_texts_exclusive(&regions, &cells)[0],
5544            "first line of the paragraph stray third line of the paragraph"
5545        );
5546        assert_eq!(
5547            (regions[2].t, regions[2].b),
5548            (400.0, 600.0),
5549            "picture untouched"
5550        );
5551
5552        let mut untouched = vec![region("text", 0.9, 0.0, 0.0, 10.0, 10.0)];
5553        super::fit_regions_to_cells(&mut untouched, &[]);
5554        assert_eq!(untouched.len(), 1, "no cells yet: nothing dropped");
5555    }
5556
5557    /// #609: one `text` region over a checklist whose lines each have a drawn
5558    /// square in front splits into a checkbox region per boxed line — the
5559    /// wrapped second line of an option stays with it, the intro line above
5560    /// the first square keeps the region's label, and a ticked square gives
5561    /// `checkbox_selected`. Without squares nothing changes.
5562    #[test]
5563    fn checklist_lines_with_squares_split_into_checkbox_regions() {
5564        use crate::checkbox::CheckBox;
5565        let cells = vec![
5566            cell("Pick any:", 100.0, 90.0, 160.0, 100.0),
5567            cell("First option", 120.0, 110.0, 180.0, 120.0),
5568            cell("Second option that", 120.0, 129.0, 200.0, 139.0),
5569            cell("wraps onto a line", 120.0, 141.0, 196.0, 151.0),
5570            cell("Third option", 120.0, 160.0, 182.0, 170.0),
5571        ];
5572        let sq = |t: f32, checked| CheckBox {
5573            l: 100.0,
5574            t,
5575            r: 112.0,
5576            b: t + 12.0,
5577            checked,
5578        };
5579        let boxes = [sq(109.0, false), sq(128.0, true), sq(159.0, false)];
5580        let block = || vec![region("text", 0.6, 100.0, 90.0, 200.0, 170.0)];
5581
5582        let mut regions = block();
5583        super::split_checkbox_lines(&mut regions, &cells, &boxes);
5584        let texts = super::region_texts_exclusive(&regions, &cells);
5585        let got: Vec<(&str, &str)> = regions
5586            .iter()
5587            .zip(&texts)
5588            .map(|(r, t)| (r.label, t.as_str()))
5589            .collect();
5590        assert_eq!(
5591            got,
5592            [
5593                ("text", "Pick any:"),
5594                ("checkbox_unselected", "First option"),
5595                ("checkbox_selected", "Second option that wraps onto a line"),
5596                ("checkbox_unselected", "Third option"),
5597            ]
5598        );
5599        // A checkbox region spans its square and its line(s).
5600        assert_eq!((regions[2].l, regions[2].t), (100.0, 128.0));
5601        assert_eq!((regions[2].r, regions[2].b), (200.0, 151.0));
5602
5603        let shape = |rs: &[Region]| -> Vec<(&'static str, [f32; 4])> {
5604            rs.iter().map(|r| (r.label, [r.l, r.t, r.r, r.b])).collect()
5605        };
5606        let mut untouched = block();
5607        super::split_checkbox_lines(&mut untouched, &cells, &[]);
5608        assert_eq!(shape(&untouched), shape(&block()));
5609        // A square far left of the text (a margin mark), or one the text
5610        // sits inside (a comb field), does not make a checkbox.
5611        for far in [
5612            CheckBox {
5613                l: 40.0,
5614                r: 52.0,
5615                ..sq(109.0, false)
5616            },
5617            CheckBox {
5618                l: 115.0,
5619                r: 127.0,
5620                ..sq(109.0, false)
5621            },
5622        ] {
5623            let mut regions = block();
5624            super::split_checkbox_lines(&mut regions, &cells, &[far]);
5625            assert_eq!(shape(&regions), shape(&block()));
5626        }
5627    }
5628
5629    /// #609: a ballot-box glyph opening a line marks a checkbox item like a
5630    /// drawn square — `☐` unchecked, `☒` checked, the glyph stripped from the
5631    /// label at emission — while a line holding two boxes (`☐ Yes ☐ No`)
5632    /// stays text of its own.
5633    #[test]
5634    fn checklist_lines_opening_with_a_ballot_box_split_into_checkbox_regions() {
5635        let cells = vec![
5636            cell("Options:", 100.0, 90.0, 150.0, 100.0),
5637            cell("\u{2610} Tea", 100.0, 110.0, 140.0, 120.0),
5638            cell("\u{2612} Coffee", 100.0, 130.0, 150.0, 140.0),
5639            cell("\u{2610} Yes \u{2610} No", 100.0, 150.0, 170.0, 160.0),
5640        ];
5641        let mut regions = vec![region("text", 0.6, 100.0, 90.0, 170.0, 160.0)];
5642        super::split_checkbox_lines(&mut regions, &cells, &[]);
5643        let texts = super::region_texts_exclusive(&regions, &cells);
5644        let got: Vec<(&str, &str)> = regions
5645            .iter()
5646            .zip(&texts)
5647            .map(|(r, t)| match r.label {
5648                "text" => (r.label, t.as_str()),
5649                _ => (r.label, super::strip_checkbox_glyph(t)),
5650            })
5651            .collect();
5652        assert_eq!(
5653            got,
5654            [
5655                ("text", "Options:"),
5656                ("checkbox_unselected", "Tea"),
5657                ("checkbox_selected", "Coffee"),
5658                // Two boxes on one line: its own text, the item closed.
5659                ("text", "\u{2610} Yes \u{2610} No"),
5660            ]
5661        );
5662        assert_eq!(super::strip_checkbox_glyph("  \u{2611}  Done"), "Done");
5663        assert_eq!(super::strip_checkbox_glyph("No box"), "No box");
5664    }
5665}