1use crate::oarocr::TextRegion;
15use oar_ocr_core::domain::structure::{
16 FormulaResult, LayoutElement, LayoutElementType, StructureResult, TableCell, TableResult,
17};
18use oar_ocr_core::processors::{
19 BoundingBox, SplitConfig as OcrSplitConfig, create_expanded_ocr_for_table, parse_cell_grid_info,
20};
21use std::cmp::Ordering;
22
23#[derive(Clone, Copy, Debug)]
26enum OcrSource {
27 Split,
29 Original(usize),
31}
32
33const EXCLUDED_FROM_OCR_LABELS: [LayoutElementType; 2] =
41 [LayoutElementType::Table, LayoutElementType::Seal];
42
43#[derive(Clone)]
44pub struct StitchConfig {
45 pub overlap_min_pixels: f32,
46 pub cell_text_min_ioa: f32,
47 pub require_text_center_inside_cell: bool,
48 pub cell_merge_min_iou: f32,
49 pub formula_to_cell_min_iou: f32,
50 pub same_line_y_tolerance: f32,
52 pub line_height_iou_threshold: f32,
54 pub enable_cross_cell_split: bool,
58}
59
60impl Default for StitchConfig {
61 fn default() -> Self {
62 Self {
63 overlap_min_pixels: 3.0,
64 cell_text_min_ioa: 0.6,
65 require_text_center_inside_cell: true,
66 cell_merge_min_iou: 0.3,
67 formula_to_cell_min_iou: 0.01,
68 same_line_y_tolerance: 10.0,
69 line_height_iou_threshold: 0.6,
70 enable_cross_cell_split: true,
71 }
72 }
73}
74
75pub struct ResultStitcher;
77
78impl ResultStitcher {
79 pub fn stitch(result: &mut StructureResult) {
88 let cfg = StitchConfig::default();
89 Self::stitch_with_config(result, &cfg);
90 }
91
92 pub fn stitch_with_config(result: &mut StructureResult, cfg: &StitchConfig) {
93 let mut used_region_indices = std::collections::HashSet::new();
95
96 let mut regions = result.text_regions.clone().unwrap_or_default();
98
99 tracing::debug!("Stitching: {} text regions", regions.len());
100
101 Self::stitch_tables(
106 &mut result.tables,
107 ®ions,
108 &result.formulas,
109 &mut used_region_indices,
110 cfg,
111 );
112
113 tracing::debug!(
114 "After stitch_tables: {} regions used",
115 used_region_indices.len()
116 );
117
118 Self::fill_formula_elements(&mut result.layout_elements, &result.formulas, cfg);
121
122 Self::inject_inline_formulas(&mut result.layout_elements, &mut regions, cfg);
128
129 Self::stitch_layout_elements(
133 &mut result.layout_elements,
134 ®ions,
135 &mut used_region_indices,
136 cfg,
137 !result.formulas.is_empty(),
138 );
139
140 tracing::debug!(
141 "After stitch_layout_elements: {} regions used",
142 used_region_indices.len()
143 );
144
145 for element in &result.layout_elements {
156 if element.element_type == LayoutElementType::Seal {
157 for (idx, region) in regions.iter().enumerate() {
158 if Self::is_overlapping(&element.bbox, ®ion.bounding_box, cfg) {
159 used_region_indices.insert(idx);
160 }
161 }
162 }
163 }
164
165 let table_bboxes: Vec<&BoundingBox> = result
170 .layout_elements
171 .iter()
172 .filter(|e| e.element_type == LayoutElementType::Table)
173 .map(|e| &e.bbox)
174 .collect();
175
176 let image_chart_bboxes: Vec<&BoundingBox> = result
177 .layout_elements
178 .iter()
179 .filter(|e| {
180 matches!(
181 e.element_type,
182 LayoutElementType::Image | LayoutElementType::Chart
183 )
184 })
185 .map(|e| &e.bbox)
186 .collect();
187
188 let figure_caption_bboxes: Vec<&BoundingBox> = result
193 .layout_elements
194 .iter()
195 .filter(|e| {
196 matches!(
197 e.element_type,
198 LayoutElementType::FigureTitle
199 | LayoutElementType::ChartTitle
200 | LayoutElementType::FigureTableChartTitle
201 )
202 })
203 .map(|e| &e.bbox)
204 .collect();
205
206 let content_element_bboxes: Vec<&BoundingBox> = result
209 .layout_elements
210 .iter()
211 .filter(|e| {
212 matches!(
213 e.element_type,
214 LayoutElementType::Text
215 | LayoutElementType::DocTitle
216 | LayoutElementType::ParagraphTitle
217 | LayoutElementType::Abstract
218 )
219 })
220 .map(|e| &e.bbox)
221 .collect();
222
223 let original_element_count = result.layout_elements.len();
224 let mut new_elements = Vec::new();
225 for (idx, region) in regions.iter().enumerate() {
226 if !used_region_indices.contains(&idx)
227 && let Some(text) = ®ion.text
228 {
229 let overlaps_table = table_bboxes
232 .iter()
233 .any(|table_bbox| region.bounding_box.ioa(table_bbox) > 0.3);
234
235 if overlaps_table {
236 continue;
238 }
239
240 let overlaps_image_chart = image_chart_bboxes
242 .iter()
243 .any(|bbox| region.bounding_box.ioa(bbox) > 0.5);
244
245 if overlaps_image_chart {
246 continue;
247 }
248
249 let in_inferred_figure_region = figure_caption_bboxes.iter().any(|cap| {
255 let orphan_bb = ®ion.bounding_box;
256 let above_caption = orphan_bb.y_max() < cap.y_max();
258 let x_margin = (cap.x_max() - cap.x_min()) * 0.1;
260 let in_x_range = orphan_bb.x_min() >= (cap.x_min() - x_margin)
261 && orphan_bb.x_max() <= (cap.x_max() + x_margin);
262 above_caption && in_x_range
263 });
264
265 if in_inferred_figure_region {
266 let inside_content_element = content_element_bboxes
268 .iter()
269 .any(|bbox| region.bounding_box.ioa(bbox) > 0.5);
270 if !inside_content_element {
271 continue;
272 }
273 }
274
275 let element_type = if region.is_formula() {
279 LayoutElementType::Formula
280 } else {
281 LayoutElementType::Text
282 };
283
284 let element = LayoutElement::new(
285 region.bounding_box.clone(),
286 element_type,
287 region.confidence.unwrap_or(0.0),
288 )
289 .with_text(text.as_ref().to_string());
290
291 new_elements.push(element);
292 }
293 }
294
295 if let Some(ref mut region_blocks) = result.region_blocks {
298 for (new_idx, new_element) in new_elements.iter().enumerate() {
299 let element_index = original_element_count + new_idx;
300
301 let mut best_region_idx: Option<usize> = None;
303 let mut best_overlap = 0.0f32;
304
305 for (region_idx, region) in region_blocks.iter().enumerate() {
306 let overlap = new_element.bbox.intersection_area(®ion.bbox);
308 if overlap > best_overlap {
309 best_overlap = overlap;
310 best_region_idx = Some(region_idx);
311 }
312 }
313
314 if let Some(region_idx) = best_region_idx {
316 region_blocks[region_idx]
317 .element_indices
318 .push(element_index);
319 }
320 }
321 }
322
323 result.layout_elements.extend(new_elements);
324
325 let width = if let Some(img) = &result.rectified_img {
329 img.width() as f32
330 } else {
331 result
333 .layout_elements
334 .iter()
335 .map(|e| e.bbox.x_max())
336 .fold(0.0f32, f32::max)
337 .max(1000.0) };
339
340 if result.region_blocks.is_none() {
344 let height = if let Some(img) = &result.rectified_img {
345 img.height() as f32
346 } else {
347 result
348 .layout_elements
349 .iter()
350 .map(|e| e.bbox.y_max())
351 .fold(0.0f32, f32::max)
352 .max(1000.0)
353 };
354 Self::sort_layout_elements_enhanced(&mut result.layout_elements, width, height);
355 }
356
357 Self::assign_order_indices(&mut result.layout_elements);
359 }
360
361 fn assign_order_indices(elements: &mut [LayoutElement]) {
366 let mut order_index = 1u32;
367 for element in elements.iter_mut() {
368 if Self::should_have_order_index(element.element_type) {
371 element.order_index = Some(order_index);
372 order_index += 1;
373 }
374 }
375 }
376
377 fn should_have_order_index(element_type: LayoutElementType) -> bool {
381 matches!(
382 element_type,
383 LayoutElementType::Text
384 | LayoutElementType::Content
385 | LayoutElementType::Abstract
386 | LayoutElementType::DocTitle
387 | LayoutElementType::ParagraphTitle
388 | LayoutElementType::Table
389 | LayoutElementType::Image
390 | LayoutElementType::Chart
391 | LayoutElementType::Formula
392 | LayoutElementType::Seal
393 | LayoutElementType::Reference
394 | LayoutElementType::ReferenceContent
395 | LayoutElementType::List
396 | LayoutElementType::FigureTitle
397 | LayoutElementType::TableTitle
398 | LayoutElementType::ChartTitle
399 | LayoutElementType::FigureTableChartTitle
400 )
401 }
402
403 fn stitch_tables(
404 tables: &mut [TableResult],
405 text_regions: &[TextRegion],
406 formulas: &[FormulaResult],
407 used_indices: &mut std::collections::HashSet<usize>,
408 cfg: &StitchConfig,
409 ) {
410 for (table_idx, table) in tables.iter_mut().enumerate() {
411 if table.cells.is_empty() {
412 continue;
413 }
414 let has_detected_cells = table.detected_cell_bboxes.is_some();
417 let e2e_like_cells = table.is_e2e && !has_detected_cells;
418
419 let table_bbox = table.bbox.clone(); let relevant_indices: Vec<usize> = text_regions
422 .iter()
423 .enumerate()
424 .filter(|(idx, region)| {
425 !used_indices.contains(idx)
426 && Self::is_overlapping(&table_bbox, ®ion.bounding_box, cfg)
427 })
428 .map(|(idx, _)| idx)
429 .collect();
430
431 let (split_regions, split_ocr_indices, _split_cell_assignments) =
435 if cfg.enable_cross_cell_split && !e2e_like_cells {
436 Self::split_cross_cell_ocr_boxes(text_regions, &relevant_indices, &table.cells)
437 } else {
438 (
439 Vec::new(),
440 std::collections::HashSet::new(),
441 std::collections::HashMap::new(),
442 )
443 };
444
445 let mut ocr_candidates: Vec<(OcrSource, TextRegion)> = Vec::new();
447
448 for region in &split_regions {
449 let mut normalized_region = region.clone();
450 Self::normalize_tiny_symbol_for_paddlex(&mut normalized_region);
451
452 if normalized_region
453 .text
454 .as_ref()
455 .map(|t| !t.trim().is_empty())
456 .unwrap_or(false)
457 {
458 ocr_candidates.push((OcrSource::Split, normalized_region));
459 }
460 }
461
462 for &ocr_idx in &relevant_indices {
464 if split_ocr_indices.contains(&ocr_idx) {
465 used_indices.insert(ocr_idx);
466 continue;
467 }
468
469 if let Some(region) = text_regions.get(ocr_idx) {
470 let mut normalized_region = region.clone();
471 Self::normalize_tiny_symbol_for_paddlex(&mut normalized_region);
472
473 if normalized_region
474 .text
475 .as_ref()
476 .map(|t| !t.trim().is_empty())
477 .unwrap_or(false)
478 {
479 ocr_candidates.push((OcrSource::Original(ocr_idx), normalized_region));
480 }
481 }
482 }
483
484 for formula in formulas {
488 let w = formula.bbox.x_max() - formula.bbox.x_min();
489 let h = formula.bbox.y_max() - formula.bbox.y_min();
490 if w <= 1.0 || h <= 1.0 {
491 continue;
492 }
493 if !Self::is_overlapping(&table_bbox, &formula.bbox, cfg) {
494 continue;
495 }
496 let latex = &formula.latex;
497 let formatted = if latex.starts_with('$') && latex.ends_with('$') {
498 latex.clone()
499 } else {
500 format!("${}$", latex)
501 };
502 let mut formula_region = TextRegion::new(formula.bbox.clone());
503 formula_region.text = Some(formatted.into());
504 formula_region.confidence = Some(1.0);
505 ocr_candidates.push((OcrSource::Split, formula_region));
506 }
507
508 let structure_tokens = table.structure_tokens.clone();
509
510 let mut td_to_cell_mapping: Option<Vec<Option<usize>>> = None;
513 if !e2e_like_cells
514 && let Some(tokens) = structure_tokens.as_deref()
515 && !ocr_candidates.is_empty()
516 && let Some((mapping, matched_candidate_indices)) =
517 Self::match_table_cells_with_structure_rows(
518 &mut table.cells,
519 tokens,
520 &ocr_candidates,
521 cfg.same_line_y_tolerance,
522 table.detected_cell_bboxes.as_deref(),
523 )
524 {
525 td_to_cell_mapping = Some(mapping);
526 for matched_idx in matched_candidate_indices {
527 if let Some((OcrSource::Original(region_idx), _)) =
528 ocr_candidates.get(matched_idx)
529 {
530 used_indices.insert(*region_idx);
531 }
532 }
533 }
534
535 if td_to_cell_mapping.is_none() {
537 let (cell_to_ocr, matched_candidate_indices) =
538 Self::match_table_and_ocr_by_iou_distance(
539 &table.cells,
540 &ocr_candidates,
541 !e2e_like_cells, e2e_like_cells, );
544
545 for matched_idx in matched_candidate_indices {
546 if let Some((OcrSource::Original(region_idx), _)) =
547 ocr_candidates.get(matched_idx)
548 {
549 used_indices.insert(*region_idx);
550 }
551 }
552
553 for (cell_idx, cell) in table.cells.iter_mut().enumerate() {
554 let has_text = cell
555 .text
556 .as_ref()
557 .map(|t| !t.trim().is_empty())
558 .unwrap_or(false);
559 if has_text {
560 continue;
561 }
562
563 if let Some(candidate_indices) = cell_to_ocr.get(&cell_idx) {
564 if e2e_like_cells {
565 let joined = Self::join_ocr_texts_paddlex_style(
566 candidate_indices,
567 &ocr_candidates,
568 );
569 if !joined.is_empty() {
570 cell.text = Some(joined);
571 }
572 } else {
573 let mut cell_text_regions: Vec<(&TextRegion, &str)> = candidate_indices
574 .iter()
575 .filter_map(|&idx| {
576 ocr_candidates
577 .get(idx)
578 .and_then(|(_, r)| r.text.as_deref().map(|t| (r, t)))
579 })
580 .collect();
581
582 Self::sort_and_join_texts(
583 &mut cell_text_regions,
584 Some(&cell.bbox),
585 cfg,
586 |joined| {
587 if !joined.is_empty() {
588 cell.text = Some(joined);
589 }
590 },
591 );
592 }
593 }
594 }
595 }
596
597 Self::normalize_checkbox_symbols_in_table(&mut table.cells);
604
605 if let Some(tokens) = structure_tokens.as_deref() {
607 let cell_texts: Vec<Option<String>> =
608 if let Some(ref td_mapping) = td_to_cell_mapping {
609 td_mapping
611 .iter()
612 .map(|cell_idx| {
613 cell_idx
614 .and_then(|idx| table.cells.get(idx))
615 .and_then(|cell| cell.text.clone())
616 })
617 .collect()
618 } else {
619 Self::collect_cell_texts_for_tokens(&table.cells, tokens)
623 };
624
625 let html_structure =
626 crate::processors::wrap_table_html_with_content(tokens, &cell_texts);
627 table.html_structure = Some(html_structure);
628 table.cell_texts = Some(cell_texts);
629 }
630
631 tracing::debug!("Table {}: matching complete.", table_idx);
632 }
633 }
634
635 fn match_table_and_ocr_by_iou_distance(
641 cells: &[TableCell],
642 ocr_candidates: &[(OcrSource, TextRegion)],
643 require_positive_iou: bool,
644 use_paddlex_distance: bool,
645 ) -> (
646 std::collections::HashMap<usize, Vec<usize>>,
647 std::collections::HashSet<usize>,
648 ) {
649 let mut cell_to_ocr: std::collections::HashMap<usize, Vec<usize>> =
650 std::collections::HashMap::new();
651 let mut matched_candidate_indices = std::collections::HashSet::new();
652
653 if cells.is_empty() || ocr_candidates.is_empty() {
654 return (cell_to_ocr, matched_candidate_indices);
655 }
656
657 for (candidate_idx, (_, region)) in ocr_candidates.iter().enumerate() {
658 let ocr_bbox = ®ion.bounding_box;
659
660 let ocr_cx = (ocr_bbox.x_min() + ocr_bbox.x_max()) / 2.0;
665 let ocr_cy = (ocr_bbox.y_min() + ocr_bbox.y_max()) / 2.0;
666 let center_cell = cells.iter().enumerate().find(|(_, cell)| {
667 ocr_cx >= cell.bbox.x_min()
668 && ocr_cx <= cell.bbox.x_max()
669 && ocr_cy >= cell.bbox.y_min()
670 && ocr_cy <= cell.bbox.y_max()
671 && ocr_bbox.ioa(&cell.bbox) > 0.7
672 });
673
674 if let Some((cell_idx, _)) = center_cell {
675 cell_to_ocr.entry(cell_idx).or_default().push(candidate_idx);
676 matched_candidate_indices.insert(candidate_idx);
677 continue;
678 }
679
680 let mut best_cell_idx: Option<usize> = None;
682 let mut min_cost = (f32::MAX, f32::MAX);
683 let mut candidate_costs: Vec<(usize, (f32, f32))> = Vec::new();
684
685 for (cell_idx, cell) in cells.iter().enumerate() {
686 let iou = Self::calculate_iou(®ion.bounding_box, &cell.bbox);
687 if require_positive_iou && iou <= 0.0 {
688 continue;
689 }
690
691 let dist = if use_paddlex_distance {
692 Self::paddlex_distance(&cell.bbox, ®ion.bounding_box)
693 } else {
694 Self::l1_distance(®ion.bounding_box, &cell.bbox)
695 };
696 let cost = (1.0 - iou, dist);
697 candidate_costs.push((cell_idx, cost));
698 if Self::is_better_paddlex_match_cost(cost, min_cost, cell_idx, best_cell_idx) {
699 min_cost = cost;
700 best_cell_idx = Some(cell_idx);
701 }
702 }
703
704 if let Some(mut cell_idx) = best_cell_idx {
705 if use_paddlex_distance {
706 cell_idx = Self::maybe_prefer_upper_boundary_cell(
707 cells,
708 ®ion.bounding_box,
709 cell_idx,
710 min_cost,
711 &candidate_costs,
712 );
713 }
714 cell_to_ocr.entry(cell_idx).or_default().push(candidate_idx);
715 matched_candidate_indices.insert(candidate_idx);
716 }
717 }
718
719 (cell_to_ocr, matched_candidate_indices)
720 }
721
722 fn is_better_paddlex_match_cost(
728 candidate_cost: (f32, f32),
729 current_cost: (f32, f32),
730 candidate_idx: usize,
731 current_idx: Option<usize>,
732 ) -> bool {
733 const COST_EPS: f32 = 1e-4;
734
735 if !candidate_cost.0.is_finite() || !candidate_cost.1.is_finite() {
737 return false;
738 }
739
740 if !current_cost.0.is_finite() || !current_cost.1.is_finite() || current_idx.is_none() {
742 return true;
743 }
744
745 if candidate_cost.0 + COST_EPS < current_cost.0 {
746 return true;
747 }
748 if (candidate_cost.0 - current_cost.0).abs() <= COST_EPS {
749 if candidate_cost.1 + COST_EPS < current_cost.1 {
750 return true;
751 }
752 if (candidate_cost.1 - current_cost.1).abs() <= COST_EPS
753 && let Some(existing_idx) = current_idx
754 {
755 return candidate_idx < existing_idx;
756 }
757 }
758
759 false
760 }
761
762 fn maybe_prefer_upper_boundary_cell(
770 cells: &[TableCell],
771 ocr_box: &BoundingBox,
772 best_cell_idx: usize,
773 best_cost: (f32, f32),
774 candidate_costs: &[(usize, (f32, f32))],
775 ) -> usize {
776 const BOUNDARY_COST_IOU_DELTA: f32 = 0.12;
777 const BOUNDARY_OVERLAP_MIN: f32 = 0.35;
778
779 let Some(best_cell) = cells.get(best_cell_idx) else {
780 return best_cell_idx;
781 };
782 let (Some(best_row), Some(best_col)) = (best_cell.row, best_cell.col) else {
783 return best_cell_idx;
784 };
785 if best_row == 0 {
786 return best_cell_idx;
787 }
788
789 let upper_cell_idx = cells
790 .iter()
791 .position(|cell| cell.row == Some(best_row - 1) && cell.col == Some(best_col));
792 let Some(upper_cell_idx) = upper_cell_idx else {
793 return best_cell_idx;
794 };
795
796 let boundary_y = best_cell.bbox.y_min();
797 if !(ocr_box.y_min() < boundary_y && ocr_box.y_max() > boundary_y) {
798 return best_cell_idx;
799 }
800
801 let best_inter = Self::compute_inter(&best_cell.bbox, ocr_box);
802 let Some(upper_cell) = cells.get(upper_cell_idx) else {
803 return best_cell_idx;
804 };
805 let upper_inter = Self::compute_inter(&upper_cell.bbox, ocr_box);
806 if best_inter < BOUNDARY_OVERLAP_MIN || upper_inter < BOUNDARY_OVERLAP_MIN {
807 return best_cell_idx;
808 }
809
810 let upper_cost = candidate_costs
811 .iter()
812 .find_map(|(idx, cost)| (*idx == upper_cell_idx).then_some(*cost));
813 let Some(upper_cost) = upper_cost else {
814 return best_cell_idx;
815 };
816 if !upper_cost.0.is_finite() || !upper_cost.1.is_finite() {
817 return best_cell_idx;
818 }
819
820 if upper_cost.0 <= best_cost.0 + BOUNDARY_COST_IOU_DELTA {
821 upper_cell_idx
822 } else {
823 best_cell_idx
824 }
825 }
826
827 fn normalize_tiny_symbol_for_paddlex(region: &mut TextRegion) {
832 let Some(text) = region.text.as_deref() else {
833 return;
834 };
835 if text.chars().count() != 1 {
836 return;
837 }
838 let Some(score) = region.confidence else {
839 return;
840 };
841
842 let width = (region.bounding_box.x_max() - region.bounding_box.x_min()).max(0.0);
843 let height = (region.bounding_box.y_max() - region.bounding_box.y_min()).max(0.0);
844
845 let replacement = if text == "=" && score < 0.45 && width <= 9.5 && height <= 7.5 {
846 Some(",")
847 } else if text == "=" && score < 0.45 && width <= 12.5 && height > 7.5 && height <= 10.5 {
848 Some("-")
849 } else if text == "0" && score < 0.20 && width <= 14.5 && height <= 14.5 {
850 Some(";")
851 } else {
852 None
853 };
854
855 if let Some(value) = replacement {
856 region.text = Some(std::sync::Arc::<str>::from(value));
857 }
858 }
859
860 fn normalize_checkbox_symbols_in_table(cells: &mut [TableCell]) {
861 let mut has_positive_candidate = false;
862 let mut has_negative_candidate = false;
863
864 for cell in cells.iter() {
865 let Some(text) = cell.text.as_deref() else {
866 continue;
867 };
868 let trimmed = text.trim();
869 if trimmed.chars().count() != 1 {
870 continue;
871 }
872 match trimmed.chars().next().unwrap_or_default() {
873 '✓' | 'ü' | 'Ü' | 'L' | '√' | '☑' => has_positive_candidate = true,
874 '✗' | 'X' | 'x' | '✕' | '✖' | '☒' => has_negative_candidate = true,
875 _ => {}
876 }
877 }
878
879 for cell in cells.iter_mut() {
880 let Some(text) = cell.text.clone() else {
881 continue;
882 };
883 let trimmed = text.trim();
884 if trimmed.chars().count() != 1 {
885 continue;
886 }
887 let mapped = match trimmed.chars().next().unwrap_or_default() {
888 'ü' | 'Ü' | '√' | '☑' => Some("✓"),
890 'L' if has_positive_candidate && has_negative_candidate => Some("✓"),
892 '✕' | '✖' | '☒' => Some("✗"),
894 'X' | 'x' if has_positive_candidate && has_negative_candidate => Some("✗"),
896 _ => None,
897 };
898
899 if let Some(symbol) = mapped {
900 cell.text = Some(symbol.to_string());
901 }
902 }
903 }
904
905 fn join_ocr_texts_paddlex_style(
907 candidate_indices: &[usize],
908 ocr_candidates: &[(OcrSource, TextRegion)],
909 ) -> String {
910 let mut joined = String::new();
911
912 for (i, &candidate_idx) in candidate_indices.iter().enumerate() {
913 let Some((_, region)) = ocr_candidates.get(candidate_idx) else {
914 continue;
915 };
916 let Some(text) = region.text.as_deref() else {
917 continue;
918 };
919
920 let mut content = text.to_string();
921 if candidate_indices.len() > 1 {
922 if content.is_empty() {
923 continue;
924 }
925 if content.starts_with(' ') {
926 content = content[1..].to_string();
927 }
928 if content.starts_with("<b>") {
929 content = content[3..].to_string();
930 }
931 if content.ends_with("</b>") {
932 content.truncate(content.len().saturating_sub(4));
933 }
934 if content.is_empty() {
935 continue;
936 }
937 if i != candidate_indices.len() - 1 && !content.ends_with(' ') {
938 content.push_str("<br/>");
939 }
940 }
941 joined.push_str(&content);
942 }
943
944 joined
945 }
946
947 fn match_table_cells_with_structure_rows(
953 cells: &mut [TableCell],
954 structure_tokens: &[String],
955 ocr_candidates: &[(OcrSource, TextRegion)],
956 row_y_tolerance: f32,
957 cell_bboxes_override: Option<&[BoundingBox]>,
958 ) -> Option<(Vec<Option<usize>>, std::collections::HashSet<usize>)> {
959 if cells.is_empty() || structure_tokens.is_empty() || ocr_candidates.is_empty() {
960 return None;
961 }
962
963 let (cell_sorted_indices, cell_row_flags) =
974 Self::sort_table_cells_boxes(cells, row_y_tolerance);
975
976 if cell_sorted_indices.is_empty() || cell_row_flags.is_empty() {
977 return None;
978 }
979
980 let mut row_start_index = Self::find_row_start_index(structure_tokens);
981 if row_start_index.is_empty() {
982 return None;
983 }
984
985 let mut cell_aligned = Self::map_and_get_max(&cell_row_flags, &row_start_index);
988 cell_aligned.push(cell_sorted_indices.len());
989 row_start_index.push(
990 structure_tokens
991 .iter()
992 .filter(|t| Self::is_td_end_token(t))
993 .count(),
994 );
995
996 let use_cross_row_dedup = cell_bboxes_override.is_some();
1005 let mut globally_matched_ocr: std::collections::HashSet<usize> =
1006 std::collections::HashSet::new();
1007 let mut all_matched: Vec<std::collections::HashMap<usize, Vec<usize>>> = Vec::new();
1008
1009 for k in 0..cell_aligned.len().saturating_sub(1) {
1010 let row_start = cell_aligned[k].min(cell_sorted_indices.len());
1011 let row_end = cell_aligned[k + 1].min(cell_sorted_indices.len());
1012
1013 let mut matched: std::collections::HashMap<usize, Vec<usize>> =
1014 std::collections::HashMap::new();
1015
1016 for (local_idx, &cell_idx) in cell_sorted_indices[row_start..row_end].iter().enumerate()
1017 {
1018 let cell_box = &cells[cell_idx.min(cells.len() - 1)].bbox;
1023
1024 for (ocr_idx, (_, ocr_region)) in ocr_candidates.iter().enumerate() {
1025 if use_cross_row_dedup && globally_matched_ocr.contains(&ocr_idx) {
1026 continue;
1027 }
1028 let ioa = ocr_region.bounding_box.ioa(cell_box);
1030 if ioa > 0.7 {
1031 matched.entry(local_idx).or_default().push(ocr_idx);
1032 }
1033 }
1034 }
1035
1036 if use_cross_row_dedup {
1037 for indices in matched.values() {
1038 globally_matched_ocr.extend(indices.iter().copied());
1039 }
1040 }
1041
1042 all_matched.push(matched);
1043 }
1044
1045 let mut td_to_cell_mapping: Vec<Option<usize>> = Vec::new();
1048 let mut matched_candidate_indices: std::collections::HashSet<usize> =
1049 std::collections::HashSet::new();
1050
1051 let mut td_index = 0usize;
1052 let mut td_count = 0usize;
1053 let mut matched_row_idx = 0usize;
1054
1055 for tag in structure_tokens {
1056 if tag == "<tr>" {
1057 td_index = 0; continue;
1059 }
1060 if !Self::is_td_end_token(tag) {
1061 continue;
1062 }
1063
1064 let row_matches = all_matched.get(matched_row_idx);
1065 let matched_ocr_indices = row_matches.and_then(|m| m.get(&td_index));
1066 let matched_text = matched_ocr_indices
1067 .and_then(|indices| Self::compose_matched_cell_text(indices, ocr_candidates));
1068
1069 if let Some(indices) = matched_ocr_indices {
1070 matched_candidate_indices.extend(indices.iter().copied());
1071 }
1072
1073 let mapped_cell_idx = cell_aligned
1079 .get(matched_row_idx)
1080 .copied()
1081 .and_then(|row_start| {
1082 let sorted_pos = row_start + td_index;
1083 cell_sorted_indices.get(sorted_pos).copied()
1084 })
1085 .filter(|&idx| idx < cells.len());
1086
1087 td_to_cell_mapping.push(mapped_cell_idx);
1088
1089 if let (Some(cell_idx), Some(text)) = (mapped_cell_idx, matched_text)
1090 && let Some(cell) = cells.get_mut(cell_idx)
1091 {
1092 let has_text = cell
1093 .text
1094 .as_ref()
1095 .map(|t| !t.trim().is_empty())
1096 .unwrap_or(false);
1097 if !has_text {
1098 cell.text = Some(text);
1099 }
1100 }
1101
1102 td_index += 1;
1103 td_count += 1;
1104
1105 if matched_row_idx + 1 < row_start_index.len()
1106 && td_count >= row_start_index[matched_row_idx + 1]
1107 {
1108 matched_row_idx += 1;
1109 }
1110 }
1111
1112 if td_to_cell_mapping.is_empty() {
1113 None
1114 } else {
1115 Some((td_to_cell_mapping, matched_candidate_indices))
1116 }
1117 }
1118
1119 fn collect_cell_texts_for_tokens(
1125 cells: &[TableCell],
1126 tokens: &[String],
1127 ) -> Vec<Option<String>> {
1128 if cells.is_empty() {
1129 return Vec::new();
1130 }
1131
1132 let token_grid = parse_cell_grid_info(tokens);
1134 let td_count = token_grid.len();
1135
1136 let mut grid_to_cell: std::collections::HashMap<(usize, usize), usize> =
1138 std::collections::HashMap::new();
1139 let mut has_grid_info = false;
1140
1141 for (cell_idx, cell) in cells.iter().enumerate() {
1142 if let (Some(row), Some(col)) = (cell.row, cell.col) {
1143 grid_to_cell.insert((row, col), cell_idx);
1144 has_grid_info = true;
1145 }
1146 }
1147
1148 if has_grid_info {
1149 token_grid
1151 .iter()
1152 .map(|gi| {
1153 grid_to_cell
1154 .get(&(gi.row, gi.col))
1155 .and_then(|&idx| cells.get(idx))
1156 .and_then(|cell| cell.text.clone())
1157 })
1158 .collect()
1159 } else {
1160 (0..td_count)
1162 .map(|i| cells.get(i).and_then(|cell| cell.text.clone()))
1163 .collect()
1164 }
1165 }
1166
1167 fn sort_table_cells_boxes(
1171 cells: &[TableCell],
1172 row_y_tolerance: f32,
1173 ) -> (Vec<usize>, Vec<usize>) {
1174 if cells.is_empty() {
1175 return (Vec::new(), Vec::new());
1176 }
1177
1178 let mut by_y: Vec<usize> = (0..cells.len()).collect();
1179 by_y.sort_by(|&a, &b| {
1180 cells[a]
1181 .bbox
1182 .y_min()
1183 .partial_cmp(&cells[b].bbox.y_min())
1184 .unwrap_or(Ordering::Equal)
1185 });
1186
1187 let mut rows: Vec<Vec<usize>> = Vec::new();
1188 let mut current_row: Vec<usize> = Vec::new();
1189 let mut current_y: Option<f32> = None;
1190
1191 for idx in by_y {
1192 let y = cells[idx].bbox.y_min();
1193 match current_y {
1194 None => {
1195 current_row.push(idx);
1196 current_y = Some(y);
1197 }
1198 Some(row_y) if (y - row_y).abs() <= row_y_tolerance => {
1199 current_row.push(idx);
1200 }
1201 Some(_) => {
1202 current_row.sort_by(|&a, &b| {
1203 cells[a]
1204 .bbox
1205 .x_min()
1206 .partial_cmp(&cells[b].bbox.x_min())
1207 .unwrap_or(Ordering::Equal)
1208 });
1209 rows.push(current_row);
1210 current_row = vec![idx];
1211 current_y = Some(y);
1212 }
1213 }
1214 }
1215
1216 if !current_row.is_empty() {
1217 current_row.sort_by(|&a, &b| {
1218 cells[a]
1219 .bbox
1220 .x_min()
1221 .partial_cmp(&cells[b].bbox.x_min())
1222 .unwrap_or(Ordering::Equal)
1223 });
1224 rows.push(current_row);
1225 }
1226
1227 let mut sorted = Vec::with_capacity(cells.len());
1228 let mut flags = Vec::with_capacity(rows.len() + 1);
1229 flags.push(0);
1230
1231 for row in rows {
1232 sorted.extend(row.iter().copied());
1233 let next = flags.last().copied().unwrap_or(0) + row.len();
1234 flags.push(next);
1235 }
1236
1237 (sorted, flags)
1238 }
1239
1240 fn find_row_start_index(structure_tokens: &[String]) -> Vec<usize> {
1242 let mut row_start_indices = Vec::new();
1243 let mut current_index = 0usize;
1244 let mut inside_row = false;
1245
1246 for token in structure_tokens {
1247 if token == "<tr>" {
1248 inside_row = true;
1249 } else if token == "</tr>" {
1250 inside_row = false;
1251 } else if Self::is_td_end_token(token) && inside_row {
1252 row_start_indices.push(current_index);
1253 inside_row = false;
1254 }
1255
1256 if Self::is_td_end_token(token) {
1257 current_index += 1;
1258 }
1259 }
1260
1261 row_start_indices
1262 }
1263
1264 fn map_and_get_max(table_cells_flag: &[usize], row_start_index: &[usize]) -> Vec<usize> {
1266 let mut max_values = Vec::with_capacity(row_start_index.len());
1267 let mut i = 0usize;
1268 let mut max_value: Option<usize> = None;
1269
1270 for &row_start in row_start_index {
1271 while i < table_cells_flag.len() && table_cells_flag[i] <= row_start {
1272 max_value =
1273 Some(max_value.map_or(table_cells_flag[i], |v| v.max(table_cells_flag[i])));
1274 i += 1;
1275 }
1276 max_values.push(max_value.unwrap_or(row_start));
1277 }
1278
1279 max_values
1280 }
1281
1282 fn is_td_end_token(token: &str) -> bool {
1284 token == "<td></td>"
1285 || token == "</td>"
1286 || (token.contains("<td") && token.contains("</td>"))
1287 }
1288
1289 fn compose_matched_cell_text(
1291 matched_indices: &[usize],
1292 ocr_candidates: &[(OcrSource, TextRegion)],
1293 ) -> Option<String> {
1294 if matched_indices.is_empty() {
1295 return None;
1296 }
1297
1298 let mut merged = String::new();
1299
1300 for (i, &ocr_idx) in matched_indices.iter().enumerate() {
1301 let Some((_, region)) = ocr_candidates.get(ocr_idx) else {
1302 continue;
1303 };
1304 let Some(raw_text) = region.text.as_deref() else {
1305 continue;
1306 };
1307
1308 let mut content = raw_text.to_string();
1309 if matched_indices.len() > 1 {
1310 if content.starts_with(' ') {
1311 content = content.chars().skip(1).collect();
1312 }
1313 content = content.replace("<b>", "");
1314 content = content.replace("</b>", "");
1315 if content.is_empty() {
1316 continue;
1317 }
1318 if i != matched_indices.len() - 1 && !content.ends_with(' ') {
1319 content.push_str("<br/>");
1320 }
1321 }
1322
1323 merged.push_str(&content);
1324 }
1325
1326 let merged = merged.trim_end().to_string();
1327 if merged.is_empty() {
1328 None
1329 } else {
1330 Some(merged)
1331 }
1332 }
1333
1334 fn compute_inter(rec1: &BoundingBox, rec2: &BoundingBox) -> f32 {
1336 let x_left = rec1.x_min().max(rec2.x_min());
1337 let y_top = rec1.y_min().max(rec2.y_min());
1338 let x_right = rec1.x_max().min(rec2.x_max());
1339 let y_bottom = rec1.y_max().min(rec2.y_max());
1340
1341 let inter_width = (x_right - x_left).max(0.0);
1342 let inter_height = (y_bottom - y_top).max(0.0);
1343 let inter_area = inter_width * inter_height;
1344
1345 let rec2_area = (rec2.x_max() - rec2.x_min()) * (rec2.y_max() - rec2.y_min());
1346 if rec2_area <= 0.0 {
1347 0.0
1348 } else {
1349 inter_area / rec2_area
1350 }
1351 }
1352
1353 fn split_cross_cell_ocr_boxes(
1360 text_regions: &[TextRegion],
1361 relevant_indices: &[usize],
1362 cells: &[oar_ocr_core::domain::structure::TableCell],
1363 ) -> (
1364 Vec<TextRegion>,
1365 std::collections::HashSet<usize>,
1366 std::collections::HashMap<usize, Vec<usize>>,
1367 ) {
1368 let mut split_regions: Vec<TextRegion> = Vec::new();
1369 let mut split_ocr_indices: std::collections::HashSet<usize> =
1370 std::collections::HashSet::new();
1371 let mut cell_assignments: std::collections::HashMap<usize, Vec<usize>> =
1372 std::collections::HashMap::new();
1373
1374 let table_regions: Vec<TextRegion> = relevant_indices
1376 .iter()
1377 .map(|&idx| text_regions[idx].clone())
1378 .collect();
1379
1380 if table_regions.is_empty() || cells.is_empty() {
1381 return (split_regions, split_ocr_indices, cell_assignments);
1382 }
1383
1384 let split_config = OcrSplitConfig::default();
1386 let (expanded, processed_local_indices) =
1387 create_expanded_ocr_for_table(&table_regions, cells, Some(&split_config));
1388
1389 for local_idx in processed_local_indices {
1391 if local_idx < relevant_indices.len() {
1392 split_ocr_indices.insert(relevant_indices[local_idx]);
1393 }
1394 }
1395
1396 for region in expanded {
1398 let region_idx = split_regions.len();
1399
1400 let mut best_cell_idx = None;
1402 let mut best_iou = 0.0f32;
1403
1404 for (cell_idx, cell) in cells.iter().enumerate() {
1405 let iou = region.bounding_box.iou(&cell.bbox);
1406 if iou > best_iou {
1407 best_iou = iou;
1408 best_cell_idx = Some(cell_idx);
1409 }
1410 }
1411
1412 if let Some(cell_idx) = best_cell_idx {
1414 cell_assignments
1415 .entry(cell_idx)
1416 .or_default()
1417 .push(region_idx);
1418 }
1419
1420 split_regions.push(region);
1421 }
1422
1423 tracing::debug!(
1424 "Cross-cell OCR splitting: {} original regions processed, {} new regions created",
1425 split_ocr_indices.len(),
1426 split_regions.len()
1427 );
1428
1429 (split_regions, split_ocr_indices, cell_assignments)
1430 }
1431
1432 fn calculate_iou(bbox1: &BoundingBox, bbox2: &BoundingBox) -> f32 {
1434 let x1_min = bbox1.x_min();
1435 let y1_min = bbox1.y_min();
1436 let x1_max = bbox1.x_max();
1437 let y1_max = bbox1.y_max();
1438
1439 let x2_min = bbox2.x_min();
1440 let y2_min = bbox2.y_min();
1441 let x2_max = bbox2.x_max();
1442 let y2_max = bbox2.y_max();
1443
1444 let inter_x_min = x1_min.max(x2_min);
1445 let inter_y_min = y1_min.max(y2_min);
1446 let inter_x_max = x1_max.min(x2_max);
1447 let inter_y_max = y1_max.min(y2_max);
1448
1449 let inter_w = (inter_x_max - inter_x_min).max(0.0);
1450 let inter_h = (inter_y_max - inter_y_min).max(0.0);
1451 let inter_area = inter_w * inter_h;
1452
1453 let area1 = (x1_max - x1_min) * (y1_max - y1_min);
1454 let area2 = (x2_max - x2_min) * (y2_max - y2_min);
1455 let union_area = area1 + area2 - inter_area;
1456
1457 if union_area > 0.0 {
1458 inter_area / union_area
1459 } else {
1460 0.0
1461 }
1462 }
1463
1464 fn l1_distance(bbox1: &BoundingBox, bbox2: &BoundingBox) -> f32 {
1466 let b1 = [bbox1.x_min(), bbox1.y_min(), bbox1.x_max(), bbox1.y_max()];
1467 let b2 = [bbox2.x_min(), bbox2.y_min(), bbox2.x_max(), bbox2.y_max()];
1468
1469 (b2[0] - b1[0]).abs()
1470 + (b2[1] - b1[1]).abs()
1471 + (b2[2] - b1[2]).abs()
1472 + (b2[3] - b1[3]).abs()
1473 }
1474
1475 fn paddlex_distance(table_box: &BoundingBox, ocr_box: &BoundingBox) -> f32 {
1477 let x1 = table_box.x_min();
1478 let y1 = table_box.y_min();
1479 let x2 = table_box.x_max();
1480 let y2 = table_box.y_max();
1481 let x3 = ocr_box.x_min();
1482 let y3 = ocr_box.y_min();
1483 let x4 = ocr_box.x_max();
1484 let y4 = ocr_box.y_max();
1485
1486 let dis = (x3 - x1).abs() + (y3 - y1).abs() + (x4 - x2).abs() + (y4 - y2).abs();
1487 let dis_2 = (x3 - x1).abs() + (y3 - y1).abs();
1488 let dis_3 = (x4 - x2).abs() + (y4 - y2).abs();
1489 dis + dis_2.min(dis_3)
1490 }
1491
1492 fn inject_inline_formulas(
1503 elements: &mut [LayoutElement],
1504 _text_regions: &mut Vec<TextRegion>,
1505 _cfg: &StitchConfig,
1506 ) {
1507 use oar_ocr_core::domain::structure::LayoutElementType;
1508
1509 let mut inline_formula_indices: Vec<usize> = Vec::new();
1510
1511 const INLINE_FORMULA_MAX_AREA: f32 = 80000.0;
1513
1514 for (idx, element) in elements.iter().enumerate() {
1515 if element.element_type != LayoutElementType::Formula {
1516 continue;
1517 }
1518
1519 let formula_text = if let Some(text) = &element.text {
1521 if !text.is_empty() {
1522 text
1523 } else {
1524 continue;
1525 }
1526 } else {
1527 continue;
1528 };
1529
1530 let formula_area = element.bbox.area();
1531 tracing::debug!(
1532 "Formula idx {}: area={:.1}, text={}",
1533 idx,
1534 formula_area,
1535 formula_text
1536 );
1537
1538 if formula_area < INLINE_FORMULA_MAX_AREA {
1540 inline_formula_indices.push(idx);
1541 tracing::debug!(
1542 "Marking formula idx {} as inline (area {:.1} < {})",
1543 idx,
1544 formula_area,
1545 INLINE_FORMULA_MAX_AREA
1546 );
1547 }
1548 }
1549
1550 for idx in &inline_formula_indices {
1552 if let Some(element) = elements.get_mut(*idx) {
1553 tracing::debug!(
1554 "Clearing inline formula idx {} to use TextRegion with label=formula",
1555 idx
1556 );
1557 element.text = None;
1558 element.order_index = None;
1559 }
1560 }
1561
1562 if !inline_formula_indices.is_empty() {
1563 tracing::debug!("Marked {} formulas as inline", inline_formula_indices.len());
1564 }
1565 }
1566
1567 fn stitch_layout_elements(
1568 elements: &mut [LayoutElement],
1569 text_regions: &[TextRegion],
1570 used_indices: &mut std::collections::HashSet<usize>,
1571 cfg: &StitchConfig,
1572 exclude_formula_from_ocr: bool,
1573 ) {
1574 tracing::debug!(
1575 "stitch_layout_elements: {} elements, {} regions, {} already used",
1576 elements.len(),
1577 text_regions.len(),
1578 used_indices.len()
1579 );
1580
1581 for (elem_idx, element) in elements.iter_mut().enumerate() {
1582 if EXCLUDED_FROM_OCR_LABELS.contains(&element.element_type)
1588 || (exclude_formula_from_ocr && element.element_type == LayoutElementType::Formula)
1589 {
1590 continue;
1591 }
1592
1593 let mut element_texts: Vec<(&TextRegion, &str)> = Vec::new();
1594
1595 for (idx, region) in text_regions.iter().enumerate() {
1596 if let Some(text) = ®ion.text
1597 && Self::is_overlapping(&element.bbox, ®ion.bounding_box, cfg)
1598 {
1599 element_texts.push((region, text));
1600 used_indices.insert(idx);
1604 }
1605 }
1606
1607 if !element_texts.is_empty() {
1608 tracing::debug!(
1609 "Element {} ({:?}): matched {} regions",
1610 elem_idx,
1611 element.element_type,
1612 element_texts.len()
1613 );
1614
1615 for (region, text) in &element_texts {
1617 tracing::debug!(" - region with label={:?}, text={:?}", region.label, text);
1618 }
1619
1620 let mut sorted_for_meta = element_texts.clone();
1623 sorted_for_meta.sort_by(|(r1, _), (r2, _)| {
1624 r1.bounding_box
1625 .center()
1626 .y
1627 .partial_cmp(&r2.bounding_box.center().y)
1628 .unwrap_or(Ordering::Equal)
1629 });
1630 let mut lines = Vec::new();
1631 let mut current_line = Vec::new();
1632 for item in std::mem::take(&mut sorted_for_meta) {
1633 if current_line.is_empty() {
1634 current_line.push(item);
1635 } else {
1636 let first_in_line = ¤t_line[0].0.bounding_box;
1637 if Self::is_same_text_line_bbox(first_in_line, &item.0.bounding_box, cfg) {
1638 current_line.push(item);
1639 } else {
1640 current_line.sort_by(|(r1, _), (r2, _)| {
1641 r1.bounding_box
1642 .center()
1643 .x
1644 .partial_cmp(&r2.bounding_box.center().x)
1645 .unwrap_or(Ordering::Equal)
1646 });
1647 lines.push(current_line);
1648 current_line = vec![item];
1649 }
1650 }
1651 }
1652 if !current_line.is_empty() {
1653 current_line.sort_by(|(r1, _), (r2, _)| {
1654 r1.bounding_box
1655 .center()
1656 .x
1657 .partial_cmp(&r2.bounding_box.center().x)
1658 .unwrap_or(Ordering::Equal)
1659 });
1660 lines.push(current_line);
1661 }
1662 for mut line in lines {
1663 sorted_for_meta.append(&mut line);
1664 }
1665
1666 if let Some((first_region, _)) = sorted_for_meta.first() {
1667 element.seg_start_x = Some(first_region.bounding_box.x_min());
1669 element.seg_end_x = sorted_for_meta
1671 .last()
1672 .map(|(region, _)| region.bounding_box.x_max());
1673
1674 let mut num_lines = 1u32;
1676 let mut prev_bbox = &first_region.bounding_box;
1677 for (region, _) in &sorted_for_meta[1..] {
1678 if !Self::is_same_text_line_bbox(prev_bbox, ®ion.bounding_box, cfg) {
1679 num_lines += 1;
1680 prev_bbox = ®ion.bounding_box;
1681 }
1682 }
1683 element.num_lines = Some(num_lines);
1684 }
1685 }
1686
1687 Self::sort_and_join_texts(&mut element_texts, Some(&element.bbox), cfg, |joined| {
1688 element.text = Some(joined);
1689 });
1690 }
1691 }
1692
1693 fn fill_formula_elements(
1698 elements: &mut [LayoutElement],
1699 formulas: &[FormulaResult],
1700 _cfg: &StitchConfig,
1701 ) {
1702 for element in elements.iter_mut() {
1703 if element.element_type != LayoutElementType::Formula {
1704 continue;
1705 }
1706
1707 if element.text.is_some() {
1709 continue;
1710 }
1711
1712 let mut best_formula: Option<&FormulaResult> = None;
1716 let mut best_score = 0.0f32;
1717
1718 for formula in formulas {
1719 let ioa_element = element.bbox.ioa(&formula.bbox);
1720 let ioa_formula = formula.bbox.ioa(&element.bbox);
1721 let score = ioa_element.max(ioa_formula);
1722 if score > best_score {
1723 best_score = score;
1724 best_formula = Some(formula);
1725 }
1726 }
1727
1728 if best_score < 0.05 {
1731 let elem_center = element.bbox.center();
1732 let mut best_dist = f32::MAX;
1733
1734 for formula in formulas {
1735 let fc = formula.bbox.center();
1736 let fc_inside = fc.x >= element.bbox.x_min()
1737 && fc.x <= element.bbox.x_max()
1738 && fc.y >= element.bbox.y_min()
1739 && fc.y <= element.bbox.y_max();
1740 let ec_inside = elem_center.x >= formula.bbox.x_min()
1741 && elem_center.x <= formula.bbox.x_max()
1742 && elem_center.y >= formula.bbox.y_min()
1743 && elem_center.y <= formula.bbox.y_max();
1744
1745 if fc_inside || ec_inside {
1746 let dx = fc.x - elem_center.x;
1747 let dy = fc.y - elem_center.y;
1748 let dist = dx * dx + dy * dy;
1749 if dist < best_dist {
1750 best_dist = dist;
1751 best_formula = Some(formula);
1752 best_score = 0.05;
1753 }
1754 }
1755 }
1756 }
1757
1758 if best_score >= 0.05
1759 && let Some(formula) = best_formula
1760 {
1761 element.text = Some(formula.latex.clone());
1762 }
1763 }
1764 }
1765
1766 fn is_overlapping(bbox1: &BoundingBox, bbox2: &BoundingBox, cfg: &StitchConfig) -> bool {
1769 let x1_min = bbox1.x_min();
1770 let y1_min = bbox1.y_min();
1771 let x1_max = bbox1.x_max();
1772 let y1_max = bbox1.y_max();
1773
1774 let x2_min = bbox2.x_min();
1775 let y2_min = bbox2.y_min();
1776 let x2_max = bbox2.x_max();
1777 let y2_max = bbox2.y_max();
1778
1779 let inter_x_min = x1_min.max(x2_min);
1780 let inter_y_min = y1_min.max(y2_min);
1781 let inter_x_max = x1_max.min(x2_max);
1782 let inter_y_max = y1_max.min(y2_max);
1783
1784 let inter_w = inter_x_max - inter_x_min;
1785 let inter_h = inter_y_max - inter_y_min;
1786
1787 inter_w > cfg.overlap_min_pixels && inter_h > cfg.overlap_min_pixels
1788 }
1789
1790 fn is_same_text_line_bbox(
1796 bbox1: &BoundingBox,
1797 bbox2: &BoundingBox,
1798 cfg: &StitchConfig,
1799 ) -> bool {
1800 let h1 = (bbox1.y_max() - bbox1.y_min()).max(1.0);
1801 let h2 = (bbox2.y_max() - bbox2.y_min()).max(1.0);
1802 let inter_h =
1803 (bbox1.y_max().min(bbox2.y_max()) - bbox1.y_min().max(bbox2.y_min())).max(0.0);
1804 let overlap_ratio = inter_h / h1.min(h2);
1805 if overlap_ratio >= cfg.line_height_iou_threshold {
1806 return true;
1807 }
1808
1809 let adaptive_tol = (h1.min(h2) * 0.5).max(1.0);
1810 let center_delta = (bbox1.center().y - bbox2.center().y).abs();
1811 center_delta <= adaptive_tol.max(cfg.same_line_y_tolerance * 0.25)
1812 }
1813
1814 fn sort_and_join_texts<F>(
1815 texts: &mut Vec<(&TextRegion, &str)>,
1816 container_bbox: Option<&BoundingBox>,
1817 cfg: &StitchConfig,
1818 update_fn: F,
1819 ) where
1820 F: FnOnce(String),
1821 {
1822 if texts.is_empty() {
1823 return;
1824 }
1825
1826 texts.sort_by(|(r1, _), (r2, _)| {
1828 r1.bounding_box
1829 .center()
1830 .y
1831 .partial_cmp(&r2.bounding_box.center().y)
1832 .unwrap_or(Ordering::Equal)
1833 });
1834 let mut lines = Vec::new();
1835 let mut current_line = Vec::new();
1836 for item in std::mem::take(texts) {
1837 if current_line.is_empty() {
1838 current_line.push(item);
1839 } else {
1840 let first_in_line = ¤t_line[0].0.bounding_box;
1841 if Self::is_same_text_line_bbox(first_in_line, &item.0.bounding_box, cfg) {
1842 current_line.push(item);
1843 } else {
1844 current_line.sort_by(|(r1, _), (r2, _)| {
1845 r1.bounding_box
1846 .center()
1847 .x
1848 .partial_cmp(&r2.bounding_box.center().x)
1849 .unwrap_or(Ordering::Equal)
1850 });
1851 lines.push(current_line);
1852 current_line = vec![item];
1853 }
1854 }
1855 }
1856 if !current_line.is_empty() {
1857 current_line.sort_by(|(r1, _), (r2, _)| {
1858 r1.bounding_box
1859 .center()
1860 .x
1861 .partial_cmp(&r2.bounding_box.center().x)
1862 .unwrap_or(Ordering::Equal)
1863 });
1864 lines.push(current_line);
1865 }
1866 for mut line in lines {
1867 texts.append(&mut line);
1868 }
1869
1870 let mut result = String::new();
1875 let mut prev_region: Option<&TextRegion> = None;
1876
1877 tracing::debug!(
1878 "sort_and_join_texts: processing {} text regions",
1879 texts.len()
1880 );
1881
1882 for (region, text) in texts.iter() {
1883 if text.is_empty() {
1884 continue;
1885 }
1886
1887 if let Some(last_region) = prev_region {
1888 if !Self::is_same_text_line_bbox(
1889 &last_region.bounding_box,
1890 ®ion.bounding_box,
1891 cfg,
1892 ) {
1893 let mut add_newline = false;
1896 let mut is_line_wrap = false;
1897
1898 if let Some(container) = container_bbox {
1899 let container_width = container.x_max() - container.x_min();
1900 let right_gap = container.x_max() - last_region.bounding_box.x_max();
1901 let tail_char = last_non_whitespace_char(&result);
1902 let ends_with_non_break_punct =
1903 tail_char.is_some_and(is_non_break_line_end_punctuation);
1904 let paragraph_gap_ratio =
1906 if tail_char.is_some_and(|c| c.is_ascii_alphabetic()) {
1907 0.5
1908 } else {
1909 0.3
1910 };
1911
1912 if !ends_with_non_break_punct
1913 && right_gap > container_width * paragraph_gap_ratio
1914 {
1915 add_newline = true;
1917 } else {
1918 is_line_wrap = true;
1920 }
1921 }
1922
1923 let prev_ends_hyphen = result.ends_with('-');
1930 if prev_ends_hyphen && is_line_wrap {
1931 result.pop();
1933 } else if add_newline {
1935 if !result.ends_with('\n') {
1936 result.push('\n');
1937 }
1938 } else {
1939 if let Some(last_char) = result.chars().last()
1941 && last_char != '\n'
1942 && needs_space_after(last_char)
1943 {
1944 result.push(' ');
1945 }
1946 }
1947 } else {
1948 let needs_spacing = if let Some(last_char) = result.chars().last()
1951 && last_char != '\n'
1952 && needs_space_after(last_char)
1953 {
1954 true
1955 } else {
1956 last_region.is_formula()
1958 };
1959
1960 if needs_spacing {
1961 result.push(' ');
1962 }
1963 }
1964 }
1965
1966 let is_formula = region.is_formula();
1970 let text_to_add = if is_formula {
1971 let already_wrapped =
1973 text.starts_with('$') || text.starts_with("\\(") || text.starts_with("\\[");
1974 if already_wrapped {
1975 text.to_string()
1976 } else {
1977 let is_display = result.is_empty() || result.ends_with('\n');
1980
1981 if is_display {
1982 format!("$${}$$", text)
1984 } else {
1985 format!("${}$", text)
1987 }
1988 }
1989 } else {
1990 text.to_string()
1991 };
1992
1993 result.push_str(&text_to_add);
1994 prev_region = Some(region);
1995 }
1996
1997 let joined = result.trim_end().to_string();
1999 update_fn(joined);
2000 }
2001
2002 fn sort_layout_elements_enhanced(
2007 elements: &mut Vec<LayoutElement>,
2008 page_width: f32,
2009 page_height: f32,
2010 ) {
2011 use oar_ocr_core::processors::layout_sorting::{SortableElement, sort_layout_enhanced};
2012
2013 if elements.is_empty() {
2014 return;
2015 }
2016
2017 let sortable_elements: Vec<_> = elements
2018 .iter()
2019 .map(|e| SortableElement {
2020 bbox: e.bbox.clone(),
2021 element_type: e.element_type,
2022 num_lines: e.num_lines,
2023 })
2024 .collect();
2025
2026 let sorted_indices = sort_layout_enhanced(&sortable_elements, page_width, page_height);
2027 if sorted_indices.len() != elements.len() {
2028 return;
2029 }
2030
2031 let sorted_elements: Vec<_> = sorted_indices
2032 .into_iter()
2033 .map(|idx| elements[idx].clone())
2034 .collect();
2035 *elements = sorted_elements;
2036 }
2037
2038 #[allow(dead_code)]
2040 fn sort_layout_elements(elements: &mut Vec<LayoutElement>, _width: f32, _cfg: &StitchConfig) {
2041 if elements.len() <= 1 {
2042 return;
2043 }
2044
2045 let bboxes: Vec<BoundingBox> = elements.iter().map(|e| e.bbox.clone()).collect();
2047 let order = crate::processors::sort_by_xycut(
2048 &bboxes,
2049 crate::processors::SortDirection::Vertical,
2050 1,
2051 );
2052
2053 if order.len() != elements.len() {
2054 return;
2055 }
2056
2057 let mut reordered = Vec::with_capacity(elements.len());
2058 for idx in order {
2059 reordered.push(elements[idx].clone());
2060 }
2061
2062 *elements = reordered;
2063 }
2064}
2065
2066fn needs_space_after(c: char) -> bool {
2069 c.is_ascii_alphabetic()
2070}
2071
2072fn last_non_whitespace_char(text: &str) -> Option<char> {
2073 text.chars().rev().find(|c| !c.is_whitespace())
2074}
2075
2076fn is_non_break_line_end_punctuation(c: char) -> bool {
2078 matches!(c, ',' | ',' | '、' | ';' | ';' | ':' | ':')
2079}
2080
2081#[cfg(test)]
2082mod tests {
2083 use super::*;
2084 use crate::oarocr::TextRegion;
2085 use oar_ocr_core::processors::BoundingBox;
2086
2087 fn make_region(bbox: BoundingBox, text: &str) -> TextRegion {
2088 TextRegion {
2089 bounding_box: bbox.clone(),
2090 dt_poly: Some(bbox.clone()),
2091 rec_poly: Some(bbox),
2092 text: Some(text.into()),
2093 confidence: Some(0.9),
2094 orientation_angle: None,
2095 word_boxes: None,
2096 label: None,
2097 }
2098 }
2099
2100 #[test]
2101 fn test_normalize_tiny_symbol_for_paddlex_dash() {
2102 let mut region = make_region(BoundingBox::from_coords(0.0, 0.0, 10.0, 9.0), "=");
2103 region.confidence = Some(0.33);
2104 ResultStitcher::normalize_tiny_symbol_for_paddlex(&mut region);
2105 assert_eq!(region.text.as_deref(), Some("-"));
2106 }
2107
2108 #[test]
2109 fn test_normalize_tiny_symbol_for_paddlex_comma() {
2110 let mut region = make_region(BoundingBox::from_coords(0.0, 0.0, 7.0, 6.0), "=");
2111 region.confidence = Some(0.40);
2112 ResultStitcher::normalize_tiny_symbol_for_paddlex(&mut region);
2113 assert_eq!(region.text.as_deref(), Some(","));
2114 }
2115
2116 #[test]
2117 fn test_normalize_tiny_symbol_for_paddlex_semicolon() {
2118 let mut region = make_region(BoundingBox::from_coords(0.0, 0.0, 12.0, 13.0), "0");
2119 region.confidence = Some(0.13);
2120 ResultStitcher::normalize_tiny_symbol_for_paddlex(&mut region);
2121 assert_eq!(region.text.as_deref(), Some(";"));
2122 }
2123
2124 #[test]
2125 fn test_is_overlapping_threshold() {
2126 let b1 = BoundingBox::from_coords(0.0, 0.0, 10.0, 10.0);
2127 let b2 = BoundingBox::from_coords(5.0, 5.0, 20.0, 20.0);
2128 let cfg = StitchConfig::default();
2129 assert!(ResultStitcher::is_overlapping(&b1, &b2, &cfg));
2130 let cfg2 = StitchConfig {
2131 overlap_min_pixels: 5.0,
2132 ..cfg.clone()
2133 };
2134 assert!(!ResultStitcher::is_overlapping(&b1, &b2, &cfg2));
2135 }
2136
2137 #[test]
2138 fn test_sort_and_join_texts_tolerance() {
2139 let b1 = BoundingBox::from_coords(0.0, 0.0, 10.0, 10.0);
2140 let b2 = BoundingBox::from_coords(12.0, 1.0, 20.0, 11.0);
2141 let r1 = TextRegion {
2142 bounding_box: b1.clone(),
2143 dt_poly: Some(b1.clone()),
2144 rec_poly: Some(b1),
2145 text: Some("A".into()),
2146 confidence: Some(0.9),
2147 orientation_angle: None,
2148 word_boxes: None,
2149 label: None,
2150 };
2151 let r2 = TextRegion {
2152 bounding_box: b2.clone(),
2153 dt_poly: Some(b2.clone()),
2154 rec_poly: Some(b2),
2155 text: Some("B".into()),
2156 confidence: Some(0.9),
2157 orientation_angle: None,
2158 word_boxes: None,
2159 label: None,
2160 };
2161 let mut texts = vec![(&r1, "A"), (&r2, "B")];
2162 let cfg = StitchConfig::default();
2163 let mut joined = String::new();
2164 ResultStitcher::sort_and_join_texts(&mut texts, None, &cfg, |j| {
2165 joined = j;
2166 });
2167 assert_eq!(joined, "A B");
2168 }
2169
2170 #[test]
2171 fn test_sort_and_join_texts_english_line_uses_larger_paragraph_gap_threshold() {
2172 let r1 = make_region(BoundingBox::from_coords(0.0, 0.0, 60.0, 10.0), "Line");
2173 let r2 = make_region(BoundingBox::from_coords(0.0, 20.0, 40.0, 30.0), "next");
2174 let mut texts = vec![(&r1, "Line"), (&r2, "next")];
2175 let cfg = StitchConfig::default();
2176 let container = BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0);
2177 let mut joined = String::new();
2178 ResultStitcher::sort_and_join_texts(&mut texts, Some(&container), &cfg, |j| joined = j);
2179 assert_eq!(joined, "Line next");
2180 }
2181
2182 #[test]
2183 fn test_sort_and_join_texts_non_english_tail_keeps_original_paragraph_gap_threshold() {
2184 let r1 = make_region(BoundingBox::from_coords(0.0, 0.0, 60.0, 10.0), "2024");
2185 let r2 = make_region(BoundingBox::from_coords(0.0, 20.0, 40.0, 30.0), "next");
2186 let mut texts = vec![(&r1, "2024"), (&r2, "next")];
2187 let cfg = StitchConfig::default();
2188 let container = BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0);
2189 let mut joined = String::new();
2190 ResultStitcher::sort_and_join_texts(&mut texts, Some(&container), &cfg, |j| joined = j);
2191 assert_eq!(joined, "2024\nnext");
2192 }
2193
2194 #[test]
2195 fn test_sort_and_join_texts_non_break_punctuation_suppresses_newline() {
2196 let r1 = make_region(BoundingBox::from_coords(0.0, 0.0, 20.0, 10.0), "Note:");
2197 let r2 = make_region(BoundingBox::from_coords(0.0, 20.0, 40.0, 30.0), "next");
2198 let mut texts = vec![(&r1, "Note:"), (&r2, "next")];
2199 let cfg = StitchConfig::default();
2200 let container = BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0);
2201 let mut joined = String::new();
2202 ResultStitcher::sort_and_join_texts(&mut texts, Some(&container), &cfg, |j| joined = j);
2203 assert_eq!(joined, "Note:next");
2204 }
2205
2206 #[test]
2207 fn test_normalize_checkbox_symbols_in_table_checkbox_like() {
2208 let mut cells = vec![
2209 TableCell::new(BoundingBox::from_coords(0.0, 0.0, 10.0, 10.0), 1.0).with_text("ü"),
2210 TableCell::new(BoundingBox::from_coords(10.0, 0.0, 20.0, 10.0), 1.0).with_text("X"),
2211 TableCell::new(BoundingBox::from_coords(20.0, 0.0, 30.0, 10.0), 1.0).with_text("L"),
2212 ];
2213
2214 ResultStitcher::normalize_checkbox_symbols_in_table(&mut cells);
2215
2216 assert_eq!(cells[0].text.as_deref(), Some("✓"));
2217 assert_eq!(cells[1].text.as_deref(), Some("✗"));
2218 assert_eq!(cells[2].text.as_deref(), Some("✓"));
2219 }
2220
2221 #[test]
2222 fn test_normalize_checkbox_symbols_in_table_keeps_ambiguous_when_not_checkbox_like() {
2223 let mut cells = vec![
2224 TableCell::new(BoundingBox::from_coords(0.0, 0.0, 10.0, 10.0), 1.0).with_text("L"),
2225 TableCell::new(BoundingBox::from_coords(10.0, 0.0, 20.0, 10.0), 1.0).with_text("A"),
2226 ];
2227
2228 ResultStitcher::normalize_checkbox_symbols_in_table(&mut cells);
2229
2230 assert_eq!(cells[0].text.as_deref(), Some("L"));
2231 assert_eq!(cells[1].text.as_deref(), Some("A"));
2232 }
2233
2234 #[test]
2235 fn test_find_row_start_index_with_compact_td_tokens() {
2236 let tokens = vec![
2237 "<table>".to_string(),
2238 "<tbody>".to_string(),
2239 "<tr>".to_string(),
2240 "<td></td>".to_string(),
2241 "<td></td>".to_string(),
2242 "</tr>".to_string(),
2243 "<tr>".to_string(),
2244 "<td rowspan=\"2\"></td>".to_string(),
2245 "<td></td>".to_string(),
2246 "</tr>".to_string(),
2247 "</tbody>".to_string(),
2248 "</table>".to_string(),
2249 ];
2250
2251 let row_start = ResultStitcher::find_row_start_index(&tokens);
2252 assert_eq!(row_start, vec![0, 2]);
2253 }
2254
2255 #[test]
2256 fn test_match_table_cells_with_structure_rows() {
2257 let mut cells = vec![
2258 TableCell::new(BoundingBox::from_coords(50.0, 0.0, 100.0, 20.0), 1.0), TableCell::new(BoundingBox::from_coords(0.0, 0.0, 50.0, 20.0), 1.0), TableCell::new(BoundingBox::from_coords(0.0, 20.0, 50.0, 40.0), 1.0), TableCell::new(BoundingBox::from_coords(50.0, 20.0, 100.0, 40.0), 1.0), ];
2263
2264 let structure_tokens = vec![
2265 "<table>".to_string(),
2266 "<tbody>".to_string(),
2267 "<tr>".to_string(),
2268 "<td></td>".to_string(),
2269 "<td></td>".to_string(),
2270 "</tr>".to_string(),
2271 "<tr>".to_string(),
2272 "<td></td>".to_string(),
2273 "<td></td>".to_string(),
2274 "</tr>".to_string(),
2275 "</tbody>".to_string(),
2276 "</table>".to_string(),
2277 ];
2278
2279 let ocr_candidates = vec![
2280 (
2281 OcrSource::Original(0),
2282 make_region(BoundingBox::from_coords(2.0, 2.0, 48.0, 18.0), "A"),
2283 ),
2284 (
2285 OcrSource::Original(1),
2286 make_region(BoundingBox::from_coords(52.0, 2.0, 98.0, 18.0), "B"),
2287 ),
2288 (
2289 OcrSource::Original(2),
2290 make_region(BoundingBox::from_coords(2.0, 22.0, 48.0, 38.0), "C"),
2291 ),
2292 (
2293 OcrSource::Original(3),
2294 make_region(BoundingBox::from_coords(52.0, 22.0, 98.0, 38.0), "D"),
2295 ),
2296 ];
2297
2298 let (mapping, matched) = ResultStitcher::match_table_cells_with_structure_rows(
2299 &mut cells,
2300 &structure_tokens,
2301 &ocr_candidates,
2302 10.0,
2303 None,
2304 )
2305 .expect("expected row-aware matching result");
2306
2307 assert_eq!(mapping, vec![Some(1), Some(0), Some(2), Some(3)]);
2308 assert_eq!(matched.len(), 4);
2309
2310 assert_eq!(cells[1].text.as_deref(), Some("A"));
2311 assert_eq!(cells[0].text.as_deref(), Some("B"));
2312 assert_eq!(cells[2].text.as_deref(), Some("C"));
2313 assert_eq!(cells[3].text.as_deref(), Some("D"));
2314 }
2315
2316 #[test]
2317 fn test_match_table_and_ocr_by_iou_distance_prefers_first_cell_on_exact_tie() {
2318 let cells = vec![
2319 TableCell::new(BoundingBox::from_coords(0.0, 0.0, 20.0, 20.0), 1.0),
2320 TableCell::new(BoundingBox::from_coords(0.0, 0.0, 20.0, 20.0), 1.0),
2321 ];
2322 let ocr_candidates = vec![(
2323 OcrSource::Original(0),
2324 make_region(BoundingBox::from_coords(2.0, 2.0, 18.0, 18.0), "X"),
2325 )];
2326
2327 let (mapping, matched) = ResultStitcher::match_table_and_ocr_by_iou_distance(
2328 &cells,
2329 &ocr_candidates,
2330 false,
2331 true,
2332 );
2333
2334 assert_eq!(matched.len(), 1);
2335 assert_eq!(mapping.get(&0), Some(&vec![0]));
2336 assert!(!mapping.contains_key(&1));
2337 }
2338
2339 #[test]
2340 fn test_match_table_and_ocr_by_iou_distance_boundary_near_tie_stays_stable() {
2341 let cells = vec![
2343 TableCell::new(BoundingBox::from_coords(0.0, 0.0, 20.0, 20.0), 1.0),
2344 TableCell::new(BoundingBox::from_coords(0.0, 9.99995, 20.0, 29.99995), 1.0),
2345 ];
2346 let ocr_candidates = vec![(
2347 OcrSource::Original(0),
2348 make_region(BoundingBox::from_coords(0.0, 10.0, 20.0, 20.0), "Y"),
2349 )];
2350
2351 let (mapping, _) = ResultStitcher::match_table_and_ocr_by_iou_distance(
2352 &cells,
2353 &ocr_candidates,
2354 false,
2355 true,
2356 );
2357
2358 assert_eq!(mapping.get(&0), Some(&vec![0]));
2360 assert!(!mapping.contains_key(&1));
2361 }
2362
2363 #[test]
2364 fn test_match_table_and_ocr_by_iou_distance_boundary_straddle_prefers_upper_row() {
2365 let cells = vec![
2368 TableCell::new(
2369 BoundingBox::from_coords(564.6841, 142.27391, 584.9476, 157.74164),
2370 1.0,
2371 )
2372 .with_position(2, 2),
2373 TableCell::new(
2374 BoundingBox::from_coords(565.3968, 158.34259, 584.0292, 171.04494),
2375 1.0,
2376 )
2377 .with_position(3, 2),
2378 ];
2379 let ocr_candidates = vec![(
2380 OcrSource::Original(0),
2381 make_region(BoundingBox::from_coords(567.0, 151.0, 583.0, 166.0), "84"),
2382 )];
2383
2384 let (mapping, matched) = ResultStitcher::match_table_and_ocr_by_iou_distance(
2385 &cells,
2386 &ocr_candidates,
2387 false,
2388 true,
2389 );
2390
2391 assert_eq!(matched.len(), 1);
2392 assert_eq!(mapping.get(&0), Some(&vec![0]));
2393 assert!(!mapping.contains_key(&1));
2394 }
2395}