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