Skip to main content

docling_pdf/
assemble.rs

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