Skip to main content

oar_ocr/oarocr/
stitching.rs

1//! Stitching module for combining OCR results.
2//!
3//! This module provides functionality to associate recognized text regions with
4//! layout elements (such as tables and paragraphs) to create a unified structured result.
5//!
6//! ## PP-StructureV3 Alignment
7//!
8//! The stitching logic follows PP-StructureV3's fusion strategy:
9//! 1. **Label-based filtering**: Special regions (formula, table, seal) are excluded from OCR matching
10//! 2. **Content preservation**: Formulas retain LaTeX, tables retain HTML structure
11//! 3. **Reading order**: Elements are assigned `order_index` based on spatial sorting
12//! 4. **Orphan handling**: Unmatched OCR regions create new text elements
13
14use 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/// Source of an OCR region reference, distinguishing between regions that were
24/// split across cell boundaries and original regions.
25#[derive(Clone, Copy, Debug)]
26enum OcrSource {
27    /// Index into the split_regions vector (created by cross-cell splitting)
28    Split,
29    /// Index into the original text_regions slice
30    Original(usize),
31}
32
33/// Labels excluded from OCR text matching in `stitch_layout_elements`.
34/// These regions have their own specialized content (LaTeX, HTML, etc.).
35/// PaddleX: formula results are injected into the OCR pool (via
36/// `convert_formula_res_to_ocr_format`), so formula blocks participate
37/// in normal OCR matching — only Table and Seal are excluded here.
38/// (Formula exclusion, when needed, is handled by `stitch_layout_elements`'s
39/// `exclude_formula_from_ocr` flag.)
40const 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    /// Fallback pixel tolerance for line grouping.
51    pub same_line_y_tolerance: f32,
52    /// Minimum vertical overlap ratio (intersection / min(line_height)) to treat two spans as one line.
53    pub line_height_iou_threshold: f32,
54    /// Whether to enable cross-cell OCR box splitting.
55    /// When enabled, OCR boxes that span multiple table cells will be split
56    /// at cell boundaries and their text distributed proportionally.
57    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
75/// Stitcher for combining results from different OCR tasks.
76pub struct ResultStitcher;
77
78impl ResultStitcher {
79    /// Stitches text regions into layout elements and tables within the structure result.
80    ///
81    /// This method follows PP-StructureV3's fusion strategy:
82    /// 1. Stitch OCR text into tables (cell-level matching)
83    /// 2. Stitch OCR text into layout elements (excluding formula/table/seal)
84    /// 3. Fill formula elements with LaTeX content from formula results
85    /// 4. Create new text elements for orphan OCR regions
86    /// 5. Sort elements and assign reading order indices
87    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        // Track which regions have been used
94        let mut used_region_indices = std::collections::HashSet::new();
95
96        // Get text regions (clone to avoid borrow issues, make mutable for injection)
97        let mut regions = result.text_regions.clone().unwrap_or_default();
98
99        tracing::debug!("Stitching: {} text regions", regions.len());
100
101        // 1. Stitch text into tables
102        // For tables, we also want recognized formulas to participate in cell content
103        // matching, similar to how formulas are injected into the OCR results used
104        // for table recognition.
105        Self::stitch_tables(
106            &mut result.tables,
107            &regions,
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        // 1.5. Fill formula elements with LaTeX content FIRST
119        // This must happen before inject_inline_formulas so formulas have text content
120        Self::fill_formula_elements(&mut result.layout_elements, &result.formulas, cfg);
121
122        // 1.6. Inject inline formulas into text regions
123        // PaddleX: Small formula elements that overlap with text elements should be
124        // absorbed into the text flow, not kept as separate layout elements.
125        // This creates TextRegion entries with label="formula" that will be wrapped
126        // with $...$ delimiters during text joining.
127        Self::inject_inline_formulas(&mut result.layout_elements, &mut regions, cfg);
128
129        // 2. Stitch text into layout elements (excluding special types)
130        // Note: after inject_inline_formulas, some formula elements have had their text cleared
131        // These won't be rendered separately in to_markdown
132        Self::stitch_layout_elements(
133            &mut result.layout_elements,
134            &regions,
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        // Note: fill_formula_elements was already called before inject_inline_formulas
146        // Do NOT call it again here, as it would re-fill formulas that were injected and cleared
147
148        // 3. Mark text regions that overlap with Seal elements as used
149        // to prevent them from becoming orphans.
150        // - Seals: content comes from specialized seal OCR.
151        // - Tables: content comes from OCR stitching. We do NOT suppress tables here because
152        //   text inside a table that wasn't assigned to a cell (in step 1) should be preserved
153        //   as an orphan (e.g. caption, header, or matching failure).
154        // - Formulas: now handled through normal OCR matching (step 2), already marked used.
155        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, &region.bounding_box, cfg) {
159                        used_region_indices.insert(idx);
160                    }
161                }
162            }
163        }
164
165        // 5. Handle unmatched text regions (create new layout elements)
166        // PP-StructureV3 alignment: Filter out orphan text regions that significantly overlap
167        // with table regions, as these are likely table cell text that failed to match cells.
168        // These shouldn't become separate layout elements.
169        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        // Collect figure/chart caption bboxes to infer undetected figure regions.
189        // When the layout model detects a caption (e.g. "Figure 3...") but misses
190        // the figure image itself, OCR text from the figure diagram becomes orphans.
191        // We infer the figure area as the region above each caption within its x-range.
192        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        // Collect text/title element bboxes to check if an orphan is already
207        // covered by a known content element (avoid filtering legitimate text)
208        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) = &region.text
228            {
229                // Filter out text that overlaps significantly with tables
230                // These are likely table cell text that didn't match any cell
231                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                    // Skip - this text is inside a table and should not be a separate element
237                    continue;
238                }
239
240                // Filter out text inside Image/Chart regions
241                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                // Filter out text in inferred figure regions (above figure/chart captions).
250                // When the layout model detects a caption but not the figure itself,
251                // OCR'd annotations from the figure diagram leak as orphan text.
252                // Check: orphan is above a caption, within its x-range, and not inside
253                // any existing text/title element.
254                let in_inferred_figure_region = figure_caption_bboxes.iter().any(|cap| {
255                    let orphan_bb = &region.bounding_box;
256                    // Orphan must be above or overlapping with the caption's top
257                    let above_caption = orphan_bb.y_max() < cap.y_max();
258                    // Orphan must be within the caption's horizontal range (with margin)
259                    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                    // Verify the orphan is NOT inside any existing text/title element
267                    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                // Check if this orphan region is a formula
276                // Create a new layout element for this orphan text
277                // If it's a formula (label="formula"), create a Formula element, otherwise Text
278                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 region_blocks exist, assign orphan elements to their containing regions
296        // and update element_indices to maintain proper grouping
297        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                // Find the region that best contains this orphan element
302                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                    // Check if this element overlaps with the region bbox
307                    let overlap = new_element.bbox.intersection_area(&region.bbox);
308                    if overlap > best_overlap {
309                        best_overlap = overlap;
310                        best_region_idx = Some(region_idx);
311                    }
312                }
313
314                // Add to the best matching region, or leave unassigned if no overlap
315                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        // 6. Sort all layout elements spatially and assign order indices
326        // PP-StructureV3: When region_blocks is present, elements are already sorted
327        // by hierarchical region order - skip re-sorting to preserve the structure
328        let width = if let Some(img) = &result.rectified_img {
329            img.width() as f32
330        } else {
331            // Estimate width from max x coordinate
332            result
333                .layout_elements
334                .iter()
335                .map(|e| e.bbox.x_max())
336                .fold(0.0f32, f32::max)
337                .max(1000.0) // default fallback
338        };
339
340        // When region_blocks exist, layout_elements are already sorted correctly
341        // by XY-cut with region hierarchy in structure.rs - do NOT re-sort here.
342        // Only sort when region_blocks is NOT present.
343        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        // Assign order indices regardless of sorting
358        Self::assign_order_indices(&mut result.layout_elements);
359    }
360
361    /// Assigns reading order indices to layout elements.
362    ///
363    /// Only elements that should be included in reading order get an index.
364    /// PP-StructureV3 includes: text, titles, tables, formulas, images, seals, etc.
365    fn assign_order_indices(elements: &mut [LayoutElement]) {
366        let mut order_index = 1u32;
367        for element in elements.iter_mut() {
368            // Assign order index to elements that should be in reading order
369            // (matching PP-StructureV3's visualize_index_labels)
370            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    /// Determines if an element type should have a reading order index.
378    ///
379    /// Based on PP-StructureV3's `visualize_index_labels`.
380    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            // Use the explicit is_e2e flag from the table analyzer to determine
415            // the matching strategy, instead of inferring from confidence values.
416            let has_detected_cells = table.detected_cell_bboxes.is_some();
417            let e2e_like_cells = table.is_e2e && !has_detected_cells;
418
419            // 1. Filter relevant text regions (those overlapping the table area)
420            let table_bbox = table.bbox.clone(); // Use table bbox
421            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, &region.bounding_box, cfg)
427                })
428                .map(|(idx, _)| idx)
429                .collect();
430
431            // 1.5. Cross-cell OCR splitting (new step)
432            // Detect OCR boxes that span multiple cells and split them at cell boundaries.
433            // This improves accuracy for complex tables with rowspan/colspan.
434            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            // Build OCR candidate pool (split regions + unsplit original regions).
446            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            // Mark split original indices as used and keep only unsplit originals in candidate pool.
463            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            // PaddleX: inject formula results into table OCR candidate pool with $...$
485            // wrapping (table_contents_for_img). This lets formulas participate in normal
486            // cell matching, so formula content appears in the correct table cells.
487            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            // Prefer PaddleX-style row-aware matching when structure tokens are available.
511            // Use row-aware matching when cell detection was used (non-E2E mode).
512            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            // Fallback matcher: assign each OCR box to the best-overlapping cell.
536            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 parity: allow nearest-cell assignment even when IoU=0.
542                        e2e_like_cells,  // E2E parity: use PaddleX distance metric.
543                    );
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            // Formulas are now injected into the OCR candidate pool above,
598            // so they participate in normal cell matching — no separate attach step needed.
599
600            // Optional postprocess for checkbox-style tables:
601            // normalize common OCR confusions like ü/L/X into ✓/✗ when the table
602            // clearly exhibits both positive and negative marker patterns.
603            Self::normalize_checkbox_symbols_in_table(&mut table.cells);
604
605            // Regenerate HTML from structure tokens and stitched cell text.
606            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                        // Use the mapping from row-aware matching
610                        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                        // Fallback: cells may not be in the same order as structure_tokens.
620                        // We need to create a mapping from cell bbox to its index, then
621                        // iterate through tokens to collect texts in the correct order.
622                        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    /// Fallback OCR->cell matcher using IoU+distance cost (PaddleX-compatible).
636    ///
637    /// Returns:
638    /// - `HashMap<cell_idx, Vec<candidate_idx>>`: assigned OCR candidates per cell
639    /// - `HashSet<candidate_idx>`: matched OCR candidate indices
640    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 = &region.bounding_box;
659
660            // Strategy 1: Center-point-in-cell with high IoA (strongest signal).
661            // If the OCR box center falls inside a cell AND the box has high overlap
662            // with that cell (IoA > 0.7), assign directly. The IoA check avoids
663            // misassignment for boxes that straddle cell boundaries.
664            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            // Strategy 2+3: IoU + distance fallback
681            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(&region.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, &region.bounding_box)
693                } else {
694                    Self::l1_distance(&region.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                        &region.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    /// PaddleX-compatible cost ordering with deterministic near-tie handling.
723    ///
724    /// PaddleX matches by sorting on `(1 - IoU, distance)` and taking the first index.
725    /// To avoid unstable flips from tiny float noise at row boundaries, we treat
726    /// near-equal costs as a tie and keep the earlier cell index.
727    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        // Ignore invalid candidates.
736        if !candidate_cost.0.is_finite() || !candidate_cost.1.is_finite() {
737            return false;
738        }
739
740        // First valid candidate always wins.
741        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    /// PaddleX-like boundary correction for E2E matching.
763    ///
764    /// PaddleX table structure boxes are integerized before matching; around row
765    /// boundaries, that can keep a straddling OCR fragment in the upper cell.
766    /// Our float boxes can shift this by <1 px and assign to the lower row.
767    /// For those near-boundary cases, prefer the directly upper cell in the same
768    /// column when both rows have substantial overlap.
769    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    /// Normalizes a few low-confidence tiny symbols toward PaddleX-like output.
828    ///
829    /// Tiny punctuation is sensitive to sub-pixel crop differences. We only apply
830    /// this to single-character, low-confidence candidates in very small boxes.
831    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                // Safe positive normalization.
889                'ü' | 'Ü' | '√' | '☑' => Some("✓"),
890                // Ambiguous L is normalized only when the table appears checkbox-like.
891                'L' if has_positive_candidate && has_negative_candidate => Some("✓"),
892                // Safe negative normalization.
893                '✕' | '✖' | '☒' => Some("✗"),
894                // Ambiguous X/x are normalized only when the table appears checkbox-like.
895                '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    /// PaddleX-style text concatenation for one cell.
906    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    /// PaddleX-style row-aware OCR-to-cell matching.
948    ///
949    /// Returns:
950    /// - `Vec<Option<usize>>`: for each `<td>` in structure order, the mapped cell index
951    /// - `HashSet<usize>`: matched OCR candidate indices
952    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        // --- Sort cells into rows ---
964        // Sort structure cells — their bboxes drive both IoA matching and the
965        // td→cell text-assignment step.  Detected-cell bboxes (cell_bboxes_override)
966        // are intentionally NOT used for IoA because the detected model can produce
967        // a different cell count per row than the structure tokens, causing local_idx
968        // to diverge from td_index and corrupting OCR-to-cell assignments.
969        //
970        // When cell_bboxes_override is present, cross-row OCR deduplication is
971        // enabled downstream to prevent large detected cells spanning multiple
972        // structure rows from duplicating content.
973        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        // Align structure-cell row flags with structure-token row boundaries.
986        // cell_aligned is used both for IoA matching (correct space) and td→cell mapping.
987        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        // --- Per-row matching: cell → OCR (PaddleX style) ---
997        // For each cell in the row, collect ALL OCR boxes with IoA > 0.7.
998        // When using detected cell bboxes (cell_bboxes_override is Some), apply
999        // cross-row deduplication: an OCR box already claimed by an earlier row is
1000        // not re-matched in a later row.  This prevents large detected cells that
1001        // span multiple structure rows from duplicating their content across those rows.
1002        // In pure E2E mode (cell_bboxes_override is None) the PaddleX v2 behavior of
1003        // independent per-row matching is preserved.
1004        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                // Always use structure cell bbox for IoA matching.  Detected-cell bboxes
1019                // (cell_bboxes_override) are not used here because their cell count per
1020                // row can differ from the structure td count, causing local_idx to
1021                // diverge from td_index and corrupt the OCR-to-cell assignment.
1022                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                    // IoA = intersection / OCR_area (PaddleX compute_inter > 0.7)
1029                    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        // --- Build td_to_cell_mapping by iterating structure tokens ---
1046        // table.cells maps exactly 1:1 with td tokens in structure order.
1047        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; // Reset cell index at row start
1058                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            // Map td position to the original cell index via sorted ordering.
1074            // Use cell_aligned (derived from structure-cell row flags) rather than
1075            // match_aligned (derived from detected-cell row flags).  When the two
1076            // models disagree on cell count per row, using match_aligned here would
1077            // offset into the wrong row of cell_sorted_indices.
1078            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    /// Collects cell texts in the order they appear in structure tokens.
1120    ///
1121    /// Uses grid-based `(row, col)` matching when cells have grid info, which
1122    /// correctly handles rowspan/colspan cases where cells.len() != td_count.
1123    /// Falls back to index-based matching when grid info is unavailable.
1124    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        // Parse grid positions for each <td> token
1133        let token_grid = parse_cell_grid_info(tokens);
1134        let td_count = token_grid.len();
1135
1136        // Build a lookup from (row, col) -> cell index for cells that have grid info
1137        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            // Grid-based matching: match tokens to cells by (row, col) position
1150            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            // Fallback: cells don't have grid info, use index-based matching
1161            (0..td_count)
1162                .map(|i| cells.get(i).and_then(|cell| cell.text.clone()))
1163                .collect()
1164        }
1165    }
1166
1167    /// Sort table cells row-by-row (top-to-bottom, left-to-right) and return row flags.
1168    ///
1169    /// Returns `(sorted_indices, flags)` where `flags` contains cumulative row starts.
1170    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    /// Find the first table-cell index for each row in structure tokens.
1241    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    /// Align row boundary flags from detected cells to structure row starts.
1265    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    /// Whether a structure token corresponds to the end of one table cell.
1283    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    /// Compose cell text from matched OCR fragments, mirroring PaddleX merge logic.
1290    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    /// Intersection over OCR area (`inter / rec2_area`), matching PaddleX `compute_inter`.
1335    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    /// Detects and splits OCR boxes that span multiple table cells.
1354    ///
1355    /// Returns:
1356    /// - Vec<TextRegion>: New text regions created from split OCR boxes
1357    /// - HashSet<usize>: Indices of original regions that were split
1358    /// - HashMap<usize, Vec<usize>>: Mapping from cell_idx -> indices in the new split_regions vec
1359    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        // Build a subset of text regions for the table
1375        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        // Use the cross-cell splitting utility
1385        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        // Map local indices back to original indices
1390        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        // Add expanded regions and track cell assignments
1397        for region in expanded {
1398            let region_idx = split_regions.len();
1399
1400            // Find the best matching cell for this expanded region
1401            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            // Only assign to a cell if there's actual overlap
1413            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    /// Calculates the Intersection over Union (IoU) between two bounding boxes.
1433    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    /// Calculates the L1 distance between two axis-aligned boxes.
1465    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    /// PaddleX table matcher distance (used in E2E path).
1476    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    /// Marks small inline formulas to be absorbed into the text flow.
1493    ///
1494    /// PaddleX: Small formula elements should be absorbed into the text flow,
1495    /// not kept as separate layout elements.
1496    ///
1497    /// This function:
1498    /// 1. Finds small formula elements that should be inline (not display formulas)
1499    /// 2. Clears their text and order_index so the formula element won't be rendered
1500    /// 3. The corresponding TextRegion with label="formula" (already created in structure.rs)
1501    ///    will become an orphan and be handled with proper $...$ wrapping
1502    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        // Size threshold: formulas smaller than 80k pixels² are likely inline
1512        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            // Only process formulas that have text
1520            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            // Small formulas are treated as inline
1539            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        // Clear inline formula elements so they won't be rendered separately
1551        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            // Skip special types that have their own content handling:
1583            // - Table: handled separately with cell-level matching
1584            // - Formula: filled with LaTeX content
1585            // - Seal: may have specialized seal OCR results
1586            // This matches PP-StructureV3's behavior in standardized_data()
1587            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) = &region.text
1597                    && Self::is_overlapping(&element.bbox, &region.bounding_box, cfg)
1598                {
1599                    element_texts.push((region, text));
1600                    // Only mark as used if not already used (to allow sharing if needed,
1601                    // though typically strict assignment is better. Some systems allow one-to-many
1602                    // matching, but here we track usage to find orphans)
1603                    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                // Debug: log all text regions being joined
1616                for (region, text) in &element_texts {
1617                    tracing::debug!("  - region with label={:?}, text={:?}", region.label, text);
1618                }
1619
1620                // Compute seg metadata (seg_start_x, seg_end_x, num_lines) for get_seg_flag.
1621                // Sort a copy to find first/last spans and count lines.
1622                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 = &current_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                    // seg_start_x: first span's left edge (PaddleX: line[0].spans[0].box[0])
1668                    element.seg_start_x = Some(first_region.bounding_box.x_min());
1669                    // seg_end_x: last span's right edge (PaddleX: line[-1].spans[-1].box[2])
1670                    element.seg_end_x = sorted_for_meta
1671                        .last()
1672                        .map(|(region, _)| region.bounding_box.x_max());
1673
1674                    // Count distinct lines (Y-groups)
1675                    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, &region.bounding_box, cfg) {
1679                            num_lines += 1;
1680                            prev_bbox = &region.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    /// Fills formula layout elements with LaTeX content from formula recognition results.
1694    ///
1695    /// This ensures formula elements have correct content even if OCR matching
1696    /// thresholds prevented proper association.
1697    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            // Skip if element already has content from OCR matching
1708            if element.text.is_some() {
1709                continue;
1710            }
1711
1712            // Find the best matching formula result by bidirectional IoA.
1713            // IoA (intersection / self_area) is much more permissive than IoU for
1714            // size-mismatched bboxes. PaddleX uses simple intersection overlap (>3px).
1715            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            // Fallback: if no IoA match, try center-containment matching.
1729            // Find formula whose center is within the element bbox (or vice versa).
1730            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    /// Checks if two bounding boxes overlap significantly (intersection dimensions > 3px).
1767    /// Matches `get_overlap_boxes_idx` logic.
1768    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    /// Checks whether two OCR spans should be grouped into the same visual line.
1791    ///
1792    /// Primary signal follows PaddleX-style line-height overlap:
1793    /// vertical_overlap / min(height1, height2) >= threshold.
1794    /// A small adaptive center-Y fallback is kept for robustness on noisy boxes.
1795    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        // Sort spatially: top-to-bottom, then left-to-right
1827        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 = &current_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        // Smart text joining following format_line logic:
1871        // - Texts on the same line are joined directly (no separator)
1872        // - A space is added only if the previous text ends with an English letter
1873        // - Newlines are added conditionally based on geometric gap (paragraph break detection)
1874        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                    &region.bounding_box,
1891                    cfg,
1892                ) {
1893                    // New visual line detected.
1894                    // Decide whether to insert '\n' (hard break) or ' ' (soft break/wrap).
1895                    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                        // PaddleX: English lines use a larger right-gap threshold.
1905                        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                            // Previous line ended far from the right edge → paragraph break.
1916                            add_newline = true;
1917                        } else {
1918                            // Previous line extends close to the right edge → line wrap.
1919                            is_line_wrap = true;
1920                        }
1921                    }
1922
1923                    // Dehyphenation: only strip trailing hyphen when the previous line
1924                    // is a wrapped line (extends close to container right edge).
1925                    // This preserves hyphens in compound words like "real-time",
1926                    // "end-to-end", "one-to-many" that end short lines.
1927                    // Matches PaddleX format_line behavior where hyphens are stripped
1928                    // at line-wrap boundaries.
1929                    let prev_ends_hyphen = result.ends_with('-');
1930                    if prev_ends_hyphen && is_line_wrap {
1931                        // Line wraps at hyphen → word-break hyphen, remove it
1932                        result.pop();
1933                        // Don't add any separator - words should be joined
1934                    } else if add_newline {
1935                        if !result.ends_with('\n') {
1936                            result.push('\n');
1937                        }
1938                    } else {
1939                        // Soft wrap - treat as space if needed (English) or join (CJK)
1940                        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                    // Same visual line - join with smart spacing
1949                    // PaddleX format_line: add space after English letters OR after formulas
1950                    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                        // PaddleX: add space after formula when next content is on same line
1957                        last_region.is_formula()
1958                    };
1959
1960                    if needs_spacing {
1961                        result.push(' ');
1962                    }
1963                }
1964            }
1965
1966            // PaddleX: formula spans are wrapped with $...$ delimiters
1967            // Inline formulas (mixed with text on same line): $formula$
1968            // Display formulas (standalone line): $$formula$$ (display math)
1969            let is_formula = region.is_formula();
1970            let text_to_add = if is_formula {
1971                // Don't double-wrap if formula model already added delimiters
1972                let already_wrapped =
1973                    text.starts_with('$') || text.starts_with("\\(") || text.starts_with("\\[");
1974                if already_wrapped {
1975                    text.to_string()
1976                } else {
1977                    // Check if this is a display formula (starts a new line with no other content yet on this line)
1978                    // Display formulas typically appear at the start of a line after a newline
1979                    let is_display = result.is_empty() || result.ends_with('\n');
1980
1981                    if is_display {
1982                        // Display formula: $$...$$
1983                        format!("$${}$$", text)
1984                    } else {
1985                        // Inline formula: $...$
1986                        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        // Trim trailing whitespace
1998        let joined = result.trim_end().to_string();
1999        update_fn(joined);
2000    }
2001
2002    /// Sorts layout elements using the enhanced xycut_enhanced algorithm.
2003    ///
2004    /// Uses cross-layout detection, direction-aware XY-cut, overlapping box shrinking,
2005    /// weighted distance insertion, and child block association for accurate reading order.
2006    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    /// Sorts layout elements using the XY-cut algorithm (legacy fallback).
2039    #[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        // Use shared XY-cut implementation from processors module.
2046        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
2066/// Checks if a space should be added after the given character.
2067/// Based on format_line logic: add space only after English letters.
2068fn 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
2076/// Punctuation that should not trigger hard paragraph breaks across line wraps.
2077fn 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), // row0 col1
2259            TableCell::new(BoundingBox::from_coords(0.0, 0.0, 50.0, 20.0), 1.0),   // row0 col0
2260            TableCell::new(BoundingBox::from_coords(0.0, 20.0, 50.0, 40.0), 1.0),  // row1 col0
2261            TableCell::new(BoundingBox::from_coords(50.0, 20.0, 100.0, 40.0), 1.0), // row1 col1
2262        ];
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        // Near a row boundary, tiny float jitter should not flip assignment order.
2342        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        // PaddleX-style tie break keeps the first cell index.
2359        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        // Mirrors the remaining PaddleX mismatch case where a tiny OCR fragment straddles
2366        // two adjacent rows in the same column.
2367        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}