1use 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
16static 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
46fn semantic_title_level_and_format(cleaned: &str) -> Option<(usize, String)> {
63 let trimmed = cleaned.trim();
64
65 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 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 let level = clustered_level.unwrap_or(2).clamp(2, 6);
110 (level, cleaned)
111}
112
113fn 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 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
209fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct RegionBlock {
312 pub bbox: BoundingBox,
314 pub confidence: f32,
316 pub order_index: Option<u32>,
318 pub element_indices: Vec<usize>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct PageContinuationFlags {
332 pub paragraph_start: bool,
334 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 pub fn as_tuple(&self) -> (bool, bool) {
348 (self.paragraph_start, self.paragraph_end)
349 }
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct StructureResult {
375 pub input_path: Arc<str>,
377 pub index: usize,
379 pub layout_elements: Vec<LayoutElement>,
381 pub tables: Vec<TableResult>,
383 pub formulas: Vec<FormulaResult>,
385 pub text_regions: Option<Vec<TextRegion>>,
387 pub orientation_angle: Option<f32>,
389 pub region_blocks: Option<Vec<RegionBlock>>,
392 #[serde(skip)]
397 pub rectified_img: Option<Arc<RgbImage>>,
398 pub page_continuation_flags: Option<PageContinuationFlags>,
402}
403
404impl StructureResult {
405 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 pub fn with_layout_elements(mut self, elements: Vec<LayoutElement>) -> Self {
423 self.layout_elements = elements;
424 self
425 }
426
427 pub fn with_tables(mut self, tables: Vec<TableResult>) -> Self {
429 self.tables = tables;
430 self
431 }
432
433 pub fn with_formulas(mut self, formulas: Vec<FormulaResult>) -> Self {
435 self.formulas = formulas;
436 self
437 }
438
439 pub fn with_text_regions(mut self, regions: Vec<TextRegion>) -> Self {
441 self.text_regions = Some(regions);
442 self
443 }
444
445 pub fn with_region_blocks(mut self, blocks: Vec<RegionBlock>) -> Self {
450 self.region_blocks = Some(blocks);
451 self
452 }
453
454 pub fn with_page_continuation_flags(mut self, flags: PageContinuationFlags) -> Self {
456 self.page_continuation_flags = Some(flags);
457 self
458 }
459
460 pub fn to_markdown(&self) -> String {
472 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 let original_image_width = self
482 .rectified_img
483 .as_ref()
484 .map(|img| img.width() as f32)
485 .or_else(|| {
486 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 let mut prev_text_element: Option<&LayoutElement> = None;
500
501 for (idx, element) in elements.iter().enumerate() {
502 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 if element.element_type == LayoutElementType::Text {
520 let overlaps_table = table_bboxes.iter().any(|table_bbox| {
521 element.bbox.ioa(table_bbox) > 0.3 });
523
524 if overlaps_table && element.confidence < 0.7 {
527 continue;
528 }
529 }
530
531 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 if !is_continuation {
542 }
544
545 match element.element_type {
546 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 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 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 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 let simplified = simplify_table_html(html);
596 let table_with_border =
597 simplified.replacen("<table>", "<table border=\"1\">", 1);
598 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 LayoutElementType::FormulaNumber => {
614 continue;
615 }
616 LayoutElementType::Formula => {
618 let raw_content = element.text.as_deref().map(|s| s.trim()).unwrap_or("");
620 if raw_content.is_empty() {
621 continue;
622 }
623 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 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 has_prev_text && has_next_text
668 };
669
670 if is_inline {
671 md.push('$');
673 md.push_str(latex_content);
674 md.push_str("$ ");
675 } else {
676 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 LayoutElementType::Image | LayoutElementType::Chart => {
687 if !md.is_empty() {
688 md.push_str("\n\n");
689 }
690 md.push_str("<div style=\"text-align: center;\"><img src=\"");
692 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 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 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 _ 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 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 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 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 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 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 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 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 _ if element.element_type.is_header() || element.element_type.is_footer() => {
807 continue;
810 }
811 _ => {
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 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 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 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 let first = &text_elements[0];
891 let paragraph_start = is_new_paragraph_start(first, page_width);
892
893 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 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 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 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 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 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 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 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 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 _ 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 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 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 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 _ 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 _ 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 _ => {
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 fn escape_html(text: &str) -> String {
1044 text.replace('&', "&")
1045 .replace('<', "<")
1046 .replace('>', ">")
1047 .replace('"', """)
1048 .replace('\'', "'")
1049 }
1050
1051 #[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 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 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 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 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 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 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 let stem = if let Some(path_str) = input_path.to_str() {
1189 if let Some(hash_idx) = path_str.rfind('#') {
1190 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 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 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
1226fn 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 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; }
1276
1277 true } else {
1279 if seg_start - context_left < COORD_THRESHOLD {
1281 return false; }
1283 true
1284 }
1285}
1286
1287fn 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); left <= threshold
1295}
1296
1297fn is_paragraph_complete(element: &LayoutElement, page_width: Option<f32>) -> bool {
1302 let right = element.bbox.x_max();
1303
1304 if let Some(width) = page_width {
1306 let right_margin = width * 0.1;
1307 return right <= (width - right_margin);
1308 }
1309
1310 true
1312}
1313
1314pub 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; 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 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 if page_first_continues && !prev_page_end_flag {
1358 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 markdown.push(' ');
1369 markdown.push_str(page_markdown.trim_start());
1370 } else {
1371 markdown.push_str(page_markdown.trim_start());
1373 }
1374 } else {
1375 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
1388fn clean_ocr_text(text: &str) -> String {
1398 text.replace("-\n", "").replace('\n', " ")
1400}
1401
1402fn 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 if templates.iter().any(|t| trimmed.eq_ignore_ascii_case(t)) {
1433 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
1446fn format_text_block(text: &str) -> String {
1452 let dehyphenated = text.replace("-\n", "");
1454 let step1 = dehyphenated.replace("\n\n", "\n");
1456 step1.replace('\n', "\n\n")
1458}
1459
1460fn format_content_block(text: &str) -> String {
1465 let step1 = text.replace("-\n", " \n");
1467 step1.replace('\n', " \n")
1469}
1470
1471fn 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
1480const BULLET_MARKERS: &[char] = &['•', '●', '◦', '▪', '◆'];
1482
1483fn has_bullet_markers(text: &str) -> bool {
1485 BULLET_MARKERS.iter().any(|&m| text.contains(m))
1486}
1487
1488fn 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
1503fn is_chinese_char(c: char) -> bool {
1507 match c {
1508 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{20000}'..='\u{2A6DF}' | '\u{2A700}'..='\u{2B73F}' | '\u{2B740}'..='\u{2B81F}' | '\u{2B820}'..='\u{2CEAF}' | '\u{2CEB0}'..='\u{2EBEF}' => true,
1516 _ => false,
1517 }
1518}
1519
1520fn is_lowercase(c: char) -> bool {
1522 c.is_ascii_lowercase()
1523}
1524
1525fn is_uppercase(c: char) -> bool {
1527 c.is_ascii_uppercase()
1528}
1529
1530fn is_digit(c: char) -> bool {
1532 c.is_ascii_digit()
1533}
1534
1535fn 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 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 let is_artifact = if i + 1 < len && chars[i + 1] == '\n' {
1566 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 i += 1; } else {
1580 result.push('-');
1581 }
1582 } else {
1583 result.push(chars[i]);
1584 }
1585 i += 1;
1586 }
1587
1588 result
1589}
1590
1591fn 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 if is_lowercase(prev) && is_lowercase(current) {
1610 if i > 1 && chars[i - 2] == '\'' {
1613 result.push(' ');
1614 }
1615 } else if is_lowercase(prev) && is_uppercase(current) {
1618 if i + 1 < chars.len() && is_lowercase(chars[i + 1]) {
1621 result.push(' ');
1622 }
1623 }
1624 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
1644fn is_letter(c: char) -> bool {
1646 is_lowercase(c) || is_uppercase(c)
1647}
1648
1649fn simplify_table_html(html: &str) -> String {
1654 html.replace("<html>", "")
1655 .replace("</html>", "")
1656 .replace("<body>", "")
1657 .replace("</body>", "")
1658}
1659
1660pub fn postprocess_text(text: &str) -> String {
1667 let text = dehyphenate(text);
1668 let text = fix_merged_words(&text);
1669
1670 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 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 if in_space && matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}') {
1692 result.pop(); result.push(c);
1694 continue;
1695 }
1696 result.push(c);
1697 in_space = false;
1698 }
1699 }
1700
1701 result
1702}
1703
1704fn 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 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(§ion_name) {
1728 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
1743fn 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 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 let height1 = y1_max - y1_min;
1760 let height2 = y2_max - y2_min;
1761 let min_height = height1.min(height2);
1762
1763 min_height > 0.0 && overlap / min_height > 0.5
1765}
1766
1767fn 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 if line.trim() == "$$" {
1780 if i + 1 < lines.len() && lines[i + 1].trim() == "$$" {
1782 i += 2;
1784 if i < lines.len() && lines[i].trim().is_empty() {
1786 i += 1;
1787 }
1788 continue;
1789 }
1790 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 while i < lines.len() && lines[i].trim() != "$$" {
1812 i += 1;
1813 }
1814 if i < lines.len() {
1815 i += 1; }
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
1831pub fn postprocess_markdown(markdown: &str) -> String {
1835 let markdown = filter_empty_formulas(markdown);
1836 let markdown = deduplicate_sections(&markdown);
1837
1838 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 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 if trimmed == "$$" {
1856 in_formula = !in_formula;
1857 result.push_str(line);
1858 result.push('\n');
1859 continue;
1860 }
1861
1862 if in_code_block {
1864 result.push_str(line);
1865 result.push('\n');
1866 continue;
1867 }
1868
1869 if in_formula {
1872 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 result.push_str(&line.replace('$', "\\$"));
1884 } else {
1885 result.push_str(line);
1886 }
1887 result.push('\n');
1888 continue;
1889 }
1890
1891 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
1909pub trait StructureResultExt {
1911 fn to_concatenated_markdown(results: &[Self]) -> String
1913 where
1914 Self: Sized;
1915
1916 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1976pub struct LayoutElement {
1977 pub bbox: BoundingBox,
1979 pub element_type: LayoutElementType,
1981 pub confidence: f32,
1983 pub label: Option<String>,
1985 pub text: Option<String>,
1987 pub order_index: Option<u32>,
1994 #[serde(skip_serializing_if = "Option::is_none")]
1998 pub seg_start_x: Option<f32>,
1999 #[serde(skip_serializing_if = "Option::is_none")]
2003 pub seg_end_x: Option<f32>,
2004 #[serde(skip_serializing_if = "Option::is_none")]
2007 pub num_lines: Option<u32>,
2008}
2009
2010impl LayoutElement {
2011 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 pub fn with_label(mut self, label: impl Into<String>) -> Self {
2028 self.label = Some(label.into());
2029 self
2030 }
2031
2032 pub fn with_text(mut self, text: impl Into<String>) -> Self {
2034 self.text = Some(text.into());
2035 self
2036 }
2037}
2038
2039#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2086pub enum LayoutElementType {
2087 DocTitle,
2089 ParagraphTitle,
2091 Text,
2093 Content,
2095 Abstract,
2097
2098 Image,
2100 Table,
2102 Chart,
2104 Formula,
2106
2107 FigureTitle,
2109 TableTitle,
2111 ChartTitle,
2113 FigureTableChartTitle,
2115
2116 Header,
2118 HeaderImage,
2120 Footer,
2122 FooterImage,
2124 Footnote,
2126
2127 Seal,
2129 Number,
2131 Reference,
2133 ReferenceContent,
2135 Algorithm,
2137 FormulaNumber,
2139 AsideText,
2141 List,
2143
2144 Region,
2147
2148 Other,
2150}
2151
2152impl LayoutElementType {
2153 pub fn as_str(&self) -> &'static str {
2157 match self {
2158 LayoutElementType::DocTitle => "doc_title",
2160 LayoutElementType::ParagraphTitle => "paragraph_title",
2161 LayoutElementType::Text => "text",
2162 LayoutElementType::Content => "content",
2163 LayoutElementType::Abstract => "abstract",
2164
2165 LayoutElementType::Image => "image",
2167 LayoutElementType::Table => "table",
2168 LayoutElementType::Chart => "chart",
2169 LayoutElementType::Formula => "formula",
2170
2171 LayoutElementType::FigureTitle => "figure_title",
2173 LayoutElementType::TableTitle => "table_title",
2174 LayoutElementType::ChartTitle => "chart_title",
2175 LayoutElementType::FigureTableChartTitle => "figure_table_chart_title",
2176
2177 LayoutElementType::Header => "header",
2179 LayoutElementType::HeaderImage => "header_image",
2180 LayoutElementType::Footer => "footer",
2181 LayoutElementType::FooterImage => "footer_image",
2182 LayoutElementType::Footnote => "footnote",
2183
2184 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 LayoutElementType::Region => "region",
2196
2197 LayoutElementType::Other => "other",
2199 }
2200 }
2201
2202 pub fn from_label(label: &str) -> Self {
2207 match label.to_lowercase().as_str() {
2208 "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 "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 "figure_title" => LayoutElementType::FigureTitle,
2225 "table_title" => LayoutElementType::TableTitle,
2226 "chart_title" => LayoutElementType::ChartTitle,
2227 "figure_table_chart_title" | "caption" => LayoutElementType::FigureTableChartTitle,
2228
2229 "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 "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" => LayoutElementType::Region,
2249
2250 _ => LayoutElementType::Other,
2253 }
2254 }
2255
2256 pub fn semantic_category(&self) -> &'static str {
2275 match self {
2276 LayoutElementType::DocTitle | LayoutElementType::ParagraphTitle => "title",
2278
2279 LayoutElementType::Text | LayoutElementType::Content | LayoutElementType::Abstract => {
2281 "text"
2282 }
2283
2284 LayoutElementType::Image | LayoutElementType::Chart => "visual",
2286
2287 LayoutElementType::Table => "table",
2289
2290 LayoutElementType::FigureTitle
2292 | LayoutElementType::TableTitle
2293 | LayoutElementType::ChartTitle
2294 | LayoutElementType::FigureTableChartTitle => "caption",
2295
2296 LayoutElementType::Header | LayoutElementType::HeaderImage => "header",
2298
2299 LayoutElementType::Footer
2301 | LayoutElementType::FooterImage
2302 | LayoutElementType::Footnote => "footer",
2303
2304 LayoutElementType::Formula | LayoutElementType::FormulaNumber => "formula",
2306
2307 LayoutElementType::Seal
2309 | LayoutElementType::Number
2310 | LayoutElementType::Reference
2311 | LayoutElementType::ReferenceContent
2312 | LayoutElementType::Algorithm
2313 | LayoutElementType::AsideText => "special",
2314
2315 LayoutElementType::List => "list",
2317
2318 LayoutElementType::Region => "region",
2320
2321 LayoutElementType::Other => "other",
2323 }
2324 }
2325
2326 pub fn is_title(&self) -> bool {
2328 matches!(
2329 self,
2330 LayoutElementType::DocTitle | LayoutElementType::ParagraphTitle
2331 )
2332 }
2333
2334 pub fn is_visual(&self) -> bool {
2336 matches!(self, LayoutElementType::Image | LayoutElementType::Chart)
2337 }
2338
2339 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 pub fn is_header(&self) -> bool {
2352 matches!(
2353 self,
2354 LayoutElementType::Header | LayoutElementType::HeaderImage
2355 )
2356 }
2357
2358 pub fn is_footer(&self) -> bool {
2360 matches!(
2361 self,
2362 LayoutElementType::Footer
2363 | LayoutElementType::FooterImage
2364 | LayoutElementType::Footnote
2365 )
2366 }
2367
2368 pub fn is_formula(&self) -> bool {
2370 matches!(
2371 self,
2372 LayoutElementType::Formula | LayoutElementType::FormulaNumber
2373 )
2374 }
2375
2376 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
2404pub 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
2443pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
2496pub struct TableResult {
2497 pub bbox: BoundingBox,
2499 pub table_type: TableType,
2501 pub classification_confidence: Option<f32>,
2503 pub structure_confidence: Option<f32>,
2505 pub cells: Vec<TableCell>,
2507 pub html_structure: Option<String>,
2509 pub cell_texts: Option<Vec<Option<String>>>,
2511 #[serde(skip)]
2513 pub structure_tokens: Option<Vec<String>>,
2514 #[serde(skip)]
2518 pub detected_cell_bboxes: Option<Vec<BoundingBox>>,
2519 #[serde(skip)]
2523 pub is_e2e: bool,
2524}
2525
2526impl TableResult {
2527 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 pub fn with_classification_confidence(mut self, confidence: f32) -> Self {
2545 self.classification_confidence = Some(confidence);
2546 self
2547 }
2548
2549 pub fn with_structure_confidence(mut self, confidence: f32) -> Self {
2551 self.structure_confidence = Some(confidence);
2552 self
2553 }
2554
2555 pub fn with_cells(mut self, cells: Vec<TableCell>) -> Self {
2557 self.cells = cells;
2558 self
2559 }
2560
2561 pub fn with_html_structure(mut self, html: impl Into<String>) -> Self {
2563 self.html_structure = Some(html.into());
2564 self
2565 }
2566
2567 pub fn with_cell_texts(mut self, texts: Vec<Option<String>>) -> Self {
2569 self.cell_texts = Some(texts);
2570 self
2571 }
2572
2573 pub fn with_structure_tokens(mut self, tokens: Vec<String>) -> Self {
2575 self.structure_tokens = Some(tokens);
2576 self
2577 }
2578
2579 pub fn with_detected_cell_bboxes(mut self, bboxes: Vec<BoundingBox>) -> Self {
2581 self.detected_cell_bboxes = Some(bboxes);
2582 self
2583 }
2584
2585 pub fn with_e2e(mut self, is_e2e: bool) -> Self {
2587 self.is_e2e = is_e2e;
2588 self
2589 }
2590
2591 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 pub fn has_structure(&self) -> bool {
2616 !self.cells.is_empty() || self.html_structure.is_some()
2617 }
2618}
2619
2620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2622pub enum TableType {
2623 Wired,
2625 Wireless,
2627 Unknown,
2629}
2630
2631impl TableType {
2632 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#[derive(Debug, Clone, Serialize, Deserialize)]
2665pub struct TableCell {
2666 pub bbox: BoundingBox,
2668 pub row: Option<usize>,
2670 pub col: Option<usize>,
2672 pub row_span: Option<usize>,
2674 pub col_span: Option<usize>,
2676 pub confidence: f32,
2678 pub text: Option<String>,
2680}
2681
2682impl TableCell {
2683 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 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 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 pub fn with_text(mut self, text: impl Into<String>) -> Self {
2712 self.text = Some(text.into());
2713 self
2714 }
2715}
2716
2717#[derive(Debug, Clone, Serialize, Deserialize)]
2719pub struct FormulaResult {
2720 pub bbox: BoundingBox,
2722 pub latex: String,
2724 pub confidence: f32,
2726}
2727
2728impl FormulaResult {
2729 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 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 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}