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