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) = (®ions[idx[a]], ®ions[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 = ®ions[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(®ions[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(®ion_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('&', "&")
835 .replace('<', "<")
836 .replace('>', ">")
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/// Assemble one page from its (already overlap-resolved) layout regions and
1674/// text cells.
1675/// Normalize a layout region (page points, top-left origin) to DocLang's 0–511
1676/// location grid: `clamp(round(512 · coord / page_dim), 0, 511)`, per axis,
1677/// order `[x0, y0, x1, y1]`. Mirrors docling_core's
1678/// `_doclang_utils._create_location_tokens_for_bbox` (resolution 512) so the
1679/// emitted `<location>` tokens line up with the Python groundtruth. Our heron
1680/// cluster boxes match docling's to within ~1 grid unit; the residual (mainly
1681/// the aspect-ratio-stretch vs letterbox preprocessing difference) is absorbed
1682/// by the conformance harness's geometry tolerance.
1683fn norm_loc(region: &Region, page_w: f32, page_h: f32) -> [u16; 4] {
1684 let q = |v: f32, dim: f32| -> u16 {
1685 if dim <= 0.0 {
1686 return 0;
1687 }
1688 let g = (512.0 * (v as f64) / (dim as f64)).round() as i64;
1689 g.clamp(0, 511) as u16
1690 };
1691 [
1692 q(region.l, page_w),
1693 q(region.t, page_h),
1694 q(region.r, page_w),
1695 q(region.b, page_h),
1696 ]
1697}
1698
1699/// Wrap a node in its layout provenance so the DocLang serializer emits the four
1700/// `<location>` tokens as the element's head (Markdown/JSON render `inner`
1701/// unchanged).
1702fn located(loc: [u16; 4], inner: Node) -> Node {
1703 Node::Located {
1704 location: loc,
1705 inner: Box::new(inner),
1706 }
1707}
1708
1709/// Stamp the real 1-based page number onto a page's leading marker (see
1710/// [`assemble_page`], which emits it with `page_no: 0` because only the
1711/// document-level collector knows the true index — `--pages` windows shift it).
1712pub fn stamp_page_no(nodes: &mut [Node], page_no: usize) {
1713 if let Some(Node::PageInfo { page_no: p, .. }) = nodes.first_mut() {
1714 *p = page_no;
1715 }
1716}
1717
1718/// A dense table grid plus its first-class cells (#240): `rows` is the text
1719/// grid every serializer renders (spans replicate their anchor's text);
1720/// `cells` are the docling-parity per-cell records (text, page-point bbox,
1721/// span rectangle, OTSL header roles). Produced by the TableFormer paths
1722/// (`tf_core`); lives in this always-compiled module so the pure-text (wasm
1723/// `pdf-text`) build sees the type.
1724#[derive(Clone, Debug)]
1725pub struct TableGrid {
1726 pub rows: Vec<Vec<String>>,
1727 pub cells: Vec<docling_core::TableCell>,
1728}
1729
1730/// The DocLang structure overlay derived from first-class cells: span
1731/// continuations (`lcel`/`ucel`/`xcel`) and per-cell header roles, so the
1732/// PDF path's DCLX carries real spans instead of a flat grid.
1733fn structure_from_cells(
1734 cells: &[docling_core::TableCell],
1735 nrows: usize,
1736 ncols: usize,
1737) -> docling_core::TableStructure {
1738 let grid = || vec![vec![false; ncols]; nrows];
1739 let mut col_cont = grid();
1740 let mut row_cont = grid();
1741 let mut row_header = grid();
1742 let mut col_header = grid();
1743 for c in cells {
1744 for r in c.start_row..(c.start_row + c.row_span).min(nrows) {
1745 for k in c.start_col..(c.start_col + c.col_span).min(ncols) {
1746 col_cont[r][k] = k > c.start_col;
1747 row_cont[r][k] = r > c.start_row;
1748 row_header[r][k] = c.row_header;
1749 col_header[r][k] = c.column_header;
1750 }
1751 }
1752 }
1753 docling_core::TableStructure {
1754 header_row: Vec::new(),
1755 col_continuation: col_cont,
1756 row_continuation: row_cont,
1757 row_header,
1758 col_header,
1759 }
1760}
1761
1762pub fn assemble_page(
1763 page: &PdfPage,
1764 regions: Vec<Region>,
1765 table_rows: &[Option<TableGrid>],
1766 enrichments: &[Option<Enrichment>],
1767) -> (Vec<Node>, Vec<(String, String)>) {
1768 let mut nodes: Vec<Node> = Vec::new();
1769 // Every page opens with an invisible page marker carrying its size in
1770 // points — what the JSON export needs to build docling's `pages` map and
1771 // denormalize the 0–511 `<location>` grid into point bboxes (#171). The
1772 // page *number* is stamped by the document-level collector (which knows
1773 // the real 1-based index, `--pages` windows included); every serializer
1774 // except JSON skips the marker, so Markdown/DocLang stay byte-identical.
1775 nodes.push(Node::PageInfo {
1776 page_no: 0,
1777 width: page.width,
1778 height: page.height,
1779 });
1780 // Recover this page's hyperlinks (anchor-precise pairs for strict
1781 // Markdown; whole-item docling-parity links are baked below and their
1782 // pairs dropped from this list so strict output doesn't double-wrap).
1783 let mut links = resolve_link_anchors(page);
1784 // Pair each region with its precomputed TableFormer grid and enrichment
1785 // (indexed by original order) and order by reading order together, so they
1786 // stay aligned.
1787 type RegionItem = (Region, Option<TableGrid>, Option<Enrichment>);
1788 let mut items: Vec<RegionItem> = regions
1789 .into_iter()
1790 .enumerate()
1791 .map(|(i, r)| {
1792 (
1793 r,
1794 table_rows.get(i).cloned().flatten(),
1795 enrichments.get(i).cloned().flatten(),
1796 )
1797 })
1798 .collect();
1799 order_regions(&mut items, page.width, page.height, |it| &it.0);
1800 // Float a margin page number to the front of reading order (docling parity:
1801 // right_to_left_02's bottom `11` is its first item). Stable, so everything
1802 // else keeps its order; no-op on pages without such a region.
1803 let page_h = page.height;
1804 items.sort_by_key(|(r, _, _)| !is_page_number(r, &page.cells, page_h));
1805 let table_rows: Vec<Option<TableGrid>> = items.iter().map(|(_, t, _)| t.clone()).collect();
1806 let enrichments: Vec<Option<Enrichment>> = items.iter().map(|(_, _, e)| e.clone()).collect();
1807 let regions: Vec<Region> = items.into_iter().map(|(r, _, _)| r).collect();
1808 // docling emits a figure's caption *before* the image marker. Pair each
1809 // picture with the caption region nearest below it and consume that caption,
1810 // so it isn't also emitted in its own (lower) reading-order position.
1811 let caption_for = pair_captions(®ions);
1812 let code_caption_for = pair_code_captions(®ions);
1813 let mut consumed = vec![false; regions.len()];
1814 for ci in caption_for.iter().flatten() {
1815 consumed[*ci] = true;
1816 }
1817 for ci in code_caption_for.iter().flatten() {
1818 consumed[*ci] = true;
1819 }
1820 // A code block's language label (`XML`, `C#`, …) is chrome, not content — the
1821 // detector emits it as its own region above the code; consume it.
1822 for (i, is_label) in code_language_labels(®ions, &page.cells)
1823 .into_iter()
1824 .enumerate()
1825 {
1826 if is_label {
1827 consumed[i] = true;
1828 }
1829 }
1830
1831 // docling `ReadingOrderPredictor.predict_merges`: join a text fragment with a
1832 // following text fragment strictly to its right (an author column that wraps
1833 // into the next, a paragraph continuing in the next column) into one block —
1834 // the intra-page half of docling's reading-order merges (cross-page/vertical
1835 // continuations stay with [`merge_continuations`]). Already-consumed regions
1836 // (paired captions, code labels) are excluded.
1837 // Exclusive docling cell assignment: computed once for the ordered region
1838 // list and reused for every serialization below, so a cell can never render
1839 // in two regions.
1840 let region_texts: Vec<String> = region_texts_exclusive(®ions, &page.cells);
1841 let is_text: Vec<bool> = regions
1842 .iter()
1843 .enumerate()
1844 .map(|(i, r)| r.label == "text" && !consumed[i])
1845 .collect();
1846 let is_skip: Vec<bool> = regions
1847 .iter()
1848 .enumerate()
1849 .map(|(i, r)| {
1850 consumed[i]
1851 || matches!(
1852 r.label,
1853 "page_header" | "page_footer" | "table" | "picture" | "caption" | "footnote"
1854 )
1855 })
1856 .collect();
1857 let boxes: Vec<(f32, f32, f32, f32)> = regions.iter().map(|r| (r.l, r.t, r.r, r.b)).collect();
1858 if docling_core::env::flag("DOCLING_RS_DEBUG_MERGES") {
1859 for (i, r) in regions.iter().enumerate() {
1860 eprintln!(
1861 "MRG {i:2} {} text={} skip={} [{:.0},{:.0},{:.0},{:.0}] {:?}",
1862 r.label,
1863 is_text[i],
1864 is_skip[i],
1865 r.l,
1866 r.t,
1867 r.r,
1868 r.b,
1869 region_texts[i].chars().take(40).collect::<String>()
1870 );
1871 }
1872 }
1873 let mut merge_suffix: Vec<String> = vec![String::new(); regions.len()];
1874 for (head, children) in
1875 crate::reading_order::predict_merges(&boxes, ®ion_texts, &is_text, &is_skip)
1876 .into_iter()
1877 .enumerate()
1878 {
1879 for c in children {
1880 let t = region_texts[c].trim();
1881 if !t.is_empty() {
1882 merge_suffix[head].push(' ');
1883 merge_suffix[head].push_str(t);
1884 }
1885 consumed[c] = true;
1886 }
1887 }
1888
1889 for (i, region) in regions.iter().enumerate() {
1890 if consumed[i] {
1891 continue;
1892 }
1893 // Page headers/footers: docling emits them as furniture blocks
1894 // (`<page_header>`/`<page_footer>` with a layer + location + text) at
1895 // their reading-order position, not as body — emit them, don't skip.
1896 if matches!(region.label, "page_header" | "page_footer") {
1897 let text = region_texts[i].clone();
1898 if !text.is_empty() {
1899 nodes.push(Node::PageFurniture {
1900 footer: region.label == "page_footer",
1901 location: norm_loc(region, page.width, page_h),
1902 text: md_escape(&text),
1903 });
1904 }
1905 continue;
1906 }
1907 if is_skipped(region.label) {
1908 continue;
1909 }
1910 // Layout provenance for this region, normalized to docling's 0–511 grid.
1911 let loc = norm_loc(region, page.width, page_h);
1912 if region.label == "picture" {
1913 // The figure pixels are cropped from the page render for image export.
1914 let caption = caption_for[i]
1915 .map(|ci| region_texts[ci].clone())
1916 .filter(|t| !t.is_empty());
1917 let classification = match &enrichments[i] {
1918 Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
1919 _ => None,
1920 };
1921 // Without the page render (text-layer-only build) a picture keeps
1922 // its caption/classification but carries no cropped pixels.
1923 #[cfg(feature = "ocr-prep")]
1924 let image = crate::timing::timed("crop_region", || crop_region(page, region));
1925 #[cfg(not(feature = "ocr-prep"))]
1926 let image: Option<PictureImage> = None;
1927 nodes.push(located(
1928 loc,
1929 Node::Picture {
1930 caption,
1931 image,
1932 classification,
1933 },
1934 ));
1935 continue;
1936 }
1937 let mut text = region_texts[i].clone();
1938 text.push_str(&merge_suffix[i]);
1939 if text.is_empty() {
1940 continue;
1941 }
1942 match region.label {
1943 // docling assembles checkboxes as TEXT_ELEM items (the region's
1944 // cells are the option label, e.g. right_to_left_03's بلی/خير)
1945 // and its Markdown serializer renders them as task-list lines
1946 // (`- [x] …`) — mirrored by [`Node::CheckboxItem`].
1947 "checkbox_selected" | "checkbox_unselected" => nodes.push(Node::CheckboxItem {
1948 checked: region.label == "checkbox_selected",
1949 text: md_escape(&text),
1950 }),
1951 // docling renders both the document title and section headers as
1952 // `##` (it never emits a top-level `#` for PDFs), so match that.
1953 "title" | "section_header" => nodes.push(located(
1954 loc,
1955 Node::Heading {
1956 level: 2,
1957 text: md_escape(&text),
1958 },
1959 )),
1960 // docling drops the rendered bullet glyph; the Markdown serializer
1961 // adds its own `- ` marker. An item whose text opens with an `N.`
1962 // enumeration marker is an ordered item (rendered `N. text`).
1963 // A leading dash stays: it is an ordinary text glyph that
1964 // docling-parse keeps, and docling's items carry it into the
1965 // Markdown (2305's OTSL list renders `- -"C" cell …`) — only the
1966 // symbol-font bullets docling-parse filters out are stripped.
1967 "list_item" => {
1968 let stripped = text
1969 .trim_start_matches(['•', '◦', '▪', '·', '*'])
1970 .trim_start()
1971 .to_string();
1972 if let Some((number, rest)) = parse_ordered_marker(&stripped) {
1973 nodes.push(Node::ListItem {
1974 ordered: true,
1975 number,
1976 first_in_list: false,
1977 text: md_escape(&rest),
1978 level: 0,
1979 marker: None,
1980 location: Some(loc),
1981 dclx: None,
1982 href: None,
1983 layer: None,
1984 });
1985 } else {
1986 nodes.push(Node::ListItem {
1987 ordered: false,
1988 number: 0,
1989 first_in_list: false,
1990 text: md_escape(&stripped),
1991 level: 0,
1992 // docling keeps the bullet as the DocLang list marker
1993 // (`<ldiv><marker>·</marker></ldiv>`); Markdown ignores it.
1994 marker: Some("·".into()),
1995 location: Some(loc),
1996 dclx: None,
1997 href: None,
1998 layer: None,
1999 });
2000 }
2001 }
2002 // TableFormer structure (cells + spans, text matched from word cells)
2003 // when available; otherwise geometric grid reconstruction; finally a
2004 // single cell.
2005 "table" | "document_index" => {
2006 // TableFormer grids carry first-class cells (#240: text +
2007 // page-point bbox + span rectangle + OTSL header roles) into
2008 // the public model, and the DocLang structure overlay derives
2009 // from them so DCLX emits real span/header tokens. The
2010 // geometric fallback has no per-cell records.
2011 let (rows, cells, structure) = match table_rows[i].clone() {
2012 Some(grid) => {
2013 let nrows = grid.rows.len();
2014 let ncols = grid.rows.first().map_or(0, Vec::len);
2015 let structure = structure_from_cells(&grid.cells, nrows, ncols);
2016 (grid.rows, Some(grid.cells), Some(structure))
2017 }
2018 None => {
2019 let rows = reconstruct_table(region, &page.cells);
2020 let rows = if rows.iter().any(|r| r.len() > 1) {
2021 rows
2022 } else {
2023 vec![vec![text.clone()]]
2024 };
2025 (rows, None, None)
2026 }
2027 };
2028 nodes.push(located(
2029 loc,
2030 Node::Table(Table {
2031 rows,
2032 location: None,
2033 structure,
2034 cell_blocks: None,
2035 cells,
2036 caption: None,
2037 }),
2038 ));
2039 }
2040 // With formula enrichment the CodeFormula model decodes the region
2041 // to LaTeX; otherwise docling emits a placeholder comment rather
2042 // than the (garbled) raw glyph text.
2043 "formula" => match &enrichments[i] {
2044 Some(Enrichment::Formula { latex }) => nodes.push(Node::Formula {
2045 latex: latex.clone(),
2046 orig: text.clone(),
2047 location: Some(loc),
2048 }),
2049 _ => nodes.push(Node::Paragraph {
2050 text: "<!-- formula-not-decoded -->".into(),
2051 }),
2052 },
2053 // Code blocks: use the space-glyph-only grouping (monospace keeps its
2054 // source spacing) and emit a fenced block, preserving the line breaks
2055 // and indentation of the source (unlike prose, which reflows). pdfium
2056 // still inserts spaces around tight punctuation (`console .log`,
2057 // `add (3 , 5)`); tighten them to match docling-parse's source spacing.
2058 "code" => {
2059 // `code_region_text` preserves line breaks/indentation and tightens
2060 // each line itself; the fallback prose `text` is tightened here.
2061 let code = code_region_text(region, &page.code_cells);
2062 let code = if code.is_empty() {
2063 tighten_code_punct(&text)
2064 } else {
2065 code
2066 };
2067 // With code enrichment the CodeFormula model rewrites the block
2068 // (and names its language); `orig` keeps the raw extraction in
2069 // docling's shape — its parser has no line-preserving code
2070 // path, so its `orig` is the same code with the lines joined
2071 // by single spaces (indentation collapsed).
2072 // docling's parser has no line-preserving code path — its code
2073 // items carry the lines joined by single spaces. That flat
2074 // form is what every byte-conformance surface serializes
2075 // (legacy Markdown, JSON, DocLang); the line-preserving
2076 // extraction rides in `pretty` for strict Markdown only.
2077 let flat = code
2078 .lines()
2079 .map(str::trim)
2080 .filter(|l| !l.is_empty())
2081 .collect::<Vec<_>>()
2082 .join(" ");
2083 let node = match &enrichments[i] {
2084 Some(Enrichment::Code {
2085 language,
2086 text: enriched,
2087 }) => Node::Code {
2088 language: language.clone(),
2089 text: enriched.clone(),
2090 orig: Some(flat),
2091 pretty: None,
2092 },
2093 _ => Node::Code {
2094 language: None,
2095 text: flat,
2096 orig: None,
2097 pretty: Some(code),
2098 },
2099 };
2100 nodes.push(located(loc, node));
2101 // docling emits the `Listing N:` caption after the code block.
2102 if let Some(ci) = code_caption_for[i] {
2103 let cap = region_texts[ci].clone();
2104 if !cap.is_empty() {
2105 nodes.push(Node::Paragraph { text: cap });
2106 }
2107 }
2108 }
2109 // text, caption, footnote → paragraph
2110 _ => {
2111 // docling parity (`PageAssembleModel._match_hyperlink`): when
2112 // link annotations cover ≥ half of the region's box, the
2113 // hyperlink attaches to the item and the legacy Markdown
2114 // serializer wraps its full text — 2206.01062's footnote URLs
2115 // render as `[1 https://…](https://…)`. Sparse in-paragraph
2116 // citation links stay below the 0.5 coverage threshold and
2117 // remain plain text, exactly like docling.
2118 //
2119 // Scope: **footnote regions only.** Upstream's page_assemble
2120 // matches every TEXT_ELEM label, but published docling
2121 // observably carries the hyperlink into the document only for
2122 // footnote items — in both committed groundtruth generations
2123 // (docling-JSON and Markdown, independent runs) the fully
2124 // covered plain-text DOI line of 2206.01062 page 1 has
2125 // `hyperlink: None` while the equally covered footnotes carry
2126 // theirs. The corpus is the conformance reference, so match
2127 // the observed behavior; widen the label set if a future
2128 // groundtruth refresh starts linking plain text too.
2129 let escaped = md_escape(&text);
2130 let hyperlink = (region.label == "footnote")
2131 .then(|| region_hyperlink(region, &page.links))
2132 .flatten();
2133 let text = match hyperlink {
2134 Some(uri) => {
2135 // The strict-mode anchor pairs this item covers are
2136 // superseded by the baked whole-item link.
2137 links.retain(|(anchor, href)| {
2138 !(href == &uri && region_texts[i].contains(anchor.as_str()))
2139 });
2140 format!("[{escaped}]({uri})")
2141 }
2142 None => escaped,
2143 };
2144 nodes.push(located(loc, Node::Paragraph { text }))
2145 }
2146 }
2147 }
2148 // A `/Rotate`-normalized scanned page (see `pdfium_backend`) was assembled
2149 // in upright space; rotate the finished geometry back so locations and the
2150 // page size are display-space, like docling and every viewer report them.
2151 if page.rotation != 0 {
2152 rotate_nodes_to_display(&mut nodes, page.rotation);
2153 }
2154 (nodes, links)
2155}
2156
2157/// Rotate one 0–511 location bbox 90° clockwise on the grid (top-left origin):
2158/// `(x, y) → (511 - y, x)`.
2159fn rot_loc_cw(l: [u16; 4]) -> [u16; 4] {
2160 [511 - l[3], l[0], 511 - l[1], l[2]]
2161}
2162
2163/// Map upright-space geometry back to display space for a page whose `/Rotate`
2164/// was normalized away before inference: every `<location>` rotates `rot`°
2165/// clockwise on the 0–511 grid (the grid is per-axis normalized, so no page
2166/// dims are needed), and the `PageInfo` size returns to the display box. Node
2167/// text and order are untouched — reading order was decided upright, which is
2168/// the whole point.
2169fn rotate_nodes_to_display(nodes: &mut [Node], rot: u16) {
2170 let quarter_turns = (rot / 90) as usize;
2171 let rot_loc = |l: &mut [u16; 4]| {
2172 for _ in 0..quarter_turns {
2173 *l = rot_loc_cw(*l);
2174 }
2175 };
2176 fn walk(node: &mut Node, rot_loc: &impl Fn(&mut [u16; 4]), swap_dims: bool) {
2177 match node {
2178 Node::PageInfo { width, height, .. } => {
2179 if swap_dims {
2180 std::mem::swap(width, height);
2181 }
2182 }
2183 Node::Located { location, inner } => {
2184 rot_loc(location);
2185 walk(inner, rot_loc, swap_dims);
2186 }
2187 Node::Furniture { inner, .. } => walk(inner, rot_loc, swap_dims),
2188 Node::Group { children, .. } => {
2189 for c in children {
2190 walk(c, rot_loc, swap_dims);
2191 }
2192 }
2193 Node::ListItem { location, .. }
2194 | Node::Formula { location, .. }
2195 | Node::Chart { location, .. } => {
2196 if let Some(l) = location {
2197 rot_loc(l);
2198 }
2199 }
2200 Node::PageFurniture { location, .. } => rot_loc(location),
2201 Node::Table(t) => {
2202 if let Some(l) = &mut t.location {
2203 rot_loc(l);
2204 }
2205 }
2206 _ => {}
2207 }
2208 }
2209 let swap_dims = quarter_turns % 2 == 1;
2210 for node in nodes {
2211 walk(node, &rot_loc, swap_dims);
2212 }
2213}
2214
2215/// Merge paragraph fragments split across a column or page break. docling joins a
2216/// paragraph whose previous fragment ends mid-sentence (a letter, not sentence
2217/// punctuation) with a lowercase continuation: `…definition of` + `lists in…` →
2218/// `…definition of lists in…`. The fragments are consecutive paragraphs, or
2219/// separated only by figure(s) the text wraps around: a column whose body flows
2220/// past a figure resumes below it (`…The wing type that is` ⟶[figure]⟶ `the most
2221/// common…`), and docling emits the whole paragraph before the figure. A heading,
2222/// table, or list between them ends the paragraph (no merge).
2223/// A paragraph that is really a figure/table caption (`Fig. 1. …`, `Table 2 …`).
2224/// Used to skip an unpaired caption when stitching a paragraph that wraps around
2225/// a figure.
2226fn looks_like_caption(text: &str) -> bool {
2227 let head: String = text.trim_start().chars().take(14).collect();
2228 (head.starts_with("Fig") || head.starts_with("Table"))
2229 && head.contains(|c: char| c.is_ascii_digit())
2230}
2231
2232/// A paragraph fragment is "open" — i.e. it might continue into the next
2233/// paragraph — when it ends mid-word (a letter) or with a wrap hyphen/dash.
2234/// docling joins `vocab-` + `ulary` → `vocab- ulary`.
2235fn paragraph_is_open(text: &str) -> bool {
2236 // docling's merge head test (`.+([a-z,\-\u00AD])\s*`): at least two chars,
2237 // ending in an ASCII lowercase letter, a comma, a hyphen, or a soft
2238 // hyphen. The comma matters: 2206's "…In phase four," resumes across the
2239 // page break. Uppercase/non-Latin endings do not merge, exactly as
2240 // upstream (the dash family is already `-` here — clean_text normalized).
2241 let t = text.trim_end();
2242 t.chars().count() >= 2
2243 && t.chars()
2244 .next_back()
2245 .is_some_and(|c| matches!(c, 'a'..='z' | ',' | '-' | '\u{ad}'))
2246}
2247
2248/// The paragraph text inside a node, looking through a [`Node::Located`]
2249/// provenance wrapper (PDF body paragraphs are wrapped since they carry a
2250/// `<location>`). Returns `None` for non-paragraph nodes.
2251fn as_paragraph(n: &Node) -> Option<&str> {
2252 match n {
2253 Node::Paragraph { text } => Some(text),
2254 Node::Located { inner, .. } => match inner.as_ref() {
2255 Node::Paragraph { text } => Some(text),
2256 _ => None,
2257 },
2258 _ => None,
2259 }
2260}
2261
2262/// Whether a node is a picture, looking through a [`Node::Located`] wrapper.
2263fn is_picture_node(n: &Node) -> bool {
2264 match n {
2265 Node::Picture { .. } => true,
2266 Node::Located { inner, .. } => matches!(inner.as_ref(), Node::Picture { .. }),
2267 _ => false,
2268 }
2269}
2270
2271/// A node a forward paragraph merge looks straight past: a figure or *table*
2272/// the text wraps around, or a page header/footer that falls between the two
2273/// fragments of a paragraph continuing across a page break (docling's merge
2274/// skip-labels: page_header, page_footer, table, picture, caption, footnote —
2275/// 2206's "…In phase four," resumes after a full caption+table+figure block).
2276fn is_merge_trailer(n: &Node) -> bool {
2277 is_picture_node(n)
2278 || matches!(
2279 n,
2280 Node::PageFurniture { .. } | Node::PageInfo { .. } | Node::Table(_)
2281 )
2282 || matches!(n, Node::Located { inner, .. } if matches!(inner.as_ref(), Node::Table(_)))
2283 || as_paragraph(n).is_some_and(looks_like_caption)
2284}
2285
2286/// Rebuild node `i` as a paragraph with `text`, preserving its `<location>`
2287/// wrapper (and thus provenance) if it had one.
2288fn reparagraph(node: &Node, text: String) -> Node {
2289 match node {
2290 Node::Located { location, .. } => located(*location, Node::Paragraph { text }),
2291 _ => Node::Paragraph { text },
2292 }
2293}
2294
2295pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
2296 let mut i = 0;
2297 while i + 1 < nodes.len() {
2298 let Some(a) = as_paragraph(&nodes[i]) else {
2299 i += 1;
2300 continue;
2301 };
2302 // A figure/table caption is a self-contained unit; body text resuming
2303 // after a figure is the continuation case, not the caption itself. Never
2304 // stitch *from* a caption — otherwise a caption that ends in a lone glyph
2305 // (`Fig. 5. … PubTabNet. μ`) would swallow a following stray figure label
2306 // (a standalone `μ`) into `… μ μ`.
2307 if looks_like_caption(a) {
2308 i += 1;
2309 continue;
2310 }
2311 if !paragraph_is_open(a) {
2312 i += 1;
2313 continue;
2314 }
2315 // The continuation is the next paragraph, looking past any figures the
2316 // text wraps around — and a figure/table caption that was emitted as its
2317 // own paragraph (an above-the-figure caption that didn't pair), since the
2318 // body text resumes after the whole figure+caption block.
2319 let mut j = i + 1;
2320 while nodes.get(j).is_some_and(is_merge_trailer) {
2321 j += 1;
2322 }
2323 // docling's continuation regex allows either case, but its merge runs
2324 // over the pre-assembly element stream; at node level an uppercase
2325 // start is overwhelmingly a new sentence/heading fragment (allowing it
2326 // swallowed 2305's formula blocks and redp's chapter openers), so the
2327 // continuation stays lowercase-start here.
2328 let cont = nodes.get(j).and_then(as_paragraph).is_some_and(|b| {
2329 b.trim_start()
2330 .chars()
2331 .next()
2332 .is_some_and(char::is_lowercase)
2333 });
2334 if cont {
2335 let a = as_paragraph(&nodes[i]).unwrap().trim_end().to_string();
2336 let b = as_paragraph(&nodes[j]).unwrap().trim_start().to_string();
2337 // A soft hyphen -- or a hard hyphen followed by a lowercase
2338 // continuation (guaranteed lowercase by the `cont` gate above) --
2339 // is a word split across the break: strip it and join without a
2340 // space, docling#3888 ("vocab-" + "ulary" -> "vocabulary");
2341 // docling's older serializer kept the artifact ("vocab- ulary").
2342 // Everything else joins with the space, as before.
2343 let merged = match a.strip_suffix('\u{ad}').or_else(|| a.strip_suffix('-')) {
2344 Some(stem) => format!("{stem}{b}"),
2345 None => format!("{a} {b}"),
2346 };
2347 // Keep node i's provenance wrapper; docling's merged paragraph keeps
2348 // the first fragment's geometry as its primary location.
2349 nodes[i] = reparagraph(&nodes[i], merged);
2350 nodes.remove(j);
2351 // Re-check i: the merged paragraph may continue further.
2352 } else {
2353 i += 1;
2354 }
2355 }
2356}
2357
2358/// How many leading nodes of `nodes` are safe to flush now — i.e. cannot be
2359/// rewritten by a future [`merge_continuations`] once more pages are appended.
2360///
2361/// A forward merge can only start from an "open" paragraph (ends mid-word) and
2362/// only reaches across trailing pictures and figure/table captions. So we scan
2363/// from the end past those skippable trailers: if the first non-skippable node is
2364/// an open paragraph, it (and the trailers after it) must be held; anything else —
2365/// a closed paragraph, a heading, a table, a list — blocks any forward merge, so
2366/// the whole buffer is safe to flush.
2367fn hold_start(nodes: &[Node]) -> usize {
2368 for k in (0..nodes.len()).rev() {
2369 // Skippable trailers (figures, page furniture, captions): a forward merge
2370 // looks straight past them.
2371 if is_merge_trailer(&nodes[k]) {
2372 continue;
2373 }
2374 match as_paragraph(&nodes[k]) {
2375 // An open body paragraph might still pull a continuation off the next
2376 // page — hold from here to the end.
2377 Some(text) if paragraph_is_open(text) => return k,
2378 // A closed paragraph, heading, table, list, etc. ends the paragraph:
2379 // nothing after it can merge backwards across it. Flush everything.
2380 _ => return nodes.len(),
2381 }
2382 }
2383 // Only skippable trailers (or empty) and no open paragraph to anchor a merge.
2384 nodes.len()
2385}
2386
2387/// Streaming counterpart of [`merge_continuations`]: feed per-page node batches in
2388/// document order and get back the prefix that is final (its cross-page merges are
2389/// resolved and no future page can change it), holding back only the small tail
2390/// that might still merge into the next page. Concatenating every flushed batch
2391/// (then [`finish`](Self::finish)) yields exactly the same nodes as running
2392/// [`merge_continuations`] once over the whole document.
2393pub(crate) struct StreamAssembler {
2394 pending: Vec<Node>,
2395}
2396
2397impl StreamAssembler {
2398 pub(crate) fn new() -> Self {
2399 Self {
2400 pending: Vec::new(),
2401 }
2402 }
2403
2404 /// Append one page's nodes, resolve merges within the buffer, and return the
2405 /// now-final prefix to emit (possibly empty).
2406 pub(crate) fn push(&mut self, mut nodes: Vec<Node>) -> Vec<Node> {
2407 self.pending.append(&mut nodes);
2408 merge_continuations(&mut self.pending);
2409 let cut = hold_start(&self.pending);
2410 let tail = self.pending.split_off(cut);
2411 std::mem::replace(&mut self.pending, tail)
2412 }
2413
2414 /// Flush whatever is left after the last page (the held tail is final once no
2415 /// more pages can follow).
2416 pub(crate) fn finish(self) -> Vec<Node> {
2417 self.pending
2418 }
2419}
2420
2421#[cfg(test)]
2422mod tests {
2423 use super::clean_text;
2424 use super::{code_region_text, merge_continuations, resolve_link_anchors, StreamAssembler};
2425 use crate::layout::Region;
2426 use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
2427 use docling_core::Node;
2428
2429 /// The int8-layout guard's coverage metric: cells under detections count,
2430 /// cells outside don't, whitespace cells are ignored, and a cell-less page
2431 /// reads as fully covered (nothing to rescue).
2432 #[test]
2433 fn layout_cell_coverage_counts_claimed_text_cells() {
2434 let cell = |text: &str, l: f32, t: f32| TextCell {
2435 text: text.into(),
2436 l,
2437 t,
2438 r: l + 40.0,
2439 b: t + 10.0,
2440 };
2441 let region = Region {
2442 label: "text",
2443 score: 0.9,
2444 l: 0.0,
2445 t: 0.0,
2446 r: 100.0,
2447 b: 50.0,
2448 };
2449 let cells = vec![
2450 cell("inside", 10.0, 10.0),
2451 cell("also inside", 10.0, 30.0),
2452 cell("outside", 10.0, 200.0),
2453 cell(" ", 10.0, 210.0), // whitespace: not counted at all
2454 ];
2455 let cov = super::layout_cell_coverage(std::slice::from_ref(®ion), &cells);
2456 assert!((cov - 2.0 / 3.0).abs() < 1e-6, "got {cov}");
2457 assert_eq!(super::layout_cell_coverage(&[], &[]), 1.0);
2458 assert_eq!(super::layout_cell_coverage(&[], &cells), 0.0);
2459 }
2460
2461 /// #165: a picture no longer claims cells at 0.2 intersection-over-self.
2462 /// A line straddling the figure border (≤80 % contained) becomes an orphan
2463 /// region and survives the contained-regulars drop — before the fix its
2464 /// cells were silently erased. A line fully inside the picture is still
2465 /// re-dropped, matching docling's Markdown (a picture's children never
2466 /// reach its serializer's output).
2467 #[test]
2468 fn border_straddling_lines_survive_picture_interior_is_still_dropped() {
2469 let pic = Region {
2470 label: "picture",
2471 score: 0.9,
2472 l: 0.0,
2473 t: 0.0,
2474 r: 100.0,
2475 b: 100.0,
2476 };
2477 // ~35 % of this cell overlaps the picture (l=90..120 of 0..100): above
2478 // the old 0.2 claim (was swallowed), below full containment (survives).
2479 let straddler = TextCell {
2480 text: "axis label".into(),
2481 l: 90.0,
2482 t: 40.0,
2483 r: 120.0,
2484 b: 48.0,
2485 };
2486 let interior = TextCell {
2487 text: "in-figure callout".into(),
2488 l: 10.0,
2489 t: 10.0,
2490 r: 60.0,
2491 b: 18.0,
2492 };
2493 let mut regions = vec![pic];
2494 super::add_orphan_regions(&mut regions, &[straddler, interior]);
2495 assert_eq!(
2496 regions.iter().filter(|r| r.label == "text").count(),
2497 2,
2498 "both unclaimed lines become orphans"
2499 );
2500 super::drop_contained_regulars(&mut regions);
2501 let texts: Vec<(f32, f32)> = regions
2502 .iter()
2503 .filter(|r| r.label == "text")
2504 .map(|r| (r.l, r.r))
2505 .collect();
2506 assert_eq!(
2507 texts,
2508 [(90.0, 120.0)],
2509 "the straddler is emitted, the fully-contained callout is not"
2510 );
2511 }
2512
2513 /// docling#3906's concern, pinned on our side: a picture detected fully
2514 /// inside a table region must survive the containment drop (upstream now
2515 /// attaches it to the table's cell; we keep it as a body sibling — either
2516 /// way it must not vanish). The text region inside the same table is the
2517 /// control: regulars are the ones the drop swallows.
2518 #[test]
2519 fn picture_inside_a_table_region_survives_the_containment_drop() {
2520 let mut regions = vec![
2521 region("table", 0.9, 0.0, 0.0, 200.0, 200.0),
2522 region("picture", 0.9, 20.0, 20.0, 120.0, 120.0),
2523 region("text", 0.9, 20.0, 140.0, 180.0, 180.0),
2524 ];
2525 super::drop_contained_regulars(&mut regions);
2526 let labels: Vec<&str> = regions.iter().map(|r| r.label).collect();
2527 assert_eq!(
2528 labels,
2529 ["table", "picture"],
2530 "the in-table picture stays; the in-table regular is the special's child"
2531 );
2532 }
2533
2534 /// A colored terms-and-conditions panel detected as `picture` demotes into
2535 /// per-paragraph `text` regions (the blank line between C.7 and C.8 splits
2536 /// them); a chart whose only text is a few narrow axis labels keeps its
2537 /// crop untouched.
2538 #[test]
2539 fn text_panels_demote_to_paragraphs_but_charts_keep_their_crop() {
2540 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
2541 text: text.to_string(),
2542 l,
2543 t,
2544 r,
2545 b,
2546 };
2547 let panel = Region {
2548 label: "picture",
2549 score: 0.9,
2550 l: 0.0,
2551 t: 0.0,
2552 r: 100.0,
2553 b: 100.0,
2554 };
2555 // Three tight lines, a blank-line gap, two more: two paragraphs.
2556 let cells = vec![
2557 cell(
2558 "C.7. Wenn Sie diesen Vertrag widerrufen,",
2559 5.0,
2560 10.0,
2561 95.0,
2562 18.0,
2563 ),
2564 cell(
2565 "haben wir Ihnen alle Zahlungen, die wir",
2566 5.0,
2567 20.0,
2568 95.0,
2569 28.0,
2570 ),
2571 cell(
2572 "von Ihnen erhalten haben, zurückzuzahlen.",
2573 5.0,
2574 30.0,
2575 90.0,
2576 38.0,
2577 ),
2578 cell(
2579 "C.8. Wir können die Rückzahlung verweigern,",
2580 5.0,
2581 52.0,
2582 95.0,
2583 60.0,
2584 ),
2585 cell(
2586 "bis wir die Waren wieder zurückerhalten haben.",
2587 5.0,
2588 62.0,
2589 92.0,
2590 70.0,
2591 ),
2592 ];
2593 let mut regions = vec![panel.clone()];
2594 super::recover_text_panels(&mut regions, &cells);
2595 assert_eq!(
2596 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
2597 ["text", "text"],
2598 "dense panel must demote into one text region per paragraph"
2599 );
2600 assert!(regions[0].b < regions[1].t, "paragraphs split at the gap");
2601 // Sparse narrow labels (a chart): picture survives.
2602 let labels = vec![
2603 cell("0", 5.0, 90.0, 8.0, 95.0),
2604 cell("50", 5.0, 50.0, 10.0, 55.0),
2605 cell("100", 5.0, 10.0, 12.0, 15.0),
2606 cell("t, s", 45.0, 96.0, 55.0, 100.0),
2607 ];
2608 let mut regions = vec![panel];
2609 super::recover_text_panels(&mut regions, &labels);
2610 assert_eq!(
2611 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
2612 ["picture"]
2613 );
2614 }
2615
2616 /// An uncaptioned chart on a scanned page whose title, axis labels, and
2617 /// OCR boxes over the plot area are dense and wide enough to pass the
2618 /// coverage/width gates still keeps its crop: its line heights are ragged
2619 /// (title face vs tick labels vs bar-area OCR), failing the uniform-leading
2620 /// gate — a real text panel is set with constant leading (#173).
2621 #[test]
2622 fn dense_titled_chart_keeps_its_crop() {
2623 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
2624 text: text.to_string(),
2625 l,
2626 t,
2627 r,
2628 b,
2629 };
2630 let chart = Region {
2631 label: "picture",
2632 score: 0.9,
2633 l: 0.0,
2634 t: 0.0,
2635 r: 100.0,
2636 b: 100.0,
2637 };
2638 // Five wide lines at wildly different heights: a 12-pt title, 20-pt OCR
2639 // boxes over the bars, 4–5-pt tick/axis labels. Coverage and median
2640 // width both clear the panel thresholds.
2641 let cells = vec![
2642 cell("Underground Water Storage", 10.0, 5.0, 90.0, 17.0),
2643 cell("aquifer recharge zone", 15.0, 30.0, 75.0, 50.0),
2644 cell("confined | unconfined | perched", 12.0, 55.0, 80.0, 59.0),
2645 cell("saturated thickness", 8.0, 70.0, 60.0, 90.0),
2646 cell("distance from well, km", 20.0, 92.0, 85.0, 97.0),
2647 ];
2648 let mut regions = vec![chart];
2649 super::recover_text_panels(&mut regions, &cells);
2650 assert_eq!(
2651 regions.iter().map(|r| r.label).collect::<Vec<_>>(),
2652 ["picture"],
2653 "ragged line heights mark a figure, not a text panel"
2654 );
2655 }
2656
2657 /// docling serializes a cluster's cells in docling-parse index order
2658 /// (`_sort_cells`) and joins them with `PageAssembleModel.sanitize_text`:
2659 /// a space after every line except one ending in `-`, which either fuses a
2660 /// wrapped word (alnum on both sides — dash dropped) or glues verbatim (a
2661 /// bare `-` cell: `[0000` `-` `0002` → `[0000 -0002`, the 2305 ORCID line;
2662 /// `-` + `"C" cell -` + `a new table cell` → `-"C" cell a new table cell`,
2663 /// its OTSL list). Verified against the corpus: pure index order beats any
2664 /// geometric re-sort (normal_4pages' heading numerals paint after their
2665 /// text and belong last: `## 들어가며 1`).
2666 #[test]
2667 fn cells_join_in_index_order_with_sanitize_text_rules() {
2668 let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
2669 text: text.to_string(),
2670 l,
2671 t,
2672 r,
2673 b,
2674 };
2675 let region = Region {
2676 label: "text",
2677 score: 1.0,
2678 l: 0.0,
2679 t: 95.0,
2680 r: 200.0,
2681 b: 130.0,
2682 };
2683 // ORCID superscript: bare dash cells keep the dash, take no space after.
2684 let orcid = vec![
2685 cell("[0000", 10.0, 100.0, 30.0, 110.0),
2686 cell("−", 30.0, 100.0, 34.0, 110.0),
2687 cell("0002", 34.0, 100.0, 50.0, 110.0),
2688 cell("−", 50.0, 100.0, 54.0, 110.0),
2689 cell("6960]", 54.0, 100.0, 70.0, 110.0),
2690 ];
2691 assert_eq!(super::region_text(®ion, &orcid), "[0000 -0002 -6960]");
2692 // Wrapped word: dash dropped, lines fused (both boundary words alnum).
2693 let wrapped = vec![
2694 cell("platforms-", 10.0, 100.0, 60.0, 110.0),
2695 cell("reflects the design", 10.0, 112.0, 90.0, 122.0),
2696 ];
2697 assert_eq!(
2698 super::region_text(®ion, &wrapped),
2699 "platformsreflects the design"
2700 );
2701 // Dash-ending line before a quote-opening one: not alnum-adjacent, so
2702 // the dash stays and the lines glue (2305's OTSL list bullets).
2703 let otsl = vec![
2704 cell("–", 10.0, 100.0, 14.0, 110.0),
2705 cell("\"C\" cell -", 16.0, 100.0, 60.0, 110.0),
2706 cell("a new table cell", 10.0, 112.0, 80.0, 122.0),
2707 ];
2708 assert_eq!(
2709 super::region_text(®ion, &otsl),
2710 "-\"C\" cell a new table cell"
2711 );
2712 // Index order is authoritative — no geometric re-sort.
2713 let numeral = vec![
2714 cell("들어가며", 30.0, 100.0, 80.0, 110.0),
2715 cell("1", 10.0, 98.0, 25.0, 112.0), // big numeral painted last
2716 ];
2717 assert_eq!(super::region_text(®ion, &numeral), "들어가며 1");
2718 }
2719
2720 /// The geometric-reliability gate, on the two shapes it has to tell apart.
2721 #[test]
2722 fn geometric_reliability_rejects_split_column_grids() {
2723 let g = |rows: &[&[&str]]| -> Vec<Vec<String>> {
2724 rows.iter()
2725 .map(|r| r.iter().map(|c| c.to_string()).collect())
2726 .collect()
2727 };
2728 // A genuine grid: dense, every column carrying entries. Nothing for
2729 // TableFormer to improve, so geometry is used as-is.
2730 assert!(super::geometric_table_is_reliable(&g(&[
2731 &["Datum", "Leistung", "Anzahl", "Kosten"],
2732 &["04.07", "Internet", "1", "40.30"],
2733 &["04.07", "Telefon", "2", "8.06"],
2734 ])));
2735 // The left-edge split artefact (the shape a scanned invoice produced):
2736 // one real label column plus values scattered across three sparse ones.
2737 assert!(!super::geometric_table_is_reliable(&g(&[
2738 &["www.magenta.at/faq", "", "", ""],
2739 &["Serviceteam", "", "", ""],
2740 &["Telefon", "0676/2000", "", ""],
2741 &["Kundennummer", "", "", "1.21699482"],
2742 &["Rechnungsnummer", "", "922769430725", ""],
2743 &["Rechnungsdatum", "", "", "04.07.2025"],
2744 ])));
2745 // A column only one row ever uses is a split artefact even when the
2746 // grid is otherwise dense.
2747 assert!(!super::geometric_table_is_reliable(&g(&[
2748 &["a", "b", ""],
2749 &["c", "d", ""],
2750 &["e", "f", "g"],
2751 ])));
2752 // Degenerate shapes are never vouched for — TableFormer may recover
2753 // structure a collapsed reconstruction lost.
2754 assert!(!super::geometric_table_is_reliable(&g(&[&[
2755 "only one column"
2756 ]])));
2757 assert!(!super::geometric_table_is_reliable(&[]));
2758 }
2759
2760 /// A `picture` region is cropped out of the rendered page, whatever built
2761 /// that page. The browser pipeline (#157) has no pdfium but does hand over
2762 /// the rasterized bitmap through `from_cells_with_image`, so it must get
2763 /// the same figure bytes the native path does — that is what makes
2764 /// `images = "embedded"` inline real pixels instead of a placeholder.
2765 #[cfg(feature = "ocr-prep")]
2766 #[test]
2767 fn picture_regions_are_cropped_from_a_host_supplied_page_image() {
2768 let mut img = image::RgbImage::new(200, 200);
2769 // Paint the figure area so the crop is distinguishable from the page.
2770 for y in 100..160 {
2771 for x in 20..120 {
2772 img.put_pixel(x, y, image::Rgb([255, 0, 0]));
2773 }
2774 }
2775 // scale 2.0: the region is in page points, the bitmap in pixels.
2776 let page = PdfPage::from_cells_with_image(100.0, 100.0, 2.0, Vec::new(), img);
2777 let region = Region {
2778 label: "picture",
2779 score: 0.9,
2780 l: 10.0,
2781 t: 50.0,
2782 r: 60.0,
2783 b: 80.0,
2784 };
2785 let (nodes, _) = super::assemble_page(&page, vec![region], &[None], &[None]);
2786 // Layout-derived nodes carry provenance, so the picture arrives wrapped.
2787 let image = nodes
2788 .iter()
2789 .find_map(|n| match n {
2790 Node::Located { inner, .. } => match &**inner {
2791 Node::Picture { image, .. } => image.as_ref(),
2792 _ => None,
2793 },
2794 Node::Picture { image, .. } => image.as_ref(),
2795 _ => None,
2796 })
2797 .expect("a picture node with cropped pixels");
2798 assert_eq!(image.mimetype, "image/png");
2799 assert_eq!((image.width, image.height), (100, 60), "region × scale");
2800 assert!(!image.data.is_empty(), "PNG bytes were encoded");
2801 }
2802
2803 #[test]
2804 fn link_anchors_split_a_shared_word_cell_between_adjacent_links() {
2805 // A common header layout: one text run holds several pipe-separated
2806 // labels, each carrying its own link annotation. Every link must get
2807 // its own label as the anchor (and the "|" separators must belong to
2808 // none), not the whole run.
2809 let annot = |l: f32, r: f32, uri: &str| LinkAnnot {
2810 l,
2811 t: 100.0,
2812 r,
2813 b: 114.0,
2814 uri: uri.into(),
2815 };
2816 let page = PdfPage {
2817 width: 600.0,
2818 height: 800.0,
2819 scale: 2.0,
2820 cells: Vec::new(),
2821 code_cells: Vec::new(),
2822 // "LinkedIn | GitHub | Credly" = 26 chars over x 100..360.
2823 word_cells: vec![cell(
2824 "LinkedIn | GitHub | Credly",
2825 100.0,
2826 100.0,
2827 360.0,
2828 114.0,
2829 )],
2830 image: image::RgbImage::new(1, 1),
2831 image_layout: None,
2832 links: vec![
2833 annot(100.0, 180.0, "https://l"),
2834 annot(200.0, 260.0, "https://g"),
2835 annot(290.0, 360.0, "https://c"),
2836 ],
2837 rotation: 0,
2838 };
2839 assert_eq!(
2840 resolve_link_anchors(&page),
2841 vec![
2842 ("LinkedIn".to_string(), "https://l".to_string()),
2843 ("GitHub".to_string(), "https://g".to_string()),
2844 ("Credly".to_string(), "https://c".to_string()),
2845 ]
2846 );
2847 }
2848
2849 /// A one-line code cell at `[l, r] × [t, b]` (top-left coords).
2850 fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
2851 TextCell {
2852 text: text.into(),
2853 l,
2854 t,
2855 r,
2856 b,
2857 }
2858 }
2859
2860 fn region(label: &'static str, score: f32, l: f32, t: f32, r: f32, b: f32) -> Region {
2861 Region {
2862 label,
2863 score,
2864 l,
2865 t,
2866 r,
2867 b,
2868 }
2869 }
2870
2871 #[test]
2872 fn resolve_collapses_nested_code_keeping_the_larger_box() {
2873 // A tight high-score `code` box and a taller lower-score near-duplicate that
2874 // contains it must collapse to one — the *larger* box, so every cell stays
2875 // covered and nothing leaks out as orphan text.
2876 let tight = region("code", 0.95, 78.0, 292.0, 300.0, 330.0);
2877 let wide = region("code", 0.66, 63.0, 260.0, 320.0, 346.0);
2878 let kept = super::resolve(vec![tight, wide]);
2879 assert_eq!(kept.len(), 1, "nested code boxes must collapse to one");
2880 assert!(
2881 kept[0].l == 63.0 && kept[0].b == 346.0,
2882 "the larger containing box is kept"
2883 );
2884 }
2885
2886 #[test]
2887 fn resolve_keeps_distinct_and_differently_typed_regions() {
2888 // A text box fully inside a lower-score *table* must NOT be collapsed (the
2889 // code dedup is code-only), and two separate code blocks stay separate.
2890 let text = region("text", 0.95, 90.0, 210.0, 200.0, 230.0);
2891 let table = region("table", 0.60, 80.0, 200.0, 400.0, 500.0);
2892 assert_eq!(super::resolve(vec![text, table]).len(), 2);
2893
2894 let code_a = region("code", 0.9, 78.0, 100.0, 300.0, 140.0);
2895 let code_b = region("code", 0.9, 78.0, 300.0, 300.0, 360.0); // far below, no overlap
2896 assert_eq!(super::resolve(vec![code_a, code_b]).len(), 2);
2897 }
2898
2899 #[test]
2900 fn code_language_label_above_code_is_detected() {
2901 // A bare "XML" token directly above a code box is a language label; a real
2902 // heading above the same code is not; a language word with no code below is
2903 // left alone.
2904 let label = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
2905 let code = region("code", 0.7, 77.0, 552.0, 290.0, 640.0);
2906 let heading = region("section_header", 0.9, 76.0, 500.0, 260.0, 512.0);
2907 let cells = vec![
2908 cell("XML", 78.0, 541.0, 94.0, 548.0), // inside `label`
2909 cell("Overview", 78.0, 501.0, 250.0, 511.0), // inside `heading`
2910 ];
2911 let drop = super::code_language_labels(&[label, code, heading], &cells);
2912 assert_eq!(drop, vec![true, false, false], "only the label is consumed");
2913
2914 // Same label with no code region present → not consumed.
2915 let label2 = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
2916 let only = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
2917 assert_eq!(super::code_language_labels(&[label2], &only), vec![false]);
2918
2919 // A label swallowed into the top of a wider code box (negative gap) is still
2920 // recognized.
2921 let inside_lbl = region("text", 0.9, 76.0, 540.0, 96.0, 549.0);
2922 let wide_code = region("code", 0.7, 63.0, 531.0, 320.0, 654.0);
2923 let cells2 = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
2924 assert_eq!(
2925 super::code_language_labels(&[inside_lbl, wide_code], &cells2),
2926 vec![true, false]
2927 );
2928
2929 assert!(super::is_code_language("XML") && super::is_code_language("c#"));
2930 assert!(!super::is_code_language("Configure") && !super::is_code_language("XML schema"));
2931 }
2932
2933 #[test]
2934 fn code_region_text_keeps_lines_and_indentation() {
2935 // Three source lines; each glyph is 6 units wide (width / chars = 6), so the
2936 // `int X;` line indented to x=22 is (22-10)/6 = 2 spaces in.
2937 let region = Region {
2938 label: "code",
2939 score: 1.0,
2940 l: 0.0,
2941 t: -5.0,
2942 r: 100.0,
2943 b: 40.0,
2944 };
2945 let cells = vec![
2946 cell("struct P {", 10.0, 0.0, 70.0, 10.0),
2947 cell("int X;", 22.0, 12.0, 58.0, 22.0),
2948 cell("}", 10.0, 24.0, 16.0, 34.0),
2949 ];
2950 assert_eq!(code_region_text(®ion, &cells), "struct P {\n int X;\n}");
2951 }
2952
2953 #[test]
2954 fn code_region_text_tightens_punctuation_without_eating_indentation() {
2955 // A fluent `.Foo()` line at x=22 (2 chars in). Per-line tightening must not
2956 // consume the leading indent space by matching " ." across it.
2957 let region = Region {
2958 label: "code",
2959 score: 1.0,
2960 l: 0.0,
2961 t: -5.0,
2962 r: 100.0,
2963 b: 40.0,
2964 };
2965 let cells = vec![
2966 cell("builder", 10.0, 0.0, 52.0, 10.0),
2967 // pdfium spaced the call: ".Foo (x)" tightens to ".Foo(x)", still 2-indented.
2968 cell(".Foo (x)", 22.0, 12.0, 70.0, 22.0),
2969 ];
2970 assert_eq!(code_region_text(®ion, &cells), "builder\n .Foo(x)");
2971 }
2972
2973 #[test]
2974 fn code_region_text_orders_out_of_order_cells_and_ignores_blank_lines() {
2975 let region = Region {
2976 label: "code",
2977 score: 1.0,
2978 l: 0.0,
2979 t: -5.0,
2980 r: 100.0,
2981 b: 60.0,
2982 };
2983 // Fed bottom-up and with a whitespace-only cell; output is top-down, no blank.
2984 let cells = vec![
2985 cell("b();", 10.0, 24.0, 34.0, 34.0),
2986 cell(" ", 10.0, 12.0, 20.0, 22.0),
2987 cell("a();", 10.0, 0.0, 34.0, 10.0),
2988 ];
2989 assert_eq!(code_region_text(®ion, &cells), "a();\nb();");
2990 // No code cells → empty, so the caller falls back to the prose text.
2991 assert_eq!(code_region_text(®ion, &[]), "");
2992 }
2993
2994 fn para(text: &str) -> Node {
2995 Node::Paragraph { text: text.into() }
2996 }
2997
2998 /// Run a node sequence through [`StreamAssembler`] with the given page splits
2999 /// and assert the flushed result equals one-shot [`merge_continuations`].
3000 fn assert_stream_eq(nodes: &[Node], splits: &[usize]) {
3001 let mut want = nodes.to_vec();
3002 merge_continuations(&mut want);
3003
3004 let mut asm = StreamAssembler::new();
3005 let mut got = Vec::new();
3006 let mut start = 0;
3007 for &end in splits {
3008 got.extend(asm.push(nodes[start..end].to_vec()));
3009 start = end;
3010 }
3011 got.extend(asm.push(nodes[start..].to_vec()));
3012 got.extend(asm.finish());
3013 assert_eq!(got, want, "stream assembly diverged (splits={splits:?})");
3014 }
3015
3016 #[test]
3017 fn stream_assembler_matches_merge_continuations() {
3018 // Open fragment + lowercase continuation split across a page boundary.
3019 let cross = [para("the definition of"), para("lists in scope")];
3020 assert_stream_eq(&cross, &[1]);
3021 assert_stream_eq(&cross, &[]);
3022
3023 // Continuation that wraps around a figure (+ its caption) on the boundary.
3024 let wrap = [
3025 para("the wing type that is"),
3026 Node::Picture {
3027 caption: None,
3028 image: None,
3029 classification: None,
3030 },
3031 para("Fig. 1. a diagram"),
3032 para("the most common kind"),
3033 ];
3034 for splits in [&[][..], &[1][..], &[2][..], &[3][..], &[1, 3][..]] {
3035 assert_stream_eq(&wrap, splits);
3036 }
3037
3038 // A heading between fragments blocks the merge (must still flush correctly).
3039 let blocked = [
3040 para("ends mid word and"),
3041 Node::Heading {
3042 level: 2,
3043 text: "New Section".into(),
3044 },
3045 para("more body here"),
3046 ];
3047 for splits in [&[][..], &[1][..], &[2][..]] {
3048 assert_stream_eq(&blocked, splits);
3049 }
3050
3051 // A chain across three pages: each page is one open lowercase fragment.
3052 let chain = [
3053 para("alpha beta"),
3054 para("gamma delta"),
3055 para("epsilon zeta"),
3056 ];
3057 assert_stream_eq(&chain, &[1, 2]);
3058 }
3059
3060 #[test]
3061 fn clean_text_dehyphenates_and_normalizes_typography() {
3062 // U+0002 line-wrap hyphen + the join space → merged word (like docling).
3063 assert_eq!(clean_text("com\u{2} pact"), "compact");
3064 assert_eq!(clean_text("end-to\u{2} end deep"), "end-toend deep");
3065 // A stray wrap hyphen (no following join) is dropped.
3066 assert_eq!(clean_text("word\u{2}"), "word");
3067 // Typographic punctuation → ASCII: every curly quote becomes `'`
3068 // (docling-parse's sanitizer table), a literal `"` stays.
3069 assert_eq!(
3070 clean_text("Graph\u{2019}s \u{201c}x\u{201d} \"y\""),
3071 "Graph's 'x' \"y\""
3072 );
3073 assert_eq!(clean_text("a\u{2026}"), "a...");
3074 // The dp default (the docling-parse sanitizer) preserves internal spacing
3075 // it placed deliberately; line breaks/tabs normalize to a space, ends trim.
3076 assert_eq!(clean_text("a b\nc"), "a b c");
3077 }
3078
3079 #[test]
3080 fn lam_alef_only_swaps_a_genuinely_reversed_ligature() {
3081 // A mid-word `alef-variant + lam` is pdfium's reversed lam-alef ligature and
3082 // is swapped back to logical `lam + alef-variant` (`ب أ ل` → `ب ل أ`).
3083 assert_eq!(
3084 clean_text("\u{0628}\u{0623}\u{0644}"),
3085 "\u{0628}\u{0644}\u{0623}"
3086 );
3087 // But when the alef-variant is *already* preceded by a lam it is the logical
3088 // ligature `لآ`; the following lam is the next syllable's letter and must not
3089 // move. `التعلم الآلي` must stay `الآلي`, not become `اللآي`.
3090 assert_eq!(
3091 clean_text("\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"),
3092 "\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"
3093 );
3094 }
3095}