Skip to main content

oar_ocr_core/domain/
structure.rs

1//! Document structure analysis result types.
2//!
3//! This module defines the result types for document structure analysis,
4//! including layout detection, table recognition, and formula recognition.
5
6use super::text_region::TextRegion;
7use crate::processors::BoundingBox;
8use image::RgbImage;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::Arc;
14use std::sync::LazyLock;
15
16/// Title numbering pattern for detecting section numbers like 1, 1.2, 1.2.3, (1), 一、etc.
17/// This follows standard title numbering pattern.
18static TITLE_NUMBERING_REGEX: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(
20        r"(?x)
21        ^\s*
22        (
23            # Arabic numerals: 1, 1.2, 1.2.3, etc.
24            [1-9][0-9]*(?:\.[1-9][0-9]*)*[\.、]?
25            |
26            # Parenthesized Arabic numerals: (1), (1.2), etc.
27            [((][1-9][0-9]*(?:\.[1-9][0-9]*)*[))]
28            |
29            # Chinese numerals with punctuation: 一、 二、
30            [一二三四五六七八九十百千万亿零壹贰叁肆伍陆柒捌玖拾][、.]?
31            |
32            # Parenthesized Chinese numerals: (一)
33            [((][一二三四五六七八九十百千万亿零壹贰叁肆伍陆柒捌玖拾]+[))]
34            |
35            # Roman numerals with delimiter (period or followed by space)
36            (?:I|II|III|IV|V|VI|VII|VIII|IX|X)(?:\.|\b)
37        )
38        (\s+)
39        (.*)
40        $
41    ",
42    )
43    .unwrap_or_else(|e| panic!("Invalid title numbering regex: {e}"))
44});
45
46/// Format a paragraph title with automatic level detection based on numbering.
47///
48/// Following PaddleX's title formatting logic:
49/// - Extracts numbering prefix (1.2.3, etc.)
50/// - Determines heading level from number of dots
51/// - Returns (level, formatted_title) where level starts from 2 (## for paragraph titles)
52///
53/// PaddleX logic: `level = dots + 1`, then uses `#{'#' * level}` which means:
54/// - "1 Introduction" (0 dots) -> level=1 -> `## 1 Introduction`
55/// - "2.1 Method" (1 dot) -> level=2 -> `### 2.1 Method`
56/// - "2.1.1 Details" (2 dots) -> level=3 -> `#### 2.1.1 Details`
57///
58/// To align with PaddleX, we return level+1 to account for the extra `#`:
59/// - "1 Introduction" -> (2, "1 Introduction") -> `## 1 Introduction`
60/// - "2.1 Method" -> (3, "2.1 Method") -> `### 2.1 Method`
61/// - "2.1.1 Details" -> (4, "2.1.1 Details") -> `#### 2.1.1 Details`
62fn semantic_title_level_and_format(cleaned: &str) -> Option<(usize, String)> {
63    let trimmed = cleaned.trim();
64
65    // Common unnumbered top-level section headers.
66    let keyword = trimmed.trim_end_matches(':').to_ascii_uppercase();
67    if matches!(
68        keyword.as_str(),
69        "ABSTRACT" | "INTRODUCTION" | "REFERENCES" | "REFERENCE"
70    ) {
71        return Some((2, trimmed.to_string()));
72    }
73
74    if let Some(captures) = TITLE_NUMBERING_REGEX.captures(cleaned) {
75        let numbering = captures.get(1).map(|m| m.as_str().trim()).unwrap_or("");
76        let title_content = captures.get(3).map(|m| m.as_str()).unwrap_or("");
77
78        let dot_count = numbering.matches('.').count();
79        let level = (dot_count + 2).clamp(2, 6);
80
81        let formatted = if title_content.is_empty() {
82            numbering.trim_end_matches('.').to_string()
83        } else {
84            format!(
85                "{} {}",
86                numbering.trim_end_matches('.'),
87                title_content.trim_start()
88            )
89        };
90        return Some((level, formatted));
91    }
92
93    None
94}
95
96fn semantic_title_level(text: &str) -> Option<usize> {
97    let cleaned = text.replace("-\n", "").replace('\n', " ");
98    semantic_title_level_and_format(&cleaned).map(|(level, _)| level)
99}
100
101fn format_title_with_level(title: &str, clustered_level: Option<usize>) -> (usize, String) {
102    // Clean up line breaks
103    let cleaned = title.replace("-\n", "").replace('\n', " ");
104    if let Some((level, formatted)) = semantic_title_level_and_format(&cleaned) {
105        return (level, formatted);
106    }
107
108    // No semantic signal: use voting hint from relative/font-size signals.
109    let level = clustered_level.unwrap_or(2).clamp(2, 6);
110    (level, cleaned)
111}
112
113/// Estimate per-title heading levels using three-signal voting:
114/// 1) semantic numbering/keyword level
115/// 2) relative indentation order
116/// 3) font-size k-means (k<=4)
117///
118fn infer_paragraph_title_levels(elements: &[LayoutElement]) -> HashMap<usize, usize> {
119    let title_indices: Vec<usize> = elements
120        .iter()
121        .enumerate()
122        .filter(|(_, e)| e.element_type == LayoutElementType::ParagraphTitle)
123        .map(|(idx, _)| idx)
124        .collect();
125    if title_indices.is_empty() {
126        return HashMap::new();
127    }
128
129    let height_samples: Vec<(usize, f32)> = title_indices
130        .iter()
131        .filter_map(|&idx| {
132            let e = &elements[idx];
133            let height = (e.bbox.y_max() - e.bbox.y_min()).max(1.0);
134            let line_h = height / e.num_lines.unwrap_or(1).max(1) as f32;
135            let v = line_h.max(1.0);
136            if v.is_finite() { Some((idx, v)) } else { None }
137        })
138        .collect();
139
140    let indent_samples: Vec<(usize, f32)> = title_indices
141        .iter()
142        .filter_map(|&idx| {
143            let x = elements[idx].bbox.x_min();
144            if x.is_finite() { Some((idx, x)) } else { None }
145        })
146        .collect();
147    let semantic_levels: HashMap<usize, usize> = title_indices
148        .iter()
149        .filter_map(|&idx| {
150            elements[idx]
151                .text
152                .as_deref()
153                .and_then(semantic_title_level)
154                .map(|level| (idx, level))
155        })
156        .collect();
157
158    let font_levels = infer_levels_by_kmeans_feature(&height_samples, true);
159    // Smaller x_min (less indent) -> higher-level heading.
160    let relative_levels = infer_levels_by_kmeans_feature(&indent_samples, false);
161
162    let mut voted = HashMap::new();
163    for idx in title_indices {
164        let semantic_level = semantic_levels.get(&idx).copied();
165        let font_level = font_levels.get(&idx).copied();
166        let relative_level = relative_levels.get(&idx).copied();
167
168        let mut score = [0u8; 7];
169        if let Some(level) = semantic_level {
170            score[level.clamp(1, 6)] += 2;
171        }
172        if let Some(level) = font_level {
173            score[level.clamp(1, 6)] += 1;
174        }
175        if let Some(level) = relative_level {
176            score[level.clamp(1, 6)] += 1;
177        }
178
179        let mut best_level = semantic_level.unwrap_or(2);
180        let mut best_score = 0u8;
181        for (level, &s) in score.iter().enumerate().skip(1) {
182            if s > best_score {
183                best_score = s;
184                best_level = level;
185            } else if s == best_score && s > 0 {
186                let is_semantic = semantic_level == Some(level);
187                let best_is_semantic = semantic_level == Some(best_level);
188                if (is_semantic && !best_is_semantic)
189                    || (is_semantic == best_is_semantic && level < best_level)
190                {
191                    best_level = level;
192                }
193            }
194        }
195
196        if best_score == 0 {
197            best_level = semantic_level
198                .or(font_level)
199                .or(relative_level)
200                .unwrap_or(2);
201        }
202
203        voted.insert(idx, best_level.clamp(1, 6));
204    }
205
206    voted
207}
208
209/// Cluster one scalar feature into heading levels with 1D k-means.
210///
211/// `descending=true` means larger feature -> higher-level heading (smaller markdown depth).
212/// `descending=false` means smaller feature -> higher-level heading.
213fn infer_levels_by_kmeans_feature(
214    samples: &[(usize, f32)],
215    descending: bool,
216) -> HashMap<usize, usize> {
217    let clean_samples: Vec<(usize, f32)> = samples
218        .iter()
219        .copied()
220        .filter(|(_, v)| v.is_finite())
221        .collect();
222    if clean_samples.len() < 2 {
223        return HashMap::new();
224    }
225
226    let mut values: Vec<f32> = clean_samples.iter().map(|(_, v)| *v).collect();
227    values.sort_by(|a, b| a.total_cmp(b));
228    let unique_count = values
229        .windows(2)
230        .filter(|w| (w[1] - w[0]).abs() > 1e-3)
231        .count()
232        + 1;
233    let k = unique_count.clamp(1, 4).min(clean_samples.len());
234    if k <= 1 {
235        return HashMap::new();
236    }
237
238    let mut centroids = (0..k)
239        .map(|i| {
240            let pos = ((i as f32 + 0.5) / k as f32 * values.len() as f32).floor() as usize;
241            values[pos.min(values.len() - 1)]
242        })
243        .collect::<Vec<_>>();
244
245    for _ in 0..16 {
246        let mut sums = vec![0.0f32; k];
247        let mut counts = vec![0usize; k];
248        for (_, value) in &clean_samples {
249            let mut best_idx = 0usize;
250            let mut best_dist = f32::INFINITY;
251            for (idx, c) in centroids.iter().enumerate() {
252                let dist = (value - c).abs();
253                if dist < best_dist {
254                    best_dist = dist;
255                    best_idx = idx;
256                }
257            }
258            sums[best_idx] += *value;
259            counts[best_idx] += 1;
260        }
261        for idx in 0..k {
262            if counts[idx] > 0 {
263                centroids[idx] = sums[idx] / counts[idx] as f32;
264            }
265        }
266    }
267
268    let mut centroid_order: Vec<(usize, f32)> = centroids.iter().copied().enumerate().collect();
269    if descending {
270        centroid_order.sort_by(|a, b| b.1.total_cmp(&a.1));
271    } else {
272        centroid_order.sort_by(|a, b| a.1.total_cmp(&b.1));
273    }
274    let rank_by_cluster: HashMap<usize, usize> = centroid_order
275        .into_iter()
276        .enumerate()
277        .map(|(rank, (cluster_idx, _))| (cluster_idx, rank))
278        .collect();
279
280    let mut result = HashMap::new();
281    for (element_idx, value) in &clean_samples {
282        let mut best_idx = 0usize;
283        let mut best_dist = f32::INFINITY;
284        for (idx, c) in centroids.iter().enumerate() {
285            let dist = (value - c).abs();
286            if dist < best_dist {
287                best_dist = dist;
288                best_idx = idx;
289            }
290        }
291        let rank = rank_by_cluster.get(&best_idx).copied().unwrap_or(0);
292        let level = (rank + 2).clamp(2, 6);
293        result.insert(*element_idx, level);
294    }
295
296    result
297}
298
299/// A detected document region block (from PP-DocBlockLayout).
300///
301/// Region blocks represent hierarchical groupings of layout elements,
302/// typically columns or logical sections of a document. They are used
303/// for hierarchical reading order determination.
304///
305/// # PP-StructureV3 Alignment
306///
307/// PP-DocBlockLayout detects "region" type blocks that group related
308/// layout elements together. Elements within the same region should
309/// be read together before moving to the next region.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct RegionBlock {
312    /// Bounding box of the region
313    pub bbox: BoundingBox,
314    /// Confidence score of the detection
315    pub confidence: f32,
316    /// Index of this region in the reading order
317    pub order_index: Option<u32>,
318    /// Indices of layout elements that belong to this region
319    pub element_indices: Vec<usize>,
320}
321
322/// Page continuation flags for multi-page document processing.
323///
324/// These flags indicate whether the page starts or ends in the middle of
325/// a semantic paragraph, which is crucial for properly concatenating
326/// markdown output from multiple pages.
327///
328/// - `paragraph_start`: `false` means this page continues a paragraph from previous page
329/// - `paragraph_end`: `false` means this page's content continues to next page
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct PageContinuationFlags {
332    /// Whether the first element on this page is a paragraph continuation
333    pub paragraph_start: bool,
334    /// Whether the last element on this page continues to the next page
335    pub paragraph_end: bool,
336}
337
338impl PageContinuationFlags {
339    pub fn new(paragraph_start: bool, paragraph_end: bool) -> Self {
340        Self {
341            paragraph_start,
342            paragraph_end,
343        }
344    }
345
346    /// Returns the tuple format (is_start, is_end) for compatibility
347    pub fn as_tuple(&self) -> (bool, bool) {
348        (self.paragraph_start, self.paragraph_end)
349    }
350}
351
352/// Result of document structure analysis.
353///
354/// This struct contains all the results from analyzing a document's structure,
355/// including layout elements, tables, formulas, and OCR results.
356///
357/// # Coordinate System
358///
359/// The coordinate system of bounding boxes depends on which preprocessing was applied:
360///
361/// - **No preprocessing**: Boxes are in the original input image's coordinate system.
362///
363/// - **Orientation correction only** (`orientation_angle` set, `rectified_img` is None):
364///   Boxes are transformed back to the original input image's coordinate system.
365///
366/// - **Rectification applied** (`rectified_img` is Some):
367///   Boxes remain in the **rectified image's coordinate system**. Neural network-based
368///   rectification (UVDoc) warps cannot be precisely inverted, so use `rectified_img`
369///   for visualization instead of the original image.
370///
371/// - **Both orientation and rectification**: Boxes are in the rectified coordinate system
372///   (rectification takes precedence since it's applied after orientation correction).
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct StructureResult {
375    /// Path to the input image file
376    pub input_path: Arc<str>,
377    /// Index of the image in a batch (0 for single image processing)
378    pub index: usize,
379    /// Detected layout elements (text regions, tables, figures, etc.)
380    pub layout_elements: Vec<LayoutElement>,
381    /// Recognized tables with their structure and content
382    pub tables: Vec<TableResult>,
383    /// Recognized mathematical formulas
384    pub formulas: Vec<FormulaResult>,
385    /// OCR text regions (if OCR was integrated)
386    pub text_regions: Option<Vec<TextRegion>>,
387    /// Document orientation angle (if orientation correction was used)
388    pub orientation_angle: Option<f32>,
389    /// Detected region blocks for hierarchical ordering (PP-DocBlockLayout)
390    /// When present, layout_elements are already sorted by region hierarchy
391    pub region_blocks: Option<Vec<RegionBlock>>,
392    /// Rectified image (if document rectification was used)
393    /// Note: Bounding boxes are already transformed back to original coordinates for rotation,
394    /// but for rectification (UVDoc), boxes are in the rectified image's coordinate system.
395    /// Use this image for visualization when rectification was applied.
396    #[serde(skip)]
397    pub rectified_img: Option<Arc<RgbImage>>,
398    /// Page continuation flags for multi-page document processing.
399    /// This indicates whether this page continues a paragraph from the previous page
400    /// or continues to the next page, which is crucial for proper markdown concatenation.
401    pub page_continuation_flags: Option<PageContinuationFlags>,
402}
403
404impl StructureResult {
405    /// Creates a new structure result.
406    pub fn new(input_path: impl Into<Arc<str>>, index: usize) -> Self {
407        Self {
408            input_path: input_path.into(),
409            index,
410            layout_elements: Vec::new(),
411            tables: Vec::new(),
412            formulas: Vec::new(),
413            text_regions: None,
414            orientation_angle: None,
415            region_blocks: None,
416            rectified_img: None,
417            page_continuation_flags: None,
418        }
419    }
420
421    /// Adds layout elements to the result.
422    pub fn with_layout_elements(mut self, elements: Vec<LayoutElement>) -> Self {
423        self.layout_elements = elements;
424        self
425    }
426
427    /// Adds tables to the result.
428    pub fn with_tables(mut self, tables: Vec<TableResult>) -> Self {
429        self.tables = tables;
430        self
431    }
432
433    /// Adds formulas to the result.
434    pub fn with_formulas(mut self, formulas: Vec<FormulaResult>) -> Self {
435        self.formulas = formulas;
436        self
437    }
438
439    /// Adds OCR text regions to the result.
440    pub fn with_text_regions(mut self, regions: Vec<TextRegion>) -> Self {
441        self.text_regions = Some(regions);
442        self
443    }
444
445    /// Adds region blocks to the result (PP-DocBlockLayout).
446    ///
447    /// Region blocks represent hierarchical groupings of layout elements.
448    /// When set, layout_elements should already be sorted by region hierarchy.
449    pub fn with_region_blocks(mut self, blocks: Vec<RegionBlock>) -> Self {
450        self.region_blocks = Some(blocks);
451        self
452    }
453
454    /// Sets page continuation flags for multi-page document processing.
455    pub fn with_page_continuation_flags(mut self, flags: PageContinuationFlags) -> Self {
456        self.page_continuation_flags = Some(flags);
457        self
458    }
459
460    /// Converts the result to a Markdown string.
461    ///
462    /// Follows PP-StructureV3's formatting rules:
463    /// - DocTitle: `# title`
464    /// - ParagraphTitle: Auto-detect numbering (1.2.3 -> ###)
465    /// - Formula: `$$latex$$`
466    /// - Table: HTML with border
467    /// - Images: `![Figure](caption)`
468    ///
469    /// Note: Low-confidence text elements that overlap with table regions are filtered out
470    /// to avoid duplicate content from table OCR.
471    pub fn to_markdown(&self) -> String {
472        // Collect table bboxes for overlap filtering
473        let table_bboxes: Vec<&BoundingBox> = self
474            .layout_elements
475            .iter()
476            .filter(|e| e.element_type == LayoutElementType::Table)
477            .map(|e| &e.bbox)
478            .collect();
479
480        // Compute original image width for image scaling (PaddleX: original_image_width)
481        let original_image_width = self
482            .rectified_img
483            .as_ref()
484            .map(|img| img.width() as f32)
485            .or_else(|| {
486                // Estimate from max element x-coordinate
487                self.layout_elements
488                    .iter()
489                    .map(|e| e.bbox.x_max())
490                    .fold(None, |acc, x| Some(acc.map_or(x, |max: f32| max.max(x))))
491            })
492            .unwrap_or(1.0);
493
494        let mut md = String::new();
495        let elements = &self.layout_elements;
496        let paragraph_title_levels = infer_paragraph_title_levels(elements);
497        // Track the most recent Text/ReferenceContent element so paragraph
498        // continuation works across intervening figures/tables.
499        let mut prev_text_element: Option<&LayoutElement> = None;
500
501        for (idx, element) in elements.iter().enumerate() {
502            // PP-StructureV3 markdown ignores auxiliary labels.
503            if matches!(
504                element.element_type,
505                LayoutElementType::Number
506                    | LayoutElementType::Footnote
507                    | LayoutElementType::Header
508                    | LayoutElementType::HeaderImage
509                    | LayoutElementType::Footer
510                    | LayoutElementType::FooterImage
511                    | LayoutElementType::AsideText
512            ) {
513                continue;
514            }
515
516            // Filter out low-confidence text elements that overlap with tables
517            // These are typically OCR artifacts from table cell text that shouldn't be
518            // output separately in markdown
519            if element.element_type == LayoutElementType::Text {
520                let overlaps_table = table_bboxes.iter().any(|table_bbox| {
521                    element.bbox.ioa(table_bbox) > 0.3 // >30% of text is inside table
522                });
523
524                // Skip low-confidence text that overlaps with table regions
525                // Standard logic filters these in the stitching phase
526                if overlaps_table && element.confidence < 0.7 {
527                    continue;
528                }
529            }
530
531            // Determine seg_start_flag for paragraph continuity (PaddleX get_seg_flag).
532            // When both current and previous are "text" and seg_start_flag is false,
533            // they belong to the same paragraph — join without \n\n separator.
534            let seg_start_flag = get_seg_flag(element, prev_text_element);
535
536            let is_continuation = element.element_type == LayoutElementType::Text
537                && prev_text_element.is_some()
538                && !seg_start_flag;
539
540            // Add separator between elements
541            if !is_continuation {
542                // Normal case: separate elements with blank line
543            }
544
545            match element.element_type {
546                // Document title
547                LayoutElementType::DocTitle => {
548                    if !md.is_empty() {
549                        md.push_str("\n\n");
550                    }
551                    if let Some(text) = &element.text {
552                        let cleaned = clean_ocr_text(text);
553                        // Downgrade section-level keywords to ## when misclassified as DocTitle
554                        let keyword = cleaned.trim().trim_end_matches(':').to_ascii_uppercase();
555                        if matches!(
556                            keyword.as_str(),
557                            "ABSTRACT" | "INTRODUCTION" | "REFERENCES" | "REFERENCE"
558                        ) {
559                            md.push_str("## ");
560                        } else {
561                            md.push_str("# ");
562                        }
563                        md.push_str(&cleaned);
564                    }
565                }
566                // Paragraph/section title - auto-detect numbering for level
567                LayoutElementType::ParagraphTitle => {
568                    if !md.is_empty() {
569                        md.push_str("\n\n");
570                    }
571                    if let Some(text) = &element.text {
572                        let cleaned = clean_ocr_text(text);
573                        let clustered = paragraph_title_levels.get(&idx).copied();
574                        let (level, formatted_title) = format_title_with_level(&cleaned, clustered);
575                        for _ in 0..level {
576                            md.push('#');
577                        }
578                        md.push(' ');
579                        md.push_str(&formatted_title);
580                    } else {
581                        md.push_str("## ");
582                    }
583                }
584                // Table - preserve HTML structure with border and center alignment
585                // Following PaddleX's format with <div style="text-align: center;"> wrapper
586                LayoutElementType::Table => {
587                    if !md.is_empty() {
588                        md.push_str("\n\n");
589                    }
590                    if let Some(table) =
591                        self.tables.iter().find(|t| t.bbox.iou(&element.bbox) > 0.5)
592                    {
593                        if let Some(html) = &table.html_structure {
594                            // Simplify table HTML (remove html/body wrappers) and add border
595                            let simplified = simplify_table_html(html);
596                            let table_with_border =
597                                simplified.replacen("<table>", "<table border=\"1\">", 1);
598                            // PaddleX format_centered_by_html: clean newlines then wrap
599                            let cleaned = clean_ocr_text(&table_with_border);
600                            md.push_str("<div style=\"text-align: center;\">");
601                            md.push_str(&cleaned);
602                            md.push_str("</div>");
603                        } else {
604                            md.push_str("[Table]");
605                        }
606                    } else {
607                        md.push_str("[Table]");
608                    }
609                }
610                // FormulaNumber - equation labels like "(1)", "Eq. 1" etc.
611                // PaddleX does NOT include formula_number in handle_funcs_dict,
612                // so these are silently skipped in markdown output.
613                LayoutElementType::FormulaNumber => {
614                    continue;
615                }
616                // Formula - detect inline vs display formula based on context
617                LayoutElementType::Formula => {
618                    // Extract and clean LaTeX content; skip if empty
619                    let raw_content = element.text.as_deref().map(|s| s.trim()).unwrap_or("");
620                    if raw_content.is_empty() {
621                        continue;
622                    }
623                    // Remove only outer $$ or $ wrappers if present (from table cell injection)
624                    let latex_content =
625                        if raw_content.starts_with("$$") && raw_content.ends_with("$$") {
626                            &raw_content[2..raw_content.len() - 2]
627                        } else if raw_content.starts_with('$') && raw_content.ends_with('$') {
628                            &raw_content[1..raw_content.len() - 1]
629                        } else {
630                            raw_content
631                        };
632
633                    // Check if this formula is on the same line as adjacent text elements
634                    // to determine if it's an inline formula or display formula.
635                    // Only consider the nearest non-formula/non-formula-number neighbor
636                    // on each side, and require BOTH sides to have text on the same line.
637                    // This prevents display formulas from being misclassified as inline
638                    // when they happen to be vertically aligned with a distant text block.
639                    let is_inline = {
640                        let has_prev_text = (0..idx)
641                            .rev()
642                            .find(|&i| {
643                                let t = elements[i].element_type;
644                                !t.is_formula() && t != LayoutElementType::FormulaNumber
645                            })
646                            .is_some_and(|i| {
647                                let prev = &elements[i];
648                                (prev.element_type == LayoutElementType::Text
649                                    || prev.element_type == LayoutElementType::ReferenceContent)
650                                    && is_same_line(&element.bbox, &prev.bbox)
651                            });
652
653                        let has_next_text = ((idx + 1)..elements.len())
654                            .find(|&i| {
655                                let t = elements[i].element_type;
656                                !t.is_formula() && t != LayoutElementType::FormulaNumber
657                            })
658                            .is_some_and(|i| {
659                                let next = &elements[i];
660                                (next.element_type == LayoutElementType::Text
661                                    || next.element_type == LayoutElementType::ReferenceContent)
662                                    && is_same_line(&element.bbox, &next.bbox)
663                            });
664
665                        // Require text on BOTH sides for inline — a formula with text
666                        // only on one side is almost always a display equation.
667                        has_prev_text && has_next_text
668                    };
669
670                    if is_inline {
671                        // Inline formula: use $...$
672                        md.push('$');
673                        md.push_str(latex_content);
674                        md.push_str("$ ");
675                    } else {
676                        // Display formula: use $$...$$
677                        if !md.is_empty() {
678                            md.push_str("\n\n");
679                        }
680                        md.push_str("$$");
681                        md.push_str(latex_content);
682                        md.push_str("$$");
683                    }
684                }
685                // Image/Chart - figure format with center alignment
686                LayoutElementType::Image | LayoutElementType::Chart => {
687                    if !md.is_empty() {
688                        md.push_str("\n\n");
689                    }
690                    // Use HTML img tag with center alignment for better rendering
691                    md.push_str("<div style=\"text-align: center;\"><img src=\"");
692                    // Generate a placeholder image name based on element bbox
693                    let img_name = format!(
694                        "imgs/img_in_{}_box_{:.0}_{:.0}_{:.0}_{:.0}.jpg",
695                        if element.element_type == LayoutElementType::Chart {
696                            "chart"
697                        } else {
698                            "image"
699                        },
700                        element.bbox.x_min(),
701                        element.bbox.y_min(),
702                        element.bbox.x_max(),
703                        element.bbox.y_max()
704                    );
705                    md.push_str(&img_name);
706                    md.push_str("\" alt=\"Image\" width=\"");
707                    // Calculate width percentage relative to original image width (PaddleX logic)
708                    let image_width = element.bbox.x_max() - element.bbox.x_min();
709                    let width_pct = (image_width / original_image_width * 100.0) as u32;
710                    let width_pct = width_pct.clamp(1, 100);
711                    md.push_str(&format!("{}%", width_pct));
712                    md.push_str("\" /></div>");
713                }
714                // Seal - show as image with text
715                LayoutElementType::Seal => {
716                    if !md.is_empty() {
717                        md.push_str("\n\n");
718                    }
719                    md.push_str("![Seal]");
720                    if let Some(text) = &element.text {
721                        md.push_str("\n> ");
722                        md.push_str(text);
723                    }
724                }
725                // Captions - with center alignment following PaddleX
726                _ if element.element_type.is_caption() => {
727                    if let Some(text) = &element.text {
728                        if !md.is_empty() {
729                            md.push_str("\n\n");
730                        }
731                        let cleaned = clean_ocr_text(text);
732                        md.push_str("<div style=\"text-align: center;\">");
733                        md.push_str(&cleaned);
734                        md.push_str(" </div>");
735                    }
736                }
737                // Abstract - following PaddleX's format_first_line_func with spliter=" "
738                LayoutElementType::Abstract => {
739                    if let Some(text) = &element.text {
740                        if !md.is_empty() {
741                            md.push_str("\n\n");
742                        }
743                        let formatted = format_first_line(text, " ", &["abstract", "摘要"], "## ");
744                        md.push_str(&formatted);
745                    }
746                }
747                // Reference - following PaddleX's format_first_line_func with spliter="\n"
748                LayoutElementType::Reference => {
749                    if let Some(text) = &element.text {
750                        if !md.is_empty() {
751                            md.push_str("\n\n");
752                        }
753                        let formatted =
754                            format_first_line(text, "\n", &["references", "参考文献"], "## ");
755                        md.push_str(&formatted);
756                    }
757                }
758                // Content (table of contents) - following PaddleX's soft breaks
759                LayoutElementType::Content => {
760                    if let Some(text) = &element.text {
761                        if !md.is_empty() {
762                            md.push_str("\n\n");
763                        }
764                        let formatted = format_content_block(text);
765                        md.push_str(&formatted);
766                    }
767                }
768                // Footnote - following PaddleX's vision_footnote handling
769                LayoutElementType::Footnote => {
770                    if let Some(text) = &element.text {
771                        if !md.is_empty() {
772                            md.push_str("\n\n");
773                        }
774                        let formatted = format_vision_footnote_block(text);
775                        md.push_str(&formatted);
776                    }
777                }
778                // List
779                LayoutElementType::List => {
780                    if let Some(text) = &element.text {
781                        if !md.is_empty() {
782                            md.push_str("\n\n");
783                        }
784                        let cleaned = format_text_block(text);
785                        // Split by newlines and format as list items
786                        for line in cleaned.lines() {
787                            let line = line.trim();
788                            if !line.is_empty() {
789                                md.push_str("- ");
790                                md.push_str(line);
791                                md.push('\n');
792                            }
793                        }
794                    }
795                }
796                // Algorithm block - PaddleX: block.content.strip("\n")
797                LayoutElementType::Algorithm => {
798                    if let Some(text) = &element.text {
799                        if !md.is_empty() {
800                            md.push_str("\n\n");
801                        }
802                        md.push_str(text.trim_matches('\n'));
803                    }
804                }
805                // Header/Footer - smaller text (typically excluded from markdown)
806                _ if element.element_type.is_header() || element.element_type.is_footer() => {
807                    // Skip headers and footers in markdown output
808                    // They typically contain page numbers and repeating info
809                    continue;
810                }
811                // Default text elements - following PaddleX's text handling
812                _ => {
813                    if let Some(text) = &element.text {
814                        let cleaned = clean_ocr_text(text);
815                        if has_bullet_markers(&cleaned) {
816                            if !md.is_empty() {
817                                md.push_str("\n\n");
818                            }
819                            format_as_bullet_list(&cleaned, &mut md);
820                        } else if is_continuation {
821                            let formatted = format_text_block(text);
822                            md.push_str(&formatted);
823                        } else {
824                            if !md.is_empty() {
825                                md.push_str("\n\n");
826                            }
827                            let formatted = format_text_block(text);
828                            md.push_str(&formatted);
829                        }
830                    }
831                }
832            }
833
834            if element.element_type == LayoutElementType::Text
835                || element.element_type == LayoutElementType::ReferenceContent
836            {
837                prev_text_element = Some(element);
838            }
839        }
840        md.trim().to_string()
841    }
842
843    /// Calculates the page continuation flags for this result.
844    ///
845    /// This follows PaddleX's `get_seg_flag` logic to determine whether
846    /// the page starts/ends in the middle of a semantic paragraph.
847    ///
848    /// Returns (paragraph_start, paragraph_end) where:
849    /// - `paragraph_start`: false means page continues from previous
850    /// - `paragraph_end`: false means content continues to next page
851    pub fn calculate_continuation_flags(&self) -> PageContinuationFlags {
852        let elements = &self.layout_elements;
853
854        if elements.is_empty() {
855            return PageContinuationFlags::new(true, true);
856        }
857
858        // Estimate page width from rectified image or element bboxes
859        let page_width = self
860            .rectified_img
861            .as_ref()
862            .map(|img| img.width() as f32)
863            .or_else(|| {
864                elements
865                    .iter()
866                    .map(|e| e.bbox.x_max())
867                    .fold(None, |acc, x| Some(acc.map_or(x, |max: f32| max.max(x))))
868            });
869
870        // Filter to only text elements for continuation analysis
871        let text_elements: Vec<_> = elements
872            .iter()
873            .filter(|e| {
874                matches!(
875                    e.element_type,
876                    LayoutElementType::Text
877                        | LayoutElementType::DocTitle
878                        | LayoutElementType::ParagraphTitle
879                        | LayoutElementType::Abstract
880                        | LayoutElementType::Reference
881                )
882            })
883            .collect();
884
885        if text_elements.is_empty() {
886            return PageContinuationFlags::new(true, true);
887        }
888
889        // Calculate paragraph start flag
890        let first = &text_elements[0];
891        let paragraph_start = is_new_paragraph_start(first, page_width);
892
893        // Calculate paragraph end flag
894        let last = &text_elements[text_elements.len() - 1];
895        let paragraph_end = is_paragraph_complete(last, page_width);
896
897        PageContinuationFlags::new(paragraph_start, paragraph_end)
898    }
899
900    /// Converts the result to an HTML string.
901    ///
902    /// Follows PP-StructureV3's formatting rules with semantic HTML tags.
903    pub fn to_html(&self) -> String {
904        let mut html = String::from(
905            "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n</head>\n<body>\n",
906        );
907
908        for element in &self.layout_elements {
909            match element.element_type {
910                // Document title
911                LayoutElementType::DocTitle => {
912                    html.push_str("<h1>");
913                    if let Some(text) = &element.text {
914                        html.push_str(&Self::escape_html(text));
915                    }
916                    html.push_str("</h1>\n");
917                }
918                // Paragraph/section title
919                LayoutElementType::ParagraphTitle => {
920                    html.push_str("<h2>");
921                    if let Some(text) = &element.text {
922                        html.push_str(&Self::escape_html(text));
923                    }
924                    html.push_str("</h2>\n");
925                }
926                // Table - embed HTML structure with simplified markup
927                LayoutElementType::Table => {
928                    if let Some(table) =
929                        self.tables.iter().find(|t| t.bbox.iou(&element.bbox) > 0.5)
930                    {
931                        if let Some(table_html) = &table.html_structure {
932                            // Simplify table HTML (remove html/body wrappers) and add border styling
933                            let simplified = simplify_table_html(table_html);
934                            let styled = simplified.replacen(
935                                "<table>",
936                                "<table border=\"1\" style=\"border-collapse: collapse;\">",
937                                1,
938                            );
939                            html.push_str(&styled);
940                            html.push('\n');
941                        } else {
942                            html.push_str("<p>[Table]</p>\n");
943                        }
944                    } else {
945                        html.push_str("<p>[Table]</p>\n");
946                    }
947                }
948                // Formula - use math tags
949                LayoutElementType::Formula | LayoutElementType::FormulaNumber => {
950                    html.push_str("<p class=\"formula\">$$");
951                    if let Some(latex) = &element.text {
952                        html.push_str(&Self::escape_html(latex));
953                    }
954                    html.push_str("$$</p>\n");
955                }
956                // Image/Chart
957                LayoutElementType::Image | LayoutElementType::Chart => {
958                    html.push_str("<figure>\n<img alt=\"Figure\" />\n");
959                    if let Some(caption) = &element.text {
960                        html.push_str("<figcaption>");
961                        html.push_str(&Self::escape_html(caption));
962                        html.push_str("</figcaption>\n");
963                    }
964                    html.push_str("</figure>\n");
965                }
966                // Seal
967                LayoutElementType::Seal => {
968                    html.push_str("<figure class=\"seal\">\n<img alt=\"Seal\" />\n");
969                    if let Some(text) = &element.text {
970                        html.push_str("<figcaption>");
971                        html.push_str(&Self::escape_html(text));
972                        html.push_str("</figcaption>\n");
973                    }
974                    html.push_str("</figure>\n");
975                }
976                // Captions
977                _ if element.element_type.is_caption() => {
978                    if let Some(text) = &element.text {
979                        html.push_str("<figcaption>");
980                        html.push_str(&Self::escape_html(text));
981                        html.push_str("</figcaption>\n");
982                    }
983                }
984                // Abstract
985                LayoutElementType::Abstract => {
986                    html.push_str("<section class=\"abstract\">\n<h3>Abstract</h3>\n<p>");
987                    if let Some(text) = &element.text {
988                        html.push_str(&Self::escape_html(text));
989                    }
990                    html.push_str("</p>\n</section>\n");
991                }
992                // Reference
993                LayoutElementType::Reference | LayoutElementType::ReferenceContent => {
994                    html.push_str("<section class=\"references\">\n<p>");
995                    if let Some(text) = &element.text {
996                        html.push_str(&Self::escape_html(text));
997                    }
998                    html.push_str("</p>\n</section>\n");
999                }
1000                // List
1001                LayoutElementType::List => {
1002                    html.push_str("<ul>\n");
1003                    if let Some(text) = &element.text {
1004                        for line in text.lines() {
1005                            html.push_str("<li>");
1006                            html.push_str(&Self::escape_html(line));
1007                            html.push_str("</li>\n");
1008                        }
1009                    }
1010                    html.push_str("</ul>\n");
1011                }
1012                // Header
1013                _ if element.element_type.is_header() => {
1014                    html.push_str("<header>");
1015                    if let Some(text) = &element.text {
1016                        html.push_str(&Self::escape_html(text));
1017                    }
1018                    html.push_str("</header>\n");
1019                }
1020                // Footer
1021                _ if element.element_type.is_footer() => {
1022                    html.push_str("<footer>");
1023                    if let Some(text) = &element.text {
1024                        html.push_str(&Self::escape_html(text));
1025                    }
1026                    html.push_str("</footer>\n");
1027                }
1028                // Default text
1029                _ => {
1030                    if let Some(text) = &element.text {
1031                        html.push_str("<p>");
1032                        html.push_str(&Self::escape_html(text));
1033                        html.push_str("</p>\n");
1034                    }
1035                }
1036            }
1037        }
1038        html.push_str("</body>\n</html>");
1039        html
1040    }
1041
1042    /// Escapes HTML special characters.
1043    fn escape_html(text: &str) -> String {
1044        text.replace('&', "&amp;")
1045            .replace('<', "&lt;")
1046            .replace('>', "&gt;")
1047            .replace('"', "&quot;")
1048            .replace('\'', "&#39;")
1049    }
1050
1051    /// Serializes the full internal result to a JSON value.
1052    ///
1053    /// Deprecated in favor of [`to_json`](Self::to_json), the documented page
1054    /// format shared with the vision-language pipeline. For the raw internal
1055    /// shape, call `serde_json::to_value(&result)` directly.
1056    #[deprecated(
1057        since = "0.10.1",
1058        note = "use `to_json(width, height)` for the shared page format, or `serde_json::to_value` for the raw result"
1059    )]
1060    pub fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1061        serde_json::to_value(self)
1062    }
1063
1064    /// Exports this page in the shared page JSON format.
1065    ///
1066    /// This is the versioned interchange format documented in
1067    /// `docs/page-format.md`, emitted identically by the vision-language
1068    /// pipeline's `PageDocument::to_json`, so consumers get one shape
1069    /// from either pipeline. Blocks follow the layout elements in reading
1070    /// order with canonical `type` labels (the original model label rides in
1071    /// `label` when it differs), pixel bounding boxes, explicit reading
1072    /// order indices, and recognized content (text, table HTML, or formula
1073    /// LaTeX). Structured table and formula detail stays in
1074    /// the result's `Serialize` output (`serde_json::to_value`), and `markdown` is the
1075    /// [`to_markdown`](Self::to_markdown) rendering.
1076    ///
1077    /// `width` and `height` are the pixel dimensions of the coordinate space
1078    /// the boxes are in: the input image, whose coordinates orientation
1079    /// correction maps boxes back to, or the rectified image when document
1080    /// rectification (UVDoc) is enabled, since rectification cannot be
1081    /// inverted. Table content comes from the paired [`TableResult`]
1082    /// HTML (the plain table, without the exporters' border and centering
1083    /// styling), because stitching leaves table element text empty.
1084    pub fn to_json(&self, width: u32, height: u32) -> serde_json::Value {
1085        let blocks = self
1086            .layout_elements
1087            .iter()
1088            .map(|element| {
1089                // A label outside the canonical vocabulary passes through
1090                // verbatim, as on the VL side.
1091                let canonical = match &element.label {
1092                    Some(label) if element.element_type == LayoutElementType::Other => {
1093                        label.as_str()
1094                    }
1095                    _ => element.element_type.as_str(),
1096                };
1097                let mut block = serde_json::json!({
1098                    "type": canonical,
1099                    "bbox": [
1100                        element.bbox.x_min(),
1101                        element.bbox.y_min(),
1102                        element.bbox.x_max(),
1103                        element.bbox.y_max(),
1104                    ],
1105                    "order": element.order_index,
1106                    "confidence": element.confidence,
1107                    "angle": null,
1108                    "content": self.block_content(element),
1109                });
1110                // The original label rides along only when it differs from
1111                // the canonical type.
1112                if let Some(label) = &element.label
1113                    && !label.eq_ignore_ascii_case(canonical)
1114                {
1115                    block["label"] = serde_json::Value::String(label.clone());
1116                }
1117                block
1118            })
1119            .collect::<Vec<_>>();
1120        serde_json::json!({
1121            "version": 1,
1122            "pipeline": "classic",
1123            "page": {
1124                "index": self.index,
1125                "width": width,
1126                "height": height,
1127                "angle": self.orientation_angle,
1128            },
1129            "blocks": blocks,
1130            "markdown": self.to_markdown(),
1131            "raw_output": null,
1132            "diagnostics": [],
1133        })
1134    }
1135
1136    /// A layout element's page-JSON content: table HTML or formula LaTeX
1137    /// from the paired result, otherwise the stitched element text.
1138    fn block_content(&self, element: &LayoutElement) -> Option<String> {
1139        match element.element_type {
1140            LayoutElementType::Table => self
1141                .tables
1142                .iter()
1143                .find(|table| table.bbox.iou(&element.bbox) > 0.5)
1144                .and_then(|table| table.html_structure.as_deref())
1145                .map(simplify_table_html),
1146            // Inline formulas have their element text cleared after being
1147            // injected into the surrounding text, so read the recognized LaTeX
1148            // from the paired formula result.
1149            LayoutElementType::Formula => self
1150                .formulas
1151                .iter()
1152                .find(|formula| formula.bbox.iou(&element.bbox) > 0.5)
1153                .map(|formula| formula.latex.clone())
1154                .or_else(|| element.text.clone()),
1155            _ => element.text.clone(),
1156        }
1157    }
1158
1159    /// Saves the analysis results to the specified directory.
1160    ///
1161    /// This generates:
1162    /// - `*_res.json`: The full structured result
1163    /// - `*_res.html`: An HTML representation
1164    ///
1165    /// Note: Markdown export with image extraction should use the example utilities
1166    /// (`examples/utils/markdown.rs`) instead, as that requires I/O operations
1167    /// that belong in the application layer. Use `StructureResult::to_markdown()`
1168    /// for pure markdown generation without side effects.
1169    ///
1170    /// # Arguments
1171    ///
1172    /// * `output_dir` - Directory to save the output files
1173    /// * `to_json` - If true, save a JSON representation
1174    /// * `to_html` - If true, save an HTML representation
1175    pub fn save_results(
1176        &self,
1177        output_dir: impl AsRef<Path>,
1178        to_json: bool,
1179        to_html: bool,
1180    ) -> std::io::Result<()> {
1181        let output_dir = output_dir.as_ref();
1182        if !output_dir.exists() {
1183            std::fs::create_dir_all(output_dir)?;
1184        }
1185
1186        let input_path = Path::new(self.input_path.as_ref());
1187        // Extract file stem, handling PDF page suffix (e.g., "file.pdf#3" -> "file_003")
1188        let stem = if let Some(path_str) = input_path.to_str() {
1189            if let Some(hash_idx) = path_str.rfind('#') {
1190                // This is a PDF page reference like "file.pdf#3"
1191                let base = &path_str[..hash_idx];
1192                let page_num = &path_str[hash_idx + 1..];
1193                let base_stem = Path::new(base)
1194                    .file_stem()
1195                    .and_then(|s| s.to_str())
1196                    .unwrap_or("result");
1197                format!("{}_{}", base_stem, page_num)
1198            } else {
1199                input_path
1200                    .file_stem()
1201                    .and_then(|s| s.to_str())
1202                    .unwrap_or("result")
1203                    .to_string()
1204            }
1205        } else {
1206            "result".to_string()
1207        };
1208
1209        // Save JSON
1210        if to_json {
1211            let json_path = output_dir.join(format!("{}.json", stem));
1212            let json_file = std::fs::File::create(json_path)?;
1213            serde_json::to_writer_pretty(json_file, self)?;
1214        }
1215
1216        // Save HTML
1217        if to_html {
1218            let html_path = output_dir.join(format!("{}.html", stem));
1219            std::fs::write(html_path, self.to_html())?;
1220        }
1221
1222        Ok(())
1223    }
1224}
1225
1226/// Determines paragraph continuity flags for the current element relative to the previous.
1227///
1228/// This implements PaddleX's `get_seg_flag` logic from `layout_parsing/utils.py`:
1229/// - `seg_start_flag = true` means this element starts a NEW paragraph
1230/// - `seg_start_flag = false` means this element CONTINUES the previous paragraph
1231///
1232/// The logic checks whether:
1233/// 1. Previous block's last line ends near the right edge (text fills to right)
1234/// 2. Current block's first line starts near the left edge (no indentation)
1235/// 3. Previous block has more than one line
1236/// 4. The two blocks are horizontally close enough
1237///
1238/// Returns `seg_start_flag` (true = new paragraph, false = continuation).
1239fn get_seg_flag(current: &LayoutElement, prev: Option<&LayoutElement>) -> bool {
1240    const COORD_THRESHOLD: f32 = 10.0;
1241
1242    let seg_start = current.seg_start_x.unwrap_or(current.bbox.x_min());
1243    let mut context_left = current.bbox.x_min();
1244    let mut context_right = current.bbox.x_max();
1245
1246    if let Some(prev) = prev {
1247        let prev_seg_end = prev.seg_end_x.unwrap_or(prev.bbox.x_max());
1248        let prev_num_lines = prev.num_lines.unwrap_or(1);
1249
1250        // Check if blocks overlap horizontally
1251        let overlap_blocks = context_left < prev.bbox.x_max() && context_right > prev.bbox.x_min();
1252
1253        let edge_distance;
1254        if overlap_blocks {
1255            context_left = context_left.min(prev.bbox.x_min());
1256            context_right = context_right.max(prev.bbox.x_max());
1257            edge_distance = 0.0;
1258        } else {
1259            edge_distance = (current.bbox.x_min() - prev.bbox.x_max()).abs();
1260        }
1261
1262        let prev_end_space_small = (context_right - prev_seg_end).abs() < COORD_THRESHOLD;
1263        let current_start_space_small = seg_start - context_left < COORD_THRESHOLD;
1264        let prev_lines_more_than_one = prev_num_lines > 1;
1265        let blocks_close = edge_distance
1266            < (prev.bbox.x_max() - prev.bbox.x_min())
1267                .max(current.bbox.x_max() - current.bbox.x_min());
1268
1269        if prev_end_space_small
1270            && current_start_space_small
1271            && prev_lines_more_than_one
1272            && blocks_close
1273        {
1274            return false; // continuation
1275        }
1276
1277        true // new paragraph
1278    } else {
1279        // First element: check if text starts near the left edge
1280        if seg_start - context_left < COORD_THRESHOLD {
1281            return false; // continuation from previous page (no indentation)
1282        }
1283        true
1284    }
1285}
1286
1287/// Checks if a text element appears to start a new paragraph.
1288///
1289/// Following PaddleX's logic: if the text starts near the left edge of the page
1290/// (within 5% of page width), it's likely the start of a new paragraph.
1291fn is_new_paragraph_start(element: &LayoutElement, page_width: Option<f32>) -> bool {
1292    let left = element.bbox.x_min();
1293    let threshold = page_width.map_or(50.0, |w| w * 0.05); // 5% of page width
1294    left <= threshold
1295}
1296
1297/// Checks if a text element appears to complete its paragraph on this page.
1298///
1299/// Following PaddleX's logic: if the text ends before the right edge of the page
1300/// (not within 10% of right margin), the paragraph likely ends here.
1301fn is_paragraph_complete(element: &LayoutElement, page_width: Option<f32>) -> bool {
1302    let right = element.bbox.x_max();
1303
1304    // If we have page width info, check if element ends before the right edge
1305    if let Some(width) = page_width {
1306        let right_margin = width * 0.1;
1307        return right <= (width - right_margin);
1308    }
1309
1310    // Conservative default: assume paragraphs end
1311    true
1312}
1313
1314/// Concatenates markdown content from multiple pages into a single document.
1315///
1316/// This follows PaddleX's `concatenate_markdown_pages` logic to intelligently
1317/// merge pages while preserving paragraph continuity.
1318///
1319/// # Arguments
1320///
1321/// * `results` - Slice of structure results from multiple pages (in order)
1322///
1323/// # Returns
1324///
1325/// A single markdown string with all pages properly concatenated
1326pub fn concatenate_markdown_pages(results: &[StructureResult]) -> String {
1327    if results.is_empty() {
1328        return String::new();
1329    }
1330
1331    if results.len() == 1 {
1332        return results[0].to_markdown();
1333    }
1334
1335    let mut markdown = String::new();
1336    let mut prev_page_end_flag = true; // First page is treated as starting fresh
1337
1338    for result in results.iter() {
1339        let flags = result
1340            .page_continuation_flags
1341            .as_ref()
1342            .cloned()
1343            .unwrap_or_else(|| result.calculate_continuation_flags());
1344
1345        let page_markdown = result.to_markdown();
1346
1347        // Skip empty pages
1348        if page_markdown.trim().is_empty() {
1349            prev_page_end_flag = flags.paragraph_end;
1350            continue;
1351        }
1352
1353        let page_first_continues = !flags.paragraph_start;
1354        let _page_last_continues = !flags.paragraph_end;
1355
1356        // Determine how to join this page
1357        if page_first_continues && !prev_page_end_flag {
1358            // Both pages are in the middle of the same paragraph
1359            // Check for Chinese characters to decide spacing
1360            let last_char = markdown.chars().last();
1361            let first_char = page_markdown.chars().next();
1362
1363            let last_is_chinese = last_char.is_some_and(is_chinese_char);
1364            let first_is_chinese = first_char.is_some_and(is_chinese_char);
1365
1366            if !last_is_chinese && !first_is_chinese {
1367                // Non-Chinese text: add space
1368                markdown.push(' ');
1369                markdown.push_str(page_markdown.trim_start());
1370            } else {
1371                // Chinese or mixed: direct concatenation
1372                markdown.push_str(page_markdown.trim_start());
1373            }
1374        } else {
1375            // New paragraph or section
1376            if !markdown.is_empty() {
1377                markdown.push_str("\n\n");
1378            }
1379            markdown.push_str(&page_markdown);
1380        }
1381
1382        prev_page_end_flag = flags.paragraph_end;
1383    }
1384
1385    markdown.trim().to_string()
1386}
1387
1388/// Cleans OCR text content by removing common artifacts.
1389///
1390/// This function removes PDF line-break hyphens and fixes spacing issues
1391/// in OCR text content. It should only be applied to raw OCR text, not to
1392/// formatted markdown or HTML.
1393///
1394/// Following PaddleX's approach:
1395/// 1. Remove hyphenation artifacts: `-\n` -> `` (join words)
1396/// 2. Convert newlines to spaces: `\n` -> ` `
1397fn clean_ocr_text(text: &str) -> String {
1398    // First remove hyphenation (word breaks), then convert newlines to spaces
1399    text.replace("-\n", "").replace('\n', " ")
1400}
1401
1402/// Formats the first non-empty line of a block if it matches a template keyword.
1403///
1404/// This is the Rust equivalent of PaddleX's `format_first_line_func`:
1405/// 1. Split text by `spliter`
1406/// 2. Find the first non-empty token
1407/// 3. If it matches any template (case-insensitive exact match), replace it with `format_func(token)`
1408/// 4. Rejoin with `spliter`
1409///
1410/// The matched token is replaced with `{heading_prefix}{token}\n`.
1411/// For abstract: `spliter=" "`, templates=["abstract","摘要"], heading_prefix=`## `
1412/// For reference: `spliter="\n"`, templates=["references","参考文献"], heading_prefix=`## `
1413fn format_first_line(
1414    text: &str,
1415    spliter: &str,
1416    templates: &[&str],
1417    heading_prefix: &str,
1418) -> String {
1419    let parts: Vec<&str> = text.split(spliter).collect();
1420    let mut result_parts: Vec<String> = Vec::with_capacity(parts.len());
1421    let mut found_first = false;
1422
1423    for part in &parts {
1424        if !found_first {
1425            let trimmed = part.trim();
1426            if trimmed.is_empty() {
1427                result_parts.push(part.to_string());
1428                continue;
1429            }
1430            found_first = true;
1431            // Check if the first non-empty token matches a template (case-insensitive)
1432            if templates.iter().any(|t| trimmed.eq_ignore_ascii_case(t)) {
1433                // Replace with formatted heading: "## <original_text>\n"
1434                result_parts.push(format!("{}{}\n", heading_prefix, trimmed));
1435            } else {
1436                result_parts.push(part.to_string());
1437            }
1438        } else {
1439            result_parts.push(part.to_string());
1440        }
1441    }
1442
1443    result_parts.join(spliter)
1444}
1445
1446/// Formats text blocks following PaddleX's text handling:
1447/// 1. First remove hyphenation: `-\n` -> `` (join broken words)
1448/// 2. Then: `.replace("\n\n", "\n").replace("\n", "\n\n")`
1449///
1450/// This converts OCR line breaks into proper paragraph breaks.
1451fn format_text_block(text: &str) -> String {
1452    // First, remove hyphenation artifacts (word breaks at line ends)
1453    let dehyphenated = text.replace("-\n", "");
1454    // Collapse double newlines to single (undo paragraph breaks)
1455    let step1 = dehyphenated.replace("\n\n", "\n");
1456    // Then, convert single newlines to paragraph breaks
1457    step1.replace('\n', "\n\n")
1458}
1459
1460/// Formats content blocks (table of contents) following PaddleX:
1461/// `.replace("-\n", "  \n").replace("\n", "  \n")`
1462///
1463/// This uses markdown's soft line break (two spaces at end of line).
1464fn format_content_block(text: &str) -> String {
1465    // Handle PDF hyphen line breaks first
1466    let step1 = text.replace("-\n", "  \n");
1467    // Convert newlines to soft breaks
1468    step1.replace('\n', "  \n")
1469}
1470
1471/// Formats vision footnote blocks following PaddleX:
1472/// 1. First remove hyphenation: `-\n` -> ``
1473/// 2. Then: `.replace("\n\n", "\n").replace("\n", "\n\n")`
1474fn format_vision_footnote_block(text: &str) -> String {
1475    let dehyphenated = text.replace("-\n", "");
1476    let step1 = dehyphenated.replace("\n\n", "\n");
1477    step1.replace('\n', "\n\n")
1478}
1479
1480/// Bullet marker characters commonly found in OCR text.
1481const BULLET_MARKERS: &[char] = &['•', '●', '◦', '▪', '◆'];
1482
1483/// Checks if text contains bullet markers that should be formatted as a list.
1484fn has_bullet_markers(text: &str) -> bool {
1485    BULLET_MARKERS.iter().any(|&m| text.contains(m))
1486}
1487
1488/// Formats text with bullet markers as a markdown list.
1489///
1490/// Splits on any bullet marker character so mixed markers (e.g. `• item1 ▪ item2`)
1491/// are all handled correctly.
1492fn format_as_bullet_list(text: &str, md: &mut String) {
1493    for item in text.split(|c: char| BULLET_MARKERS.contains(&c)) {
1494        let item = item.trim();
1495        if !item.is_empty() {
1496            md.push_str("- ");
1497            md.push_str(item);
1498            md.push('\n');
1499        }
1500    }
1501}
1502
1503/// Checks if a character is a Chinese character.
1504///
1505/// Used to determine spacing rules when concatenating pages.
1506fn is_chinese_char(c: char) -> bool {
1507    match c {
1508        '\u{4E00}'..='\u{9FFF}' | // CJK Unified Ideographs
1509        '\u{3400}'..='\u{4DBF}' | // CJK Unified Ideographs Extension A
1510        '\u{20000}'..='\u{2A6DF}' | // CJK Unified Ideographs Extension B
1511        '\u{2A700}'..='\u{2B73F}' | // CJK Unified Ideographs Extension C
1512        '\u{2B740}'..='\u{2B81F}' | // CJK Unified Ideographs Extension D
1513        '\u{2B820}'..='\u{2CEAF}' | // CJK Unified Ideographs Extension E
1514        '\u{2CEB0}'..='\u{2EBEF}' => // CJK Unified Ideographs Extension F
1515            true,
1516        _ => false,
1517    }
1518}
1519
1520/// Checks if a character is a lowercase letter.
1521fn is_lowercase(c: char) -> bool {
1522    c.is_ascii_lowercase()
1523}
1524
1525/// Checks if a character is an uppercase letter.
1526fn is_uppercase(c: char) -> bool {
1527    c.is_ascii_uppercase()
1528}
1529
1530/// Checks if a character is a digit.
1531fn is_digit(c: char) -> bool {
1532    c.is_ascii_digit()
1533}
1534
1535/// Removes PDF hyphenation artifacts from text.
1536/// Dehyphenation: only handles hyphen-newline patterns (word breaks across lines).
1537///
1538/// Matches PaddleX's behavior where hyphens are only stripped at line boundaries
1539/// (hyphen immediately followed by newline). Mid-word hyphens in compound words
1540/// like "real-time", "end-to-end", "one-to-many" are preserved.
1541fn dehyphenate(text: &str) -> String {
1542    let mut result = String::with_capacity(text.len());
1543    let chars: Vec<char> = text.chars().collect();
1544    let len = chars.len();
1545    let mut i = 0;
1546
1547    // Helper to check if we're in a URL-like pattern
1548    let is_url_context = |pos: usize| -> bool {
1549        let start = pos.saturating_sub(10);
1550        let end = (pos + 5).min(len);
1551        let window: String = chars[start..end].iter().collect();
1552        window.contains("http") || window.contains("www") || window.contains("://")
1553    };
1554
1555    while i < len {
1556        if chars[i] == '-' {
1557            if is_url_context(i) {
1558                result.push('-');
1559                i += 1;
1560                continue;
1561            }
1562
1563            // Only dehyphenate when hyphen is followed by newline (line-break hyphenation).
1564            // Pattern: "word-\nletter" becomes "wordletter".
1565            let is_artifact = if i + 1 < len && chars[i + 1] == '\n' {
1566                // Hyphen followed by newline — check if next line starts with lowercase
1567                if i + 2 < len {
1568                    is_lowercase(chars[i + 2])
1569                } else {
1570                    false
1571                }
1572            } else {
1573                false
1574            };
1575
1576            if is_artifact {
1577                // Skip the hyphen and the following newline
1578                i += 1; // skip newline (will be incremented again at end of loop)
1579            } else {
1580                result.push('-');
1581            }
1582        } else {
1583            result.push(chars[i]);
1584        }
1585        i += 1;
1586    }
1587
1588    result
1589}
1590
1591/// Fixes missing spaces between merged words.
1592///
1593/// OCR and PDF extraction can result in merged words like
1594/// "enhancetheencoder'sfeaturerepresentation" or "48.1%AP".
1595/// This function detects and fixes common patterns.
1596fn fix_merged_words(text: &str) -> String {
1597    let mut result = String::with_capacity(text.len());
1598    let chars: Vec<char> = text.chars().collect();
1599    let mut i = 0;
1600
1601    while i < chars.len() {
1602        let current = chars[i];
1603
1604        if i > 0 {
1605            let prev = chars[i - 1];
1606
1607            // Detect missing space between lowercase and lowercase (after apostrophe or consonant)
1608            // e.g., "encoder'sfeature" -> "encoder's feature"
1609            if is_lowercase(prev) && is_lowercase(current) {
1610                // Only add space if previous was apostrophe or word boundary context
1611                // This is a heuristic - in practice you'd want more sophisticated NLP
1612                if i > 1 && chars[i - 2] == '\'' {
1613                    result.push(' ');
1614                }
1615                // Also detect lowercase followed by uppercase
1616                // e.g., "RT-DETRis" -> "RT-DETR is"
1617            } else if is_lowercase(prev) && is_uppercase(current) {
1618                // Check if the uppercase starts a new word (not an acronym)
1619                // If next char is lowercase, it's likely a new word
1620                if i + 1 < chars.len() && is_lowercase(chars[i + 1]) {
1621                    result.push(' ');
1622                }
1623            }
1624            // Detect digit/percent followed by letter, or letter-digit-letter pattern
1625            // e.g., "48.1%AP" -> "48.1% AP"
1626            // e.g., "RT-DETRv3" shouldn't be split, but "model 100instances" -> "model 100 instances"
1627            else if ((is_digit(prev) || prev == '%') && is_uppercase(current))
1628                || (is_letter(prev)
1629                    && is_digit(current)
1630                    && i + 1 < chars.len()
1631                    && is_letter(chars[i + 1]))
1632            {
1633                result.push(' ');
1634            }
1635        }
1636
1637        result.push(current);
1638        i += 1;
1639    }
1640
1641    result
1642}
1643
1644/// Checks if a character is a letter.
1645fn is_letter(c: char) -> bool {
1646    is_lowercase(c) || is_uppercase(c)
1647}
1648
1649/// Simplifies table HTML by removing wrapper tags, following PaddleX's `simplify_table_func`.
1650///
1651/// This removes `<html>`, `</html>`, `<body>`, and `</body>` tags from table HTML
1652/// to produce cleaner markdown output.
1653fn simplify_table_html(html: &str) -> String {
1654    html.replace("<html>", "")
1655        .replace("</html>", "")
1656        .replace("<body>", "")
1657        .replace("</body>", "")
1658}
1659
1660/// Post-processes text content to fix common OCR/PDF artifacts.
1661///
1662/// This applies multiple cleanup steps:
1663/// 1. Dehyphenation - removes line-break hyphens
1664/// 2. Word merging fixes - adds missing spaces
1665/// 3. Spacing normalization - fixes multiple spaces
1666pub fn postprocess_text(text: &str) -> String {
1667    let text = dehyphenate(text);
1668    let text = fix_merged_words(&text);
1669
1670    // Normalize whitespace (collapse multiple spaces, fix spacing after punctuation)
1671    let mut result = String::new();
1672    let mut in_space = false;
1673
1674    for c in text.chars() {
1675        if c.is_whitespace() {
1676            if !in_space && !result.is_empty() {
1677                result.push(' ');
1678                in_space = true;
1679            }
1680        } else {
1681            // Fix missing space after period (when followed by letter)
1682            if c == '.' && !result.is_empty() {
1683                let last = result.chars().last().unwrap();
1684                if is_letter(last) || is_digit(last) {
1685                    result.push('.');
1686                    in_space = true;
1687                    continue;
1688                }
1689            }
1690            // Fix spacing after punctuation
1691            if in_space && matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}') {
1692                result.pop(); // Remove the space before punctuation
1693                result.push(c);
1694                continue;
1695            }
1696            result.push(c);
1697            in_space = false;
1698        }
1699    }
1700
1701    result
1702}
1703
1704/// Removes duplicate section headers from concatenated markdown.
1705///
1706/// When concatenating pages, section headers like "**Abstract**" or
1707/// "**References**" may appear multiple times. This function deduplicates
1708/// them while preserving the first occurrence.
1709fn deduplicate_sections(markdown: &str) -> String {
1710    let mut result = String::new();
1711    let mut seen_sections: std::collections::HashSet<String> = std::collections::HashSet::new();
1712
1713    for line in markdown.lines() {
1714        let trimmed = line.trim();
1715
1716        // Check for common section header patterns
1717        let is_section_header =
1718            trimmed.starts_with("**") && trimmed.ends_with("**") && trimmed.len() > 4;
1719
1720        let section_name = if is_section_header {
1721            trimmed[2..trimmed.len() - 2].to_string()
1722        } else {
1723            String::new()
1724        };
1725
1726        if is_section_header {
1727            if seen_sections.contains(&section_name) {
1728                // Skip duplicate section header
1729                continue;
1730            }
1731            seen_sections.insert(section_name);
1732        }
1733
1734        if !result.is_empty() {
1735            result.push('\n');
1736        }
1737        result.push_str(line);
1738    }
1739
1740    result
1741}
1742
1743/// Checks if two bounding boxes are on the same line (have significant vertical overlap).
1744///
1745/// Two boxes are considered on the same line if their vertical overlap is greater than
1746/// 50% of the smaller box's height.
1747fn is_same_line(bbox1: &BoundingBox, bbox2: &BoundingBox) -> bool {
1748    let y1_min = bbox1.y_min();
1749    let y1_max = bbox1.y_max();
1750    let y2_min = bbox2.y_min();
1751    let y2_max = bbox2.y_max();
1752
1753    // Calculate vertical overlap
1754    let overlap_start = y1_min.max(y2_min);
1755    let overlap_end = y1_max.min(y2_max);
1756    let overlap = (overlap_end - overlap_start).max(0.0);
1757
1758    // Calculate minimum height
1759    let height1 = y1_max - y1_min;
1760    let height2 = y2_max - y2_min;
1761    let min_height = height1.min(height2);
1762
1763    // Consider same line if overlap > 50% of min height
1764    min_height > 0.0 && overlap / min_height > 0.5
1765}
1766
1767/// Filters empty formula blocks from markdown.
1768///
1769/// Formula blocks with no LaTeX content like `$$\n$$` are removed.
1770fn filter_empty_formulas(markdown: &str) -> String {
1771    let mut result = String::new();
1772    let lines: Vec<&str> = markdown.lines().collect();
1773    let mut i = 0;
1774
1775    while i < lines.len() {
1776        let line = lines[i];
1777
1778        // Check for empty formula block pattern
1779        if line.trim() == "$$" {
1780            // Check if next line is also $$ (empty formula)
1781            if i + 1 < lines.len() && lines[i + 1].trim() == "$$" {
1782                // Skip both lines
1783                i += 2;
1784                // Also skip the blank line after
1785                if i < lines.len() && lines[i].trim().is_empty() {
1786                    i += 1;
1787                }
1788                continue;
1789            }
1790            // Check if the next non-empty line contains actual content
1791            let mut j = i + 1;
1792            let has_content = if j < lines.len() {
1793                let mut found = false;
1794                while j < lines.len() {
1795                    if lines[j].trim() == "$$" {
1796                        break;
1797                    }
1798                    if !lines[j].trim().is_empty() {
1799                        found = true;
1800                        break;
1801                    }
1802                    j += 1;
1803                }
1804                found
1805            } else {
1806                false
1807            };
1808
1809            if !has_content {
1810                // Skip to closing $$
1811                while i < lines.len() && lines[i].trim() != "$$" {
1812                    i += 1;
1813                }
1814                if i < lines.len() {
1815                    i += 1; // Skip closing $$
1816                }
1817                continue;
1818            }
1819        }
1820
1821        if !result.is_empty() {
1822            result.push('\n');
1823        }
1824        result.push_str(line);
1825        i += 1;
1826    }
1827
1828    result
1829}
1830
1831/// Applies all post-processing steps to concatenated markdown.
1832///
1833/// This is the main entry point for cleaning up concatenated markdown output.
1834pub fn postprocess_markdown(markdown: &str) -> String {
1835    let markdown = filter_empty_formulas(markdown);
1836    let markdown = deduplicate_sections(&markdown);
1837
1838    // Apply text post-processing line by line for text content
1839    let mut result = String::new();
1840    let mut in_code_block = false;
1841    let mut in_formula = false;
1842
1843    for line in markdown.lines() {
1844        let trimmed = line.trim();
1845
1846        // Detect code blocks
1847        if trimmed.starts_with("```") {
1848            in_code_block = !in_code_block;
1849            result.push_str(line);
1850            result.push('\n');
1851            continue;
1852        }
1853
1854        // Detect formula blocks
1855        if trimmed == "$$" {
1856            in_formula = !in_formula;
1857            result.push_str(line);
1858            result.push('\n');
1859            continue;
1860        }
1861
1862        // Skip processing inside code blocks
1863        if in_code_block {
1864            result.push_str(line);
1865            result.push('\n');
1866            continue;
1867        }
1868
1869        // If inside a formula block, ensure it doesn't contain unescaped dollar signs
1870        // which cause KaTeX "Can't use function '$' in math mode" errors.
1871        if in_formula {
1872            // If the formula content looks like regular text (many spaces, few backslashes)
1873            // and contains a $, KaTeX will fail. We escape the $ inside the math block.
1874            let contains_dollar = line.contains('$');
1875            let is_plain_text = line.split_whitespace().count() > 3 && !line.contains('\\');
1876
1877            if contains_dollar && is_plain_text {
1878                result.push_str(&line.replace('$', "\\$"));
1879            } else if contains_dollar {
1880                // Escape bare dollar signs inside the math block to avoid
1881                // "Can't use function '$' in math mode" KaTeX errors while
1882                // preserving literal dollars (e.g. \text{$10}).
1883                result.push_str(&line.replace('$', "\\$"));
1884            } else {
1885                result.push_str(line);
1886            }
1887            result.push('\n');
1888            continue;
1889        }
1890
1891        // Process text content (skip headers, lists, etc.)
1892        if trimmed.starts_with('#')
1893            || trimmed.starts_with('*')
1894            || trimmed.starts_with('>')
1895            || trimmed.starts_with('|')
1896            || trimmed.starts_with('-')
1897            || trimmed.starts_with('+')
1898        {
1899            result.push_str(line);
1900        } else {
1901            result.push_str(&postprocess_text(line));
1902        }
1903        result.push('\n');
1904    }
1905
1906    result
1907}
1908
1909/// Extension trait for convenient multi-page processing.
1910pub trait StructureResultExt {
1911    /// Converts multiple results to a single concatenated markdown.
1912    fn to_concatenated_markdown(results: &[Self]) -> String
1913    where
1914        Self: Sized;
1915
1916    /// Saves multiple results with concatenated markdown.
1917    fn save_multi_page_results(
1918        results: &[Self],
1919        output_dir: impl AsRef<std::path::Path>,
1920        base_name: &str,
1921        to_json: bool,
1922        to_markdown: bool,
1923        to_html: bool,
1924    ) -> std::io::Result<()>
1925    where
1926        Self: Sized;
1927}
1928
1929impl StructureResultExt for StructureResult {
1930    fn to_concatenated_markdown(results: &[Self]) -> String {
1931        concatenate_markdown_pages(results)
1932    }
1933
1934    fn save_multi_page_results(
1935        results: &[Self],
1936        output_dir: impl AsRef<std::path::Path>,
1937        base_name: &str,
1938        to_json: bool,
1939        to_markdown: bool,
1940        to_html: bool,
1941    ) -> std::io::Result<()>
1942    where
1943        Self: Sized,
1944    {
1945        let output_dir = output_dir.as_ref();
1946        if !output_dir.exists() {
1947            std::fs::create_dir_all(output_dir)?;
1948        }
1949
1950        // Save individual page results
1951        for (idx, result) in results.iter().enumerate() {
1952            let page_dir = output_dir.join(format!("page_{:03}", idx));
1953            std::fs::create_dir_all(&page_dir)?;
1954            result.save_results(&page_dir, to_json, to_html)?;
1955        }
1956
1957        // Save concatenated markdown
1958        if to_markdown {
1959            let concat_md_path = output_dir.join(format!("{}.md", base_name));
1960            std::fs::write(concat_md_path, Self::to_concatenated_markdown(results))?;
1961        }
1962
1963        // Save concatenated JSON (array of results)
1964        if to_json {
1965            let concat_json_path = output_dir.join(format!("{}.json", base_name));
1966            let json_file = std::fs::File::create(concat_json_path)?;
1967            serde_json::to_writer_pretty(json_file, &results)?;
1968        }
1969
1970        Ok(())
1971    }
1972}
1973
1974/// A layout element detected in the document.
1975#[derive(Debug, Clone, Serialize, Deserialize)]
1976pub struct LayoutElement {
1977    /// Bounding box of the element
1978    pub bbox: BoundingBox,
1979    /// Type of the layout element
1980    pub element_type: LayoutElementType,
1981    /// Confidence score for the detection
1982    pub confidence: f32,
1983    /// Optional label for the element (original model label)
1984    pub label: Option<String>,
1985    /// Optional text content for the element
1986    pub text: Option<String>,
1987    /// Reading order index (1-based, assigned during stitching)
1988    ///
1989    /// This index represents the element's position in the reading order.
1990    /// Only elements that should be included in reading flow (text, tables,
1991    /// formulas, images, etc.) will have an order index assigned.
1992    /// Headers, footers, and other auxiliary elements may have `None`.
1993    pub order_index: Option<u32>,
1994    /// X-coordinate of the first text span's left edge within this element.
1995    /// Used by `get_seg_flag` to detect paragraph continuity across blocks.
1996    /// Computed during stitching from the first OCR region (after spatial sort).
1997    #[serde(skip_serializing_if = "Option::is_none")]
1998    pub seg_start_x: Option<f32>,
1999    /// X-coordinate of the last text span's right edge within this element.
2000    /// Used by `get_seg_flag` to detect paragraph continuity across blocks.
2001    /// Computed during stitching from the last OCR region (after spatial sort).
2002    #[serde(skip_serializing_if = "Option::is_none")]
2003    pub seg_end_x: Option<f32>,
2004    /// Number of text lines within this element.
2005    /// Used by `get_seg_flag` to detect paragraph continuity across blocks.
2006    #[serde(skip_serializing_if = "Option::is_none")]
2007    pub num_lines: Option<u32>,
2008}
2009
2010impl LayoutElement {
2011    /// Creates a new layout element.
2012    pub fn new(bbox: BoundingBox, element_type: LayoutElementType, confidence: f32) -> Self {
2013        Self {
2014            bbox,
2015            element_type,
2016            confidence,
2017            label: None,
2018            text: None,
2019            order_index: None,
2020            seg_start_x: None,
2021            seg_end_x: None,
2022            num_lines: None,
2023        }
2024    }
2025
2026    /// Sets the label for the element.
2027    pub fn with_label(mut self, label: impl Into<String>) -> Self {
2028        self.label = Some(label.into());
2029        self
2030    }
2031
2032    /// Sets the text content for the element.
2033    pub fn with_text(mut self, text: impl Into<String>) -> Self {
2034        self.text = Some(text.into());
2035        self
2036    }
2037}
2038
2039/// Layout element type supporting PP-StructureV3's full label set.
2040///
2041/// This enum represents both **semantic categories** and **fine-grained labels** for layout elements.
2042/// PP-StructureV3 models output 20 or 23 class labels depending on the model variant.
2043///
2044/// The original model-specific label is preserved in `LayoutElement.label` field.
2045///
2046/// # PP-StructureV3 Label Categories
2047///
2048/// **Document structure:**
2049/// - `DocTitle` - Document title (doc_title)
2050/// - `ParagraphTitle` - Section/paragraph title (paragraph_title)
2051/// - `Text` - General text content
2052/// - `Content` - Table of contents (content)
2053/// - `Abstract` - Abstract section
2054///
2055/// **Visual elements:**
2056/// - `Image` - Images/figures (image, figure)
2057/// - `Table` - Tables
2058/// - `Chart` - Charts/graphs
2059/// - `Formula` - Mathematical formulas
2060///
2061/// **Captions and titles:**
2062/// - `FigureTitle` - Figure caption (figure_title)
2063/// - `TableTitle` - Table caption (table_title)
2064/// - `ChartTitle` - Chart caption (chart_title)
2065/// - `FigureTableChartTitle` - Combined caption type
2066///
2067/// **Page structure:**
2068/// - `Header` - Page header
2069/// - `HeaderImage` - Header image
2070/// - `Footer` - Page footer
2071/// - `FooterImage` - Footer image
2072/// - `Footnote` - Footnotes
2073///
2074/// **Special elements:**
2075/// - `Seal` - Stamps/official seals
2076/// - `Number` - Page numbers
2077/// - `Reference` - References section
2078/// - `ReferenceContent` - Reference content
2079/// - `Algorithm` - Algorithm blocks
2080/// - `FormulaNumber` - Formula numbers
2081/// - `AsideText` - Marginal/aside text
2082/// - `List` - List items
2083///
2084/// - `Other` - Unknown/unmapped labels
2085#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2086pub enum LayoutElementType {
2087    /// Document title
2088    DocTitle,
2089    /// Paragraph/section title
2090    ParagraphTitle,
2091    /// General text content
2092    Text,
2093    /// Table of contents
2094    Content,
2095    /// Abstract section
2096    Abstract,
2097
2098    /// Image or figure
2099    Image,
2100    /// Table
2101    Table,
2102    /// Chart or graph
2103    Chart,
2104    /// Mathematical formula
2105    Formula,
2106
2107    /// Figure caption/title
2108    FigureTitle,
2109    /// Table caption/title
2110    TableTitle,
2111    /// Chart caption/title
2112    ChartTitle,
2113    /// Combined figure/table/chart title (PP-DocLayout)
2114    FigureTableChartTitle,
2115
2116    /// Page header
2117    Header,
2118    /// Header image
2119    HeaderImage,
2120    /// Page footer
2121    Footer,
2122    /// Footer image
2123    FooterImage,
2124    /// Footnote
2125    Footnote,
2126
2127    /// Stamp or official seal
2128    Seal,
2129    /// Page number
2130    Number,
2131    /// Reference section
2132    Reference,
2133    /// Reference content (PP-DocLayout_plus-L)
2134    ReferenceContent,
2135    /// Algorithm block
2136    Algorithm,
2137    /// Formula number
2138    FormulaNumber,
2139    /// Marginal/aside text
2140    AsideText,
2141    /// List items
2142    List,
2143
2144    /// Generic document region block (PP-DocBlockLayout)
2145    /// Used for hierarchical layout ordering and block grouping
2146    Region,
2147
2148    /// Other/unknown (original label preserved in LayoutElement.label)
2149    Other,
2150}
2151
2152impl LayoutElementType {
2153    /// Returns the string representation of the element type.
2154    ///
2155    /// This returns the PP-StructureV3 compatible label string.
2156    pub fn as_str(&self) -> &'static str {
2157        match self {
2158            // Document Structure
2159            LayoutElementType::DocTitle => "doc_title",
2160            LayoutElementType::ParagraphTitle => "paragraph_title",
2161            LayoutElementType::Text => "text",
2162            LayoutElementType::Content => "content",
2163            LayoutElementType::Abstract => "abstract",
2164
2165            // Visual Elements
2166            LayoutElementType::Image => "image",
2167            LayoutElementType::Table => "table",
2168            LayoutElementType::Chart => "chart",
2169            LayoutElementType::Formula => "formula",
2170
2171            // Captions
2172            LayoutElementType::FigureTitle => "figure_title",
2173            LayoutElementType::TableTitle => "table_title",
2174            LayoutElementType::ChartTitle => "chart_title",
2175            LayoutElementType::FigureTableChartTitle => "figure_table_chart_title",
2176
2177            // Page Structure
2178            LayoutElementType::Header => "header",
2179            LayoutElementType::HeaderImage => "header_image",
2180            LayoutElementType::Footer => "footer",
2181            LayoutElementType::FooterImage => "footer_image",
2182            LayoutElementType::Footnote => "footnote",
2183
2184            // Special Elements
2185            LayoutElementType::Seal => "seal",
2186            LayoutElementType::Number => "number",
2187            LayoutElementType::Reference => "reference",
2188            LayoutElementType::ReferenceContent => "reference_content",
2189            LayoutElementType::Algorithm => "algorithm",
2190            LayoutElementType::FormulaNumber => "formula_number",
2191            LayoutElementType::AsideText => "aside_text",
2192            LayoutElementType::List => "list",
2193
2194            // Region (PP-DocBlockLayout)
2195            LayoutElementType::Region => "region",
2196
2197            // Fallback
2198            LayoutElementType::Other => "other",
2199        }
2200    }
2201
2202    /// Creates a LayoutElementType from a string label with fine-grained mapping.
2203    ///
2204    /// This method maps model output labels to their corresponding fine-grained types,
2205    /// preserving the full PP-StructureV3 label set (20/23 classes).
2206    pub fn from_label(label: &str) -> Self {
2207        match label.to_lowercase().as_str() {
2208            // Document Structure
2209            "doc_title" => LayoutElementType::DocTitle,
2210            "paragraph_title" | "title" => LayoutElementType::ParagraphTitle,
2211            "text" | "paragraph" => LayoutElementType::Text,
2212            "content" => LayoutElementType::Content,
2213            "abstract" => LayoutElementType::Abstract,
2214
2215            // Visual Elements
2216            "image" | "figure" => LayoutElementType::Image,
2217            "table" => LayoutElementType::Table,
2218            "chart" | "flowchart" => LayoutElementType::Chart,
2219            "formula" | "equation" | "display_formula" | "inline_formula" => {
2220                LayoutElementType::Formula
2221            }
2222
2223            // Captions
2224            "figure_title" => LayoutElementType::FigureTitle,
2225            "table_title" => LayoutElementType::TableTitle,
2226            "chart_title" => LayoutElementType::ChartTitle,
2227            "figure_table_chart_title" | "caption" => LayoutElementType::FigureTableChartTitle,
2228
2229            // Page Structure
2230            "header" => LayoutElementType::Header,
2231            "header_image" => LayoutElementType::HeaderImage,
2232            "footer" => LayoutElementType::Footer,
2233            "footer_image" => LayoutElementType::FooterImage,
2234            "footnote" | "vision_footnote" => LayoutElementType::Footnote,
2235
2236            // Special Elements
2237            "seal" => LayoutElementType::Seal,
2238            "number" => LayoutElementType::Number,
2239            "reference" => LayoutElementType::Reference,
2240            "reference_content" => LayoutElementType::ReferenceContent,
2241            "algorithm" => LayoutElementType::Algorithm,
2242            "formula_number" => LayoutElementType::FormulaNumber,
2243            "aside_text" => LayoutElementType::AsideText,
2244            "list" => LayoutElementType::List,
2245            "vertical_text" => LayoutElementType::Text,
2246
2247            // Region (PP-DocBlockLayout)
2248            "region" => LayoutElementType::Region,
2249
2250            // Everything else maps to Other
2251            // The original label is preserved in LayoutElement.label
2252            _ => LayoutElementType::Other,
2253        }
2254    }
2255
2256    /// Returns the semantic category for this element type.
2257    ///
2258    /// This method groups fine-grained types into broader semantic categories,
2259    /// useful for processing logic that doesn't need fine-grained distinctions.
2260    ///
2261    /// # Categories
2262    ///
2263    /// - **Title**: DocTitle, ParagraphTitle
2264    /// - **Text**: Text, Content, Abstract
2265    /// - **Visual**: Image, Chart
2266    /// - **Table**: Table
2267    /// - **Caption**: FigureTitle, TableTitle, ChartTitle, FigureTableChartTitle
2268    /// - **Header**: Header, HeaderImage
2269    /// - **Footer**: Footer, FooterImage, Footnote
2270    /// - **Formula**: Formula, FormulaNumber
2271    /// - **Special**: Seal, Number, Reference, ReferenceContent, Algorithm, AsideText
2272    /// - **List**: List
2273    /// - **Other**: Other
2274    pub fn semantic_category(&self) -> &'static str {
2275        match self {
2276            // Title category
2277            LayoutElementType::DocTitle | LayoutElementType::ParagraphTitle => "title",
2278
2279            // Text category
2280            LayoutElementType::Text | LayoutElementType::Content | LayoutElementType::Abstract => {
2281                "text"
2282            }
2283
2284            // Visual category
2285            LayoutElementType::Image | LayoutElementType::Chart => "visual",
2286
2287            // Table category
2288            LayoutElementType::Table => "table",
2289
2290            // Caption category
2291            LayoutElementType::FigureTitle
2292            | LayoutElementType::TableTitle
2293            | LayoutElementType::ChartTitle
2294            | LayoutElementType::FigureTableChartTitle => "caption",
2295
2296            // Header category
2297            LayoutElementType::Header | LayoutElementType::HeaderImage => "header",
2298
2299            // Footer category
2300            LayoutElementType::Footer
2301            | LayoutElementType::FooterImage
2302            | LayoutElementType::Footnote => "footer",
2303
2304            // Formula category
2305            LayoutElementType::Formula | LayoutElementType::FormulaNumber => "formula",
2306
2307            // Special category
2308            LayoutElementType::Seal
2309            | LayoutElementType::Number
2310            | LayoutElementType::Reference
2311            | LayoutElementType::ReferenceContent
2312            | LayoutElementType::Algorithm
2313            | LayoutElementType::AsideText => "special",
2314
2315            // List category
2316            LayoutElementType::List => "list",
2317
2318            // Region category (PP-DocBlockLayout)
2319            LayoutElementType::Region => "region",
2320
2321            // Other
2322            LayoutElementType::Other => "other",
2323        }
2324    }
2325
2326    /// Returns whether this element type is a title variant.
2327    pub fn is_title(&self) -> bool {
2328        matches!(
2329            self,
2330            LayoutElementType::DocTitle | LayoutElementType::ParagraphTitle
2331        )
2332    }
2333
2334    /// Returns whether this element type is a visual element (image, chart, figure).
2335    pub fn is_visual(&self) -> bool {
2336        matches!(self, LayoutElementType::Image | LayoutElementType::Chart)
2337    }
2338
2339    /// Returns whether this element type is a caption variant.
2340    pub fn is_caption(&self) -> bool {
2341        matches!(
2342            self,
2343            LayoutElementType::FigureTitle
2344                | LayoutElementType::TableTitle
2345                | LayoutElementType::ChartTitle
2346                | LayoutElementType::FigureTableChartTitle
2347        )
2348    }
2349
2350    /// Returns whether this element type is a header variant.
2351    pub fn is_header(&self) -> bool {
2352        matches!(
2353            self,
2354            LayoutElementType::Header | LayoutElementType::HeaderImage
2355        )
2356    }
2357
2358    /// Returns whether this element type is a footer variant.
2359    pub fn is_footer(&self) -> bool {
2360        matches!(
2361            self,
2362            LayoutElementType::Footer
2363                | LayoutElementType::FooterImage
2364                | LayoutElementType::Footnote
2365        )
2366    }
2367
2368    /// Returns whether this element type is a formula variant.
2369    pub fn is_formula(&self) -> bool {
2370        matches!(
2371            self,
2372            LayoutElementType::Formula | LayoutElementType::FormulaNumber
2373        )
2374    }
2375
2376    /// Returns whether this element type contains text content that should be OCR'd.
2377    pub fn should_ocr(&self) -> bool {
2378        matches!(
2379            self,
2380            LayoutElementType::Text
2381                | LayoutElementType::Content
2382                | LayoutElementType::Abstract
2383                | LayoutElementType::DocTitle
2384                | LayoutElementType::ParagraphTitle
2385                | LayoutElementType::FigureTitle
2386                | LayoutElementType::TableTitle
2387                | LayoutElementType::ChartTitle
2388                | LayoutElementType::FigureTableChartTitle
2389                | LayoutElementType::Header
2390                | LayoutElementType::HeaderImage
2391                | LayoutElementType::Footer
2392                | LayoutElementType::FooterImage
2393                | LayoutElementType::Footnote
2394                | LayoutElementType::Reference
2395                | LayoutElementType::ReferenceContent
2396                | LayoutElementType::Algorithm
2397                | LayoutElementType::AsideText
2398                | LayoutElementType::List
2399                | LayoutElementType::Number
2400        )
2401    }
2402}
2403
2404/// Removes heavily-overlapping layout elements in-place.
2405///
2406/// This mirrors PP-Structure-style overlap suppression where text takes priority over images.
2407/// Returns the number of elements removed.
2408pub fn remove_overlapping_layout_elements(
2409    layout_elements: &mut Vec<LayoutElement>,
2410    overlap_threshold: f32,
2411) -> usize {
2412    use std::collections::HashSet;
2413
2414    if layout_elements.len() <= 1 {
2415        return 0;
2416    }
2417
2418    let bboxes: Vec<_> = layout_elements.iter().map(|e| e.bbox.clone()).collect();
2419    let labels: Vec<&str> = layout_elements
2420        .iter()
2421        .map(|e| e.element_type.as_str())
2422        .collect();
2423
2424    let remove_indices =
2425        crate::processors::get_overlap_removal_indices(&bboxes, &labels, overlap_threshold);
2426    if remove_indices.is_empty() {
2427        return 0;
2428    }
2429
2430    let remove_set: HashSet<usize> = remove_indices.into_iter().collect();
2431    let before = layout_elements.len();
2432
2433    let mut idx = 0;
2434    layout_elements.retain(|_| {
2435        let keep = !remove_set.contains(&idx);
2436        idx += 1;
2437        keep
2438    });
2439
2440    before.saturating_sub(layout_elements.len())
2441}
2442
2443/// Applies small, PP-Structure-style label fixes to layout elements.
2444///
2445/// This is intended to capture lightweight "glue" heuristics that shouldn't live in `predict`.
2446pub fn apply_standardized_layout_label_fixes(layout_elements: &mut [LayoutElement]) {
2447    if layout_elements.is_empty() {
2448        return;
2449    }
2450
2451    let mut footnote_indices: Vec<usize> = Vec::new();
2452    let mut paragraph_title_indices: Vec<usize> = Vec::new();
2453    let mut bottom_text_y_max: f32 = 0.0;
2454    let mut max_block_area: f32 = 0.0;
2455    let mut doc_title_num: usize = 0;
2456
2457    for (idx, elem) in layout_elements.iter().enumerate() {
2458        let area =
2459            (elem.bbox.x_max() - elem.bbox.x_min()) * (elem.bbox.y_max() - elem.bbox.y_min());
2460        max_block_area = max_block_area.max(area);
2461
2462        match elem.element_type {
2463            LayoutElementType::Footnote => footnote_indices.push(idx),
2464            LayoutElementType::ParagraphTitle => paragraph_title_indices.push(idx),
2465            LayoutElementType::Text => {
2466                bottom_text_y_max = bottom_text_y_max.max(elem.bbox.y_max());
2467            }
2468            LayoutElementType::DocTitle => doc_title_num += 1,
2469            _ => {}
2470        }
2471    }
2472
2473    for idx in footnote_indices {
2474        if layout_elements[idx].bbox.y_max() < bottom_text_y_max {
2475            layout_elements[idx].element_type = LayoutElementType::Text;
2476            layout_elements[idx].label = Some("text".to_string());
2477        }
2478    }
2479
2480    let only_one_paragraph_title = paragraph_title_indices.len() == 1 && doc_title_num == 0;
2481    if only_one_paragraph_title {
2482        let idx = paragraph_title_indices[0];
2483        let area = (layout_elements[idx].bbox.x_max() - layout_elements[idx].bbox.x_min())
2484            * (layout_elements[idx].bbox.y_max() - layout_elements[idx].bbox.y_min());
2485
2486        let title_area_ratio_threshold = 0.3f32;
2487        if area > max_block_area * title_area_ratio_threshold {
2488            layout_elements[idx].element_type = LayoutElementType::DocTitle;
2489            layout_elements[idx].label = Some("doc_title".to_string());
2490        }
2491    }
2492}
2493
2494/// Result of table recognition.
2495#[derive(Debug, Clone, Serialize, Deserialize)]
2496pub struct TableResult {
2497    /// Bounding box of the table in the original image
2498    pub bbox: BoundingBox,
2499    /// Table type (wired or wireless)
2500    pub table_type: TableType,
2501    /// Confidence score for table type classification (None if classifier wasn't configured/run)
2502    pub classification_confidence: Option<f32>,
2503    /// Confidence score for table structure recognition (None if structure recognition failed)
2504    pub structure_confidence: Option<f32>,
2505    /// Detected table cells
2506    pub cells: Vec<TableCell>,
2507    /// HTML structure of the table (if available)
2508    pub html_structure: Option<String>,
2509    /// OCR text content for each cell (if OCR was integrated)
2510    pub cell_texts: Option<Vec<Option<String>>>,
2511    /// Structure tokens from table structure recognition (used for HTML generation after stitching)
2512    #[serde(skip)]
2513    pub structure_tokens: Option<Vec<String>>,
2514    /// Detected cell bounding boxes from the cell detection model (in page coordinates).
2515    /// Stored separately from `cells` (which carry structure/grid metadata from the structure model)
2516    /// and used by the stitcher for row-aware IoA-based OCR matching.
2517    #[serde(skip)]
2518    pub detected_cell_bboxes: Option<Vec<BoundingBox>>,
2519    /// Whether the table was processed in end-to-end (E2E) mode.
2520    /// When true, cells come from the structure model only (no separate cell detection).
2521    /// Used by the stitcher to select the appropriate OCR matching strategy.
2522    #[serde(skip)]
2523    pub is_e2e: bool,
2524}
2525
2526impl TableResult {
2527    /// Creates a new table result.
2528    pub fn new(bbox: BoundingBox, table_type: TableType) -> Self {
2529        Self {
2530            bbox,
2531            table_type,
2532            classification_confidence: None,
2533            structure_confidence: None,
2534            cells: Vec::new(),
2535            html_structure: None,
2536            cell_texts: None,
2537            structure_tokens: None,
2538            detected_cell_bboxes: None,
2539            is_e2e: false,
2540        }
2541    }
2542
2543    /// Sets the classification confidence.
2544    pub fn with_classification_confidence(mut self, confidence: f32) -> Self {
2545        self.classification_confidence = Some(confidence);
2546        self
2547    }
2548
2549    /// Sets the structure recognition confidence.
2550    pub fn with_structure_confidence(mut self, confidence: f32) -> Self {
2551        self.structure_confidence = Some(confidence);
2552        self
2553    }
2554
2555    /// Sets the table cells.
2556    pub fn with_cells(mut self, cells: Vec<TableCell>) -> Self {
2557        self.cells = cells;
2558        self
2559    }
2560
2561    /// Sets the HTML structure.
2562    pub fn with_html_structure(mut self, html: impl Into<String>) -> Self {
2563        self.html_structure = Some(html.into());
2564        self
2565    }
2566
2567    /// Sets the cell texts from OCR.
2568    pub fn with_cell_texts(mut self, texts: Vec<Option<String>>) -> Self {
2569        self.cell_texts = Some(texts);
2570        self
2571    }
2572
2573    /// Sets the structure tokens for later HTML generation.
2574    pub fn with_structure_tokens(mut self, tokens: Vec<String>) -> Self {
2575        self.structure_tokens = Some(tokens);
2576        self
2577    }
2578
2579    /// Stores detected cell bounding boxes for the stitcher's row-aware IoA matcher.
2580    pub fn with_detected_cell_bboxes(mut self, bboxes: Vec<BoundingBox>) -> Self {
2581        self.detected_cell_bboxes = Some(bboxes);
2582        self
2583    }
2584
2585    /// Marks this table as processed in end-to-end (E2E) mode.
2586    pub fn with_e2e(mut self, is_e2e: bool) -> Self {
2587        self.is_e2e = is_e2e;
2588        self
2589    }
2590
2591    /// Returns the best available confidence score for this table.
2592    ///
2593    /// This method provides a unified confidence API for callers who want to filter
2594    /// tables by confidence without caring whether classification or structure
2595    /// recognition was used. Priority:
2596    /// 1. If both classification and structure confidence are available, returns
2597    ///    the minimum (most conservative estimate)
2598    /// 2. If only structure confidence is available (common when classifier isn't
2599    ///    configured), returns that
2600    /// 3. If only classification confidence is available, returns that
2601    /// 4. Returns `None` only if neither confidence is available (stub result)
2602    pub fn confidence(&self) -> Option<f32> {
2603        match (self.classification_confidence, self.structure_confidence) {
2604            (Some(cls), Some(str)) => Some(cls.min(str)),
2605            (None, Some(str)) => Some(str),
2606            (Some(cls), None) => Some(cls),
2607            (None, None) => None,
2608        }
2609    }
2610
2611    /// Returns true if this table has valid structure data.
2612    ///
2613    /// A table is considered valid if it has either cells or an HTML structure.
2614    /// Stub results (created when structure recognition fails) will return false.
2615    pub fn has_structure(&self) -> bool {
2616        !self.cells.is_empty() || self.html_structure.is_some()
2617    }
2618}
2619
2620/// Type of table.
2621#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2622pub enum TableType {
2623    /// Table with visible borders
2624    Wired,
2625    /// Table without visible borders
2626    Wireless,
2627    /// Unknown table type
2628    Unknown,
2629}
2630
2631impl TableType {
2632    /// Stable configuration spelling for this table type.
2633    pub const fn as_str(self) -> &'static str {
2634        match self {
2635            Self::Wired => "wired",
2636            Self::Wireless => "wireless",
2637            Self::Unknown => "unknown",
2638        }
2639    }
2640}
2641
2642impl AsRef<str> for TableType {
2643    fn as_ref(&self) -> &str {
2644        self.as_str()
2645    }
2646}
2647
2648impl std::str::FromStr for TableType {
2649    type Err = String;
2650
2651    fn from_str(value: &str) -> Result<Self, Self::Err> {
2652        match value.trim().to_ascii_lowercase().as_str() {
2653            "wired" => Ok(Self::Wired),
2654            "wireless" => Ok(Self::Wireless),
2655            "unknown" => Ok(Self::Unknown),
2656            _ => Err(format!(
2657                "unknown table type {value:?}; expected 'wired' or 'wireless'"
2658            )),
2659        }
2660    }
2661}
2662
2663/// A cell in a table.
2664#[derive(Debug, Clone, Serialize, Deserialize)]
2665pub struct TableCell {
2666    /// Bounding box of the cell
2667    pub bbox: BoundingBox,
2668    /// Row index (0-based)
2669    pub row: Option<usize>,
2670    /// Column index (0-based)
2671    pub col: Option<usize>,
2672    /// Row span
2673    pub row_span: Option<usize>,
2674    /// Column span
2675    pub col_span: Option<usize>,
2676    /// Confidence score for the cell detection
2677    pub confidence: f32,
2678    /// Text content of the cell (if available)
2679    pub text: Option<String>,
2680}
2681
2682impl TableCell {
2683    /// Creates a new table cell.
2684    pub fn new(bbox: BoundingBox, confidence: f32) -> Self {
2685        Self {
2686            bbox,
2687            row: None,
2688            col: None,
2689            row_span: None,
2690            col_span: None,
2691            confidence,
2692            text: None,
2693        }
2694    }
2695
2696    /// Sets the row and column indices.
2697    pub fn with_position(mut self, row: usize, col: usize) -> Self {
2698        self.row = Some(row);
2699        self.col = Some(col);
2700        self
2701    }
2702
2703    /// Sets the row and column spans.
2704    pub fn with_span(mut self, row_span: usize, col_span: usize) -> Self {
2705        self.row_span = Some(row_span);
2706        self.col_span = Some(col_span);
2707        self
2708    }
2709
2710    /// Sets the text content.
2711    pub fn with_text(mut self, text: impl Into<String>) -> Self {
2712        self.text = Some(text.into());
2713        self
2714    }
2715}
2716
2717/// Result of formula recognition.
2718#[derive(Debug, Clone, Serialize, Deserialize)]
2719pub struct FormulaResult {
2720    /// Bounding box of the formula in the original image
2721    pub bbox: BoundingBox,
2722    /// LaTeX representation of the formula
2723    pub latex: String,
2724    /// Confidence score for the recognition
2725    pub confidence: f32,
2726}
2727
2728impl FormulaResult {
2729    /// Creates a new formula result.
2730    pub fn new(bbox: BoundingBox, latex: impl Into<String>, confidence: f32) -> Self {
2731        Self {
2732            bbox,
2733            latex: latex.into(),
2734            confidence,
2735        }
2736    }
2737}
2738
2739#[cfg(test)]
2740mod tests {
2741    use super::*;
2742
2743    #[test]
2744    fn page_json_matches_the_documented_format() {
2745        // The golden fixture from docs/page-format.md — one titled block
2746        // with a model label that differs from the canonical type — plus
2747        // the table block the classic fixture adds: stitching leaves table
2748        // element text empty, so its content is the plain HTML from the
2749        // paired TableResult.
2750        let element = LayoutElement::new(
2751            BoundingBox::from_coords(100.0, 200.0, 900.0, 400.0),
2752            LayoutElementType::DocTitle,
2753            0.875,
2754        )
2755        .with_label("Title")
2756        .with_text("Hello");
2757        let mut element = element;
2758        element.order_index = Some(1);
2759        let mut table_element = LayoutElement::new(
2760            BoundingBox::from_coords(100.0, 500.0, 900.0, 900.0),
2761            LayoutElementType::Table,
2762            0.75,
2763        );
2764        table_element.order_index = Some(2);
2765        let mut table = TableResult::new(
2766            BoundingBox::from_coords(100.0, 500.0, 900.0, 900.0),
2767            TableType::Wired,
2768        );
2769        table.html_structure =
2770            Some("<html><body><table><tr><td>cell</td></tr></table></body></html>".to_string());
2771        let result = StructureResult::new("page.png", 0)
2772            .with_layout_elements(vec![element, table_element])
2773            .with_tables(vec![table]);
2774        let expected = serde_json::json!({
2775            "version": 1,
2776            "pipeline": "classic",
2777            "page": { "index": 0, "width": 1000, "height": 2000, "angle": null },
2778            "blocks": [
2779                {
2780                    "type": "doc_title",
2781                    "label": "Title",
2782                    "bbox": [100.0, 200.0, 900.0, 400.0],
2783                    "order": 1,
2784                    "confidence": 0.875,
2785                    "angle": null,
2786                    "content": "Hello"
2787                },
2788                {
2789                    "type": "table",
2790                    "bbox": [100.0, 500.0, 900.0, 900.0],
2791                    "order": 2,
2792                    "confidence": 0.75,
2793                    "angle": null,
2794                    "content": "<table><tr><td>cell</td></tr></table>"
2795                }
2796            ],
2797            "markdown": result.to_markdown(),
2798            "raw_output": null,
2799            "diagnostics": []
2800        });
2801        let page = result.to_json(1000, 2000);
2802        // The markdown wiring is pinned to the exporter rather than a
2803        // literal string; the renderer has its own tests.
2804        assert_eq!(page, expected);
2805    }
2806
2807    #[test]
2808    fn test_structure_result_creation() {
2809        let result = StructureResult::new("test.jpg", 0);
2810        assert_eq!(result.input_path.as_ref(), "test.jpg");
2811        assert_eq!(result.index, 0);
2812        assert!(result.layout_elements.is_empty());
2813        assert!(result.tables.is_empty());
2814        assert!(result.formulas.is_empty());
2815        assert!(result.text_regions.is_none());
2816    }
2817
2818    #[test]
2819    fn test_layout_element_type_as_str() {
2820        assert_eq!(LayoutElementType::Text.as_str(), "text");
2821        assert_eq!(LayoutElementType::Table.as_str(), "table");
2822        assert_eq!(LayoutElementType::Formula.as_str(), "formula");
2823    }
2824
2825    #[test]
2826    fn test_table_result_creation() {
2827        let bbox = BoundingBox::from_coords(0.0, 0.0, 100.0, 100.0);
2828        let table = TableResult::new(bbox, TableType::Wired);
2829        assert_eq!(table.table_type, TableType::Wired);
2830        assert!(table.cells.is_empty());
2831        assert!(table.html_structure.is_none());
2832    }
2833
2834    #[test]
2835    fn test_structure_result_export() {
2836        let bbox = BoundingBox::from_coords(0.0, 0.0, 100.0, 100.0);
2837        let mut result = StructureResult::new("test.jpg", 0);
2838
2839        let title = LayoutElement::new(bbox.clone(), LayoutElementType::DocTitle, 1.0)
2840            .with_text("Test Document");
2841
2842        let text =
2843            LayoutElement::new(bbox.clone(), LayoutElementType::Text, 1.0).with_text("Hello world");
2844
2845        result = result.with_layout_elements(vec![title, text]);
2846
2847        let md = result.to_markdown();
2848        assert!(md.contains("# Test Document"));
2849        assert!(md.contains("Hello world"));
2850
2851        let html = result.to_html();
2852        assert!(html.contains("<h1>Test Document</h1>"));
2853        assert!(html.contains("<p>Hello world</p>"));
2854    }
2855
2856    #[test]
2857    fn test_format_title_with_level_keywords() {
2858        let (level, text) = format_title_with_level("Abstract", None);
2859        assert_eq!(level, 2);
2860        assert_eq!(text, "Abstract");
2861
2862        let (level, text) = format_title_with_level("References:", None);
2863        assert_eq!(level, 2);
2864        assert_eq!(text, "References:");
2865    }
2866
2867    #[test]
2868    fn test_format_title_with_level_cluster_fallback() {
2869        let (level, text) = format_title_with_level("Unnumbered Heading", Some(4));
2870        assert_eq!(level, 4);
2871        assert_eq!(text, "Unnumbered Heading");
2872    }
2873
2874    #[test]
2875    fn test_to_markdown_skips_footnote() {
2876        let mut result = StructureResult::new("test.jpg", 0);
2877        let body = LayoutElement::new(
2878            BoundingBox::from_coords(0.0, 0.0, 100.0, 30.0),
2879            LayoutElementType::Text,
2880            1.0,
2881        )
2882        .with_text("Body");
2883        let footnote = LayoutElement::new(
2884            BoundingBox::from_coords(0.0, 40.0, 100.0, 60.0),
2885            LayoutElementType::Footnote,
2886            1.0,
2887        )
2888        .with_text("Footnote text");
2889        result = result.with_layout_elements(vec![body, footnote]);
2890
2891        let md = result.to_markdown();
2892        assert!(md.contains("Body"));
2893        assert!(!md.contains("Footnote text"));
2894    }
2895
2896    #[test]
2897    fn test_to_markdown_doc_title_joins_lines_with_space() {
2898        let mut result = StructureResult::new("test.jpg", 0);
2899        let title = LayoutElement::new(
2900            BoundingBox::from_coords(0.0, 0.0, 100.0, 20.0),
2901            LayoutElementType::DocTitle,
2902            1.0,
2903        )
2904        .with_text("Main\nTitle");
2905        result = result.with_layout_elements(vec![title]);
2906        let md = result.to_markdown();
2907        assert!(md.contains("# Main Title"));
2908    }
2909
2910    #[test]
2911    fn test_to_markdown_content_uses_soft_breaks() {
2912        let mut result = StructureResult::new("test.jpg", 0);
2913        let toc = LayoutElement::new(
2914            BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0),
2915            LayoutElementType::Content,
2916            1.0,
2917        )
2918        .with_text("1 Intro\n2 Method");
2919        result = result.with_layout_elements(vec![toc]);
2920        let md = result.to_markdown();
2921        assert!(md.contains("1 Intro  \n2 Method"));
2922    }
2923
2924    #[test]
2925    fn test_infer_paragraph_title_levels_by_height() {
2926        let titles = vec![
2927            LayoutElement::new(
2928                BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0),
2929                LayoutElementType::ParagraphTitle,
2930                1.0,
2931            )
2932            .with_text("Large"),
2933            LayoutElement::new(
2934                BoundingBox::from_coords(0.0, 50.0, 100.0, 74.0),
2935                LayoutElementType::ParagraphTitle,
2936                1.0,
2937            )
2938            .with_text("Medium"),
2939            LayoutElement::new(
2940                BoundingBox::from_coords(0.0, 80.0, 100.0, 98.0),
2941                LayoutElementType::ParagraphTitle,
2942                1.0,
2943            )
2944            .with_text("Small"),
2945        ];
2946
2947        let levels = infer_paragraph_title_levels(&titles);
2948        let l0 = levels.get(&0).copied().unwrap_or(2);
2949        let l1 = levels.get(&1).copied().unwrap_or(2);
2950        let l2 = levels.get(&2).copied().unwrap_or(2);
2951        assert!(l0 <= l1 && l1 <= l2);
2952    }
2953
2954    #[test]
2955    fn test_infer_paragraph_title_levels_semantic_vote_wins_tie() {
2956        let titles = vec![
2957            LayoutElement::new(
2958                BoundingBox::from_coords(0.0, 0.0, 100.0, 40.0),
2959                LayoutElementType::ParagraphTitle,
2960                1.0,
2961            )
2962            .with_text("1.1 Detail"),
2963            LayoutElement::new(
2964                BoundingBox::from_coords(0.0, 50.0, 100.0, 70.0),
2965                LayoutElementType::ParagraphTitle,
2966                1.0,
2967            )
2968            .with_text("2 Intro"),
2969        ];
2970
2971        let levels = infer_paragraph_title_levels(&titles);
2972        assert_eq!(levels.get(&0).copied(), Some(3));
2973        assert_eq!(levels.get(&1).copied(), Some(2));
2974    }
2975
2976    #[test]
2977    fn test_infer_paragraph_title_levels_uses_relative_indent_signal() {
2978        let titles = vec![
2979            LayoutElement::new(
2980                BoundingBox::from_coords(0.0, 0.0, 100.0, 24.0),
2981                LayoutElementType::ParagraphTitle,
2982                1.0,
2983            )
2984            .with_text("Heading A"),
2985            LayoutElement::new(
2986                BoundingBox::from_coords(40.0, 40.0, 140.0, 64.0),
2987                LayoutElementType::ParagraphTitle,
2988                1.0,
2989            )
2990            .with_text("Heading B"),
2991        ];
2992
2993        let levels = infer_paragraph_title_levels(&titles);
2994        let left_level = levels.get(&0).copied().unwrap_or(2);
2995        let indented_level = levels.get(&1).copied().unwrap_or(2);
2996        assert!(left_level < indented_level);
2997    }
2998}